diff --git a/CHANGELOG.md b/CHANGELOG.md index 95d1c5e..7f05c16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/app/mod.rs b/src/app/mod.rs index fb41d2f..a102fcf 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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 { 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, diff --git a/src/core/messages.rs b/src/core/messages.rs index 11bafd0..da8df7b 100644 --- a/src/core/messages.rs +++ b/src/core/messages.rs @@ -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(_) diff --git a/src/core/mod.rs b/src/core/mod.rs index 11e28df..b5fe8e4 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -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(); diff --git a/src/presence.rs b/src/presence.rs index 69ec24c..9192242 100644 --- a/src/presence.rs +++ b/src/presence.rs @@ -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) -> 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.