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>
This commit is contained in:
2026-06-28 15:55:29 -04:00
co-authored by Claude Opus 4.8
parent 96e3e0ba10
commit c1de7efbc5
5 changed files with 124 additions and 14 deletions
+31 -2
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),
@@ -1894,6 +1897,11 @@ 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.
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());
@@ -3684,7 +3692,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 +3780,27 @@ 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.
let title_row: Element<'_, AppMessage> = if has_friends {
row![
text("FRIENDS").size(18).color(color_text),
horizontal_space(),
button(text("⟳ Rescan").size(12))
.on_press(AppMessage::RefreshFriends)
.style(b_style(color_surface, color_blue, color_text, 6.0))
.padding(6),
]
.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,