diff --git a/LOTUS_FEATURES.md b/LOTUS_FEATURES.md index 9ab0f017b..3895db28b 100644 --- a/LOTUS_FEATURES.md +++ b/LOTUS_FEATURES.md @@ -1259,6 +1259,13 @@ Accessible via **Room/Space Settings → Policy Lists** (admin only). - Gates both `notify()` (visual/OS notifications) and `playSound()` (audio alerts) - When active, notifications are silently dropped rather than queued +### Pause Notifications (snooze) + +- A **cross-platform** "Pause Notifications" control in **Settings → Notifications** (the desktop-tray Do Not Disturb only worked on the desktop app; web/mobile had no manual pause). +- Quick presets: 30 minutes / 1 hour / 4 hours / Until 8 AM / **Until I resume** (indefinite), plus a **Resume** button; the tile shows the live "Paused until …" status (via `formatFriendlyDateTime`) and flips back to "on" the moment the snooze lapses. +- Persisted (`cinny_notification_snooze_until_v1`) as the epoch-ms instant to pause until (`0` = off), so a snooze survives a reload. Feeds the same notification gate as Focus Assist / Quiet Hours (`ClientNonUIFeatures`), suppressing both `notify()` and `playSound()`. +- Pure, unit-tested helpers `isSnoozeActive` / `nextTimeAtHour` / `SNOOZE_INDEFINITE` in `src/app/utils/snooze.ts` (`snooze.test.ts`); persisted atom in `src/app/state/notificationSnooze.ts`. + ### Full Push Rule Editor A complete UI for managing Matrix push notification rules: diff --git a/src/app/features/settings/notifications/SystemNotification.tsx b/src/app/features/settings/notifications/SystemNotification.tsx index 4f957808c..9a8aaf356 100644 --- a/src/app/features/settings/notifications/SystemNotification.tsx +++ b/src/app/features/settings/notifications/SystemNotification.tsx @@ -1,12 +1,20 @@ -import React, { useCallback } from 'react'; -import { Box, Text, Switch, Button, color, config, Spinner } from 'folds'; +import React, { useCallback, useEffect, useState } from 'react'; +import { Box, Text, Switch, Button, Chip, Icon, Icons, color, config, Spinner } from 'folds'; import { IPusherRequest } from 'matrix-js-sdk'; +import { useAtomValue, useSetAtom } from 'jotai'; import { NOTIFICATION_SOUND_MAP } from '../../../utils/notificationSounds'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../styles.css'; import { SettingTile } from '../../../components/setting-tile'; import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; +import { + isSnoozeActive, + nextTimeAtHour, + notificationSnoozeUntilAtom, + SNOOZE_INDEFINITE, +} from '../../../state/notificationSnooze'; +import { formatFriendlyDateTime } from '../../../utils/datetimeInput'; import { getNotificationState, usePermissionState } from '../../../hooks/usePermission'; import { useEmailNotifications } from '../../../hooks/useEmailNotifications'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; @@ -120,6 +128,72 @@ const selectStyle: React.CSSProperties = { outline: 'none', }; +const SNOOZE_PRESETS: Array<{ label: string; resolve: (now: number) => number }> = [ + { label: '30 minutes', resolve: (now) => now + 30 * 60_000 }, + { label: '1 hour', resolve: (now) => now + 60 * 60_000 }, + { label: '4 hours', resolve: (now) => now + 4 * 60 * 60_000 }, + { label: 'Until 8 AM', resolve: (now) => nextTimeAtHour(8, now) }, + { label: 'Until I resume', resolve: () => SNOOZE_INDEFINITE }, +]; + +// Cross-platform "pause notifications" — sets a snooze instant that the +// notification gate (ClientNonUIFeatures) reads to suppress alerts + sounds. +function PauseNotifications() { + const snoozeUntil = useAtomValue(notificationSnoozeUntilAtom); + const setSnoozeUntil = useSetAtom(notificationSnoozeUntilAtom); + // While paused, tick so the status flips to "on" the moment the snooze lapses. + const [, setTick] = useState(0); + const active = isSnoozeActive(snoozeUntil); + + useEffect(() => { + if (!active) return undefined; + const id = setInterval(() => setTick((n) => n + 1), 30_000); + return () => clearInterval(id); + }, [active]); + + const status = !active + ? 'Notifications are on.' + : snoozeUntil >= SNOOZE_INDEFINITE + ? 'Paused until you resume.' + : `Paused until ${formatFriendlyDateTime(snoozeUntil)}.`; + + return ( + {status} + } + after={ + active ? ( + + ) : undefined + } + > + + {SNOOZE_PRESETS.map((preset) => ( + setSnoozeUntil(preset.resolve(Date.now()))} + > + {preset.label} + + ))} + + + ); +} + export function SystemNotification() { const notifPermission = usePermissionState('notifications', getNotificationState()); const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications'); @@ -174,6 +248,14 @@ export function SystemNotification() { } /> + + + ( + 'cinny_notification_snooze_until_v1', + 0, + createJSONStorage(() => localStorage), + { getOnInit: true }, +); diff --git a/src/app/utils/snooze.test.ts b/src/app/utils/snooze.test.ts new file mode 100644 index 000000000..1d6bbc262 --- /dev/null +++ b/src/app/utils/snooze.test.ts @@ -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); +}); diff --git a/src/app/utils/snooze.ts b/src/app/utils/snooze.ts new file mode 100644 index 000000000..d00f329d7 --- /dev/null +++ b/src/app/utils/snooze.ts @@ -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(); +}