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:
@@ -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 (
|
||||
<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() {
|
||||
const notifPermission = usePermissionState('notifications', getNotificationState());
|
||||
const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications');
|
||||
@@ -174,6 +248,14 @@ export function SystemNotification() {
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<PauseNotifications />
|
||||
</SequenceCard>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
|
||||
Reference in New Issue
Block a user