From 0aaf6be5297dce54aaf8206f4fcae5436210143b Mon Sep 17 00:00:00 2001 From: Mollusk Date: Sun, 28 Jun 2026 16:19:37 -0400 Subject: [PATCH] feat(friends): live "scanned Nm ago" indicator after a manual rescan Adds a relative-time indicator in the Friends panel header showing how long ago the last manual Rescan completed: "just now", "2m ago", "1h 2m ago", "2d 2h ago". It advances on its own via a 30s RescanLabelTick subscription (only armed once a rescan has happened), so the label stays current without user interaction. Placed in the panel header rather than the status bar: the status bar is a single ephemeral label overwritten by every other action, so it can't host a persistent, live-updating timestamp without clobbering other statuses. The completion event (FriendsRescanned) stamps the time; formatting is a pure, unit-tested helper (format_relative_ago). 470 lib tests pass, clippy clean. Co-Authored-By: Claude Opus 4.8 --- src/app/mod.rs | 96 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 89 insertions(+), 7 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 75e7e70..2433154 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -477,6 +477,8 @@ pub enum AppMessage { DismissClockSkewWarning, /// Auto-clear cadence while the clock-skew warning banner is visible. ClockSkewWarningTick, + /// Slow re-render so the friends "scanned Nm ago" indicator advances over time. + RescanLabelTick, /// Choose a room layout (applied live + persisted, closes the popup). SelectRoomLayout(RoomLayout), /// Choose a UI theme (applied live + persisted). @@ -607,6 +609,11 @@ pub struct AppState { /// Carried in the minted ticket so joiners inherit it; empty = unnamed room. room_name_input: String, status_message: String, + /// When the last manual friends "Rescan" pass completed, for the live + /// "scanned Nm ago" indicator in the Friends panel. `None` until the first + /// rescan this session. Only the on-demand button updates this, not the 15s + /// auto-refresh. + last_rescan: Option, self_id: String, ticket: String, is_muted: bool, @@ -935,6 +942,7 @@ impl Default for AppState { ticket_input: "".to_string(), room_name_input: "".to_string(), status_message: "Ready to connect".to_string(), + last_rescan: None, self_id: "".to_string(), ticket: "".to_string(), is_muted: false, @@ -1150,7 +1158,15 @@ fn subscription(state: &AppState) -> Subscription { } else { Subscription::none() }; - Subscription::batch(vec![core_sub, event_sub, audio_sub, clock_skew_sub]) + // Advance the friends "scanned Nm ago" indicator. Minute granularity, so a 30s + // tick keeps it within ~30s of accurate; only runs once a rescan has happened. + let rescan_label_sub = if state.last_rescan.is_some() { + iced::time::every(std::time::Duration::from_secs(30)) + .map(|_| AppMessage::RescanLabelTick) + } else { + Subscription::none() + }; + Subscription::batch(vec![core_sub, event_sub, audio_sub, clock_skew_sub, rescan_label_sub]) } fn shutdown_timeout_task() -> Task { @@ -1701,9 +1717,11 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.friend_presence.insert(id, presence); } UiEvent::FriendsRescanned => { - // The manual pass finished; replace the transient "Rescanning…" - // banner, but only if it's still showing (don't clobber a status + // The manual pass finished. Stamp the time for the live "scanned + // Nm ago" indicator, and replace the transient "Rescanning…" + // banner — but only if it's still showing (don't clobber a status // the user has since triggered, e.g. by joining a room). + state.last_rescan = Some(std::time::Instant::now()); if state.status_message == "Rescanning friends…" { state.status_message = "Friends rescanned.".to_string(); } @@ -2090,6 +2108,10 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { AppMessage::ClockSkewWarningTick => { clear_expired_clock_skew_warning(state, std::time::Instant::now()); } + AppMessage::RescanLabelTick => { + // No state change; the tick exists purely to re-render the relative + // "scanned Nm ago" label as time passes. + } AppMessage::SelectRoomLayout(layout) => { state.config.room_layout = layout; state.config.save(); @@ -2878,6 +2900,37 @@ fn format_duration(total_secs: u64) -> String { } } +/// A compact relative-time label for the friends "last scanned" indicator, e.g. +/// `just now`, `2m ago`, `1h 2m ago`, `3d ago`. Minute granularity (the indicator +/// re-renders on a slow tick), so anything under a minute reads "just now". Pure +/// so the formatting is unit-testable without a clock. +fn format_relative_ago(elapsed: std::time::Duration) -> String { + let secs = elapsed.as_secs(); + if secs < 60 { + return "just now".to_string(); + } + let mins = secs / 60; + if mins < 60 { + return format!("{mins}m ago"); + } + let hours = mins / 60; + if hours < 24 { + let rem_m = mins % 60; + return if rem_m == 0 { + format!("{hours}h ago") + } else { + format!("{hours}h {rem_m}m ago") + }; + } + let days = hours / 24; + let rem_h = hours % 24; + if rem_h == 0 { + format!("{days}d ago") + } else { + format!("{days}d {rem_h}h ago") + } +} + fn format_clock_skew_duration(skew_secs: u64) -> String { let minutes = skew_secs.max(1).saturating_add(59) / 60; if minutes == 1 { @@ -3796,18 +3849,28 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> { } else { column![].into() }; - // Header: title + a manual "Rescan" button. Presence is also auto-refreshed - // every 60s, but the button forces an immediate pass for instant feedback. - // Only shown when there are friends to scan. + // Header: title + a "scanned Nm ago" indicator + a manual "Rescan" button. + // Presence is also auto-refreshed every 15s, but the button forces an immediate + // pass for instant feedback. Only shown when there are friends to scan. The + // indicator advances live via the RescanLabelTick subscription. + let scanned_label: Element<'_, AppMessage> = match state.last_rescan { + Some(t) => text(format!("scanned {}", format_relative_ago(t.elapsed()))) + .size(11) + .color(color_subtext) + .into(), + None => column![].into(), + }; let title_row: Element<'_, AppMessage> = if has_friends { row![ text("FRIENDS").size(18).color(color_text), horizontal_space(), + scanned_label, button(text("⟳ Rescan").size(12)) .on_press(AppMessage::RefreshFriends) .style(b_style(color_surface, color_blue, color_text, 6.0)) .padding(6), ] + .spacing(8) .align_y(iced::alignment::Vertical::Center) .into() } else { @@ -7590,7 +7653,7 @@ impl Program for Icon { mod tests { use super::{ attachment_default_name, clear_expired_clock_skew_warning, format_clock_skew_duration, - format_duration, initial_window_position, reconnect_attempt_chime, reconnected_chime, + format_duration, format_relative_ago, initial_window_position, reconnect_attempt_chime, reconnected_chime, set_peer_gate_config, set_peer_volume_config, show_clock_skew_warning, update, AppConfig, AppMessage, AppState, AttachmentCache, AttachmentState, ChatEntry, ClockSkewBanner, GateMeter, METER_MAX, UiEvent, CLOCK_SKEW_WARNING_VISIBLE_SECS, @@ -8140,6 +8203,25 @@ mod tests { assert_eq!(format_duration(3725), "1:02:05"); } + #[test] + fn format_relative_ago_buckets_by_minute() { + use std::time::Duration; + // Under a minute reads "just now" (no seconds-level churn). + assert_eq!(format_relative_ago(Duration::from_secs(0)), "just now"); + assert_eq!(format_relative_ago(Duration::from_secs(59)), "just now"); + // Minutes. + assert_eq!(format_relative_ago(Duration::from_secs(60)), "1m ago"); + assert_eq!(format_relative_ago(Duration::from_secs(125)), "2m ago"); + assert_eq!(format_relative_ago(Duration::from_secs(59 * 60)), "59m ago"); + // Hours, with minutes only when non-zero. + assert_eq!(format_relative_ago(Duration::from_secs(3600)), "1h ago"); + assert_eq!(format_relative_ago(Duration::from_secs(3600 + 120)), "1h 2m ago"); + assert_eq!(format_relative_ago(Duration::from_secs(23 * 3600 + 59 * 60)), "23h 59m ago"); + // Days, with hours only when non-zero. + assert_eq!(format_relative_ago(Duration::from_secs(24 * 3600)), "1d ago"); + assert_eq!(format_relative_ago(Duration::from_secs(50 * 3600)), "2d 2h ago"); + } + #[test] fn settings_categories_are_stable_and_grouped_for_navigation() { use super::SettingsCategory;