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:
2026-06-14 23:06:49 -04:00
co-authored by Claude Opus 4.8
parent e917c5393f
commit 7e75ae31af
3 changed files with 180 additions and 5 deletions
+91 -3
View File
@@ -17,16 +17,45 @@ use std::sync::{Mutex, OnceLock};
static ENABLED: AtomicBool = AtomicBool::new(true);
/// Enable or disable all notification chimes globally.
/// Per-sound enable flags (W6), indexed by `Sound::index`. The master `ENABLED`
/// toggle gates everything; these silence individual events while the master
/// stays on. Both must be true for a chime to play (see `should_play`). All
/// start true so behaviour is unchanged until a user opts a sound out.
static SOUND_ENABLED: [AtomicBool; Sound::COUNT] = {
// The repeat-init `const` is the standard way to build an atomic array; the
// interior-mutability lint is a false positive here (we want one fresh
// `AtomicBool` per slot, which is exactly what this does).
#[allow(clippy::declare_interior_mutable_const)]
const TRUE: AtomicBool = AtomicBool::new(true);
[TRUE; Sound::COUNT]
};
/// Enable or disable all notification chimes globally (the master toggle).
pub fn set_enabled(enabled: bool) {
ENABLED.store(enabled, Ordering::Relaxed);
}
/// Check if notifications are enabled.
/// Check if notifications are enabled (the master toggle).
pub fn is_enabled() -> bool {
ENABLED.load(Ordering::Relaxed)
}
/// Enable or disable the chime for a single event, independent of the master.
pub fn set_sound_enabled(sound: Sound, enabled: bool) {
SOUND_ENABLED[sound.index()].store(enabled, Ordering::Relaxed);
}
/// Whether the chime for `sound` is enabled (its own flag only, not the master).
pub fn is_sound_enabled(sound: Sound) -> bool {
SOUND_ENABLED[sound.index()].load(Ordering::Relaxed)
}
/// The single gate deciding whether a chime plays: the master toggle AND the
/// per-sound flag must both be on. Pure so the rule is unit-testable.
pub fn should_play(master_enabled: bool, sound_enabled: bool) -> bool {
master_enabled && sound_enabled
}
/// A notification event with a distinct chime.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -50,6 +79,35 @@ pub enum Sound {
}
impl Sound {
/// Every notification event, in a stable order (also the `index` order).
pub const ALL: [Sound; Sound::COUNT] = [
Sound::SelfJoin,
Sound::PeerJoin,
Sound::PeerLeave,
Sound::ReconnectAttempt,
Sound::Reconnected,
Sound::SelfLeave,
Sound::MicToggle,
Sound::ReconnectFailed,
];
/// Number of distinct notification events.
pub const COUNT: usize = 8;
/// Stable 0-based index into the per-sound flag array. Must match `ALL`.
fn index(self) -> usize {
match self {
Sound::SelfJoin => 0,
Sound::PeerJoin => 1,
Sound::PeerLeave => 2,
Sound::ReconnectAttempt => 3,
Sound::Reconnected => 4,
Sound::SelfLeave => 5,
Sound::MicToggle => 6,
Sound::ReconnectFailed => 7,
}
}
/// Embedded WAV bytes for this sound.
fn bytes(self) -> &'static [u8] {
match self {
@@ -106,7 +164,7 @@ pub fn validate_custom_path(path_str: &str) -> Option<bool> {
/// Play a notification chime, fire-and-forget. Never blocks; never errors out.
pub fn play(sound: Sound, custom_path: Option<&str>) {
if !is_enabled() {
if !should_play(is_enabled(), is_sound_enabled(sound)) {
return;
}
let custom_path = custom_path.map(String::from);
@@ -167,6 +225,36 @@ fn spawn_player(path: &Path) {
mod tests {
use super::*;
#[test]
fn test_should_play_truth_table() {
// Plays only when BOTH the master and the per-sound flag are on.
assert!(should_play(true, true));
assert!(!should_play(true, false));
assert!(!should_play(false, true));
assert!(!should_play(false, false));
}
#[test]
fn test_sound_indices_unique_and_match_all() {
// `index()` must be a 0..COUNT bijection in `ALL` order, or the flag
// array would alias two sounds onto one slot.
for (expected, sound) in Sound::ALL.iter().enumerate() {
assert_eq!(sound.index(), expected, "index drift for {sound:?}");
}
assert_eq!(Sound::ALL.len(), Sound::COUNT);
}
#[test]
fn test_set_and_query_per_sound_flag() {
// Toggling one sound's flag is independent of the others. (Uses
// PeerLeave to avoid clashing with flags other tests might touch.)
set_sound_enabled(Sound::PeerLeave, false);
assert!(!is_sound_enabled(Sound::PeerLeave));
assert!(is_sound_enabled(Sound::PeerJoin));
set_sound_enabled(Sound::PeerLeave, true);
assert!(is_sound_enabled(Sound::PeerLeave));
}
#[test]
fn test_expand_tilde() {
if let Some(home) = dirs::home_dir() {