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
+21 -2
View File
@@ -141,6 +141,8 @@ pub enum AppMessage {
ToggleNotifications(bool),
ToggleEchoCancellation(bool),
CustomSoundPathChanged(Sound, String),
/// Toggle the per-sound enable flag for a single chime (W6).
ToggleSoundEnabled(Sound, bool),
ToggleMicTest(bool),
/// Start/stop recording the call; the core confirms via Recording{Started,Stopped}.
ToggleRecording,
@@ -282,6 +284,9 @@ impl Default for AppState {
config.controls_width = clamp_controls_width(config.controls_width, ww);
config.chat_drawer_width = clamp_chat_drawer_width(config.chat_drawer_width, ww);
notify::set_enabled(config.notifications_enabled);
for sound in Sound::ALL {
notify::set_sound_enabled(sound, config.sound_enabled(sound));
}
let _ = controller.send(CoreCommand::SetNoiseGateThreshold(config.noise_gate_threshold));
let _ = controller.send(CoreCommand::SetInputVolume(config.input_volume));
let _ = controller.send(CoreCommand::SetOutputVolume(config.output_volume));
@@ -724,6 +729,11 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
Sound::ReconnectFailed => state.config.custom_sound_reconnect_failed = path_opt,
}
}
AppMessage::ToggleSoundEnabled(sound, enabled) => {
state.config.set_sound_enabled(sound, enabled);
state.config.save();
notify::set_sound_enabled(sound, enabled);
}
AppMessage::ToggleRecording => {
// Optimistic intent; the core flips `recording` for real via the
// Recording{Started,Stopped} events (so a failed start won't lie).
@@ -1121,12 +1131,21 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
Some(false) => text("✗ File not found").size(10).color(color_red),
};
// Per-sound enable checkbox (W6). Interactive only while the master
// toggle is on — without it every chime is muted anyway, so we drop
// `on_toggle` to render it greyed out and inert.
let mut enable = checkbox(state.config.sound_enabled(sound)).size(16);
if state.config.notifications_enabled {
enable = enable.on_toggle(move |on| AppMessage::ToggleSoundEnabled(sound, on));
}
column![
row![
enable,
text(label).size(12).color(color_subtext),
horizontal_space(),
validation_widget,
].align_y(iced::alignment::Vertical::Center),
].spacing(6).align_y(iced::alignment::Vertical::Center),
text_input("Default (embedded)...", path)
.on_input(move |val| AppMessage::CustomSoundPathChanged(sound, val))
.style(t_style)
@@ -1460,7 +1479,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.label("Enable sound notifications")
.on_toggle(AppMessage::ToggleNotifications),
vertical_space(6.0),
text("Custom chime files (WAV paths) — leave blank for the built-in sounds.").size(12).color(color_subtext),
text("Tick a sound to enable its chime; untick to silence just that one. Optional WAV path overrides the built-in sound (blank = built-in).").size(12).color(color_subtext),
row![
path_field("Self Join", Sound::SelfJoin),
path_field("Peer Join", Sound::PeerJoin),