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
+12
View File
@@ -114,6 +114,14 @@ impl PeerSpeakTicket {
pub fn label_of(ticket_str: &str) -> String {
ticket_str.parse::<PeerSpeakTicket>().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::<PeerSpeakTicket>().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!({