diff --git a/CHANGELOG.md b/CHANGELOG.md index fd2ad40..33e056f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to PeerSpeak are documented here. ## [Unreleased] +### Added +- **Chat message sounds.** Successful outgoing messages and admitted incoming + messages now have distinct notification chimes, each with its own enable + toggle and optional custom WAV path in Notifications settings. +- **Contact presence sounds.** The home-screen contacts list now announces a + contact becoming online or offline. Initial online contacts are announced; + initial offline results stay silent. Both events have independent toggles and + optional custom WAV paths. +- **Notification sound browser.** Every notification event now has a native + Browse button for choosing a custom WAV instead of typing its path manually. + ### Fixed - **Low-latency screen sharing stays near the live edge again.** mpv's timestamp pacing could let stale frames accumulate across the reliable diff --git a/assets/sounds/chat-received.wav b/assets/sounds/chat-received.wav new file mode 100644 index 0000000..5efc969 Binary files /dev/null and b/assets/sounds/chat-received.wav differ diff --git a/assets/sounds/chat-sent.wav b/assets/sounds/chat-sent.wav new file mode 100644 index 0000000..83dd150 Binary files /dev/null and b/assets/sounds/chat-sent.wav differ diff --git a/assets/sounds/contact-offline.wav b/assets/sounds/contact-offline.wav new file mode 100644 index 0000000..bc67ce0 Binary files /dev/null and b/assets/sounds/contact-offline.wav differ diff --git a/assets/sounds/contact-online.wav b/assets/sounds/contact-online.wav new file mode 100644 index 0000000..ea8b4fa Binary files /dev/null and b/assets/sounds/contact-online.wav differ diff --git a/assets/sounds/generate_chimes.py b/assets/sounds/generate_chimes.py index e46e500..8cecbe3 100644 --- a/assets/sounds/generate_chimes.py +++ b/assets/sounds/generate_chimes.py @@ -76,6 +76,14 @@ CHIMES = { "mic-toggle.wav": [(E5, 0.08)], # Reconnect gave up: disappointing low two-note fall. "reconnect-failed.wav": [(C5, 0.15), (349.23, 0.30)], + # Our chat message entered the room: a tiny bright acknowledgement. + "chat-sent.wav": [(1046.50, 0.06)], + # A peer message arrived: a soft two-note lift, distinct but unobtrusive. + "chat-received.wav": [(E5, 0.07), (G5, 0.11)], + # A saved contact came online: a light, higher two-note arrival. + "contact-online.wav": [(E5, 0.09), (880.00, 0.18)], + # A saved contact went offline: the same tonal family falling away. + "contact-offline.wav": [(E5, 0.09), (440.00, 0.18)], } diff --git a/src/app/mod.rs b/src/app/mod.rs index 5236b59..1375437 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -760,6 +760,10 @@ pub enum AppMessage { ToggleNotifications(bool), ToggleEchoCancellation(bool), CustomSoundPathChanged(Sound, String), + /// Open a native WAV picker for one notification event. + BrowseCustomSound(Sound), + /// Result of the notification WAV picker (`None` = cancelled). + CustomSoundFilePicked(Sound, Option), /// Toggle the per-sound enable flag for a single chime (W6). ToggleSoundEnabled(Sound, bool), /// Open / cancel the "Regenerate identity?" confirm modal (W7). @@ -1339,16 +1343,20 @@ impl AppState { /// so Retry can re-dispatch, unless the entry is already gone (history /// eviction / room reset), in which case the payload is dropped so its map /// can't leak. Either way an id with no matching entry is a harmless no-op. - fn apply_send_result(&mut self, local_id: u64, error: Option) { + /// Returns `true` only when a successful result matched a live local echo, + /// which is the boundary used for the outgoing-message notification. + fn apply_send_result(&mut self, local_id: u64, error: Option) -> bool { match error { None => { - self.set_send_status(local_id, SendStatus::Broadcast); + let matched = self.set_send_status(local_id, SendStatus::Broadcast); self.send_payloads.remove(&local_id); + matched } Some(e) => { if !self.set_send_status(local_id, SendStatus::Failed(e)) { self.send_payloads.remove(&local_id); } + false } } } @@ -1379,9 +1387,30 @@ impl AppState { Sound::SelfLeave => &self.config.custom_sound_self_leave, Sound::MicToggle => &self.config.custom_sound_mic_toggle, Sound::ReconnectFailed => &self.config.custom_sound_reconnect_failed, + Sound::ChatSent => &self.config.custom_sound_chat_sent, + Sound::ChatReceived => &self.config.custom_sound_chat_received, + Sound::ContactOnline => &self.config.custom_sound_contact_online, + Sound::ContactOffline => &self.config.custom_sound_contact_offline, }; opt.as_deref().unwrap_or("") } + + fn set_custom_sound_path(&mut self, sound: Sound, path: Option) { + match sound { + Sound::SelfJoin => self.config.custom_sound_self_join = path, + Sound::PeerJoin => self.config.custom_sound_peer_join = path, + Sound::PeerLeave => self.config.custom_sound_peer_leave = path, + Sound::ReconnectAttempt => self.config.custom_sound_reconnect_attempt = path, + Sound::Reconnected => self.config.custom_sound_reconnected = path, + Sound::SelfLeave => self.config.custom_sound_self_leave = path, + Sound::MicToggle => self.config.custom_sound_mic_toggle = path, + Sound::ReconnectFailed => self.config.custom_sound_reconnect_failed = path, + Sound::ChatSent => self.config.custom_sound_chat_sent = path, + Sound::ChatReceived => self.config.custom_sound_chat_received = path, + Sound::ContactOnline => self.config.custom_sound_contact_online = path, + Sound::ContactOffline => self.config.custom_sound_contact_offline = path, + } + } } impl Default for AppState { @@ -1871,6 +1900,47 @@ fn reconnected_chime( was_reconnect.then_some(Sound::Reconnected) } +/// Return the landing-page contact chime for one definitive presence update. +/// An initial online result is an arrival (so contacts already online at app +/// startup are announced), while an initial offline result is silent. Online +/// includes both plain `Online` and `InRoom`; moving between those two states is +/// not a connection transition. Updates continue to populate the presence map +/// off-home, but notification sounds are intentionally limited to the home page. +fn friend_presence_notification( + screen: Screen, + previous: Option<&crate::presence::FriendPresence>, + next: &crate::presence::FriendPresence, +) -> Option { + if screen != Screen::Home { + return None; + } + + let online = |presence: &crate::presence::FriendPresence| { + matches!( + presence, + crate::presence::FriendPresence::Online + | crate::presence::FriendPresence::InRoom { .. } + ) + }; + match (previous.map(online), online(next)) { + (None | Some(false), true) => Some(Sound::ContactOnline), + (Some(true), false) => Some(Sound::ContactOffline), + _ => None, + } +} + +/// Convert a native picker result into the persisted notification path. The +/// dialog filter is advisory on some desktops, so enforce WAV here as well. +/// `None` (cancel) and a non-WAV selection leave the existing setting untouched. +fn selected_wav_path(picked: Option) -> Option { + let path = picked?; + let is_wav = path + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("wav")); + is_wav.then(|| path.to_string_lossy().into_owned()) +} + fn in_call(state: &AppState) -> bool { !state.ticket.is_empty() } @@ -2310,7 +2380,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { // failure it's retained for Retry — unless the entry is gone // (history eviction / room reset), in which case drop it so // the payload map can't leak. - state.apply_send_result(local_id, error); + if state.apply_send_result(local_id, error) { + notify::play( + Sound::ChatSent, + state.config.custom_sound_chat_sent.as_deref(), + ); + } } UiEvent::ChatMessage { from, @@ -2343,6 +2418,10 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { local_send: None, }, ); + notify::play( + Sound::ChatReceived, + state.config.custom_sound_chat_received.as_deref(), + ); } } UiEvent::AttachmentReady { from, id, data } => { @@ -2512,7 +2591,15 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.friends_read_only = read_only; } UiEvent::FriendPresence { id, presence } => { + let sound = friend_presence_notification( + state.current_screen, + state.friend_presence.get(&id), + &presence, + ); state.friend_presence.insert(id, presence); + if let Some(sound) = sound { + notify::play(sound, Some(state.custom_sound_path(sound))); + } } UiEvent::FriendsRescanned => { // The manual pass finished. Stamp the time for the live "scanned @@ -2887,15 +2974,36 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { } else { Some(path) }; - match sound { - Sound::SelfJoin => state.config.custom_sound_self_join = path_opt, - Sound::PeerJoin => state.config.custom_sound_peer_join = path_opt, - Sound::PeerLeave => state.config.custom_sound_peer_leave = path_opt, - Sound::ReconnectAttempt => state.config.custom_sound_reconnect_attempt = path_opt, - Sound::Reconnected => state.config.custom_sound_reconnected = path_opt, - Sound::SelfLeave => state.config.custom_sound_self_leave = path_opt, - Sound::MicToggle => state.config.custom_sound_mic_toggle = path_opt, - Sound::ReconnectFailed => state.config.custom_sound_reconnect_failed = path_opt, + state.set_custom_sound_path(sound, path_opt); + } + AppMessage::BrowseCustomSound(sound) => { + let initial_dir = { + let current = state.custom_sound_path(sound); + (!current.trim().is_empty()) + .then(|| notify::expand_tilde(current)) + .and_then(|path| path.parent().map(std::path::Path::to_path_buf)) + .filter(|path| path.is_dir()) + }; + return Task::perform( + async move { + let mut dialog = rfd::AsyncFileDialog::new() + .add_filter("WAV audio", &["wav"]) + .set_title("Choose a notification sound"); + if let Some(dir) = initial_dir { + dialog = dialog.set_directory(dir); + } + dialog + .pick_file() + .await + .map(|handle| handle.path().to_path_buf()) + }, + move |picked| AppMessage::CustomSoundFilePicked(sound, picked), + ); + } + AppMessage::CustomSoundFilePicked(sound, picked) => { + if let Some(path) = selected_wav_path(picked) { + state.set_custom_sound_path(sound, Some(path)); + state.config.save(); } } AppMessage::ToggleSoundEnabled(sound, enabled) => { @@ -5282,10 +5390,19 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { ] .spacing(6) .align_y(iced::alignment::Vertical::Center), - context_input("Default (embedded)...", path) - .on_input(move |val| AppMessage::CustomSoundPathChanged(sound, val)) - .style(t_style) - .padding(8) + row![ + context_input("Default (embedded)...", path) + .on_input(move |val| AppMessage::CustomSoundPathChanged(sound, val)) + .style(t_style) + .padding(8) + .width(iced::Length::Fill), + button(text("Browse…").size(11)) + .on_press(AppMessage::BrowseCustomSound(sound)) + .style(b_style(color_surface, color_blue, color_text, 5.0)) + .padding([8, 10]), + ] + .spacing(6) + .width(iced::Length::Fill) ] .spacing(4) .width(iced::Length::Fill) @@ -6035,6 +6152,14 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { path_field("Mic Toggle", Sound::MicToggle), path_field("Reconnect Failed", Sound::ReconnectFailed), ].spacing(20).width(iced::Length::Fill), + row![ + path_field("Chat Sent", Sound::ChatSent), + path_field("Chat Received", Sound::ChatReceived), + ].spacing(20).width(iced::Length::Fill), + row![ + path_field("Contact Online", Sound::ContactOnline), + path_field("Contact Offline", Sound::ContactOffline), + ].spacing(20).width(iced::Length::Fill), ].spacing(8).width(iced::Length::Fill), ] .spacing(10) @@ -9544,12 +9669,12 @@ mod tests { use super::sendqueue::{self, LocalSend, SendStatus}; use super::{ AppConfig, AppMessage, AppState, AttachmentCache, AttachmentState, - CLOCK_SKEW_WARNING_VISIBLE_SECS, ChatEntry, ClockSkewBanner, GateMeter, METER_MAX, + CLOCK_SKEW_WARNING_VISIBLE_SECS, ChatEntry, ClockSkewBanner, GateMeter, METER_MAX, Screen, ScreenBounds, UiEvent, attachment_default_name, clamp_window_position, clear_expired_clock_skew_warning, format_clock_skew_duration, 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, + format_relative_ago, friend_presence_notification, initial_window_position, + now_playing_label, reconnect_attempt_chime, reconnected_chime, selected_wav_path, + set_peer_gate_config, set_peer_volume_config, show_clock_skew_warning, update, }; use iroh::SecretKey; use std::collections::VecDeque; @@ -10640,6 +10765,91 @@ mod tests { const W: f32 = 200.0; + #[test] + fn initial_contact_presence_announces_only_online() { + use crate::presence::FriendPresence; + + assert_eq!( + friend_presence_notification(Screen::Home, None, &FriendPresence::Online), + Some(Sound::ContactOnline) + ); + assert_eq!( + friend_presence_notification(Screen::Home, None, &FriendPresence::Offline), + None + ); + } + + #[test] + fn contact_presence_chimes_only_on_online_boundary() { + use crate::presence::FriendPresence; + + let in_room = FriendPresence::InRoom { + name: "Game night".to_string(), + ticket: "ticket".to_string(), + }; + assert_eq!( + friend_presence_notification(Screen::Home, Some(&FriendPresence::Offline), &in_room,), + Some(Sound::ContactOnline) + ); + assert_eq!( + friend_presence_notification( + Screen::Home, + Some(&FriendPresence::Online), + &FriendPresence::Offline, + ), + Some(Sound::ContactOffline) + ); + assert_eq!( + friend_presence_notification(Screen::Home, Some(&FriendPresence::Online), &in_room,), + None + ); + assert_eq!( + friend_presence_notification( + Screen::Home, + Some(&FriendPresence::Offline), + &FriendPresence::Offline, + ), + None + ); + } + + #[test] + fn contact_presence_is_silent_away_from_landing_page() { + use crate::presence::FriendPresence; + + assert_eq!( + friend_presence_notification(Screen::Room, None, &FriendPresence::Online), + None + ); + assert_eq!( + friend_presence_notification( + Screen::Settings, + Some(&FriendPresence::Online), + &FriendPresence::Offline, + ), + None + ); + } + + #[test] + fn selected_notification_sound_accepts_wav_and_preserves_cancel() { + use std::path::PathBuf; + + assert_eq!(selected_wav_path(None), None); + assert_eq!( + selected_wav_path(Some(PathBuf::from("/tmp/notify.mp3"))), + None + ); + assert_eq!( + selected_wav_path(Some(PathBuf::from("/tmp/notify.wav"))), + Some("/tmp/notify.wav".to_string()) + ); + assert_eq!( + selected_wav_path(Some(PathBuf::from("/tmp/notify.WAV"))), + Some("/tmp/notify.WAV".to_string()) + ); + } + #[test] fn gate_drag_maps_left_edge_to_zero() { assert_eq!(GateMeter::x_to_threshold(0.0, W), 0.0); @@ -10923,7 +11133,7 @@ mod tests { // Empty queue + a fresh full pacer → dispatched immediately. assert_eq!(status_of(&state, id), Some(SendStatus::Pending)); assert!(state.send_payloads.contains_key(&id)); - state.apply_send_result(id, None); + assert!(state.apply_send_result(id, None)); assert_eq!(status_of(&state, id), Some(SendStatus::Broadcast)); // A completed send releases its retry payload. assert!(!state.send_payloads.contains_key(&id)); @@ -10934,7 +11144,7 @@ mod tests { let mut state = AppState::default(); let id = push_own(&mut state, "yo"); state.submit_send(id, PendingSend::Text("yo".to_string())); - state.apply_send_result(id, Some("not in a room".to_string())); + assert!(!state.apply_send_result(id, Some("not in a room".to_string()))); assert_eq!( status_of(&state, id), Some(SendStatus::Failed("not in a room".to_string())) @@ -10950,11 +11160,11 @@ mod tests { state.submit_send(a, PendingSend::Text("a".to_string())); let b = push_own(&mut state, "b"); state.submit_send(b, PendingSend::Text("b".to_string())); - state.apply_send_result(a, None); + assert!(state.apply_send_result(a, None)); assert_eq!(status_of(&state, a), Some(SendStatus::Broadcast)); assert_eq!(status_of(&state, b), Some(SendStatus::Pending)); // A result for an id with no matching entry is a harmless no-op. - state.apply_send_result(9999, None); + assert!(!state.apply_send_result(9999, None)); assert_eq!(status_of(&state, b), Some(SendStatus::Pending)); } @@ -10968,7 +11178,7 @@ mod tests { state .chat_messages .retain(|m| m.local_send.as_ref().map(|s| s.id) != Some(id)); - state.apply_send_result(id, Some("dead".to_string())); + assert!(!state.apply_send_result(id, Some("dead".to_string()))); // No entry to mark → the payload must not leak. assert!(!state.send_payloads.contains_key(&id)); } @@ -10983,7 +11193,7 @@ mod tests { assert!(state.send_queue.is_empty()); assert!(state.send_payloads.is_empty()); // A late result for the pre-reset send touches nothing and adds no entry. - state.apply_send_result(id, None); + assert!(!state.apply_send_result(id, None)); assert!(state.chat_messages.is_empty()); assert!(state.send_payloads.is_empty()); } diff --git a/src/config.rs b/src/config.rs index ce03759..8ca4a9e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -478,6 +478,14 @@ pub struct AppConfig { pub custom_sound_mic_toggle: Option, #[serde(default)] pub custom_sound_reconnect_failed: Option, + #[serde(default)] + pub custom_sound_chat_sent: Option, + #[serde(default)] + pub custom_sound_chat_received: Option, + #[serde(default)] + pub custom_sound_contact_online: Option, + #[serde(default)] + pub custom_sound_contact_offline: Option, /// Per-sound enable flags (W6). The master `notifications_enabled` toggle /// gates ALL chimes; these let the user silence individual events while the /// master stays on. A chime plays only if the master AND its flag are true. @@ -498,6 +506,14 @@ pub struct AppConfig { pub sound_mic_toggle_enabled: bool, #[serde(default = "default_true")] pub sound_reconnect_failed_enabled: bool, + #[serde(default = "default_true")] + pub sound_chat_sent_enabled: bool, + #[serde(default = "default_true")] + pub sound_chat_received_enabled: bool, + #[serde(default = "default_true")] + pub sound_contact_online_enabled: bool, + #[serde(default = "default_true")] + pub sound_contact_offline_enabled: bool, /// Optional override for the `pixelpass` binary location (screen share). /// Empty / unset = look it up on `$PATH`. Hand-editable; no Settings UI yet. #[serde(default)] @@ -601,6 +617,10 @@ impl Default for AppConfig { custom_sound_self_leave: None, custom_sound_mic_toggle: None, custom_sound_reconnect_failed: None, + custom_sound_chat_sent: None, + custom_sound_chat_received: None, + custom_sound_contact_online: None, + custom_sound_contact_offline: None, sound_self_join_enabled: true, sound_peer_join_enabled: true, sound_peer_leave_enabled: true, @@ -609,6 +629,10 @@ impl Default for AppConfig { sound_self_leave_enabled: true, sound_mic_toggle_enabled: true, sound_reconnect_failed_enabled: true, + sound_chat_sent_enabled: true, + sound_chat_received_enabled: true, + sound_contact_online_enabled: true, + sound_contact_offline_enabled: true, pixelpass_path: None, screen_share: ScreenShareSettings::default(), recents: Vec::new(), @@ -639,6 +663,10 @@ impl AppConfig { Sound::SelfLeave => self.sound_self_leave_enabled, Sound::MicToggle => self.sound_mic_toggle_enabled, Sound::ReconnectFailed => self.sound_reconnect_failed_enabled, + Sound::ChatSent => self.sound_chat_sent_enabled, + Sound::ChatReceived => self.sound_chat_received_enabled, + Sound::ContactOnline => self.sound_contact_online_enabled, + Sound::ContactOffline => self.sound_contact_offline_enabled, } } @@ -653,6 +681,10 @@ impl AppConfig { Sound::SelfLeave => self.sound_self_leave_enabled = enabled, Sound::MicToggle => self.sound_mic_toggle_enabled = enabled, Sound::ReconnectFailed => self.sound_reconnect_failed_enabled = enabled, + Sound::ChatSent => self.sound_chat_sent_enabled = enabled, + Sound::ChatReceived => self.sound_chat_received_enabled = enabled, + Sound::ContactOnline => self.sound_contact_online_enabled = enabled, + Sound::ContactOffline => self.sound_contact_offline_enabled = enabled, } } @@ -940,6 +972,10 @@ mod tests { assert!(deserialized.custom_sound_self_leave.is_none()); assert!(deserialized.custom_sound_mic_toggle.is_none()); assert!(deserialized.custom_sound_reconnect_failed.is_none()); + assert!(deserialized.custom_sound_chat_sent.is_none()); + assert!(deserialized.custom_sound_chat_received.is_none()); + assert!(deserialized.custom_sound_contact_online.is_none()); + assert!(deserialized.custom_sound_contact_offline.is_none()); assert_eq!(deserialized.screen_share, ScreenShareSettings::default()); assert_eq!(deserialized.screen_share.quality, ShareQuality::Auto); assert_eq!(deserialized.screen_share.player, SharePlayer::Mpv); diff --git a/src/notify.rs b/src/notify.rs index ad97253..1d7adcd 100644 --- a/src/notify.rs +++ b/src/notify.rs @@ -80,6 +80,14 @@ pub enum Sound { MicToggle, /// Reconnect failed / peer evicted. ReconnectFailed, + /// One of our chat messages was broadcast to the room. + ChatSent, + /// A chat message from another participant was admitted. + ChatReceived, + /// A saved contact was detected online on the home screen. + ContactOnline, + /// A saved contact previously seen online went offline on the home screen. + ContactOffline, } impl Sound { @@ -93,10 +101,14 @@ impl Sound { Sound::SelfLeave, Sound::MicToggle, Sound::ReconnectFailed, + Sound::ChatSent, + Sound::ChatReceived, + Sound::ContactOnline, + Sound::ContactOffline, ]; /// Number of distinct notification events. - pub const COUNT: usize = 8; + pub const COUNT: usize = 12; /// Stable 0-based index into the per-sound flag array. Must match `ALL`. fn index(self) -> usize { @@ -109,6 +121,10 @@ impl Sound { Sound::SelfLeave => 5, Sound::MicToggle => 6, Sound::ReconnectFailed => 7, + Sound::ChatSent => 8, + Sound::ChatReceived => 9, + Sound::ContactOnline => 10, + Sound::ContactOffline => 11, } } @@ -123,6 +139,10 @@ impl Sound { Sound::SelfLeave => include_bytes!("../assets/sounds/self-leave.wav"), Sound::MicToggle => include_bytes!("../assets/sounds/mic-toggle.wav"), Sound::ReconnectFailed => include_bytes!("../assets/sounds/reconnect-failed.wav"), + Sound::ChatSent => include_bytes!("../assets/sounds/chat-sent.wav"), + Sound::ChatReceived => include_bytes!("../assets/sounds/chat-received.wav"), + Sound::ContactOnline => include_bytes!("../assets/sounds/contact-online.wav"), + Sound::ContactOffline => include_bytes!("../assets/sounds/contact-offline.wav"), } } @@ -137,6 +157,10 @@ impl Sound { Sound::SelfLeave => "self-leave", Sound::MicToggle => "mic-toggle", Sound::ReconnectFailed => "reconnect-failed", + Sound::ChatSent => "chat-sent", + Sound::ChatReceived => "chat-received", + Sound::ContactOnline => "contact-online", + Sound::ContactOffline => "contact-offline", } } }