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:
@@ -1259,6 +1259,13 @@ Accessible via **Room/Space Settings → Policy Lists** (admin only).
|
|||||||
- Gates both `notify()` (visual/OS notifications) and `playSound()` (audio alerts)
|
- Gates both `notify()` (visual/OS notifications) and `playSound()` (audio alerts)
|
||||||
- When active, notifications are silently dropped rather than queued
|
- 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
|
### Full Push Rule Editor
|
||||||
|
|
||||||
A complete UI for managing Matrix push notification rules:
|
A complete UI for managing Matrix push notification rules:
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { Box, Text, Switch, Button, color, config, Spinner } from 'folds';
|
import { Box, Text, Switch, Button, Chip, Icon, Icons, color, config, Spinner } from 'folds';
|
||||||
import { IPusherRequest } from 'matrix-js-sdk';
|
import { IPusherRequest } from 'matrix-js-sdk';
|
||||||
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
import { NOTIFICATION_SOUND_MAP } from '../../../utils/notificationSounds';
|
import { NOTIFICATION_SOUND_MAP } from '../../../utils/notificationSounds';
|
||||||
import { SequenceCard } from '../../../components/sequence-card';
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
import { SequenceCardStyle } from '../styles.css';
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
import { SettingTile } from '../../../components/setting-tile';
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
import { useSetting } from '../../../state/hooks/settings';
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../../state/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 { getNotificationState, usePermissionState } from '../../../hooks/usePermission';
|
||||||
import { useEmailNotifications } from '../../../hooks/useEmailNotifications';
|
import { useEmailNotifications } from '../../../hooks/useEmailNotifications';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
@@ -120,6 +128,72 @@ const selectStyle: React.CSSProperties = {
|
|||||||
outline: 'none',
|
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 (
|
||||||
|
<SettingTile
|
||||||
|
title="Pause Notifications"
|
||||||
|
description={
|
||||||
|
<span style={active ? { color: color.Warning.Main } : undefined}>{status}</span>
|
||||||
|
}
|
||||||
|
after={
|
||||||
|
active ? (
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => setSnoozeUntil(0)}
|
||||||
|
before={<Icon size="100" src={Icons.BellRing} />}
|
||||||
|
>
|
||||||
|
<Text size="B300">Resume</Text>
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Box gap="200" wrap="Wrap" style={{ marginTop: config.space.S200 }}>
|
||||||
|
{SNOOZE_PRESETS.map((preset) => (
|
||||||
|
<Chip
|
||||||
|
key={preset.label}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
radii="Pill"
|
||||||
|
onClick={() => setSnoozeUntil(preset.resolve(Date.now()))}
|
||||||
|
>
|
||||||
|
<Text size="B300">{preset.label}</Text>
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</SettingTile>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SystemNotification() {
|
export function SystemNotification() {
|
||||||
const notifPermission = usePermissionState('notifications', getNotificationState());
|
const notifPermission = usePermissionState('notifications', getNotificationState());
|
||||||
const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications');
|
const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications');
|
||||||
@@ -174,6 +248,14 @@ export function SystemNotification() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
<SequenceCard
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<PauseNotifications />
|
||||||
|
</SequenceCard>
|
||||||
<SequenceCard
|
<SequenceCard
|
||||||
className={SequenceCardStyle}
|
className={SequenceCardStyle}
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from 'matrix-js-sdk';
|
} from 'matrix-js-sdk';
|
||||||
import { focusAssistActiveAtom } from '../../state/focusAssist';
|
import { focusAssistActiveAtom } from '../../state/focusAssist';
|
||||||
import { manualDndAtom } from '../../state/manualDnd';
|
import { manualDndAtom } from '../../state/manualDnd';
|
||||||
|
import { isSnoozeActive, notificationSnoozeUntilAtom } from '../../state/notificationSnooze';
|
||||||
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
||||||
import LogoSVG from '../../../../public/res/lotus.png';
|
import LogoSVG from '../../../../public/res/lotus.png';
|
||||||
import LogoUnreadSVG from '../../../../public/res/lotus-unread.png';
|
import LogoUnreadSVG from '../../../../public/res/lotus-unread.png';
|
||||||
@@ -157,6 +158,7 @@ function InviteNotifications() {
|
|||||||
const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled');
|
const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled');
|
||||||
const focusAssistActive = useAtomValue(focusAssistActiveAtom);
|
const focusAssistActive = useAtomValue(focusAssistActiveAtom);
|
||||||
const manualDnd = useAtomValue(manualDndAtom);
|
const manualDnd = useAtomValue(manualDndAtom);
|
||||||
|
const snoozeUntil = useAtomValue(notificationSnoozeUntilAtom);
|
||||||
const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart');
|
const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart');
|
||||||
const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd');
|
const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd');
|
||||||
const [inviteSoundId] = useSetting(settingsAtom, 'inviteSoundId');
|
const [inviteSoundId] = useSetting(settingsAtom, 'inviteSoundId');
|
||||||
@@ -256,6 +258,7 @@ function InviteNotifications() {
|
|||||||
const quietActive =
|
const quietActive =
|
||||||
focusAssistActive ||
|
focusAssistActive ||
|
||||||
manualDnd ||
|
manualDnd ||
|
||||||
|
isSnoozeActive(snoozeUntil) ||
|
||||||
(quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd));
|
(quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd));
|
||||||
if (quietActive) return;
|
if (quietActive) return;
|
||||||
|
|
||||||
@@ -276,6 +279,7 @@ function InviteNotifications() {
|
|||||||
quietHoursEnd,
|
quietHoursEnd,
|
||||||
focusAssistActive,
|
focusAssistActive,
|
||||||
manualDnd,
|
manualDnd,
|
||||||
|
snoozeUntil,
|
||||||
inviteSoundId,
|
inviteSoundId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -389,6 +393,7 @@ function MessageNotifications() {
|
|||||||
const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled');
|
const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled');
|
||||||
const focusAssistActive = useAtomValue(focusAssistActiveAtom);
|
const focusAssistActive = useAtomValue(focusAssistActiveAtom);
|
||||||
const manualDnd = useAtomValue(manualDndAtom);
|
const manualDnd = useAtomValue(manualDndAtom);
|
||||||
|
const snoozeUntil = useAtomValue(notificationSnoozeUntilAtom);
|
||||||
const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart');
|
const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart');
|
||||||
const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd');
|
const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd');
|
||||||
const [messageSoundId] = useSetting(settingsAtom, 'messageSoundId');
|
const [messageSoundId] = useSetting(settingsAtom, 'messageSoundId');
|
||||||
@@ -533,6 +538,7 @@ function MessageNotifications() {
|
|||||||
const quietActive =
|
const quietActive =
|
||||||
focusAssistActive ||
|
focusAssistActive ||
|
||||||
manualDnd ||
|
manualDnd ||
|
||||||
|
isSnoozeActive(snoozeUntil) ||
|
||||||
(quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd));
|
(quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd));
|
||||||
if (quietActive) return;
|
if (quietActive) return;
|
||||||
|
|
||||||
@@ -569,6 +575,7 @@ function MessageNotifications() {
|
|||||||
quietHoursEnd,
|
quietHoursEnd,
|
||||||
focusAssistActive,
|
focusAssistActive,
|
||||||
manualDnd,
|
manualDnd,
|
||||||
|
snoozeUntil,
|
||||||
messageSoundId,
|
messageSoundId,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { atomWithStorage, createJSONStorage } from 'jotai/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-platform "pause notifications" / snooze.
|
||||||
|
*
|
||||||
|
* Unlike `manualDndAtom` (which only mirrors the desktop tray toggle and is
|
||||||
|
* session-only), this is a user-set snooze that works on web/mobile/desktop and
|
||||||
|
* survives a reload. The value is the epoch-ms instant until which notifications
|
||||||
|
* are paused; `0` means not snoozed. `SNOOZE_INDEFINITE` represents "paused until
|
||||||
|
* I turn it back on".
|
||||||
|
*
|
||||||
|
* The pure helpers live in `utils/snooze.ts` (unit-tested); re-exported here so
|
||||||
|
* consumers have a single import site.
|
||||||
|
*/
|
||||||
|
export { SNOOZE_INDEFINITE, isSnoozeActive, nextTimeAtHour } from '../utils/snooze';
|
||||||
|
|
||||||
|
export const notificationSnoozeUntilAtom = atomWithStorage<number>(
|
||||||
|
'cinny_notification_snooze_until_v1',
|
||||||
|
0,
|
||||||
|
createJSONStorage(() => localStorage),
|
||||||
|
{ getOnInit: true },
|
||||||
|
);
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user