feat(notifications): cross-platform "Pause Notifications" / snooze

Manual DND only existed via the desktop tray (manualDndAtom), so web/mobile
users had no way to pause notifications, and there was no snooze-for-a-duration
anywhere. Add a "Pause Notifications" control in Settings > Notifications:

- Presets: 30 min / 1 hour / 4 hours / Until 8 AM / Until I resume, plus Resume;
  live "Paused until ..." status that flips back on when the snooze lapses.
- Persisted snooze instant (cinny_notification_snooze_until_v1) so it survives a
  reload; 0 = off, SNOOZE_INDEFINITE = until resumed.
- Feeds the existing notification gate (ClientNonUIFeatures, both the message and
  invite monitors) alongside Focus Assist / manual DND / Quiet Hours, suppressing
  notify() and playSound().
- Pure helpers isSnoozeActive/nextTimeAtHour/SNOOZE_INDEFINITE in utils/snooze.ts
  (+5 unit tests); persisted atom in state/notificationSnooze.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 19:08:02 -04:00
co-authored by Claude Opus 4.8
parent e7e6d44a31
commit 61f1733f50
6 changed files with 181 additions and 2 deletions
@@ -14,6 +14,7 @@ import {
} from 'matrix-js-sdk';
import { focusAssistActiveAtom } from '../../state/focusAssist';
import { manualDndAtom } from '../../state/manualDnd';
import { isSnoozeActive, notificationSnoozeUntilAtom } from '../../state/notificationSnooze';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import LogoSVG from '../../../../public/res/lotus.png';
import LogoUnreadSVG from '../../../../public/res/lotus-unread.png';
@@ -157,6 +158,7 @@ function InviteNotifications() {
const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled');
const focusAssistActive = useAtomValue(focusAssistActiveAtom);
const manualDnd = useAtomValue(manualDndAtom);
const snoozeUntil = useAtomValue(notificationSnoozeUntilAtom);
const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart');
const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd');
const [inviteSoundId] = useSetting(settingsAtom, 'inviteSoundId');
@@ -256,6 +258,7 @@ function InviteNotifications() {
const quietActive =
focusAssistActive ||
manualDnd ||
isSnoozeActive(snoozeUntil) ||
(quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd));
if (quietActive) return;
@@ -276,6 +279,7 @@ function InviteNotifications() {
quietHoursEnd,
focusAssistActive,
manualDnd,
snoozeUntil,
inviteSoundId,
]);
@@ -389,6 +393,7 @@ function MessageNotifications() {
const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled');
const focusAssistActive = useAtomValue(focusAssistActiveAtom);
const manualDnd = useAtomValue(manualDndAtom);
const snoozeUntil = useAtomValue(notificationSnoozeUntilAtom);
const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart');
const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd');
const [messageSoundId] = useSetting(settingsAtom, 'messageSoundId');
@@ -533,6 +538,7 @@ function MessageNotifications() {
const quietActive =
focusAssistActive ||
manualDnd ||
isSnoozeActive(snoozeUntil) ||
(quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd));
if (quietActive) return;
@@ -569,6 +575,7 @@ function MessageNotifications() {
quietHoursEnd,
focusAssistActive,
manualDnd,
snoozeUntil,
messageSoundId,
],
);