feat(notify): per-sound notification toggles (W6)
Add a per-event enable checkbox next to each chime in Settings so a user can silence individual sounds (e.g. keep 'message'/peer-join but drop reconnect chimes) while the master 'Enable sound notifications' toggle stays as the global kill-switch. The gate lives in one place at the play() seam: a pure should_play(master, sound) AND that's unit-tested, fed by a per-sound AtomicBool array in notify keyed by a stable Sound::index/ALL. Flags persist as 8 sound_*_enabled bools in AppConfig (default true, so upgrades are silent-change-free) with sound_enabled/set_sound_enabled accessors centralizing the field mapping. The per-sound checkbox greys out (drops on_toggle) while the master is off. +3 notify unit tests (should_play truth table, index bijection, flag set/query independence) + extended config backward-compat test. 230 lib tests green, clippy clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
use crate::notify::Sound;
|
||||
use crate::theme::AppTheme;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
@@ -198,6 +199,26 @@ pub struct AppConfig {
|
||||
pub custom_sound_mic_toggle: Option<String>,
|
||||
#[serde(default)]
|
||||
pub custom_sound_reconnect_failed: 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.
|
||||
/// Default true so behaviour is unchanged until a user opts a sound out.
|
||||
#[serde(default = "default_true")]
|
||||
pub sound_self_join_enabled: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub sound_peer_join_enabled: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub sound_peer_leave_enabled: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub sound_reconnect_attempt_enabled: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub sound_reconnected_enabled: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub sound_self_leave_enabled: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub sound_mic_toggle_enabled: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub sound_reconnect_failed_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)]
|
||||
@@ -246,6 +267,14 @@ impl Default for AppConfig {
|
||||
custom_sound_self_leave: None,
|
||||
custom_sound_mic_toggle: None,
|
||||
custom_sound_reconnect_failed: None,
|
||||
sound_self_join_enabled: true,
|
||||
sound_peer_join_enabled: true,
|
||||
sound_peer_leave_enabled: true,
|
||||
sound_reconnect_attempt_enabled: true,
|
||||
sound_reconnected_enabled: true,
|
||||
sound_self_leave_enabled: true,
|
||||
sound_mic_toggle_enabled: true,
|
||||
sound_reconnect_failed_enabled: true,
|
||||
pixelpass_path: None,
|
||||
window_width: default_window_width(),
|
||||
window_height: default_window_height(),
|
||||
@@ -256,6 +285,35 @@ impl Default for AppConfig {
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
/// Whether the chime for `sound` is enabled (its own per-sound flag; does
|
||||
/// NOT factor in the master `notifications_enabled` toggle — see `notify`).
|
||||
pub fn sound_enabled(&self, sound: Sound) -> bool {
|
||||
match sound {
|
||||
Sound::SelfJoin => self.sound_self_join_enabled,
|
||||
Sound::PeerJoin => self.sound_peer_join_enabled,
|
||||
Sound::PeerLeave => self.sound_peer_leave_enabled,
|
||||
Sound::ReconnectAttempt => self.sound_reconnect_attempt_enabled,
|
||||
Sound::Reconnected => self.sound_reconnected_enabled,
|
||||
Sound::SelfLeave => self.sound_self_leave_enabled,
|
||||
Sound::MicToggle => self.sound_mic_toggle_enabled,
|
||||
Sound::ReconnectFailed => self.sound_reconnect_failed_enabled,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the per-sound enable flag for `sound`.
|
||||
pub fn set_sound_enabled(&mut self, sound: Sound, enabled: bool) {
|
||||
match sound {
|
||||
Sound::SelfJoin => self.sound_self_join_enabled = enabled,
|
||||
Sound::PeerJoin => self.sound_peer_join_enabled = enabled,
|
||||
Sound::PeerLeave => self.sound_peer_leave_enabled = enabled,
|
||||
Sound::ReconnectAttempt => self.sound_reconnect_attempt_enabled = enabled,
|
||||
Sound::Reconnected => self.sound_reconnected_enabled = enabled,
|
||||
Sound::SelfLeave => self.sound_self_leave_enabled = enabled,
|
||||
Sound::MicToggle => self.sound_mic_toggle_enabled = enabled,
|
||||
Sound::ReconnectFailed => self.sound_reconnect_failed_enabled = enabled,
|
||||
}
|
||||
}
|
||||
|
||||
fn config_path() -> Option<PathBuf> {
|
||||
dirs::config_dir().map(|mut p| {
|
||||
p.push("peerspeak");
|
||||
@@ -325,6 +383,16 @@ 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());
|
||||
// Configs predating the per-sound flags (W6) enable every chime, so an
|
||||
// upgrade is silent-change-free.
|
||||
for sound in Sound::ALL {
|
||||
assert!(deserialized.sound_enabled(sound), "{sound:?} should default on");
|
||||
}
|
||||
// The accessor and mutator agree round-trip.
|
||||
let mut cfg = AppConfig::default();
|
||||
cfg.set_sound_enabled(Sound::PeerJoin, false);
|
||||
assert!(!cfg.sound_enabled(Sound::PeerJoin));
|
||||
assert!(cfg.sound_enabled(Sound::SelfJoin));
|
||||
// Configs predating the remembered window size load the default size.
|
||||
assert_eq!(deserialized.window_width, 900.0);
|
||||
assert_eq!(deserialized.window_height, 760.0);
|
||||
|
||||
Reference in New Issue
Block a user