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();
+}