feat(friends): live "scanned Nm ago" indicator after a manual rescan
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-28 16:19:37 -04:00
co-authored by Claude Opus 4.8
parent d059386aee
commit 0aaf6be529
+89 -7
View File
@@ -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<std::time::Instant>,
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<AppMessage> {
} 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<AppMessage> {
@@ -1701,9 +1717,11 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
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> {
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<AppMessage> 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;