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] ## [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 ### Fixed
- **Low-latency screen sharing stays near the live edge again.** mpv's - **Low-latency screen sharing stays near the live edge again.** mpv's
timestamp pacing could let stale frames accumulate across the reliable 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)], "mic-toggle.wav": [(E5, 0.08)],
# Reconnect gave up: disappointing low two-note fall. # Reconnect gave up: disappointing low two-note fall.
"reconnect-failed.wav": [(C5, 0.15), (349.23, 0.30)], "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), ToggleNotifications(bool),
ToggleEchoCancellation(bool), ToggleEchoCancellation(bool),
CustomSoundPathChanged(Sound, String), 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). /// Toggle the per-sound enable flag for a single chime (W6).
ToggleSoundEnabled(Sound, bool), ToggleSoundEnabled(Sound, bool),
/// Open / cancel the "Regenerate identity?" confirm modal (W7). /// 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 /// 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 /// 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. /// 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 { match error {
None => { 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); self.send_payloads.remove(&local_id);
matched
} }
Some(e) => { Some(e) => {
if !self.set_send_status(local_id, SendStatus::Failed(e)) { if !self.set_send_status(local_id, SendStatus::Failed(e)) {
self.send_payloads.remove(&local_id); self.send_payloads.remove(&local_id);
} }
false
} }
} }
} }
@@ -1379,9 +1387,30 @@ impl AppState {
Sound::SelfLeave => &self.config.custom_sound_self_leave, Sound::SelfLeave => &self.config.custom_sound_self_leave,
Sound::MicToggle => &self.config.custom_sound_mic_toggle, Sound::MicToggle => &self.config.custom_sound_mic_toggle,
Sound::ReconnectFailed => &self.config.custom_sound_reconnect_failed, 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("") 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 { impl Default for AppState {
@@ -1871,6 +1900,47 @@ fn reconnected_chime(
was_reconnect.then_some(Sound::Reconnected) 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 { fn in_call(state: &AppState) -> bool {
!state.ticket.is_empty() !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 // failure it's retained for Retry — unless the entry is gone
// (history eviction / room reset), in which case drop it so // (history eviction / room reset), in which case drop it so
// the payload map can't leak. // 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 { UiEvent::ChatMessage {
from, from,
@@ -2343,6 +2418,10 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
local_send: None, local_send: None,
}, },
); );
notify::play(
Sound::ChatReceived,
state.config.custom_sound_chat_received.as_deref(),
);
} }
} }
UiEvent::AttachmentReady { from, id, data } => { UiEvent::AttachmentReady { from, id, data } => {
@@ -2512,7 +2591,15 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.friends_read_only = read_only; state.friends_read_only = read_only;
} }
UiEvent::FriendPresence { id, presence } => { UiEvent::FriendPresence { id, presence } => {
let sound = friend_presence_notification(
state.current_screen,
state.friend_presence.get(&id),
&presence,
);
state.friend_presence.insert(id, presence); state.friend_presence.insert(id, presence);
if let Some(sound) = sound {
notify::play(sound, Some(state.custom_sound_path(sound)));
}
} }
UiEvent::FriendsRescanned => { UiEvent::FriendsRescanned => {
// The manual pass finished. Stamp the time for the live "scanned // 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 { } else {
Some(path) Some(path)
}; };
match sound { state.set_custom_sound_path(sound, path_opt);
Sound::SelfJoin => state.config.custom_sound_self_join = path_opt, }
Sound::PeerJoin => state.config.custom_sound_peer_join = path_opt, AppMessage::BrowseCustomSound(sound) => {
Sound::PeerLeave => state.config.custom_sound_peer_leave = path_opt, let initial_dir = {
Sound::ReconnectAttempt => state.config.custom_sound_reconnect_attempt = path_opt, let current = state.custom_sound_path(sound);
Sound::Reconnected => state.config.custom_sound_reconnected = path_opt, (!current.trim().is_empty())
Sound::SelfLeave => state.config.custom_sound_self_leave = path_opt, .then(|| notify::expand_tilde(current))
Sound::MicToggle => state.config.custom_sound_mic_toggle = path_opt, .and_then(|path| path.parent().map(std::path::Path::to_path_buf))
Sound::ReconnectFailed => state.config.custom_sound_reconnect_failed = path_opt, .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) => { AppMessage::ToggleSoundEnabled(sound, enabled) => {
@@ -5282,10 +5390,19 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
] ]
.spacing(6) .spacing(6)
.align_y(iced::alignment::Vertical::Center), .align_y(iced::alignment::Vertical::Center),
context_input("Default (embedded)...", path) row![
.on_input(move |val| AppMessage::CustomSoundPathChanged(sound, val)) context_input("Default (embedded)...", path)
.style(t_style) .on_input(move |val| AppMessage::CustomSoundPathChanged(sound, val))
.padding(8) .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) .spacing(4)
.width(iced::Length::Fill) .width(iced::Length::Fill)
@@ -6035,6 +6152,14 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
path_field("Mic Toggle", Sound::MicToggle), path_field("Mic Toggle", Sound::MicToggle),
path_field("Reconnect Failed", Sound::ReconnectFailed), path_field("Reconnect Failed", Sound::ReconnectFailed),
].spacing(20).width(iced::Length::Fill), ].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(8).width(iced::Length::Fill),
] ]
.spacing(10) .spacing(10)
@@ -9544,12 +9669,12 @@ mod tests {
use super::sendqueue::{self, LocalSend, SendStatus}; use super::sendqueue::{self, LocalSend, SendStatus};
use super::{ use super::{
AppConfig, AppMessage, AppState, AttachmentCache, AttachmentState, 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, ScreenBounds, UiEvent, attachment_default_name, clamp_window_position,
clear_expired_clock_skew_warning, format_clock_skew_duration, format_duration, clear_expired_clock_skew_warning, format_clock_skew_duration, format_duration,
format_relative_ago, initial_window_position, now_playing_label, reconnect_attempt_chime, format_relative_ago, friend_presence_notification, initial_window_position,
reconnected_chime, set_peer_gate_config, set_peer_volume_config, show_clock_skew_warning, now_playing_label, reconnect_attempt_chime, reconnected_chime, selected_wav_path,
update, set_peer_gate_config, set_peer_volume_config, show_clock_skew_warning, update,
}; };
use iroh::SecretKey; use iroh::SecretKey;
use std::collections::VecDeque; use std::collections::VecDeque;
@@ -10640,6 +10765,91 @@ mod tests {
const W: f32 = 200.0; 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] #[test]
fn gate_drag_maps_left_edge_to_zero() { fn gate_drag_maps_left_edge_to_zero() {
assert_eq!(GateMeter::x_to_threshold(0.0, W), 0.0); 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. // Empty queue + a fresh full pacer → dispatched immediately.
assert_eq!(status_of(&state, id), Some(SendStatus::Pending)); assert_eq!(status_of(&state, id), Some(SendStatus::Pending));
assert!(state.send_payloads.contains_key(&id)); 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)); assert_eq!(status_of(&state, id), Some(SendStatus::Broadcast));
// A completed send releases its retry payload. // A completed send releases its retry payload.
assert!(!state.send_payloads.contains_key(&id)); assert!(!state.send_payloads.contains_key(&id));
@@ -10934,7 +11144,7 @@ mod tests {
let mut state = AppState::default(); let mut state = AppState::default();
let id = push_own(&mut state, "yo"); let id = push_own(&mut state, "yo");
state.submit_send(id, PendingSend::Text("yo".to_string())); 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!( assert_eq!(
status_of(&state, id), status_of(&state, id),
Some(SendStatus::Failed("not in a room".to_string())) Some(SendStatus::Failed("not in a room".to_string()))
@@ -10950,11 +11160,11 @@ mod tests {
state.submit_send(a, PendingSend::Text("a".to_string())); state.submit_send(a, PendingSend::Text("a".to_string()));
let b = push_own(&mut state, "b"); let b = push_own(&mut state, "b");
state.submit_send(b, PendingSend::Text("b".to_string())); 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, a), Some(SendStatus::Broadcast));
assert_eq!(status_of(&state, b), Some(SendStatus::Pending)); assert_eq!(status_of(&state, b), Some(SendStatus::Pending));
// A result for an id with no matching entry is a harmless no-op. // 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)); assert_eq!(status_of(&state, b), Some(SendStatus::Pending));
} }
@@ -10968,7 +11178,7 @@ mod tests {
state state
.chat_messages .chat_messages
.retain(|m| m.local_send.as_ref().map(|s| s.id) != Some(id)); .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. // No entry to mark → the payload must not leak.
assert!(!state.send_payloads.contains_key(&id)); assert!(!state.send_payloads.contains_key(&id));
} }
@@ -10983,7 +11193,7 @@ mod tests {
assert!(state.send_queue.is_empty()); assert!(state.send_queue.is_empty());
assert!(state.send_payloads.is_empty()); assert!(state.send_payloads.is_empty());
// A late result for the pre-reset send touches nothing and adds no entry. // 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.chat_messages.is_empty());
assert!(state.send_payloads.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>, pub custom_sound_mic_toggle: Option<String>,
#[serde(default)] #[serde(default)]
pub custom_sound_reconnect_failed: Option<String>, 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 /// Per-sound enable flags (W6). The master `notifications_enabled` toggle
/// gates ALL chimes; these let the user silence individual events while the /// 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. /// 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, pub sound_mic_toggle_enabled: bool,
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub sound_reconnect_failed_enabled: bool, 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). /// Optional override for the `pixelpass` binary location (screen share).
/// Empty / unset = look it up on `$PATH`. Hand-editable; no Settings UI yet. /// Empty / unset = look it up on `$PATH`. Hand-editable; no Settings UI yet.
#[serde(default)] #[serde(default)]
@@ -601,6 +617,10 @@ impl Default for AppConfig {
custom_sound_self_leave: None, custom_sound_self_leave: None,
custom_sound_mic_toggle: None, custom_sound_mic_toggle: None,
custom_sound_reconnect_failed: 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_self_join_enabled: true,
sound_peer_join_enabled: true, sound_peer_join_enabled: true,
sound_peer_leave_enabled: true, sound_peer_leave_enabled: true,
@@ -609,6 +629,10 @@ impl Default for AppConfig {
sound_self_leave_enabled: true, sound_self_leave_enabled: true,
sound_mic_toggle_enabled: true, sound_mic_toggle_enabled: true,
sound_reconnect_failed_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, pixelpass_path: None,
screen_share: ScreenShareSettings::default(), screen_share: ScreenShareSettings::default(),
recents: Vec::new(), recents: Vec::new(),
@@ -639,6 +663,10 @@ impl AppConfig {
Sound::SelfLeave => self.sound_self_leave_enabled, Sound::SelfLeave => self.sound_self_leave_enabled,
Sound::MicToggle => self.sound_mic_toggle_enabled, Sound::MicToggle => self.sound_mic_toggle_enabled,
Sound::ReconnectFailed => self.sound_reconnect_failed_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::SelfLeave => self.sound_self_leave_enabled = enabled,
Sound::MicToggle => self.sound_mic_toggle_enabled = enabled, Sound::MicToggle => self.sound_mic_toggle_enabled = enabled,
Sound::ReconnectFailed => self.sound_reconnect_failed_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_self_leave.is_none());
assert!(deserialized.custom_sound_mic_toggle.is_none()); assert!(deserialized.custom_sound_mic_toggle.is_none());
assert!(deserialized.custom_sound_reconnect_failed.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, ScreenShareSettings::default());
assert_eq!(deserialized.screen_share.quality, ShareQuality::Auto); assert_eq!(deserialized.screen_share.quality, ShareQuality::Auto);
assert_eq!(deserialized.screen_share.player, SharePlayer::Mpv); assert_eq!(deserialized.screen_share.player, SharePlayer::Mpv);
+25 -1
View File
@@ -80,6 +80,14 @@ pub enum Sound {
MicToggle, MicToggle,
/// Reconnect failed / peer evicted. /// Reconnect failed / peer evicted.
ReconnectFailed, 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 { impl Sound {
@@ -93,10 +101,14 @@ impl Sound {
Sound::SelfLeave, Sound::SelfLeave,
Sound::MicToggle, Sound::MicToggle,
Sound::ReconnectFailed, Sound::ReconnectFailed,
Sound::ChatSent,
Sound::ChatReceived,
Sound::ContactOnline,
Sound::ContactOffline,
]; ];
/// Number of distinct notification events. /// 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`. /// Stable 0-based index into the per-sound flag array. Must match `ALL`.
fn index(self) -> usize { fn index(self) -> usize {
@@ -109,6 +121,10 @@ impl Sound {
Sound::SelfLeave => 5, Sound::SelfLeave => 5,
Sound::MicToggle => 6, Sound::MicToggle => 6,
Sound::ReconnectFailed => 7, 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::SelfLeave => include_bytes!("../assets/sounds/self-leave.wav"),
Sound::MicToggle => include_bytes!("../assets/sounds/mic-toggle.wav"), Sound::MicToggle => include_bytes!("../assets/sounds/mic-toggle.wav"),
Sound::ReconnectFailed => include_bytes!("../assets/sounds/reconnect-failed.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::SelfLeave => "self-leave",
Sound::MicToggle => "mic-toggle", Sound::MicToggle => "mic-toggle",
Sound::ReconnectFailed => "reconnect-failed", Sound::ReconnectFailed => "reconnect-failed",
Sound::ChatSent => "chat-sent",
Sound::ChatReceived => "chat-received",
Sound::ContactOnline => "contact-online",
Sound::ContactOffline => "contact-offline",
} }
} }
} }