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:
+12
-4
@@ -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
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user