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
+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
]