Skip to content

Commit 74c65f0

Browse files
committed
feat: all-mode notifications live-muted-off (refs #337)
Upgrades the All-mode mute toggle to a three-state setting. `allModeMuteToasts` (boolean) is replaced by `allModeNotifications: 'live' | 'muted' | 'off'`, migrated inside mergeProfileSettings (legacy true -> muted, false/absent/ invalid -> live). 'muted' keeps today's semantics unchanged (connections run, toasts/sound suppressed, badge/history still accumulate). 'off' gates ProfileNotificationConnector's render site in NotificationHandler alongside the desktop/web platform check, so no connector mounts and zero All-mode websockets/pollers exist; mobile FCM is untouched. UI: the Switch on NotificationSettings becomes a Select (data-testid="all-mode-notifications- select"), localized across all 5 locales.
1 parent 7008691 commit 74c65f0

13 files changed

Lines changed: 207 additions & 60 deletions

app/src/components/NotificationHandler.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,14 +292,19 @@ export function NotificationHandler() {
292292
// so N extra websockets would only cost battery with no gap to close.
293293
// Native keeps today's deterministic single-connection + FCM-anchor
294294
// semantics unchanged.
295+
//
296+
// allModeNotifications === 'off' (refs #337): no connector mounts at all,
297+
// so zero All-mode websockets/pollers exist and nothing accumulates from
298+
// live paths. 'muted' still mounts every connector - only toast/sound
299+
// display is suppressed, at the useNotificationAllModeToasts seam.
295300
return (
296301
<>
297302
<ProfileSwitchDialog
298303
pending={pendingSwitch}
299304
onConfirm={handleConfirmSwitch}
300305
onCancel={handleCancelSwitch}
301306
/>
302-
{Platform.isDesktopOrWeb && scope?.mode === 'all' &&
307+
{Platform.isDesktopOrWeb && scope?.mode === 'all' && scope.settings.allModeNotifications !== 'off' &&
303308
scope.profiles.map((profile) => (
304309
<ProfileNotificationConnector key={profile.id} profile={profile} />
305310
))}

app/src/components/__tests__/NotificationHandler.test.tsx

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ type ScopeLike = {
5757
mode: string;
5858
profile: { id: string; name: string } | null;
5959
profiles: { id: string; name: string }[];
60-
settings: Record<string, never>;
60+
settings: { allModeNotifications?: 'live' | 'muted' | 'off' };
6161
} | null;
6262
const mockUseProfileScope = vi.fn<() => ScopeLike>(() => null);
6363
vi.mock('../../hooks/useProfileScope', () => ({
@@ -196,4 +196,38 @@ describe('NotificationHandler All-mode fan-out (refs #337)', () => {
196196

197197
expect(queryByTestId('connector-profile-1')).not.toBeInTheDocument();
198198
});
199+
200+
// refs #337: the live/muted/off upgrade. 'off' must not mount any
201+
// connector at all - zero websockets/pollers, nothing accumulates from
202+
// live paths (distinct from 'muted', which still connects and only
203+
// suppresses toast/sound display at the useNotificationAllModeToasts seam).
204+
it('mounts no connectors when allModeNotifications is off', () => {
205+
mockUseProfileScope.mockReturnValue({
206+
mode: 'all',
207+
profile: null,
208+
profiles: [
209+
{ id: 'profile-a', name: 'Home' },
210+
{ id: 'profile-b', name: 'Work' },
211+
],
212+
settings: { allModeNotifications: 'off' },
213+
});
214+
215+
const { queryByTestId } = renderHandler();
216+
217+
expect(queryByTestId('connector-profile-a')).not.toBeInTheDocument();
218+
expect(queryByTestId('connector-profile-b')).not.toBeInTheDocument();
219+
});
220+
221+
it('still mounts connectors when allModeNotifications is muted', () => {
222+
mockUseProfileScope.mockReturnValue({
223+
mode: 'all',
224+
profile: null,
225+
profiles: [{ id: 'profile-a', name: 'Home' }],
226+
settings: { allModeNotifications: 'muted' },
227+
});
228+
229+
const { getByTestId } = renderHandler();
230+
231+
expect(getByTestId('connector-profile-a')).toBeInTheDocument();
232+
});
199233
});

app/src/hooks/__tests__/useNotificationAllModeToasts.test.tsx

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ type ScopeLike = {
3939
mode: 'all' | 'single';
4040
profile: null;
4141
profiles: { id: string; name: string }[];
42-
settings: { allModeMuteToasts: boolean };
42+
settings: { allModeNotifications: 'live' | 'muted' | 'off' };
4343
} | null;
4444

4545
const mockScope = vi.fn<() => ScopeLike>(() => null);
@@ -90,7 +90,7 @@ describe('useNotificationAllModeToasts (refs #337)', () => {
9090
mode: 'single',
9191
profile: null,
9292
profiles: [{ id: PROFILE_A, name: 'Home' }],
93-
settings: { allModeMuteToasts: false },
93+
settings: { allModeNotifications: 'live' },
9494
});
9595
useNotificationStore.getState().updateProfileSettings(PROFILE_A, { enabled: true, showToasts: true });
9696
renderIt();
@@ -108,7 +108,7 @@ describe('useNotificationAllModeToasts (refs #337)', () => {
108108
mode: 'all',
109109
profile: null,
110110
profiles: [{ id: PROFILE_A, name: 'Home' }, { id: PROFILE_B, name: 'Work' }],
111-
settings: { allModeMuteToasts: false },
111+
settings: { allModeNotifications: 'live' },
112112
});
113113
useNotificationStore.getState().updateProfileSettings(PROFILE_A, { enabled: true, showToasts: true });
114114
useNotificationStore.getState().updateProfileSettings(PROFILE_B, { enabled: true, showToasts: true });
@@ -130,7 +130,7 @@ describe('useNotificationAllModeToasts (refs #337)', () => {
130130
mode: 'all',
131131
profile: null,
132132
profiles: [{ id: PROFILE_A, name: 'Home' }, { id: PROFILE_B, name: 'Work' }],
133-
settings: { allModeMuteToasts: false },
133+
settings: { allModeNotifications: 'live' },
134134
});
135135
useNotificationStore.getState().updateProfileSettings(PROFILE_A, { enabled: true, showToasts: true });
136136
useNotificationStore.getState().updateProfileSettings(PROFILE_B, { enabled: true, showToasts: true });
@@ -152,7 +152,7 @@ describe('useNotificationAllModeToasts (refs #337)', () => {
152152
mode: 'all',
153153
profile: null,
154154
profiles: [{ id: PROFILE_A, name: 'Home' }, { id: PROFILE_B, name: 'Work' }],
155-
settings: { allModeMuteToasts: false },
155+
settings: { allModeNotifications: 'live' },
156156
});
157157
useNotificationStore.getState().updateProfileSettings(PROFILE_A, { enabled: true, showToasts: true });
158158
useNotificationStore.getState().updateProfileSettings(PROFILE_B, { enabled: true, showToasts: true });
@@ -178,7 +178,7 @@ describe('useNotificationAllModeToasts (refs #337)', () => {
178178
mode: 'all',
179179
profile: null,
180180
profiles: [{ id: PROFILE_A, name: 'Home' }, { id: PROFILE_B, name: 'Work' }],
181-
settings: { allModeMuteToasts: false },
181+
settings: { allModeNotifications: 'live' },
182182
});
183183
useNotificationStore.getState().updateProfileSettings(PROFILE_A, { enabled: true, showToasts: true });
184184
useNotificationStore.getState().updateProfileSettings(PROFILE_B, { enabled: true, showToasts: false });
@@ -205,7 +205,7 @@ describe('useNotificationAllModeToasts (refs #337)', () => {
205205
mode: 'all',
206206
profile: null,
207207
profiles: [{ id: PROFILE_A, name: 'Home' }, { id: PROFILE_B, name: 'Work' }],
208-
settings: { allModeMuteToasts: false },
208+
settings: { allModeNotifications: 'live' },
209209
});
210210
useNotificationStore.getState().updateProfileSettings(PROFILE_A, { enabled: true, showToasts: true, playSound: true });
211211
useNotificationStore.getState().updateProfileSettings(PROFILE_B, { enabled: true, showToasts: true, playSound: true });
@@ -226,7 +226,7 @@ describe('useNotificationAllModeToasts (refs #337)', () => {
226226
mode: 'all',
227227
profile: null,
228228
profiles: [{ id: PROFILE_A, name: 'Home' }],
229-
settings: { allModeMuteToasts: true },
229+
settings: { allModeNotifications: 'muted' },
230230
});
231231
useNotificationStore.getState().updateProfileSettings(PROFILE_A, { enabled: true, showToasts: true, playSound: true });
232232
renderIt();
@@ -250,7 +250,7 @@ describe('useNotificationAllModeToasts (refs #337)', () => {
250250
mode: 'all',
251251
profile: null,
252252
profiles: [{ id: PROFILE_A, name: 'Home' }, { id: PROFILE_B, name: 'Work' }],
253-
settings: { allModeMuteToasts: false },
253+
settings: { allModeNotifications: 'live' },
254254
});
255255
useNotificationStore.getState().updateProfileSettings(PROFILE_A, { enabled: true, showToasts: true });
256256
// Disabled overall, even though showToasts itself is on.
@@ -276,7 +276,7 @@ describe('useNotificationAllModeToasts (refs #337)', () => {
276276
mode: 'all',
277277
profile: null,
278278
profiles: [{ id: PROFILE_A, name: 'Home' }],
279-
settings: { allModeMuteToasts: false },
279+
settings: { allModeNotifications: 'live' },
280280
});
281281
useNotificationStore.getState().updateProfileSettings(PROFILE_A, { enabled: true, showToasts: true });
282282
// Simulate a stale/persisted event already present before the hook ever

app/src/hooks/useNotificationAllModeToasts.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@
1212
* in the window still shows the normal per-event toast. At most one
1313
* notification sound plays per window.
1414
*
15-
* The all-mode mute toggle (settings store, ALL_PROFILES_ID bucket)
16-
* suppresses toasts and sound entirely; badge counts and history are
17-
* unaffected (addEvent, in stores/notifications.ts, always runs regardless).
15+
* The all-mode notifications setting (settings store, ALL_PROFILES_ID bucket)
16+
* 'muted' value suppresses toasts and sound entirely; badge counts and
17+
* history are unaffected (addEvent, in stores/notifications.ts, always runs
18+
* regardless). 'off' means no connector ever mounts, so this hook simply
19+
* never sees a new event for that profile.
1820
*
1921
* Single mode is untouched: this hook no-ops unless scope.mode is 'all'.
2022
*/
@@ -160,7 +162,7 @@ export function useNotificationAllModeToasts(): void {
160162

161163
// lastSeenAtRef is updated above regardless of mute, so nothing already
162164
// seen replays into a toast once the user unmutes.
163-
if (newlyArrived.length === 0 || scope.settings.allModeMuteToasts) return;
165+
if (newlyArrived.length === 0 || scope.settings.allModeNotifications === 'muted') return;
164166

165167
burstRef.current.push(...newlyArrived);
166168
if (!timerRef.current) {

app/src/locales/de/translation.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -932,8 +932,13 @@
932932
"overview_disabled": "Deaktiviert",
933933
"overview_direct_mode_host": "Direktabfrage",
934934
"overview_no_host": "Kein Host festgelegt",
935-
"all_mode_mute_toggle": "Alle-Server-Meldungen stummschalten",
936-
"all_mode_mute_toggle_desc": "Unterdrückt Toast-Meldungen und Ton bei aggregierter Ansicht aller Server. Zähler und Verlauf werden weiterhin aktualisiert."
935+
"all_mode_notifications_label": "Alle-Server-Benachrichtigungen",
936+
"all_mode_notifications_live": "Live",
937+
"all_mode_notifications_live_desc": "Jeder Server verbindet sich live; Toast-Meldungen und Ton werden bei Ereignissen abgespielt.",
938+
"all_mode_notifications_muted": "Stumm",
939+
"all_mode_notifications_muted_desc": "Jeder Server bleibt verbunden, aber Toast-Meldungen und Ton werden unterdrückt. Zähler und Verlauf werden weiterhin aktualisiert.",
940+
"all_mode_notifications_off": "Aus",
941+
"all_mode_notifications_off_desc": "Bei aggregierter Ansicht verbindet sich kein Server. Nichts wird aktualisiert, bis dies wieder aktiviert wird."
937942
},
938943
"notifications": {
939944
"status": {

app/src/locales/en/translation.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -932,8 +932,13 @@
932932
"overview_disabled": "Disabled",
933933
"overview_direct_mode_host": "Direct polling",
934934
"overview_no_host": "No host set",
935-
"all_mode_mute_toggle": "Mute All-mode toasts",
936-
"all_mode_mute_toggle_desc": "Suppress toast notifications and sound while aggregating every server. Badge counts and history still update."
935+
"all_mode_notifications_label": "All-mode notifications",
936+
"all_mode_notifications_live": "Live",
937+
"all_mode_notifications_live_desc": "Every server connects live; toasts and sound play as events arrive.",
938+
"all_mode_notifications_muted": "Muted",
939+
"all_mode_notifications_muted_desc": "Every server stays connected, but toasts and sound are suppressed. Badge counts and history still update.",
940+
"all_mode_notifications_off": "Off",
941+
"all_mode_notifications_off_desc": "No server connects while aggregating. Nothing updates until you switch this back on."
937942
},
938943
"notifications": {
939944
"status": {

app/src/locales/es/translation.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -932,8 +932,13 @@
932932
"overview_disabled": "Deshabilitado",
933933
"overview_direct_mode_host": "Sondeo directo",
934934
"overview_no_host": "Sin host configurado",
935-
"all_mode_mute_toggle": "Silenciar avisos en modo Todos",
936-
"all_mode_mute_toggle_desc": "Suprime los avisos emergentes y el sonido al agregar todos los servidores. El contador y el historial se siguen actualizando."
935+
"all_mode_notifications_label": "Notificaciones en modo Todos",
936+
"all_mode_notifications_live": "En vivo",
937+
"all_mode_notifications_live_desc": "Cada servidor se conecta en vivo; los avisos y el sonido se reproducen al llegar eventos.",
938+
"all_mode_notifications_muted": "Silenciado",
939+
"all_mode_notifications_muted_desc": "Cada servidor permanece conectado, pero los avisos y el sonido se suprimen. El contador y el historial se siguen actualizando.",
940+
"all_mode_notifications_off": "Desactivado",
941+
"all_mode_notifications_off_desc": "Ningún servidor se conecta al agregar. Nada se actualiza hasta que vuelvas a activarlo."
937942
},
938943
"notifications": {
939944
"status": {

app/src/locales/fr/translation.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -932,8 +932,13 @@
932932
"overview_disabled": "Désactivé",
933933
"overview_direct_mode_host": "Sondage direct",
934934
"overview_no_host": "Aucun hôte défini",
935-
"all_mode_mute_toggle": "Couper les notifications du mode Tous",
936-
"all_mode_mute_toggle_desc": "Supprime les notifications et le son lors de l'agrégation de tous les serveurs. Le badge et l'historique continuent d'être mis à jour."
935+
"all_mode_notifications_label": "Notifications du mode Tous",
936+
"all_mode_notifications_live": "En direct",
937+
"all_mode_notifications_live_desc": "Chaque serveur se connecte en direct ; les notifications et le son sont émis à l'arrivée des événements.",
938+
"all_mode_notifications_muted": "Coupé",
939+
"all_mode_notifications_muted_desc": "Chaque serveur reste connecté, mais les notifications et le son sont supprimés. Le badge et l'historique continuent d'être mis à jour.",
940+
"all_mode_notifications_off": "Désactivé",
941+
"all_mode_notifications_off_desc": "Aucun serveur ne se connecte lors de l'agrégation. Rien ne se met à jour tant que ce n'est pas réactivé."
937942
},
938943
"notifications": {
939944
"status": {

app/src/locales/zh/translation.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -932,8 +932,13 @@
932932
"overview_disabled": "已禁用",
933933
"overview_direct_mode_host": "直接轮询",
934934
"overview_no_host": "未设置主机",
935-
"all_mode_mute_toggle": "静音全部模式提示",
936-
"all_mode_mute_toggle_desc": "聚合查看所有服务器时禁止提示通知和声音。角标计数和历史记录仍会更新。"
935+
"all_mode_notifications_label": "全部模式通知",
936+
"all_mode_notifications_live": "实时",
937+
"all_mode_notifications_live_desc": "每个服务器实时连接;事件到达时播放提示和声音。",
938+
"all_mode_notifications_muted": "静音",
939+
"all_mode_notifications_muted_desc": "每个服务器保持连接,但禁止提示和声音。角标计数和历史记录仍会更新。",
940+
"all_mode_notifications_off": "关闭",
941+
"all_mode_notifications_off_desc": "聚合查看时不连接任何服务器。重新开启前不会有任何更新。"
937942
},
938943
"notifications": {
939944
"status": {

app/src/pages/NotificationSettings.tsx

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { useShallow } from 'zustand/react/shallow';
1313
import { useCurrentProfile, useProfileById } from '../hooks/useCurrentProfile';
1414
import { useProfileScope } from '../hooks/useProfileScope';
1515
import { useProfileStore } from '../stores/profile';
16-
import { useSettingsStore } from '../stores/settings';
16+
import { useSettingsStore, type AllModeNotifications } from '../stores/settings';
1717
import { getMonitors } from '../api/monitors';
1818
import { useAuthSlice } from '../stores/auth';
1919
import { ProfilePicker } from '../components/profile-picker';
@@ -22,6 +22,7 @@ import { Button } from '../components/ui/button';
2222
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card';
2323
import { Label } from '../components/ui/label';
2424
import { Switch } from '../components/ui/switch';
25+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../components/ui/select';
2526
import { Badge } from '../components/ui/badge';
2627
import {
2728
Bell,
@@ -72,12 +73,12 @@ export default function NotificationSettings() {
7273
const isConnected = connectionState === 'connected';
7374
const disconnect = () => currentProfile && storeDisconnect(currentProfile.id);
7475

75-
// All-mode mute toggle: an ALL-bucket app setting (not a per-profile
76-
// notification setting), so it lives in the settings store and is
77-
// read/written against the ALL_PROFILES_ID sentinel (refs #337).
76+
// All-mode notifications (live/muted/off): an ALL-bucket app setting (not
77+
// a per-profile notification setting), so it lives in the settings store
78+
// and is read/written against the ALL_PROFILES_ID sentinel (refs #337).
7879
// `scope.settings` is already the merged ALL-bucket settings whenever
7980
// scope.mode is 'all' (useProfileScope), so no extra selector is needed.
80-
const allModeMuteToasts = scope?.settings.allModeMuteToasts ?? false;
81+
const allModeNotifications = scope?.settings.allModeNotifications ?? 'live';
8182
const updateAllModeSettings = useSettingsStore((s) => s.updateProfileSettings);
8283

8384
// Subscribe reactively to this profile's settings and unread count so the
@@ -367,21 +368,38 @@ export default function NotificationSettings() {
367368
<Card>
368369
<CardContent className="flex items-center justify-between gap-3 p-4">
369370
<div className="flex-1 space-y-0.5">
370-
<Label htmlFor="all-mode-mute-toggle" className="text-base font-semibold">
371-
{t('notification_settings.all_mode_mute_toggle')}
371+
<Label htmlFor="all-mode-notifications-select" className="text-base font-semibold">
372+
{t('notification_settings.all_mode_notifications_label')}
372373
</Label>
373374
<p className="text-sm text-muted-foreground">
374-
{t('notification_settings.all_mode_mute_toggle_desc')}
375+
{t(`notification_settings.all_mode_notifications_${allModeNotifications}_desc`)}
375376
</p>
376377
</div>
377-
<Switch
378-
id="all-mode-mute-toggle"
379-
checked={allModeMuteToasts}
380-
onCheckedChange={(checked) =>
381-
updateAllModeSettings(ALL_PROFILES_ID, { allModeMuteToasts: checked })
378+
<Select
379+
value={allModeNotifications}
380+
onValueChange={(value) =>
381+
updateAllModeSettings(ALL_PROFILES_ID, { allModeNotifications: value as AllModeNotifications })
382382
}
383-
data-testid="all-mode-mute-toggle"
384-
/>
383+
>
384+
<SelectTrigger
385+
id="all-mode-notifications-select"
386+
className="w-32"
387+
data-testid="all-mode-notifications-select"
388+
>
389+
<SelectValue />
390+
</SelectTrigger>
391+
<SelectContent>
392+
<SelectItem value="live" data-testid="all-mode-notifications-option-live">
393+
{t('notification_settings.all_mode_notifications_live')}
394+
</SelectItem>
395+
<SelectItem value="muted" data-testid="all-mode-notifications-option-muted">
396+
{t('notification_settings.all_mode_notifications_muted')}
397+
</SelectItem>
398+
<SelectItem value="off" data-testid="all-mode-notifications-option-off">
399+
{t('notification_settings.all_mode_notifications_off')}
400+
</SelectItem>
401+
</SelectContent>
402+
</Select>
385403
</CardContent>
386404
</Card>
387405
<NotificationOverview

0 commit comments

Comments
 (0)