feat(w7): recently-joined rooms list with one-click rejoin (P5)

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 14:58:14 -04:00
co-authored by Claude Opus 4.8
parent 83b1bbcc9d
commit c12d15ed7d
6 changed files with 295 additions and 4 deletions
+172
View File
@@ -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<Recent>, 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<Recent>, 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");
}
}