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
+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.