//! 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"); } }