Compare commits

...
Author SHA1 Message Date
b553a94875 feat(music): relocate playlist into a now-playing bar + slide-out drawer
The full music panel was rendered inline at all times (its own card in
the 3-column layout, stuffed into the Controls panel otherwise), which
crowded every layout. Move it behind two toggles:

- A room-only ♪ button in the top bar shows/hides a slim 56px
  now-playing player bar (track + ⏮ ⏸/▶ ⏭ + position + expand). The
  preference persists (AppConfig.show_player_bar).
- The bar's ⤢ button opens the full panel in a resizable right-edge
  drawer (DividerKind::PlaylistDrawer, mirrors the Chat drawer). When
  open, body_w shrinks so the layouts' fixed panels don't overflow.

Removes all inline playlist placement (3-col card + ThreeColPlaylist
divider, ctrl_music block) and the now-dead clamp/consts. Pure
now_playing_label seam + drawer-width clamp test. 474 lib tests, clippy
-D warnings clean, release build green.

Implemented by Codex (gpt-5.5), reviewed + gates re-run by Claude.

Co-Authored-By: Codex <codex@openai.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 16:59:49 -04:00
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
6 changed files with 474 additions and 97 deletions
+6
View File
@@ -4,6 +4,12 @@ All notable changes to PeerSpeak are documented here.
## [Unreleased] ## [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 ### 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. - **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.
+346 -81
View File
@@ -237,15 +237,15 @@ pub enum DividerKind {
/// Horizontal divider between the main row and the Chat dock (resizes the /// Horizontal divider between the main row and the Chat dock (resizes the
/// Chat dock height). /// Chat dock height).
Chat, Chat,
/// Horizontal divider between Chat and the standalone Playlist card in the
/// 3-column layout (resizes the Playlist card height).
ThreeColPlaylist,
/// Vertical divider between Chat and Controls in the 3-column layout (resizes /// Vertical divider between Chat and Controls in the 3-column layout (resizes
/// the Controls panel width). /// the Controls panel width).
Controls, Controls,
/// Vertical divider on the left edge of the Chat drawer (resizes the drawer /// Vertical divider on the left edge of the Chat drawer (resizes the drawer
/// width) in the drawer layout. /// width) in the drawer layout.
ChatDrawer, ChatDrawer,
/// Vertical divider on the left edge of the Playlist drawer (resizes the
/// drawer width).
PlaylistDrawer,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -264,11 +264,6 @@ const CHAT_MIN_H: f32 = 110.0;
/// Minimum height reserved above the Chat dock (header + main row) when resizing /// Minimum height reserved above the Chat dock (header + main row) when resizing
/// the dock (px). /// the dock (px).
const ABOVE_CHAT_MIN_H: f32 = 300.0; const ABOVE_CHAT_MIN_H: f32 = 300.0;
/// Minimum height of the standalone Playlist card in the 3-column layout (px).
const THREECOL_PLAYLIST_MIN_H: f32 = 150.0;
/// Minimum height reserved for the Chat above the Playlist card in the 3-column
/// layout when resizing the card (px).
const THREECOL_CHAT_MIN_H: f32 = 160.0;
/// Thickness of a draggable divider (px). /// Thickness of a draggable divider (px).
const DIVIDER_THICKNESS: f32 = 8.0; const DIVIDER_THICKNESS: f32 = 8.0;
/// Upper bound for waiting on orderly core shutdown before letting the window exit. /// Upper bound for waiting on orderly core shutdown before letting the window exit.
@@ -290,13 +285,6 @@ fn clamp_chat_height(height: f32, window_h: f32) -> f32 {
height.clamp(CHAT_MIN_H, max) height.clamp(CHAT_MIN_H, max)
} }
/// Clamp the 3-column Playlist card height so neither it nor the Chat above it
/// drops below its minimum, given the current window height.
fn clamp_threecol_playlist_height(height: f32, window_h: f32) -> f32 {
let max = (window_h - THREECOL_CHAT_MIN_H).max(THREECOL_PLAYLIST_MIN_H);
height.clamp(THREECOL_PLAYLIST_MIN_H, max)
}
/// Minimum width of the Chat column / drawer (px). /// Minimum width of the Chat column / drawer (px).
const CHAT_MIN_W: f32 = 200.0; const CHAT_MIN_W: f32 = 200.0;
@@ -315,6 +303,13 @@ fn clamp_chat_drawer_width(width: f32, window_w: f32) -> f32 {
width.clamp(CHAT_MIN_W, max) width.clamp(CHAT_MIN_W, max)
} }
/// Clamp the Playlist drawer width so neither it nor the room body drops below
/// its minimum, given the current window width.
fn clamp_playlist_drawer_width(width: f32, window_w: f32) -> f32 {
let max = (window_w - PARTICIPANTS_MIN_W - CONTROLS_MIN_W).max(CHAT_MIN_W);
width.clamp(CHAT_MIN_W, max)
}
#[allow(clippy::large_enum_variant)] #[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum AppMessage { pub enum AppMessage {
@@ -366,6 +361,9 @@ pub enum AppMessage {
AddFriendFromRoom(EndpointId), AddFriendFromRoom(EndpointId),
RemoveFriend(EndpointId), RemoveFriend(EndpointId),
RenameFriend(EndpointId, String), 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 /// Join the gathering a friend is in (W7 B2), via the member ticket their
/// presence reply carried. Mirrors a manual ticket join. /// presence reply carried. Mirrors a manual ticket join.
JoinFriendRoom(String), JoinFriendRoom(String),
@@ -441,6 +439,10 @@ pub enum AppMessage {
MusicSetVolume(f32), MusicSetVolume(f32),
/// Set the tuned-in source's music playback volume (and persist it). /// Set the tuned-in source's music playback volume (and persist it).
MusicSetSourceVolume(f32), MusicSetSourceVolume(f32),
/// Show / hide the room now-playing player bar (persisted).
TogglePlayerBar,
/// Open / close the full playlist drawer.
TogglePlaylistDrawer,
/// Remove the playlist track at this index. /// Remove the playlist track at this index.
MusicRemove(usize), MusicRemove(usize),
/// Move a personal playlist track one slot up/down. /// Move a personal playlist track one slot up/down.
@@ -474,6 +476,8 @@ pub enum AppMessage {
DismissClockSkewWarning, DismissClockSkewWarning,
/// Auto-clear cadence while the clock-skew warning banner is visible. /// Auto-clear cadence while the clock-skew warning banner is visible.
ClockSkewWarningTick, 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). /// Choose a room layout (applied live + persisted, closes the popup).
SelectRoomLayout(RoomLayout), SelectRoomLayout(RoomLayout),
/// Choose a UI theme (applied live + persisted). /// Choose a UI theme (applied live + persisted).
@@ -604,6 +608,11 @@ pub struct AppState {
/// Carried in the minted ticket so joiners inherit it; empty = unnamed room. /// Carried in the minted ticket so joiners inherit it; empty = unnamed room.
room_name_input: String, room_name_input: String,
status_message: 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, self_id: String,
ticket: String, ticket: String,
is_muted: bool, is_muted: bool,
@@ -744,6 +753,8 @@ pub struct AppState {
clock_skew_warning: Option<ClockSkewBanner>, clock_skew_warning: Option<ClockSkewBanner>,
/// Whether the Chat drawer is open (drawer layout only). /// Whether the Chat drawer is open (drawer layout only).
drawer_chat_open: bool, drawer_chat_open: bool,
/// Whether the Playlist drawer is open beside the room body.
playlist_drawer_open: bool,
/// Raw mic level (normalized RMS, `0.0..=1.0`) for the settings meter. /// Raw mic level (normalized RMS, `0.0..=1.0`) for the settings meter.
mic_level: f32, mic_level: f32,
/// Whether the standalone (off-call) mic test stream is running. /// Whether the standalone (off-call) mic test stream is running.
@@ -861,10 +872,10 @@ impl Default for AppState {
config.participants_width = config.participants_width =
clamp_participants_width(config.participants_width, ww); clamp_participants_width(config.participants_width, ww);
config.chat_height = clamp_chat_height(config.chat_height, wh); config.chat_height = clamp_chat_height(config.chat_height, wh);
config.threecol_playlist_height =
clamp_threecol_playlist_height(config.threecol_playlist_height, wh);
config.controls_width = clamp_controls_width(config.controls_width, ww); config.controls_width = clamp_controls_width(config.controls_width, ww);
config.chat_drawer_width = clamp_chat_drawer_width(config.chat_drawer_width, ww); config.chat_drawer_width = clamp_chat_drawer_width(config.chat_drawer_width, ww);
config.playlist_drawer_width =
clamp_playlist_drawer_width(config.playlist_drawer_width, ww);
notify::set_enabled(config.notifications_enabled); notify::set_enabled(config.notifications_enabled);
for sound in Sound::ALL { for sound in Sound::ALL {
notify::set_sound_enabled(sound, config.sound_enabled(sound)); notify::set_sound_enabled(sound, config.sound_enabled(sound));
@@ -932,6 +943,7 @@ impl Default for AppState {
ticket_input: "".to_string(), ticket_input: "".to_string(),
room_name_input: "".to_string(), room_name_input: "".to_string(),
status_message: "Ready to connect".to_string(), status_message: "Ready to connect".to_string(),
last_rescan: None,
self_id: "".to_string(), self_id: "".to_string(),
ticket: "".to_string(), ticket: "".to_string(),
is_muted: false, is_muted: false,
@@ -992,6 +1004,7 @@ impl Default for AppState {
share_app_audio_supported: true, share_app_audio_supported: true,
clock_skew_warning: None, clock_skew_warning: None,
drawer_chat_open: false, drawer_chat_open: false,
playlist_drawer_open: false,
mic_level: 0.0, mic_level: 0.0,
mic_test_active: false, mic_test_active: false,
connecting: HashSet::new(), connecting: HashSet::new(),
@@ -1147,7 +1160,15 @@ fn subscription(state: &AppState) -> Subscription<AppMessage> {
} else { } else {
Subscription::none() 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> { fn shutdown_timeout_task() -> Task<AppMessage> {
@@ -1697,6 +1718,16 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
UiEvent::FriendPresence { id, presence } => { UiEvent::FriendPresence { id, presence } => {
state.friend_presence.insert(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 } => { UiEvent::PresenceModeReverted { mode } => {
// Core corrected the committed presence mode. Mirror + persist so // Core corrected the committed presence mode. Mirror + persist so
// the picker reflects the discovery state the endpoint actually has. // the picker reflects the discovery state the endpoint actually has.
@@ -1894,6 +1925,19 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
let _ = state.controller.send(CoreCommand::RenameFriend(id, new_name)); 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) => { AppMessage::JoinFriendRoom(ticket) => {
// Join via the friend's member ticket (same path as a manual join). // 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()); let input_device = state.selected_input.as_ref().map(|d| d.name.clone());
@@ -2014,14 +2058,6 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.window_size.height, state.window_size.height,
); );
} }
DividerKind::ThreeColPlaylist => {
// The Playlist card sits at the bottom of the middle column; dragging the
// divider down (positive delta) gives Chat more room and shrinks the card.
state.config.threecol_playlist_height = clamp_threecol_playlist_height(
state.config.threecol_playlist_height - delta,
state.window_size.height,
);
}
DividerKind::Controls => { DividerKind::Controls => {
// Controls sits on the right; dragging the divider right (positive // Controls sits on the right; dragging the divider right (positive
// delta) gives Chat more room and shrinks Controls. // delta) gives Chat more room and shrinks Controls.
@@ -2038,6 +2074,14 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.window_size.width, state.window_size.width,
); );
} }
DividerKind::PlaylistDrawer => {
// The drawer sits on the right; dragging its left-edge divider
// left (negative delta) widens the drawer.
state.config.playlist_drawer_width = clamp_playlist_drawer_width(
state.config.playlist_drawer_width - delta,
state.window_size.width,
);
}
} }
} }
AppMessage::OpenLayoutPicker => { AppMessage::OpenLayoutPicker => {
@@ -2066,6 +2110,10 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
AppMessage::ClockSkewWarningTick => { AppMessage::ClockSkewWarningTick => {
clear_expired_clock_skew_warning(state, std::time::Instant::now()); 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) => { AppMessage::SelectRoomLayout(layout) => {
state.config.room_layout = layout; state.config.room_layout = layout;
state.config.save(); state.config.save();
@@ -2304,6 +2352,16 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
AppMessage::ToggleDrawerChat => { AppMessage::ToggleDrawerChat => {
state.drawer_chat_open = !state.drawer_chat_open; state.drawer_chat_open = !state.drawer_chat_open;
} }
AppMessage::TogglePlayerBar => {
state.config.show_player_bar = !state.config.show_player_bar;
if !state.config.show_player_bar {
state.playlist_drawer_open = false;
}
state.config.save();
}
AppMessage::TogglePlaylistDrawer => {
state.playlist_drawer_open = !state.playlist_drawer_open;
}
AppMessage::ChatSubmit => { AppMessage::ChatSubmit => {
let text = sanitize_chat(&state.chat_input); let text = sanitize_chat(&state.chat_input);
if !text.is_empty() { if !text.is_empty() {
@@ -2737,12 +2795,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
clamp_participants_width(state.config.participants_width, size.width); clamp_participants_width(state.config.participants_width, size.width);
state.config.chat_height = state.config.chat_height =
clamp_chat_height(state.config.chat_height, size.height); clamp_chat_height(state.config.chat_height, size.height);
state.config.threecol_playlist_height =
clamp_threecol_playlist_height(state.config.threecol_playlist_height, size.height);
state.config.controls_width = state.config.controls_width =
clamp_controls_width(state.config.controls_width, size.width); clamp_controls_width(state.config.controls_width, size.width);
state.config.chat_drawer_width = state.config.chat_drawer_width =
clamp_chat_drawer_width(state.config.chat_drawer_width, size.width); clamp_chat_drawer_width(state.config.chat_drawer_width, size.width);
state.config.playlist_drawer_width =
clamp_playlist_drawer_width(state.config.playlist_drawer_width, size.width);
} }
AppMessage::EventOccurred(Event::Window(iced::window::Event::Moved(position))) => { AppMessage::EventOccurred(Event::Window(iced::window::Event::Moved(position))) => {
// Remember the position in-memory; written to disk once on close. // Remember the position in-memory; written to disk once on close.
@@ -2854,6 +2912,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 { fn format_clock_skew_duration(skew_secs: u64) -> String {
let minutes = skew_secs.max(1).saturating_add(59) / 60; let minutes = skew_secs.max(1).saturating_add(59) / 60;
if minutes == 1 { if minutes == 1 {
@@ -3044,6 +3133,15 @@ fn can_broadcast_music(state: &AppState) -> bool {
state.music_broadcasting && state.music_listening_to.is_none() state.music_broadcasting && state.music_listening_to.is_none()
} }
/// Text shown on the now-playing player bar. Pure for testing.
fn now_playing_label(listening_to: Option<&str>, current_track: Option<&str>) -> String {
match (listening_to, current_track) {
(Some(peer), _) => format!("Tuned in to {peer}"),
(None, Some(track)) => track.to_string(),
(None, None) => "Nothing playing".to_string(),
}
}
fn stop_music_broadcast(state: &mut AppState) { fn stop_music_broadcast(state: &mut AppState) {
let old_current = state.music_broadcast_id.take(); let old_current = state.music_broadcast_id.take();
let old_next = state.music_broadcast_next.take().map(|(_, id, _)| id); let old_next = state.music_broadcast_next.take().map(|(_, id, _)| id);
@@ -3684,7 +3782,11 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
Some(crate::presence::FriendPresence::Online) => { Some(crate::presence::FriendPresence::Online) => {
text("● online").size(11).color(color_green).into() 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( friend_rows = friend_rows.push(
row![ row![
@@ -3768,10 +3870,37 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
} else { } else {
column![].into() 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( container(
column![ column![
text("FRIENDS").size(if has_friends { 18 } else { 14 }).color(color_text), title_row,
intro, intro,
vertical_space(if has_friends { 10.0 } else { 4.0 }), vertical_space(if has_friends { 10.0 } else { 4.0 }),
readonly_warning, readonly_warning,
@@ -3907,7 +4036,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
}; };
// The Hotkeys info button is always available (hotkeys are app-wide). The // The Hotkeys info button is always available (hotkeys are app-wide). The
// room-layout button is hidden on the Home screen, leaving only it + Settings. // room-only player button is hidden off-room; the layout button is hidden on Home.
let info_button = tooltip( let info_button = tooltip(
button(icon(IconKind::Info, 18.0, color_text)) button(icon(IconKind::Info, 18.0, color_text))
.on_press(AppMessage::OpenHotkeyInfo) .on_press(AppMessage::OpenHotkeyInfo)
@@ -3920,6 +4049,27 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
) )
.gap(8); .gap(8);
let player_bar_button: Element<'_, AppMessage> =
if state.current_screen == Screen::Room {
let active = state.config.show_player_bar;
let bg = if active { color_blue } else { color_surface };
let fg = if active { color_crust } else { color_text };
tooltip(
button(text("").size(15))
.on_press(AppMessage::TogglePlayerBar)
.style(b_style(bg, color_blue, fg, 6.0))
.padding(8),
container(text("Player bar").size(11).color(color_text))
.padding(8)
.style(c_style(color_crust, color_surface, 6.0)),
iced::widget::tooltip::Position::Bottom,
)
.gap(8)
.into()
} else {
iced::widget::Space::new().width(0.0).height(0.0).into()
};
let layout_button: Element<'_, AppMessage> = if state.current_screen == Screen::Home { let layout_button: Element<'_, AppMessage> = if state.current_screen == Screen::Home {
iced::widget::Space::new().width(0.0).height(0.0).into() iced::widget::Space::new().width(0.0).height(0.0).into()
} else { } else {
@@ -3943,6 +4093,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
let top_bar = row![ let top_bar = row![
horizontal_space(), horizontal_space(),
player_bar_button,
info_button, info_button,
layout_button, layout_button,
button( button(
@@ -5556,37 +5707,65 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.width(iced::Length::Fill) .width(iced::Length::Fill)
.into() .into()
}; };
// W22: in the 3-column layout the Playlist gets its own card stacked under Chat let music_bar_status = status_snapshot(&state.music_status);
// (built below in the ThreeColumn body arm); in every other layout it stays in let music_bar_playing = music_bar_status.playing_id.is_some();
// the Controls panel. `music_panel` is consumed by exactly one of these. let music_bar_play_label = if music_bar_playing && music_bar_status.paused {
let three_col = matches!(state.config.room_layout, RoomLayout::ThreeColumn); ""
let (ctrl_music, playlist_card): (Element<'_, AppMessage>, Option<Element<'_, AppMessage>>) = } else if music_bar_playing {
if three_col { ""
let card = container( } else {
column![ ""
text("Playlist").size(18).color(color_blue), };
vertical_space(8.0), let listening_peer_name = state
scrollable(music_panel) .music_listening_to
.width(iced::Length::Fill) .and_then(|peer| state.peers.get(&peer).map(|p| p.name.as_str()));
.height(iced::Length::Fill), let current_track_name = state
] .music_current
) .and_then(|i| state.music_playlist.get(i).map(|track| track.name.as_str()));
.style(c_style(color_mantle, color_surface, 8.0)) let elapsed = format_clip_time(music_bar_status.position);
.padding(12) let duration = music_bar_status
.width(iced::Length::Fill) .total
.height(iced::Length::Fill); .map(format_clip_time)
(column![].into(), Some(card.into())) .unwrap_or_else(|| "--:--".to_string());
} else { let player_bar = container(
( row![
column![ text("").size(18).color(color_blue),
vertical_space(20.0), text(now_playing_label(listening_peer_name, current_track_name))
text("Playlist").size(18).color(color_blue), .size(13)
music_panel, .color(color_text)
] .width(iced::Length::Fill),
.into(), button(text("").size(13))
None, .on_press(AppMessage::MusicPrev)
) .style(b_style(color_surface, color_blue, color_text, 6.0))
}; .padding(7),
button(text(music_bar_play_label).size(13))
.on_press(AppMessage::MusicPlayPause)
.style(b_style(color_blue, color_lavender, color_crust, 6.0))
.padding(7),
button(text("").size(13))
.on_press(AppMessage::MusicNext)
.style(b_style(color_surface, color_blue, color_text, 6.0))
.padding(7),
text(format!("{elapsed} / {duration}"))
.size(11)
.color(color_subtext),
button(text(if state.playlist_drawer_open { "" } else { "" }).size(13))
.on_press(AppMessage::TogglePlaylistDrawer)
.style(b_style(
if state.playlist_drawer_open { color_blue } else { color_surface },
color_blue,
if state.playlist_drawer_open { color_crust } else { color_text },
6.0,
))
.padding(7),
]
.spacing(8)
.align_y(iced::alignment::Vertical::Center)
)
.style(c_style(color_mantle, color_surface, 8.0))
.padding(10)
.width(iced::Length::Fill)
.height(iced::Length::Fixed(56.0));
let ctrl_buttons = column![ let ctrl_buttons = column![
button(btn_content(mute_kind, mute_text, mute_fg)) button(btn_content(mute_kind, mute_text, mute_fg))
.on_press(AppMessage::ToggleMutePressed) .on_press(AppMessage::ToggleMutePressed)
@@ -5690,7 +5869,6 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.padding(14) .padding(14)
.width(iced::Length::Fill) .width(iced::Length::Fill)
}, },
ctrl_music,
]; ];
// Leave is the exit control, so it's pinned below the scrolling controls // Leave is the exit control, so it's pinned below the scrolling controls
@@ -6004,13 +6182,35 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.height(iced::Length::Fixed(DIVIDER_THICKNESS)) .height(iced::Length::Fixed(DIVIDER_THICKNESS))
}; };
let playlist_drawer = container(
column![
text("Playlist").size(18).color(color_blue),
vertical_space(8.0),
scrollable(music_panel)
.width(iced::Length::Fill)
.height(iced::Length::Fill),
]
.spacing(0)
)
.style(c_style(color_mantle, color_surface, 8.0))
.padding(12)
.width(iced::Length::Fixed(state.config.playlist_drawer_width))
.height(iced::Length::Fill);
let drawer_open = state.playlist_drawer_open && state.config.show_player_bar;
let body_w = if drawer_open {
state.window_size.width - state.config.playlist_drawer_width - DIVIDER_THICKNESS
} else {
state.window_size.width
};
// Assemble the body per the chosen room layout. `chat_inner` is moved into // Assemble the body per the chosen room layout. `chat_inner` is moved into
// exactly one arm (allowed across mutually-exclusive match arms). // exactly one arm (allowed across mutually-exclusive match arms).
let pw = state.config.participants_width; let pw = state.config.participants_width;
let body: Element<'_, AppMessage> = match state.config.room_layout { let body: Element<'_, AppMessage> = match state.config.room_layout {
RoomLayout::BottomDock => { RoomLayout::BottomDock => {
// Cap Participants so the Fill Controls panel keeps its minimum. // Cap Participants so the Fill Controls panel keeps its minimum.
let avail = state.window_size.width - 30.0; let avail = body_w - 30.0;
let pwb = pw.min((avail - CONTROLS_MIN_W - DIVIDER_THICKNESS).max(PARTICIPANTS_MIN_W)); let pwb = pw.min((avail - CONTROLS_MIN_W - DIVIDER_THICKNESS).max(PARTICIPANTS_MIN_W));
let main = row![ let main = row![
peers_panel.width(iced::Length::Fixed(pwb)), peers_panel.width(iced::Length::Fixed(pwb)),
@@ -6027,7 +6227,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
column![main, hdiv(DividerKind::Chat), chat].into() column![main, hdiv(DividerKind::Chat), chat].into()
} }
RoomLayout::ThreeColumn => { RoomLayout::ThreeColumn => {
let avail = state.window_size.width - 30.0; // outer padding let avail = body_w - 30.0; // outer padding
let pw3 = pw.min( let pw3 = pw.min(
(avail - state.config.controls_width - CHAT_MIN_W - 2.0 * DIVIDER_THICKNESS) (avail - state.config.controls_width - CHAT_MIN_W - 2.0 * DIVIDER_THICKNESS)
.max(PARTICIPANTS_MIN_W), .max(PARTICIPANTS_MIN_W),
@@ -6037,19 +6237,10 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.padding(12) .padding(12)
.width(iced::Length::Fill) .width(iced::Length::Fill)
.height(iced::Length::Fill); .height(iced::Length::Fill);
// The Playlist card was built above iff this is the 3-column layout.
let card = playlist_card.expect("playlist_card is Some for ThreeColumn");
let middle = column![
chat,
hdiv(DividerKind::ThreeColPlaylist),
container(card).height(iced::Length::Fixed(state.config.threecol_playlist_height)),
]
.width(iced::Length::Fill)
.height(iced::Length::Fill);
row![ row![
peers_panel.width(iced::Length::Fixed(pw3)), peers_panel.width(iced::Length::Fixed(pw3)),
vdiv(DividerKind::Panels), vdiv(DividerKind::Panels),
middle, chat,
vdiv(DividerKind::Controls), vdiv(DividerKind::Controls),
control_panel.width(iced::Length::Fixed(state.config.controls_width)), control_panel.width(iced::Length::Fixed(state.config.controls_width)),
] ]
@@ -6058,7 +6249,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.into() .into()
} }
RoomLayout::Drawer => { RoomLayout::Drawer => {
let avail = state.window_size.width - 30.0; let avail = body_w - 30.0;
if state.drawer_chat_open { if state.drawer_chat_open {
// Participants + Chat drawer are both fixed; cap Participants so // Participants + Chat drawer are both fixed; cap Participants so
// the Fill Controls panel between them keeps its minimum. // the Fill Controls panel between them keeps its minimum.
@@ -6130,9 +6321,30 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
iced::widget::Space::new().width(0.0).height(0.0).into() iced::widget::Space::new().width(0.0).height(0.0).into()
}; };
let room = container( let main_area = column![
column![top_bar, header_container, clock_skew_banner, vertical_space(12.0), body] header_container,
) clock_skew_banner,
vertical_space(12.0),
body,
];
let main_with_bar: Element<'_, AppMessage> = if state.config.show_player_bar {
column![main_area.height(iced::Length::Fill), player_bar].into()
} else {
main_area.into()
};
let inner: Element<'_, AppMessage> = if drawer_open {
row![
column![main_with_bar].width(iced::Length::Fill),
vdiv(DividerKind::PlaylistDrawer),
playlist_drawer,
]
.height(iced::Length::Fill)
.into()
} else {
main_with_bar
};
let room = container(column![top_bar, inner])
.padding(15) .padding(15)
.width(iced::Length::Fill) .width(iced::Length::Fill)
.height(iced::Length::Fill) .height(iced::Length::Fill)
@@ -7545,8 +7757,8 @@ impl Program<AppMessage> for Icon {
mod tests { mod tests {
use super::{ use super::{
attachment_default_name, clear_expired_clock_skew_warning, format_clock_skew_duration, 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, now_playing_label,
set_peer_gate_config, set_peer_volume_config, show_clock_skew_warning, update, AppConfig, 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, AppMessage, AppState, AttachmentCache, AttachmentState, ChatEntry, ClockSkewBanner,
GateMeter, METER_MAX, UiEvent, CLOCK_SKEW_WARNING_VISIBLE_SECS, GateMeter, METER_MAX, UiEvent, CLOCK_SKEW_WARNING_VISIBLE_SECS,
}; };
@@ -8095,6 +8307,25 @@ mod tests {
assert_eq!(format_duration(3725), "1:02:05"); 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] #[test]
fn settings_categories_are_stable_and_grouped_for_navigation() { fn settings_categories_are_stable_and_grouped_for_navigation() {
use super::SettingsCategory; use super::SettingsCategory;
@@ -8203,6 +8434,40 @@ mod tests {
assert!(d.is_finite() && d >= CHAT_MIN_W); assert!(d.is_finite() && d >= CHAT_MIN_W);
} }
#[test]
fn playlist_drawer_width_clamps() {
use super::{clamp_playlist_drawer_width, CHAT_MIN_W, CONTROLS_MIN_W, PARTICIPANTS_MIN_W};
let window_w = 1000.0;
let max = window_w - PARTICIPANTS_MIN_W - CONTROLS_MIN_W;
// Mid-range passes through.
assert_eq!(clamp_playlist_drawer_width(320.0, window_w), 320.0);
// Below minimum snaps up.
assert_eq!(clamp_playlist_drawer_width(10.0, window_w), CHAT_MIN_W);
// Above maximum leaves the rest of the room at its reserved width.
assert_eq!(clamp_playlist_drawer_width(800.0, window_w), max);
// Tiny window stays finite and at/above the minimum (no inverted range).
let d = clamp_playlist_drawer_width(400.0, 100.0);
assert!(d.is_finite() && d >= CHAT_MIN_W);
}
#[test]
fn now_playing_label_prefers_tuned_peer() {
assert_eq!(
now_playing_label(Some("Alice"), Some("local.flac")),
"Tuned in to Alice"
);
}
#[test]
fn now_playing_label_uses_current_track_when_local() {
assert_eq!(now_playing_label(None, Some("local.flac")), "local.flac");
}
#[test]
fn now_playing_label_handles_empty_player() {
assert_eq!(now_playing_label(None, None), "Nothing playing");
}
use crate::notify::Sound; use crate::notify::Sound;
use iroh::EndpointId; use iroh::EndpointId;
use std::collections::HashSet; use std::collections::HashSet;
+18
View File
@@ -138,6 +138,10 @@ fn default_chat_drawer_width() -> f32 {
320.0 320.0
} }
fn default_playlist_drawer_width() -> f32 {
320.0
}
fn default_window_width() -> f32 { fn default_window_width() -> f32 {
900.0 900.0
} }
@@ -174,6 +178,9 @@ pub struct AppConfig {
/// W22 music: opt-in shared listening broadcast toggle. Local preference. /// W22 music: opt-in shared listening broadcast toggle. Local preference.
#[serde(default)] #[serde(default)]
pub music_broadcast: bool, pub music_broadcast: bool,
/// Show the slim now-playing player bar in the room screen.
#[serde(default = "default_true")]
pub show_player_bar: bool,
/// When true, `clip_volume` governs every clip. When false, each clip keeps /// When true, `clip_volume` governs every clip. When false, each clip keeps
/// its own (in-memory) level and the universal slider is inactive. /// its own (in-memory) level and the universal slider is inactive.
#[serde(default = "default_true")] #[serde(default = "default_true")]
@@ -207,6 +214,9 @@ pub struct AppConfig {
/// Chat drawer width for the drawer layout (px). /// Chat drawer width for the drawer layout (px).
#[serde(default = "default_chat_drawer_width")] #[serde(default = "default_chat_drawer_width")]
pub chat_drawer_width: f32, pub chat_drawer_width: f32,
/// Playlist drawer width for the room-screen right-edge music panel (px).
#[serde(default = "default_playlist_drawer_width")]
pub playlist_drawer_width: f32,
/// Chosen arrangement of the in-call room screen. /// Chosen arrangement of the in-call room screen.
#[serde(default)] #[serde(default)]
pub room_layout: RoomLayout, pub room_layout: RoomLayout,
@@ -348,6 +358,7 @@ impl Default for AppConfig {
music_playlist: Vec::new(), music_playlist: Vec::new(),
music_volume: 1.0, music_volume: 1.0,
music_broadcast: false, music_broadcast: false,
show_player_bar: true,
clip_volume_universal: true, clip_volume_universal: true,
network_mode: NetworkMode::default(), network_mode: NetworkMode::default(),
presence_mode: crate::presence::PresenceMode::default(), presence_mode: crate::presence::PresenceMode::default(),
@@ -358,6 +369,7 @@ impl Default for AppConfig {
threecol_playlist_height: default_threecol_playlist_height(), threecol_playlist_height: default_threecol_playlist_height(),
controls_width: default_controls_width(), controls_width: default_controls_width(),
chat_drawer_width: default_chat_drawer_width(), chat_drawer_width: default_chat_drawer_width(),
playlist_drawer_width: default_playlist_drawer_width(),
room_layout: RoomLayout::default(), room_layout: RoomLayout::default(),
theme: AppTheme::default(), theme: AppTheme::default(),
avatar: crate::avatar::Avatar::default(), avatar: crate::avatar::Avatar::default(),
@@ -517,6 +529,8 @@ mod tests {
assert_eq!(deserialized.room_layout, RoomLayout::BottomDock); assert_eq!(deserialized.room_layout, RoomLayout::BottomDock);
assert_eq!(deserialized.controls_width, 280.0); assert_eq!(deserialized.controls_width, 280.0);
assert_eq!(deserialized.chat_drawer_width, 320.0); assert_eq!(deserialized.chat_drawer_width, 320.0);
assert_eq!(deserialized.playlist_drawer_width, 320.0);
assert!(deserialized.show_player_bar);
assert!(deserialized.custom_sound_self_join.is_none()); assert!(deserialized.custom_sound_self_join.is_none());
assert!(deserialized.custom_sound_peer_join.is_none()); assert!(deserialized.custom_sound_peer_join.is_none());
assert!(deserialized.custom_sound_peer_leave.is_none()); assert!(deserialized.custom_sound_peer_leave.is_none());
@@ -701,6 +715,7 @@ mod tests {
assert!(def.music_playlist.is_empty()); assert!(def.music_playlist.is_empty());
assert_eq!(def.music_volume, 1.0); assert_eq!(def.music_volume, 1.0);
assert!(!def.music_broadcast); assert!(!def.music_broadcast);
assert!(def.show_player_bar);
assert!(def.clip_volume_universal); assert!(def.clip_volume_universal);
// Missing in JSON → unity (serde default). // Missing in JSON → unity (serde default).
@@ -712,6 +727,7 @@ mod tests {
assert!(cfg_missing.music_playlist.is_empty()); assert!(cfg_missing.music_playlist.is_empty());
assert_eq!(cfg_missing.music_volume, 1.0); assert_eq!(cfg_missing.music_volume, 1.0);
assert!(!cfg_missing.music_broadcast); assert!(!cfg_missing.music_broadcast);
assert!(cfg_missing.show_player_bar);
// Configs predating the toggle default to universal mode. // Configs predating the toggle default to universal mode.
assert!(cfg_missing.clip_volume_universal); assert!(cfg_missing.clip_volume_universal);
@@ -723,6 +739,7 @@ mod tests {
music_playlist: vec!["/tmp/song.ogg".to_string()], music_playlist: vec!["/tmp/song.ogg".to_string()],
music_volume: 0.6, music_volume: 0.6,
music_broadcast: true, music_broadcast: true,
show_player_bar: false,
clip_volume_universal: false, clip_volume_universal: false,
..AppConfig::default() ..AppConfig::default()
}; };
@@ -734,6 +751,7 @@ mod tests {
assert_eq!(round_tripped.music_playlist, vec!["/tmp/song.ogg".to_string()]); assert_eq!(round_tripped.music_playlist, vec!["/tmp/song.ogg".to_string()]);
assert_eq!(round_tripped.music_volume, 0.6); assert_eq!(round_tripped.music_volume, 0.6);
assert!(round_tripped.music_broadcast); assert!(round_tripped.music_broadcast);
assert!(!round_tripped.show_player_bar);
assert!(!round_tripped.clip_volume_universal); assert!(!round_tripped.clip_volume_universal);
} }
+10
View File
@@ -103,6 +103,10 @@ pub enum CoreCommand {
RemoveFriend(EndpointId), RemoveFriend(EndpointId),
/// Locally rename a friend (W7). /// Locally rename a friend (W7).
RenameFriend(EndpointId, String), 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 / /// Set our presence posture (W7). Gates the idle listener (answer friends-only /
/// invisible) and the outbound ping scheduler (invisible = fully dark). Sent at /// invisible) and the outbound ping scheduler (invisible = fully dark). Sent at
/// startup from config and whenever the user changes it. /// startup from config and whenever the user changes it.
@@ -203,6 +207,7 @@ pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
} }
| CoreCommand::RemoveFriend(_) | CoreCommand::RemoveFriend(_)
| CoreCommand::RenameFriend(_, _) | CoreCommand::RenameFriend(_, _)
| CoreCommand::RefreshFriends
| CoreCommand::SetPresenceMode(_) | CoreCommand::SetPresenceMode(_)
| CoreCommand::SetGamePresenceEnabled(_) | CoreCommand::SetGamePresenceEnabled(_)
| CoreCommand::SetGameOverride(_) | CoreCommand::SetGameOverride(_)
@@ -291,6 +296,11 @@ pub enum UiEvent {
/// joinable gathering (with a one-click ticket). Emitted by the outbound ping /// joinable gathering (with a one-click ticket). Emitted by the outbound ping
/// scheduler; absence of a recent event = treat as offline. /// scheduler; absence of a recent event = treat as offline.
FriendPresence { id: EndpointId, presence: FriendPresence }, 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 /// Core corrected the committed presence posture. Usually the Discoverable
/// time-box elapsed and the core auto-reverted to `Normal`; on discovery apply /// time-box elapsed and the core auto-reverted to `Normal`; on discovery apply
/// failure, this carries the previous truthful mode. The GUI must mirror + /// 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; .await;
} }
/// How often the outbound presence scheduler refreshes friends' status. Slow on /// How often the outbound presence scheduler refreshes friends' status. Each pass
/// purpose — presence is best-effort, not real-time, and each pass opens a short /// opens one short connection per friend with a saved address, so the cost scales
/// connection per friend. /// with friend-count, not a fixed per-tick cost. 15s keeps the list feeling live
const PING_INTERVAL: Duration = Duration::from_secs(60); /// 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()` /// 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). /// has a moment to finish (otherwise the first probes fail and friends flash offline).
const PING_STARTUP_DELAY: Duration = Duration::from_secs(3); const PING_STARTUP_DELAY: Duration = Duration::from_secs(3);
/// One outbound presence-refresh pass (W7 B2): probe every friend that has a saved /// One outbound presence-refresh pass (W7 B2): probe every friend and emit a
/// address and emit their interpreted status. Friends with no saved address are /// *definitive* status for each, so the UI self-heals every pass instead of only
/// skipped (a bare id can't resolve without discovery) and stay offline in the UI /// ratcheting a friend upward. A friend with a saved address is probed and mapped
/// until first contact populates their address via `note_seen`. Probes run in /// via [`crate::presence::presence_from_probe`] (a failed probe -> `Offline`); a
/// parallel (friend counts are small); an unreachable friend just yields nothing. /// 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( async fn probe_friends_once(
endpoint: Endpoint, endpoint: Endpoint,
friends: crate::friends::FriendStore, friends: crate::friends::FriendStore,
@@ -986,18 +991,25 @@ async fn probe_friends_once(
) { ) {
let mut set = tokio::task::JoinSet::new(); let mut set = tokio::task::JoinSet::new();
for f in friends.list() { for f in friends.list() {
let Some(addr) = f.last_addr.clone() else { continue };
let id = f.id; 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(); let ep = endpoint.clone();
set.spawn(async move { set.spawn(async move {
match crate::presence_net::probe(&ep, addr).await { let presence = match crate::presence_net::probe(&ep, addr).await {
Ok((from, reply)) => crate::presence::interpret_pong(&reply, from).map(|p| (id, p)), Ok((from, reply)) => crate::presence::presence_from_probe(Some((&reply, from))),
Err(_) => None, Err(_) => crate::presence::presence_from_probe(None),
} };
(id, presence)
}); });
} }
while let Some(res) = set.join_next().await { 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; 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) => { CoreCommand::SetPresenceMode(mode) => {
let previous_mode = *presence_mode.lock().unwrap(); let previous_mode = *presence_mode.lock().unwrap();
let now = tokio::time::Instant::now(); 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) 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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum FriendPresence { pub enum FriendPresence {
/// Online, but not in a gathering we can join. /// 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 /// Online and in a joinable gathering (name already sanitized, ticket already
/// validated as parseable). /// validated as parseable).
InRoom { name: String, ticket: String }, 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 /// 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -252,6 +272,33 @@ mod tests {
assert_eq!(got, Some(FriendPresence::Online)); 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] #[test]
fn interpret_pong_sanitizes_a_hostile_room_name() { fn interpret_pong_sanitizes_a_hostile_room_name() {
// Control/bidi characters in a peer-supplied name are stripped. // Control/bidi characters in a peer-supplied name are stripped.