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
+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 list continues to auto-refresh every 60 seconds.
### Added
- **Manual "⟳ Rescan" button** on the Friends panel that refreshes everyone's presence immediately, instead of waiting for the next 60-second 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.
+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,
+5
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(_)
+34 -11
View File
@@ -974,11 +974,14 @@ const PING_INTERVAL: Duration = Duration::from_secs(60);
/// 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 +989,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 +2504,19 @@ 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.
if *presence_mode.lock().unwrap() != crate::presence::PresenceMode::Invisible {
tokio::spawn(probe_friends_once(
net.endpoint.clone(),
friends.lock().unwrap().clone(),
ui_tx.clone(),
));
}
}
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.