Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b553a94875 | ||
|
|
0aaf6be529 | ||
|
|
d059386aee | ||
|
|
79a091b1b3 | ||
|
|
c1de7efbc5 |
@@ -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 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
|
||||
- **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
@@ -237,15 +237,15 @@ pub enum DividerKind {
|
||||
/// Horizontal divider between the main row and the Chat dock (resizes the
|
||||
/// Chat dock height).
|
||||
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
|
||||
/// the Controls panel width).
|
||||
Controls,
|
||||
/// Vertical divider on the left edge of the Chat drawer (resizes the drawer
|
||||
/// width) in the drawer layout.
|
||||
ChatDrawer,
|
||||
/// Vertical divider on the left edge of the Playlist drawer (resizes the
|
||||
/// drawer width).
|
||||
PlaylistDrawer,
|
||||
}
|
||||
|
||||
#[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
|
||||
/// the dock (px).
|
||||
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).
|
||||
const DIVIDER_THICKNESS: f32 = 8.0;
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// 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).
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AppMessage {
|
||||
@@ -366,6 +361,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),
|
||||
@@ -441,6 +439,10 @@ pub enum AppMessage {
|
||||
MusicSetVolume(f32),
|
||||
/// Set the tuned-in source's music playback volume (and persist it).
|
||||
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.
|
||||
MusicRemove(usize),
|
||||
/// Move a personal playlist track one slot up/down.
|
||||
@@ -474,6 +476,8 @@ pub enum AppMessage {
|
||||
DismissClockSkewWarning,
|
||||
/// Auto-clear cadence while the clock-skew warning banner is visible.
|
||||
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).
|
||||
SelectRoomLayout(RoomLayout),
|
||||
/// 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.
|
||||
room_name_input: 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,
|
||||
ticket: String,
|
||||
is_muted: bool,
|
||||
@@ -744,6 +753,8 @@ pub struct AppState {
|
||||
clock_skew_warning: Option<ClockSkewBanner>,
|
||||
/// Whether the Chat drawer is open (drawer layout only).
|
||||
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.
|
||||
mic_level: f32,
|
||||
/// Whether the standalone (off-call) mic test stream is running.
|
||||
@@ -861,10 +872,10 @@ impl Default for AppState {
|
||||
config.participants_width =
|
||||
clamp_participants_width(config.participants_width, ww);
|
||||
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.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);
|
||||
for sound in Sound::ALL {
|
||||
notify::set_sound_enabled(sound, config.sound_enabled(sound));
|
||||
@@ -932,6 +943,7 @@ impl Default for AppState {
|
||||
ticket_input: "".to_string(),
|
||||
room_name_input: "".to_string(),
|
||||
status_message: "Ready to connect".to_string(),
|
||||
last_rescan: None,
|
||||
self_id: "".to_string(),
|
||||
ticket: "".to_string(),
|
||||
is_muted: false,
|
||||
@@ -992,6 +1004,7 @@ impl Default for AppState {
|
||||
share_app_audio_supported: true,
|
||||
clock_skew_warning: None,
|
||||
drawer_chat_open: false,
|
||||
playlist_drawer_open: false,
|
||||
mic_level: 0.0,
|
||||
mic_test_active: false,
|
||||
connecting: HashSet::new(),
|
||||
@@ -1147,7 +1160,15 @@ fn subscription(state: &AppState) -> Subscription<AppMessage> {
|
||||
} else {
|
||||
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> {
|
||||
@@ -1697,6 +1718,16 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
UiEvent::FriendPresence { 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 } => {
|
||||
// Core corrected the committed presence mode. Mirror + persist so
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
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) => {
|
||||
// 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());
|
||||
@@ -2014,14 +2058,6 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
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 => {
|
||||
// Controls sits on the right; dragging the divider right (positive
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
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 => {
|
||||
@@ -2066,6 +2110,10 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
AppMessage::ClockSkewWarningTick => {
|
||||
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) => {
|
||||
state.config.room_layout = layout;
|
||||
state.config.save();
|
||||
@@ -2304,6 +2352,16 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
AppMessage::ToggleDrawerChat => {
|
||||
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 => {
|
||||
let text = sanitize_chat(&state.chat_input);
|
||||
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);
|
||||
state.config.chat_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 =
|
||||
clamp_controls_width(state.config.controls_width, size.width);
|
||||
state.config.chat_drawer_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))) => {
|
||||
// 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 {
|
||||
let minutes = skew_secs.max(1).saturating_add(59) / 60;
|
||||
if minutes == 1 {
|
||||
@@ -3044,6 +3133,15 @@ fn can_broadcast_music(state: &AppState) -> bool {
|
||||
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) {
|
||||
let old_current = state.music_broadcast_id.take();
|
||||
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) => {
|
||||
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 +3870,37 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
|
||||
} else {
|
||||
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(
|
||||
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,
|
||||
@@ -3907,7 +4036,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
};
|
||||
|
||||
// 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(
|
||||
button(icon(IconKind::Info, 18.0, color_text))
|
||||
.on_press(AppMessage::OpenHotkeyInfo)
|
||||
@@ -3920,6 +4049,27 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
)
|
||||
.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 {
|
||||
iced::widget::Space::new().width(0.0).height(0.0).into()
|
||||
} else {
|
||||
@@ -3943,6 +4093,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
|
||||
let top_bar = row![
|
||||
horizontal_space(),
|
||||
player_bar_button,
|
||||
info_button,
|
||||
layout_button,
|
||||
button(
|
||||
@@ -5556,37 +5707,65 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
.width(iced::Length::Fill)
|
||||
.into()
|
||||
};
|
||||
// W22: in the 3-column layout the Playlist gets its own card stacked under Chat
|
||||
// (built below in the ThreeColumn body arm); in every other layout it stays in
|
||||
// the Controls panel. `music_panel` is consumed by exactly one of these.
|
||||
let three_col = matches!(state.config.room_layout, RoomLayout::ThreeColumn);
|
||||
let (ctrl_music, playlist_card): (Element<'_, AppMessage>, Option<Element<'_, AppMessage>>) =
|
||||
if three_col {
|
||||
let card = container(
|
||||
column![
|
||||
text("Playlist").size(18).color(color_blue),
|
||||
vertical_space(8.0),
|
||||
scrollable(music_panel)
|
||||
.width(iced::Length::Fill)
|
||||
.height(iced::Length::Fill),
|
||||
]
|
||||
)
|
||||
.style(c_style(color_mantle, color_surface, 8.0))
|
||||
.padding(12)
|
||||
.width(iced::Length::Fill)
|
||||
.height(iced::Length::Fill);
|
||||
(column![].into(), Some(card.into()))
|
||||
} else {
|
||||
(
|
||||
column![
|
||||
vertical_space(20.0),
|
||||
text("Playlist").size(18).color(color_blue),
|
||||
music_panel,
|
||||
]
|
||||
.into(),
|
||||
None,
|
||||
)
|
||||
};
|
||||
let music_bar_status = status_snapshot(&state.music_status);
|
||||
let music_bar_playing = music_bar_status.playing_id.is_some();
|
||||
let music_bar_play_label = if music_bar_playing && music_bar_status.paused {
|
||||
"▶"
|
||||
} else if music_bar_playing {
|
||||
"⏸"
|
||||
} else {
|
||||
"▶"
|
||||
};
|
||||
let listening_peer_name = state
|
||||
.music_listening_to
|
||||
.and_then(|peer| state.peers.get(&peer).map(|p| p.name.as_str()));
|
||||
let current_track_name = state
|
||||
.music_current
|
||||
.and_then(|i| state.music_playlist.get(i).map(|track| track.name.as_str()));
|
||||
let elapsed = format_clip_time(music_bar_status.position);
|
||||
let duration = music_bar_status
|
||||
.total
|
||||
.map(format_clip_time)
|
||||
.unwrap_or_else(|| "--:--".to_string());
|
||||
let player_bar = container(
|
||||
row![
|
||||
text("♪").size(18).color(color_blue),
|
||||
text(now_playing_label(listening_peer_name, current_track_name))
|
||||
.size(13)
|
||||
.color(color_text)
|
||||
.width(iced::Length::Fill),
|
||||
button(text("⏮").size(13))
|
||||
.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![
|
||||
button(btn_content(mute_kind, mute_text, mute_fg))
|
||||
.on_press(AppMessage::ToggleMutePressed)
|
||||
@@ -5690,7 +5869,6 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
.padding(14)
|
||||
.width(iced::Length::Fill)
|
||||
},
|
||||
ctrl_music,
|
||||
];
|
||||
|
||||
// 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))
|
||||
};
|
||||
|
||||
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
|
||||
// exactly one arm (allowed across mutually-exclusive match arms).
|
||||
let pw = state.config.participants_width;
|
||||
let body: Element<'_, AppMessage> = match state.config.room_layout {
|
||||
RoomLayout::BottomDock => {
|
||||
// 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 main = row![
|
||||
peers_panel.width(iced::Length::Fixed(pwb)),
|
||||
@@ -6027,7 +6227,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
column![main, hdiv(DividerKind::Chat), chat].into()
|
||||
}
|
||||
RoomLayout::ThreeColumn => {
|
||||
let avail = state.window_size.width - 30.0; // outer padding
|
||||
let avail = body_w - 30.0; // outer padding
|
||||
let pw3 = pw.min(
|
||||
(avail - state.config.controls_width - CHAT_MIN_W - 2.0 * DIVIDER_THICKNESS)
|
||||
.max(PARTICIPANTS_MIN_W),
|
||||
@@ -6037,19 +6237,10 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
.padding(12)
|
||||
.width(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![
|
||||
peers_panel.width(iced::Length::Fixed(pw3)),
|
||||
vdiv(DividerKind::Panels),
|
||||
middle,
|
||||
chat,
|
||||
vdiv(DividerKind::Controls),
|
||||
control_panel.width(iced::Length::Fixed(state.config.controls_width)),
|
||||
]
|
||||
@@ -6058,7 +6249,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
.into()
|
||||
}
|
||||
RoomLayout::Drawer => {
|
||||
let avail = state.window_size.width - 30.0;
|
||||
let avail = body_w - 30.0;
|
||||
if state.drawer_chat_open {
|
||||
// Participants + Chat drawer are both fixed; cap Participants so
|
||||
// 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()
|
||||
};
|
||||
|
||||
let room = container(
|
||||
column![top_bar, header_container, clock_skew_banner, vertical_space(12.0), body]
|
||||
)
|
||||
let main_area = column![
|
||||
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)
|
||||
.width(iced::Length::Fill)
|
||||
.height(iced::Length::Fill)
|
||||
@@ -7545,8 +7757,8 @@ impl Program<AppMessage> for Icon {
|
||||
mod tests {
|
||||
use super::{
|
||||
attachment_default_name, clear_expired_clock_skew_warning, format_clock_skew_duration,
|
||||
format_duration, initial_window_position, reconnect_attempt_chime, reconnected_chime,
|
||||
set_peer_gate_config, set_peer_volume_config, show_clock_skew_warning, update, AppConfig,
|
||||
format_duration, format_relative_ago, initial_window_position, now_playing_label,
|
||||
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,
|
||||
GateMeter, METER_MAX, UiEvent, CLOCK_SKEW_WARNING_VISIBLE_SECS,
|
||||
};
|
||||
@@ -8095,6 +8307,25 @@ mod tests {
|
||||
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]
|
||||
fn settings_categories_are_stable_and_grouped_for_navigation() {
|
||||
use super::SettingsCategory;
|
||||
@@ -8203,6 +8434,40 @@ mod tests {
|
||||
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 iroh::EndpointId;
|
||||
use std::collections::HashSet;
|
||||
|
||||
@@ -138,6 +138,10 @@ fn default_chat_drawer_width() -> f32 {
|
||||
320.0
|
||||
}
|
||||
|
||||
fn default_playlist_drawer_width() -> f32 {
|
||||
320.0
|
||||
}
|
||||
|
||||
fn default_window_width() -> f32 {
|
||||
900.0
|
||||
}
|
||||
@@ -174,6 +178,9 @@ pub struct AppConfig {
|
||||
/// W22 music: opt-in shared listening broadcast toggle. Local preference.
|
||||
#[serde(default)]
|
||||
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
|
||||
/// its own (in-memory) level and the universal slider is inactive.
|
||||
#[serde(default = "default_true")]
|
||||
@@ -207,6 +214,9 @@ pub struct AppConfig {
|
||||
/// Chat drawer width for the drawer layout (px).
|
||||
#[serde(default = "default_chat_drawer_width")]
|
||||
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.
|
||||
#[serde(default)]
|
||||
pub room_layout: RoomLayout,
|
||||
@@ -348,6 +358,7 @@ impl Default for AppConfig {
|
||||
music_playlist: Vec::new(),
|
||||
music_volume: 1.0,
|
||||
music_broadcast: false,
|
||||
show_player_bar: true,
|
||||
clip_volume_universal: true,
|
||||
network_mode: NetworkMode::default(),
|
||||
presence_mode: crate::presence::PresenceMode::default(),
|
||||
@@ -358,6 +369,7 @@ impl Default for AppConfig {
|
||||
threecol_playlist_height: default_threecol_playlist_height(),
|
||||
controls_width: default_controls_width(),
|
||||
chat_drawer_width: default_chat_drawer_width(),
|
||||
playlist_drawer_width: default_playlist_drawer_width(),
|
||||
room_layout: RoomLayout::default(),
|
||||
theme: AppTheme::default(),
|
||||
avatar: crate::avatar::Avatar::default(),
|
||||
@@ -517,6 +529,8 @@ mod tests {
|
||||
assert_eq!(deserialized.room_layout, RoomLayout::BottomDock);
|
||||
assert_eq!(deserialized.controls_width, 280.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_peer_join.is_none());
|
||||
assert!(deserialized.custom_sound_peer_leave.is_none());
|
||||
@@ -701,6 +715,7 @@ mod tests {
|
||||
assert!(def.music_playlist.is_empty());
|
||||
assert_eq!(def.music_volume, 1.0);
|
||||
assert!(!def.music_broadcast);
|
||||
assert!(def.show_player_bar);
|
||||
assert!(def.clip_volume_universal);
|
||||
|
||||
// Missing in JSON → unity (serde default).
|
||||
@@ -712,6 +727,7 @@ mod tests {
|
||||
assert!(cfg_missing.music_playlist.is_empty());
|
||||
assert_eq!(cfg_missing.music_volume, 1.0);
|
||||
assert!(!cfg_missing.music_broadcast);
|
||||
assert!(cfg_missing.show_player_bar);
|
||||
// Configs predating the toggle default to universal mode.
|
||||
assert!(cfg_missing.clip_volume_universal);
|
||||
|
||||
@@ -723,6 +739,7 @@ mod tests {
|
||||
music_playlist: vec!["/tmp/song.ogg".to_string()],
|
||||
music_volume: 0.6,
|
||||
music_broadcast: true,
|
||||
show_player_bar: false,
|
||||
clip_volume_universal: false,
|
||||
..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_volume, 0.6);
|
||||
assert!(round_tripped.music_broadcast);
|
||||
assert!(!round_tripped.show_player_bar);
|
||||
assert!(!round_tripped.clip_volume_universal);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(_)
|
||||
@@ -291,6 +296,11 @@ pub enum UiEvent {
|
||||
/// joinable gathering (with a one-click ticket). Emitted by the outbound ping
|
||||
/// scheduler; absence of a recent event = treat as offline.
|
||||
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
|
||||
/// time-box elapsed and the core auto-reverted to `Normal`; on discovery apply
|
||||
/// failure, this carries the previous truthful mode. The GUI must mirror +
|
||||
|
||||
+46
-15
@@ -966,19 +966,24 @@ async fn persist_and_emit_friends(
|
||||
.await;
|
||||
}
|
||||
|
||||
/// How often the outbound presence scheduler refreshes friends' status. Slow on
|
||||
/// purpose — presence is best-effort, not real-time, and each pass opens a short
|
||||
/// connection per friend.
|
||||
const PING_INTERVAL: Duration = Duration::from_secs(60);
|
||||
/// How often the outbound presence scheduler refreshes friends' status. Each pass
|
||||
/// opens one short connection per friend with a saved address, so the cost scales
|
||||
/// with friend-count, not a fixed per-tick cost. 15s keeps the list feeling live
|
||||
/// 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()`
|
||||
/// 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 +991,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 +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) => {
|
||||
let previous_mode = *presence_mode.lock().unwrap();
|
||||
let now = tokio::time::Instant::now();
|
||||
|
||||
+48
-1
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user