4 Commits
Author SHA1 Message Date
molluskandClaude Opus 4.8 0aaf6be529 feat(friends): live "scanned Nm ago" indicator after a manual rescan
windows-build / windows-build (push) Has been cancelled
cargo-deny / cargo-deny (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>
2026-06-28 16:19:37 -04:00
molluskandClaude Opus 4.8 d059386aee fix(friends): clear the "Rescanning…" status when the pass completes
The manual Rescan set a persistent "Rescanning friends…" status but
nothing ever cleared it: the probe pass emits per-friend presence events
with no "done" signal, so the banner stuck forever (an offline friend's
probe can take up to the 10s IO timeout, and there was no terminal event
after).

Core now emits a `FriendsRescanned` UiEvent after the manual pass finishes
(only the on-demand button, never the 15s auto-refresh, so the status bar
isn't churned each interval). The GUI replaces the transient banner with
"Friends rescanned." — guarded so it won't clobber a status the user has
since triggered. Invisible mode probes no one, so the button now explains
that instead of showing a banner that resolves with nothing changed.

469 lib tests pass, clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 16:10:52 -04:00
molluskandClaude Opus 4.8 79a091b1b3 tune(friends): shorten presence auto-refresh 60s -> 15s
The 60s cadence predated the self-heal fix. 15s keeps the friends list
tracking online/in-room/offline changes more closely; each pass is still
just one short connection per friend, so the cost is small at typical
friend-list sizes. The manual Rescan button covers anything faster.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:58:33 -04:00
molluskandClaude Opus 4.8 c1de7efbc5 fix(friends): self-heal presence + add manual Rescan button
The friends list only ever updated a friend's status on a *successful*
presence probe, so it could ratchet a status up (offline -> online -> in a
room) but never down. A friend who dropped, left a room, or went invisible
kept showing a stale "online"/"in a room" status until PeerSpeak was
relaunched (which cleared the in-memory presence map back to offline).

The 60s auto-refresh scheduler already existed; the bug was that
`probe_friends_once` emitted nothing on a failed probe. Now every pass
reports a *definitive* status for every friend: a failed probe (or a
friend with no known address) is mapped to a new `FriendPresence::Offline`
via the pure, tested `presence::presence_from_probe`, so the list
self-heals each cycle.

Also adds a manual "⟳ Rescan" button to the Friends panel (new
`CoreCommand::RefreshFriends` -> immediate probe pass) for instant
feedback instead of waiting up to 60s.

469 lib tests pass, clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:55:29 -04:00
5 changed files with 241 additions and 20 deletions
+6
View File
@@ -4,6 +4,12 @@ All notable changes to PeerSpeak are documented here.
## [Unreleased]
### Fixed
- **Friends list now reflects status changes without a restart.** A presence probe that fails now actively marks the friend **offline**, so a friend who goes offline, leaves a room, or turns invisible no longer lingers showing a stale "online" / "in a room" status until PeerSpeak is relaunched. Previously only successful probes updated the list, so it could ratchet a friend's status up but never down. The auto-refresh interval was also shortened from 60s to **15s** so the list tracks changes more closely.
### Added
- **Manual "⟳ Rescan" button** on the Friends panel that refreshes everyone's presence immediately, instead of waiting for the next auto-refresh.
### Licensing
- **PeerSpeak is now released under the MIT License** (previously an unlicensed private build). Added a `LICENSE` file and a `THIRD_PARTY_LICENSES` file enumerating the full dependency manifest plus the canonical text of every referenced license, with notices for the statically bundled Opus codec and the embedded fonts (Iced-Icons, Cantarell/OFL-1.1). Both files ship in the Arch and Debian packages.
+131 -4
View File
@@ -366,6 +366,9 @@ pub enum AppMessage {
AddFriendFromRoom(EndpointId),
RemoveFriend(EndpointId),
RenameFriend(EndpointId, String),
/// Manually rescan all friends' presence now (the ⟳ button), instead of waiting
/// for the 60s scheduler tick. Statuses update as the probe replies arrive.
RefreshFriends,
/// Join the gathering a friend is in (W7 B2), via the member ticket their
/// presence reply carried. Mirrors a manual ticket join.
JoinFriendRoom(String),
@@ -474,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).
@@ -604,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,
@@ -932,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,
@@ -1147,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> {
@@ -1697,6 +1716,16 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
UiEvent::FriendPresence { id, presence } => {
state.friend_presence.insert(id, presence);
}
UiEvent::FriendsRescanned => {
// 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();
}
}
UiEvent::PresenceModeReverted { mode } => {
// Core corrected the committed presence mode. Mirror + persist so
// the picker reflects the discovery state the endpoint actually has.
@@ -1894,6 +1923,19 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
let _ = state.controller.send(CoreCommand::RenameFriend(id, new_name));
}
}
AppMessage::RefreshFriends => {
// Kick an immediate presence pass; the list updates as replies land and
// the banner clears on the FriendsRescanned completion event. While
// Invisible we probe no one, so say why instead of a banner that resolves
// with nothing changed.
if state.config.presence_mode == PresenceMode::Invisible {
state.status_message =
"You're invisible — turn on presence to scan friends.".to_string();
} else {
let _ = state.controller.send(CoreCommand::RefreshFriends);
state.status_message = "Rescanning friends…".to_string();
}
}
AppMessage::JoinFriendRoom(ticket) => {
// Join via the friend's member ticket (same path as a manual join).
let input_device = state.selected_input.as_ref().map(|d| d.name.clone());
@@ -2066,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();
@@ -2854,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 {
@@ -3684,7 +3761,11 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
Some(crate::presence::FriendPresence::Online) => {
text("● online").size(11).color(color_green).into()
}
None => text("○ offline").size(11).color(color_subtext).into(),
// An explicit Offline (probe failed / no address) and a missing
// entry (not yet probed) both render as offline.
Some(crate::presence::FriendPresence::Offline) | None => {
text("○ offline").size(11).color(color_subtext).into()
}
};
friend_rows = friend_rows.push(
row![
@@ -3768,10 +3849,37 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
} else {
column![].into()
};
// 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 {
text("FRIENDS").size(14).color(color_text).into()
};
container(
column![
text("FRIENDS").size(if has_friends { 18 } else { 14 }).color(color_text),
title_row,
intro,
vertical_space(if has_friends { 10.0 } else { 4.0 }),
readonly_warning,
@@ -7545,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,
@@ -8095,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;
+10
View File
@@ -103,6 +103,10 @@ pub enum CoreCommand {
RemoveFriend(EndpointId),
/// Locally rename a friend (W7).
RenameFriend(EndpointId, String),
/// Run an immediate presence-refresh pass over all friends (the manual
/// "Rescan" button). Same work the 60s scheduler does on each tick, on demand —
/// no waiting for the next interval. A no-op while Invisible.
RefreshFriends,
/// Set our presence posture (W7). Gates the idle listener (answer friends-only /
/// invisible) and the outbound ping scheduler (invisible = fully dark). Sent at
/// startup from config and whenever the user changes it.
@@ -203,6 +207,7 @@ pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
}
| CoreCommand::RemoveFriend(_)
| CoreCommand::RenameFriend(_, _)
| CoreCommand::RefreshFriends
| CoreCommand::SetPresenceMode(_)
| CoreCommand::SetGamePresenceEnabled(_)
| CoreCommand::SetGameOverride(_)
@@ -291,6 +296,11 @@ pub enum UiEvent {
/// joinable gathering (with a one-click ticket). Emitted by the outbound ping
/// scheduler; absence of a recent event = treat as offline.
FriendPresence { id: EndpointId, presence: FriendPresence },
/// A manual "Rescan" pass finished (every friend has been probed and its
/// per-friend `FriendPresence` already emitted). Lets the GUI clear the
/// transient "Rescanning…" status. Sent only for the on-demand button, not the
/// periodic auto-refresh, so the status bar isn't churned every interval.
FriendsRescanned,
/// Core corrected the committed presence posture. Usually the Discoverable
/// time-box elapsed and the core auto-reverted to `Normal`; on discovery apply
/// failure, this carries the previous truthful mode. The GUI must mirror +
+46 -15
View File
@@ -966,19 +966,24 @@ async fn persist_and_emit_friends(
.await;
}
/// How often the outbound presence scheduler refreshes friends' status. Slow on
/// purpose — presence is best-effort, not real-time, and each pass opens a short
/// connection per friend.
const PING_INTERVAL: Duration = Duration::from_secs(60);
/// How often the outbound presence scheduler refreshes friends' status. Each pass
/// opens one short connection per friend with a saved address, so the cost scales
/// with friend-count, not a fixed per-tick cost. 15s keeps the list feeling live
/// without aggressively probing peers for a best-effort signal; the manual Rescan
/// button covers the "update now" case below this interval.
const PING_INTERVAL: Duration = Duration::from_secs(15);
/// Delay before the FIRST presence pass, so the endpoint's background `online()`
/// has a moment to finish (otherwise the first probes fail and friends flash offline).
const PING_STARTUP_DELAY: Duration = Duration::from_secs(3);
/// One outbound presence-refresh pass (W7 B2): probe every friend that has a saved
/// address and emit their interpreted status. Friends with no saved address are
/// skipped (a bare id can't resolve without discovery) and stay offline in the UI
/// until first contact populates their address via `note_seen`. Probes run in
/// parallel (friend counts are small); an unreachable friend just yields nothing.
/// One outbound presence-refresh pass (W7 B2): probe every friend and emit a
/// *definitive* status for each, so the UI self-heals every pass instead of only
/// ratcheting a friend upward. A friend with a saved address is probed and mapped
/// via [`crate::presence::presence_from_probe`] (a failed probe -> `Offline`); a
/// friend with no saved address (a bare add-by-id we've never met in a room) is
/// reported `Offline` directly, since a bare id can't resolve without discovery.
/// Probes run in parallel (friend counts are small). This is the fix for stale
/// "online"/"in a room" statuses lingering after a friend drops or leaves a room.
async fn probe_friends_once(
endpoint: Endpoint,
friends: crate::friends::FriendStore,
@@ -986,18 +991,25 @@ async fn probe_friends_once(
) {
let mut set = tokio::task::JoinSet::new();
for f in friends.list() {
let Some(addr) = f.last_addr.clone() else { continue };
let id = f.id;
let Some(addr) = f.last_addr.clone() else {
// Nothing to dial yet — report Offline so a prior status can't stick.
let _ = ui_tx
.send(UiEvent::FriendPresence { id, presence: crate::presence::FriendPresence::Offline })
.await;
continue;
};
let ep = endpoint.clone();
set.spawn(async move {
match crate::presence_net::probe(&ep, addr).await {
Ok((from, reply)) => crate::presence::interpret_pong(&reply, from).map(|p| (id, p)),
Err(_) => None,
}
let presence = match crate::presence_net::probe(&ep, addr).await {
Ok((from, reply)) => crate::presence::presence_from_probe(Some((&reply, from))),
Err(_) => crate::presence::presence_from_probe(None),
};
(id, presence)
});
}
while let Some(res) = set.join_next().await {
if let Ok(Some((id, presence))) = res {
if let Ok((id, presence)) = res {
let _ = ui_tx.send(UiEvent::FriendPresence { id, presence }).await;
}
}
@@ -2494,6 +2506,25 @@ async fn run_core_loop(
}
}
CoreCommand::RefreshFriends => {
// Manual "Rescan": run an immediate probe pass (same as a scheduler
// tick), detached so it can't block command handling. Honour
// Invisible — stay fully dark and touch no friend's machine. A
// `FriendsRescanned` event always follows so the UI's transient
// "Rescanning…" status clears even when probing was skipped.
let visible =
*presence_mode.lock().unwrap() != crate::presence::PresenceMode::Invisible;
let endpoint = net.endpoint.clone();
let snapshot = friends.lock().unwrap().clone();
let tx = ui_tx.clone();
tokio::spawn(async move {
if visible {
probe_friends_once(endpoint, snapshot, tx.clone()).await;
}
let _ = tx.send(UiEvent::FriendsRescanned).await;
});
}
CoreCommand::SetPresenceMode(mode) => {
let previous_mode = *presence_mode.lock().unwrap();
let now = tokio::time::Instant::now();
+48 -1
View File
@@ -100,7 +100,11 @@ pub fn should_answer(from: &EndpointId, friends: &FriendStore, mode: PresenceMod
mode.answers_pings() && friends.contains(from)
}
/// What we learned about a friend from a successful ping reply.
/// What we learned about a friend's reachability. `Online`/`InRoom` come from a
/// successful ping reply (see [`interpret_pong`]); `Offline` is produced by the
/// presence scheduler when a probe fails or the friend has no known address, so a
/// friend who drops or leaves is *actively* downgraded rather than left showing a
/// stale status. The UI also treats a missing entry as offline.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FriendPresence {
/// Online, but not in a gathering we can join.
@@ -108,6 +112,8 @@ pub enum FriendPresence {
/// Online and in a joinable gathering (name already sanitized, ticket already
/// validated as parseable).
InRoom { name: String, ticket: String },
/// Unreachable: the probe failed, or we have no address to probe yet.
Offline,
}
/// Interpret a peer's reply defensively. `from` must be the connection's
@@ -140,6 +146,20 @@ pub fn interpret_pong(msg: &ControlMsg, from: EndpointId) -> Option<FriendPresen
}
}
/// Map a single probe outcome to a definitive [`FriendPresence`], used by the
/// presence scheduler. `Some((reply, from))` is a received message from the
/// authenticated remote `from`; `None` means the probe failed (offline /
/// unreachable / refused). Anything that doesn't interpret as a real presence —
/// a probe error, or a non-`Pong` reply — becomes [`FriendPresence::Offline`], so
/// a friend who drops is actively downgraded instead of keeping a stale status.
/// Pure so the scheduler's downgrade behaviour is unit-testable without a network.
pub fn presence_from_probe(reply: Option<(&ControlMsg, EndpointId)>) -> FriendPresence {
match reply {
Some((msg, from)) => interpret_pong(msg, from).unwrap_or(FriendPresence::Offline),
None => FriendPresence::Offline,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -252,6 +272,33 @@ mod tests {
assert_eq!(got, Some(FriendPresence::Online));
}
#[test]
fn presence_from_probe_maps_outcomes_to_definitive_status() {
let friend = id();
// A failed probe (no reply) is an explicit downgrade to Offline, so the UI
// clears a friend who has dropped instead of keeping a stale status.
assert_eq!(presence_from_probe(None), FriendPresence::Offline);
// A successful Pong with no room is Online.
assert_eq!(
presence_from_probe(Some((&ControlMsg::Pong { room: None }, friend))),
FriendPresence::Online
);
// A successful Pong advertising the friend's own room is InRoom.
let t = valid_ticket(friend);
assert_eq!(
presence_from_probe(Some((
&ControlMsg::Pong { room: Some(RoomPresence { name: "Den".into(), ticket: t.clone() }) },
friend,
))),
FriendPresence::InRoom { name: "Den".into(), ticket: t }
);
// A non-reply (a stray Ping) is not a presence -> Offline, never a false Online.
assert_eq!(
presence_from_probe(Some((&ControlMsg::Ping, friend))),
FriendPresence::Offline
);
}
#[test]
fn interpret_pong_sanitizes_a_hostile_room_name() {
// Control/bidi characters in a peer-supplied name are stripped.