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 <noreply@anthropic.com>
This commit is contained in:
+89
-7
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user