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 -4
View File
@@ -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
+90
View File
@@ -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> {
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<AppMessage> {
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<AppMessage> = 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
]
+8
View File
@@ -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<String>,
/// 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<crate::recents::Recent>,
/// 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]
+1
View File
@@ -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;
+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!({
+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");
}
}