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:
@@ -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
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user