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
+40
View File
@@ -0,0 +1,40 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { isSnoozeActive, nextTimeAtHour, SNOOZE_INDEFINITE } from './snooze';
test('isSnoozeActive: future instant is active, past/zero is not', () => {
const now = 1_000_000;
assert.equal(isSnoozeActive(now + 1, now), true);
assert.equal(isSnoozeActive(now, now), false);
assert.equal(isSnoozeActive(now - 1, now), false);
assert.equal(isSnoozeActive(0, now), false);
});
test('isSnoozeActive: SNOOZE_INDEFINITE stays active far into the future', () => {
const farFuture = Date.UTC(2100, 0, 1);
assert.equal(isSnoozeActive(SNOOZE_INDEFINITE, farFuture), true);
});
test('nextTimeAtHour: picks today when the hour is still ahead', () => {
const now = new Date(2026, 0, 5, 6, 30).getTime(); // 06:30 local
const target = nextTimeAtHour(8, now);
const d = new Date(target);
assert.equal(d.getHours(), 8);
assert.equal(d.getMinutes(), 0);
assert.equal(d.getDate(), 5); // still today
assert.ok(target > now);
});
test('nextTimeAtHour: rolls to tomorrow when the hour has passed', () => {
const now = new Date(2026, 0, 5, 9, 0).getTime(); // 09:00 local, past 08:00
const target = nextTimeAtHour(8, now);
const d = new Date(target);
assert.equal(d.getHours(), 8);
assert.equal(d.getDate(), 6); // tomorrow
});
test('nextTimeAtHour: exactly at the hour rolls to tomorrow (soonest future)', () => {
const now = new Date(2026, 0, 5, 8, 0).getTime(); // exactly 08:00
const target = nextTimeAtHour(8, now);
assert.equal(new Date(target).getDate(), 6);
});
+21
View File
@@ -0,0 +1,21 @@
// Pure helpers for the cross-platform "pause notifications" / snooze feature.
// Kept free of jotai/localStorage so they're unit-testable in isolation; the
// persisted atom lives in state/notificationSnooze.ts.
// Represents "paused until I turn it back on" (a far-future instant).
export const SNOOZE_INDEFINITE = Number.MAX_SAFE_INTEGER;
// Is the snooze currently active (i.e. suppressing notifications)?
export function isSnoozeActive(until: number, now: number = Date.now()): boolean {
return until > now;
}
// The soonest future instant at the given local hour (0-23), minute 0 — used for
// the "Until 8 AM" preset. If that hour has already passed today, rolls to
// tomorrow.
export function nextTimeAtHour(hour: number, now: number = Date.now()): number {
const d = new Date(now);
d.setHours(hour, 0, 0, 0);
if (d.getTime() <= now) d.setDate(d.getDate() + 1);
return d.getTime();
}