feat(notifications): add chat and contact sounds
CI / check (push) Successful in 2m39s

This commit is contained in:
2026-07-19 02:02:03 -04:00
parent 4bfc18463b
commit c427231858
9 changed files with 316 additions and 27 deletions
+11
View File
@@ -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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+8
View File
@@ -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)],
}
+236 -26
View File
@@ -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<std::path::PathBuf>),
/// 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<String>) {
/// 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<String>) -> 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<String>) {
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<Sound> {
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<std::path::PathBuf>) -> Option<String> {
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<AppMessage> {
// 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<AppMessage> {
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<AppMessage> {
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<AppMessage> {
} 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());
}
+36
View File
@@ -478,6 +478,14 @@ pub struct AppConfig {
pub custom_sound_mic_toggle: Option<String>,
#[serde(default)]
pub custom_sound_reconnect_failed: Option<String>,
#[serde(default)]
pub custom_sound_chat_sent: Option<String>,
#[serde(default)]
pub custom_sound_chat_received: Option<String>,
#[serde(default)]
pub custom_sound_contact_online: Option<String>,
#[serde(default)]
pub custom_sound_contact_offline: Option<String>,
/// 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);
+25 -1
View File
@@ -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",
}
}
}