From c12d15ed7dba52f893c94cb7b794284bee681c89 Mon Sep 17 00:00:00 2001 From: Mollusk Date: Tue, 16 Jun 2026 14:58:14 -0400 Subject: [PATCH] feat(w7): recently-joined rooms list with one-click rejoin (P5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a purely-local, most-recent-first recents list so users can hop back into a room they were just in — meaningful now that rooms carry cosmetic labels. - src/recents.rs (new): `Recent {name, ticket, joined_at}`, `push_recent` (de-dupes by room `topic_id`, refresh-and-move-to-front, caps at RECENTS_MAX=12), `remove_recent`, `relative_time` ("5m ago"). 6 tests. - PeerSpeakTicket::topic_of — the stable room identity used as the de-dup key (host addr + label change between members/sessions; topic doesn't). - AppConfig.recents (`#[serde(default)]`, back-compat) — local UI state, never sent over the wire. - Recorded on RoomJoined (label via label_of); rendered as a "Recent rooms" block in connect_card (each entry → JoinRecent, ✕ → RemoveRecent), shown only when non-empty. Rejoin is best-effort by design: the stored ticket only admits us while the room is still live and reachable (reliability is P6 discovery + the member-issued ticket floor, not this list). 263 lib tests green, clippy --all-targets clean. Recents UI screenshot-verified (seeded config → ages + Untitled-room fallback render). Co-Authored-By: Claude Opus 4.8 --- docs/contacts-plan.md | 16 +++- src/app/mod.rs | 90 ++++++++++++++++++++++ src/config.rs | 8 ++ src/lib.rs | 1 + src/network/mod.rs | 12 +++ src/recents.rs | 172 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 295 insertions(+), 4 deletions(-) create mode 100644 src/recents.rs diff --git a/docs/contacts-plan.md b/docs/contacts-plan.md index 623ea68..98fb439 100644 --- a/docs/contacts-plan.md +++ b/docs/contacts-plan.md @@ -167,7 +167,7 @@ address book), though a lightweight mutual-add is optional polish. online/offline/in-room+Join. Bootstrap caveat: a friend with no saved addr shows offline until one ticket-based call seeds `last_addr`. -### P5 — Recents + UI — UI status/Join + room labels DONE; recents + add-from-room PENDING +### P5 — Recents + UI — ✅ DONE (UI status/Join, room labels, add-from-room, recents) Friends-list UI with status (online / offline / in-room + Join) + Invisible/Normal/ Discoverable controls — **DONE (B2)**; the Friends panel + presence picker moved to the **home screen** 2026-06-16 (`173585f`/`ddc78f1`/`be42941`). **Cosmetic room @@ -181,9 +181,17 @@ a live/2-machine confirm. **Add-friend-from-room — DONE 2026-06-16 (`fb17fd1`) participant card has a star — clickable ☆ adds that peer (pulling their live presence name + addr so they're reachable immediately, unlike a bare add-by-id), gold ★ once they're already a friend; hidden while friends are read-only. `AddFriendFromRoom` msg. -⚠️ star + click want a live 2-machine confirm (needs a peer in the room). **STILL -pending:** local **recents** list (cosmetic room tags — now that labels exist they'd be -meaningful). +⚠️ star + click want a live 2-machine confirm (needs a peer in the room). **Recents — +DONE 2026-06-16:** a purely-local, most-recent-first list of joined rooms +(`src/recents.rs` — `Recent {name,ticket,joined_at}`, `push_recent` de-dupes by +`topic_id` via the new `PeerSpeakTicket::topic_of`, caps at `RECENTS_MAX`=12; +`relative_time` for "5m ago"; 6 unit tests). Persisted in `AppConfig.recents` +(`#[serde(default)]`, back-compat). Recorded on `RoomJoined` (label via `label_of`), +rendered as a "Recent rooms" block in `connect_card` (each entry = label/"Untitled room" ++ relative time → `JoinRecent`, plus a ✕ → `RemoveRecent`); only shown when non-empty. +Rejoin is best-effort (works only while the room is still live + reachable through the +stored ticket — reliability is P6/the member-ticket floor, not this list). +**Screenshot-verified** (seeded config → 3 recents render with correct ages + fallback). ### P6 — Opt-in discovery — Small Wire the *discoverable* state to n0 DNS publish (default off, time-boxed). Lookup diff --git a/src/app/mod.rs b/src/app/mod.rs index 3283367..a1cea20 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -153,6 +153,11 @@ pub enum AppMessage { /// Join the gathering a friend is in (W7 B2), via the member ticket their /// presence reply carried. Mirrors a manual ticket join. JoinFriendRoom(String), + /// Rejoin a room from the recents list (W7 P5), via its stored ticket. Mirrors + /// a manual ticket join; best-effort (works only while the room is still live). + JoinRecent(String), + /// Drop a room from the recents list (W7 P5), the × on a recent entry. + RemoveRecent(String), EventOccurred(Event), NavigateToSettings, NavigateBack, @@ -600,6 +605,16 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { AppMessage::UiEventReceived(event) => { match event { UiEvent::RoomJoined { ticket, self_id } => { + // Remember this gathering for one-click rejoin (W7 P5). The + // emitted ticket is the canonical room door (topic + member + // addr + label); push_recent de-dupes by topic and persists. + let label = crate::network::PeerSpeakTicket::label_of(&ticket); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + crate::recents::push_recent(&mut state.config.recents, label, ticket.clone(), now); + state.config.save(); state.ticket = ticket; state.self_id = self_id; state.status_message = "Connected".to_string(); @@ -888,6 +903,29 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { avatar: state.config.avatar.clone(), }); } + AppMessage::JoinRecent(ticket) => { + // Rejoin a remembered room (same path as a manual ticket join). It's + // best-effort: the door only admits us while the room is still live. + let input_device = state.selected_input.as_ref().map(|d| d.name.clone()); + let output_device = state.selected_output.as_ref().map(|d| d.name.clone()); + state.status_message = "Rejoining a recent room...".to_string(); + state.config.username = state.name.clone(); + state.config.save(); + state.mic_test_active = false; + let _ = state.controller.send(CoreCommand::Join { + name: state.name.clone(), + ticket, + room_name: String::new(), // rejoining: the label comes from the ticket + input_device, + output_device, + echo_cancellation: state.config.echo_cancellation_enabled, + avatar: state.config.avatar.clone(), + }); + } + AppMessage::RemoveRecent(ticket) => { + crate::recents::remove_recent(&mut state.config.recents, &ticket); + state.config.save(); + } AppMessage::ToggleNotifications(enabled) => { state.config.notifications_enabled = enabled; state.config.save(); @@ -1252,6 +1290,7 @@ fn connect_card(state: &AppState) -> Element<'_, AppMessage> { let color_subtext = pal.subtext; let color_blue = pal.blue; let color_lavender = pal.lavender; + let color_maroon = pal.maroon; let c_style = move |bg: Color, b_color: Color, radius: f32| { move |_theme: &Theme| container::Style { @@ -1288,6 +1327,55 @@ fn connect_card(state: &AppState) -> Element<'_, AppMessage> { selection: color_blue, }; + // Recently-joined rooms (W7 P5): a one-click rejoin list. Only shown when + // non-empty so the launch card stays clean on a fresh install. + let recents_group: Element = if state.config.recents.is_empty() { + column![].into() + } else { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let mut rows = column![].spacing(6).width(iced::Length::Fill); + for r in &state.config.recents { + let label = { + let n = crate::sanitize::sanitize_name(&r.name); + if n.is_empty() { "Untitled room".to_string() } else { n } + }; + let when = crate::recents::relative_time(now, r.joined_at); + let entry = button( + row![ + text(label).size(14).color(color_text), + horizontal_space(), + text(when).size(11).color(color_subtext), + ] + .align_y(iced::alignment::Vertical::Center), + ) + .on_press(AppMessage::JoinRecent(r.ticket.clone())) + .style(b_style(color_crust, color_surface, color_text, 6.0)) + .padding(8) + .width(iced::Length::Fill); + rows = rows.push( + row![ + entry, + button(text("✕").size(12)) + .on_press(AppMessage::RemoveRecent(r.ticket.clone())) + .style(b_style(color_surface, color_maroon, color_text, 6.0)) + .padding(8), + ] + .spacing(6) + .align_y(iced::alignment::Vertical::Center), + ); + } + column![ + text("Recent rooms").size(14).color(color_subtext), + vertical_space(4.0), + rows, + ] + .width(iced::Length::Fill) + .into() + }; + let logo = text("PEERSPEAK").size(36).color(color_blue); let subtitle = text("NAT-traversing full-mesh voice chat").size(16).color(color_subtext); @@ -1345,6 +1433,8 @@ fn connect_card(state: &AppState) -> Element<'_, AppMessage> { text("— OR —").size(12).color(color_surface).align_x(iced::alignment::Horizontal::Center), vertical_space(16.0), join_group, + vertical_space(16.0), + recents_group, vertical_space(10.0), status ] diff --git a/src/config.rs b/src/config.rs index 124a966..4e9d00d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -227,6 +227,11 @@ pub struct AppConfig { /// Empty / unset = look it up on `$PATH`. Hand-editable; no Settings UI yet. #[serde(default)] pub pixelpass_path: Option, + /// Recently-joined rooms (W7), most-recent-first. Purely local UI state for a + /// one-click rejoin; never sent over the wire. De-duped by room topic and + /// capped (see `recents`). Defaulted empty so older configs upgrade cleanly. + #[serde(default)] + pub recents: Vec, /// Last window size (px), restored as the initial size on next launch. /// Saved on close. #[serde(default = "default_window_width")] @@ -281,6 +286,7 @@ impl Default for AppConfig { sound_mic_toggle_enabled: true, sound_reconnect_failed_enabled: true, pixelpass_path: None, + recents: Vec::new(), window_width: default_window_width(), window_height: default_window_height(), window_x: None, @@ -403,6 +409,8 @@ mod tests { // Configs predating the remembered window size load the default size. assert_eq!(deserialized.window_width, 900.0); assert_eq!(deserialized.window_height, 760.0); + // Configs predating the recents list load an empty list. + assert!(deserialized.recents.is_empty()); } #[test] diff --git a/src/lib.rs b/src/lib.rs index 6ca9b11..5d63743 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,6 +14,7 @@ pub mod notify; pub mod screenshare; pub mod sanitize; pub mod avatar; +pub mod recents; use std::path::PathBuf; use std::sync::OnceLock; diff --git a/src/network/mod.rs b/src/network/mod.rs index 83cf03e..7b0665a 100644 --- a/src/network/mod.rs +++ b/src/network/mod.rs @@ -114,6 +114,14 @@ impl PeerSpeakTicket { pub fn label_of(ticket_str: &str) -> String { ticket_str.parse::().map(|t| t.name).unwrap_or_default() } + + /// The room's `topic_id` embedded in a ticket string, or `None` if the ticket + /// can't be parsed. Pure; used as the stable room identity for de-duplicating + /// the recents list (the host address and label change between members/sessions, + /// but the topic uniquely identifies the gathering). + pub fn topic_of(ticket_str: &str) -> Option<[u8; 32]> { + ticket_str.parse::().ok().map(|t| t.topic_id) + } } impl std::fmt::Display for PeerSpeakTicket { @@ -251,6 +259,10 @@ mod tests { assert_eq!(PeerSpeakTicket::label_of(&restamped), "HangOut"); // An unparseable ticket has no label rather than panicking. assert_eq!(PeerSpeakTicket::label_of("not-a-ticket"), ""); + // topic_of reads the room identity, and returns None for a bad ticket. + assert_eq!(PeerSpeakTicket::topic_of(&labelled), Some(topic_id)); + assert_eq!(PeerSpeakTicket::topic_of(&restamped), Some(topic_id)); + assert_eq!(PeerSpeakTicket::topic_of("not-a-ticket"), None); // Backward-compat: a pre-label ticket JSON (no `name` key) still parses, // defaulting the label to "". let legacy_json = serde_json::json!({ diff --git a/src/recents.rs b/src/recents.rs new file mode 100644 index 0000000..0586360 --- /dev/null +++ b/src/recents.rs @@ -0,0 +1,172 @@ +//! Recently-joined rooms (W7 P5) — a small, purely-local, cosmetic convenience +//! list. Each entry remembers a gathering you were in: its label, the canonical +//! share ticket (a door you can try to re-enter through), and when you last +//! joined it. It is *not* a presence or reachability primitive — rejoining is +//! best-effort and only succeeds while someone is still in the room and reachable +//! through the stored ticket. Reliability across moves is P6 (discovery) and the +//! member-issued ticket floor, not this list. +//! +//! De-duplication is keyed on the room's `topic_id` (the stable room identity), +//! so re-joining the same gathering refreshes one entry instead of stacking +//! duplicates, even as the host address and label change between members/sessions. + +use crate::network::PeerSpeakTicket; +use serde::{Deserialize, Serialize}; + +/// How many recent rooms to keep. Oldest entries fall off past this. +pub const RECENTS_MAX: usize = 12; + +/// One recently-joined room. Stored locally in `AppConfig`; never sent over the +/// wire. `ticket` is the canonical share ticket captured on join (carries the +/// topic + a member address + the label). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Recent { + /// Cosmetic room label at join time (already sanitized upstream). May be empty + /// for an unlabeled room — the UI shows a fallback then. + pub name: String, + /// The share ticket to attempt a rejoin through. + pub ticket: String, + /// Unix seconds when we last joined this room. Used for ordering + "x ago". + pub joined_at: u64, +} + +/// The de-dup key for a recent: the room's `topic_id` when the ticket parses, +/// else the raw ticket string (so an unparseable entry still de-dups against an +/// identical one rather than stacking). Pure. +fn dedup_key(ticket: &str) -> Result<[u8; 32], &str> { + PeerSpeakTicket::topic_of(ticket).ok_or(ticket) +} + +/// Whether two tickets refer to the same room (same `topic_id`, or — for +/// unparseable tickets — the same exact string). +fn same_room(a: &str, b: &str) -> bool { + dedup_key(a) == dedup_key(b) +} + +/// Record a just-joined room at the front of `list` (most-recent-first). +/// +/// If the room (by `topic_id`) is already present, its entry is refreshed — +/// the newest ticket, label, and timestamp win — and moved to the front rather +/// than duplicated. The list is then capped to [`RECENTS_MAX`]. Pure: the caller +/// supplies `now` (unix seconds) and persists the list afterwards. +pub fn push_recent(list: &mut Vec, name: String, ticket: String, now: u64) { + list.retain(|r| !same_room(&r.ticket, &ticket)); + list.insert(0, Recent { name, ticket, joined_at: now }); + list.truncate(RECENTS_MAX); +} + +/// Drop the recent whose ticket refers to the same room as `ticket` (the × in +/// the UI). A no-op if no entry matches. Pure. +pub fn remove_recent(list: &mut Vec, ticket: &str) { + list.retain(|r| !same_room(&r.ticket, ticket)); +} + +/// A short human label for how long ago `then` was, relative to `now` (both unix +/// seconds): "just now", "5m ago", "3h ago", "2d ago". Saturates at days. Pure; +/// `then > now` (clock skew) reads as "just now". +pub fn relative_time(now: u64, then: u64) -> String { + let secs = now.saturating_sub(then); + if secs < 60 { + "just now".to_string() + } else if secs < 3600 { + format!("{}m ago", secs / 60) + } else if secs < 86_400 { + format!("{}h ago", secs / 3600) + } else { + format!("{}d ago", secs / 86_400) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::network::PeerSpeakTicket; + use iroh::{EndpointAddr, SecretKey}; + + /// Build a real, parseable ticket for a fresh room with the given label. + fn ticket(name: &str, topic: [u8; 32]) -> String { + let host = SecretKey::generate().public(); + PeerSpeakTicket { host_addr: EndpointAddr::from(host), topic_id: topic, name: name.into() } + .to_string() + } + + #[test] + fn push_prepends_and_orders_most_recent_first() { + let mut list = Vec::new(); + push_recent(&mut list, "A".into(), ticket("A", [1; 32]), 100); + push_recent(&mut list, "B".into(), ticket("B", [2; 32]), 200); + assert_eq!(list.len(), 2); + assert_eq!(list[0].name, "B"); + assert_eq!(list[1].name, "A"); + } + + #[test] + fn rejoining_same_topic_refreshes_not_duplicates() { + let mut list = Vec::new(); + // Same room (topic [9;32]) joined twice, even via a different member's + // ticket and a renamed label, must collapse to one refreshed entry. + push_recent(&mut list, "HangOut".into(), ticket("HangOut", [9; 32]), 100); + push_recent(&mut list, "A".into(), ticket("A", [1; 32]), 150); + let newer = ticket("HangOut v2", [9; 32]); + push_recent(&mut list, "HangOut v2".into(), newer.clone(), 300); + assert_eq!(list.len(), 2); + // The refreshed room is now at the front with the new label + timestamp. + assert_eq!(list[0].name, "HangOut v2"); + assert_eq!(list[0].ticket, newer); + assert_eq!(list[0].joined_at, 300); + } + + #[test] + fn capped_at_recents_max_dropping_oldest() { + let mut list = Vec::new(); + for i in 0..(RECENTS_MAX as u64 + 5) { + let mut topic = [0u8; 32]; + topic[0] = i as u8; + push_recent(&mut list, format!("R{i}"), ticket("r", topic), i); + } + assert_eq!(list.len(), RECENTS_MAX); + // The newest is at the front; the oldest survivors fell off. + assert_eq!(list[0].name, format!("R{}", RECENTS_MAX as u64 + 4)); + } + + #[test] + fn remove_drops_matching_room_only() { + let mut list = Vec::new(); + let keep = ticket("keep", [1; 32]); + let drop = ticket("drop", [2; 32]); + push_recent(&mut list, "keep".into(), keep.clone(), 100); + push_recent(&mut list, "drop".into(), drop.clone(), 200); + // Removing by a DIFFERENT member's ticket for the same room still matches. + let drop_other_member = ticket("drop", [2; 32]); + remove_recent(&mut list, &drop_other_member); + assert_eq!(list.len(), 1); + assert_eq!(list[0].name, "keep"); + // Removing something not present is a no-op. + remove_recent(&mut list, &ticket("nope", [7; 32])); + assert_eq!(list.len(), 1); + } + + #[test] + fn unparseable_tickets_dedup_by_exact_string() { + let mut list = Vec::new(); + push_recent(&mut list, "junk".into(), "not-a-ticket".into(), 10); + push_recent(&mut list, "junk-again".into(), "not-a-ticket".into(), 20); + // Same raw string → one entry, refreshed. + assert_eq!(list.len(), 1); + assert_eq!(list[0].joined_at, 20); + // A different unparseable string is a distinct entry. + push_recent(&mut list, "other".into(), "other-junk".into(), 30); + assert_eq!(list.len(), 2); + } + + #[test] + fn relative_time_buckets() { + assert_eq!(relative_time(1000, 1000), "just now"); + assert_eq!(relative_time(1000, 970), "just now"); // < 60s + assert_eq!(relative_time(1000, 700), "5m ago"); + assert_eq!(relative_time(100_000, 100_000 - 3 * 3600), "3h ago"); + assert_eq!(relative_time(1_000_000, 1_000_000 - 2 * 86_400), "2d ago"); + // Clock skew (then in the future) saturates to "just now", never panics. + assert_eq!(relative_time(100, 500), "just now"); + } +}