feat: keep undo toasts, errors and reminders as long as the notification duration says

This commit is contained in:
Linus Rath
2026-10-29 17:53:45 +01:00
parent be5e2dbc53
commit 8d2ebccf08
5 changed files with 239 additions and 6 deletions
+25 -4
View File
@@ -11,6 +11,7 @@ import { useCalendarNotificationStore } from '@/stores/calendar-notification-sto
import { useToastStore } from '@/stores/toast-store';
import { getPendingAlerts, getPendingTaskAlerts, getNextAlertFireTime, buildAlertKey } from '@/lib/calendar-alerts';
import { playNotificationSound } from '@/lib/notification-sound';
import { lastingToastDuration } from '@/lib/toast-duration';
import { getPathPrefix } from '@/lib/browser-navigation';
import type { CalendarEvent } from '@/lib/jmap/types';
import { calendarHooks } from '@/lib/plugin-hooks';
@@ -21,6 +22,26 @@ const PROACTIVE_FETCH_HOURS = 24;
const PROACTIVE_THROTTLE_MS = CHECK_INTERVAL_MS * 5;
// Longest single wait for the next reminder; the periodic check covers the rest.
const MAX_WAKE_DELAY_MS = 6 * 60 * 60 * 1000;
/** How long a reminder stays at the shortest "Notification duration". */
export const REMINDER_TOAST_MS = 15000;
/**
* How long a reminder toast stays: until it is closed with Visual alerts on
* (someone who can't hear the sound must not miss it) or with "until
* closed"; otherwise the "Notification duration", at least 15 seconds.
*/
export function reminderToastDuration(): number {
if (useSettingsStore.getState().visualAlerts) return 0;
return lastingToastDuration(REMINDER_TOAST_MS);
}
/**
* Whether a reminder plays its sound. Do not disturb keeps reminders on
* screen (they were asked for) but silent.
*/
function reminderSoundOn(soundSetting: boolean): boolean {
return soundSetting && !useSettingsStore.getState().doNotDisturb;
}
export function useCalendarAlerts() {
const { isAuthenticated, client } = useAuthStore();
@@ -64,7 +85,7 @@ export function useCalendarAlerts() {
shownKeysRef.current.add(key);
acknowledgeAlert(key, alert.fireTimeMs);
if (calendarNotificationSound) {
if (reminderSoundOn(calendarNotificationSound)) {
playNotificationSound(notificationSoundChoice);
}
@@ -90,7 +111,7 @@ export function useCalendarAlerts() {
type: 'info',
title: alert.event.title || t('alert_title'),
message,
duration: 15000,
duration: reminderToastDuration(),
onClick: () => {
window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`;
},
@@ -110,7 +131,7 @@ export function useCalendarAlerts() {
shownKeysRef.current.add(key);
acknowledgeAlert(key, taskAlert.fireTimeMs);
if (calendarNotificationSound) {
if (reminderSoundOn(calendarNotificationSound)) {
playNotificationSound(notificationSoundChoice);
}
@@ -122,7 +143,7 @@ export function useCalendarAlerts() {
type: 'info',
title: taskAlert.task.title || t('alert_title'),
message: taskMsg,
duration: 15000,
duration: reminderToastDuration(),
onClick: () => {
window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`;
},
+144
View File
@@ -0,0 +1,144 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { toast, useToastStore } from '@/stores/toast-store';
import { useSettingsStore } from '@/stores/settings-store';
import { usePolicyStore } from '@/stores/policy-store';
import { useAuthStore } from '@/stores/auth-store';
import { useCalendarStore } from '@/stores/calendar-store';
import { useCalendarNotificationStore } from '@/stores/calendar-notification-store';
import { useCalendarAlerts } from '@/hooks/use-calendar-alerts';
import type { CalendarEvent } from '@/lib/jmap/types';
const playNotificationSound = vi.hoisted(() => vi.fn());
vi.mock('@/lib/notification-sound', () => ({ playNotificationSound }));
const last = () => useToastStore.getState().toasts.at(-1)!;
const undo = { label: 'Undo', onClick: () => {} };
function restrictMin(min: number | undefined) {
usePolicyStore.setState({
getRestriction: (key: string) => (key === 'toastDuration' && min !== undefined ? { min } : undefined),
} as never);
}
describe('toast durations', () => {
const original = usePolicyStore.getState().getRestriction;
beforeEach(() => {
useToastStore.setState({ toasts: [] });
useSettingsStore.setState({ toastDuration: 5 });
restrictMin(undefined);
});
afterEach(() => {
usePolicyStore.setState({ getRestriction: original } as never);
});
it('keeps the time a caller picked at the shortest setting', () => {
toast.success('Email archived', { action: undo, duration: 8000 });
expect(last().duration).toBe(8000);
toast.success('Saved');
expect(last().duration).toBe(5000);
});
it('makes toasts with an action stay as long as the setting says', () => {
useSettingsStore.setState({ toastDuration: 20 });
toast.success('Email archived', { action: undo, duration: 8000 });
expect(last().duration).toBe(20000);
// A plain notice keeps its own time.
toast.success('Saved');
expect(last().duration).toBe(5000);
});
it('keeps errors at least as long as toasts with an action', () => {
useSettingsStore.setState({ toastDuration: 60 });
toast.error('Failed to delete email');
expect(last().duration).toBe(60000);
useSettingsStore.setState({ toastDuration: 5 });
toast.error('Failed to delete email again');
expect(last().duration).toBe(10000);
});
it('keeps them until closed with "until I close them"', () => {
useSettingsStore.setState({ toastDuration: 0 });
toast.success('Email archived', { action: undo, duration: 8000 });
expect(last().duration).toBe(0);
toast.error('Failed');
expect(last().duration).toBe(0);
});
it('leaves a toast whose action ends at a fixed time alone', () => {
useSettingsStore.setState({ toastDuration: 60 });
toast.info('Sending in 10 s', { action: undo, duration: 10000, pauseOnHover: false });
expect(last().duration).toBe(10000);
});
it('raises a setting below the admin minimum to it', () => {
restrictMin(20);
useSettingsStore.setState({ toastDuration: 5 });
toast.success('Email archived', { action: undo, duration: 8000 });
expect(last().duration).toBe(20000);
});
});
function eventDueNow(id: string): CalendarEvent {
const start = new Date(Date.now() + 10 * 60_000);
return {
id,
uid: `uid-${id}`,
title: `Meeting ${id}`,
start: start.toISOString().slice(0, 19),
utcStart: start.toISOString(),
duration: 'PT1H',
calendarIds: { c1: true },
useDefaultAlerts: false,
alerts: {
a1: { '@type': 'Alert', action: 'display', trigger: { '@type': 'AbsoluteTrigger', when: new Date(Date.now() - 1000).toISOString() } },
},
} as unknown as CalendarEvent;
}
describe('reminder toasts', () => {
beforeEach(() => {
vi.useFakeTimers();
playNotificationSound.mockClear();
useToastStore.setState({ toasts: [] });
useCalendarNotificationStore.getState().clearAll();
useAuthStore.setState({ isAuthenticated: true, client: null });
useSettingsStore.setState({
calendarNotificationsEnabled: true, calendarNotificationSound: true, enableCalendarTasks: false,
toastDuration: 5, visualAlerts: false, doNotDisturb: false,
});
useCalendarStore.setState({ events: [], calendars: [], supportsCalendar: true });
});
afterEach(() => {
vi.useRealTimers();
});
const showReminder = (id: string) => {
useCalendarStore.setState({ events: [eventDueNow(id)] });
renderHook(() => useCalendarAlerts());
act(() => { vi.advanceTimersByTime(1000); });
return useToastStore.getState().toasts.find((t) => t.title === `Meeting ${id}`)!;
};
it('stays 15 seconds at the shortest setting, and longer with a longer one', () => {
expect(showReminder('r1').duration).toBe(15000);
useSettingsStore.setState({ toastDuration: 60 });
expect(showReminder('r2').duration).toBe(60000);
});
it('stays until closed with Visual alerts on or with "until closed"', () => {
useSettingsStore.setState({ visualAlerts: true });
expect(showReminder('r3').duration).toBe(0);
useSettingsStore.setState({ visualAlerts: false, toastDuration: 0 });
expect(showReminder('r4').duration).toBe(0);
});
it('still shows with Do not disturb on, but without a sound', () => {
useSettingsStore.setState({ doNotDisturb: true });
expect(showReminder('r5')).toBeDefined();
expect(playNotificationSound).not.toHaveBeenCalled();
useSettingsStore.setState({ doNotDisturb: false });
showReminder('r6');
expect(playNotificationSound).toHaveBeenCalled();
});
});
+56
View File
@@ -0,0 +1,56 @@
import { useSettingsStore } from '@/stores/settings-store';
import { usePolicyStore } from '@/stores/policy-store';
import { toastDurationAtLeast } from '@/lib/accessibility-settings';
import type { Toast } from '@/components/ui/toast';
/** How long a toast stays when nothing else says so. */
export const DEFAULT_TOAST_MS = 5000;
/** Errors stay longer than other toasts. */
export const DEFAULT_ERROR_TOAST_MS = 10000;
/**
* The "Notification duration" setting in seconds, raised to the admin's
* minimum when it is below it. 0 keeps toasts until they are closed.
*/
export function userToastSeconds(): number {
let value = 5;
let min: number | undefined;
try {
const stored = useSettingsStore.getState().toastDuration;
if (typeof stored === 'number' && stored >= 0) value = stored;
const restriction = usePolicyStore.getState().getRestriction?.('toastDuration');
if (typeof restriction?.min === 'number') min = restriction.min;
} catch {
// A store that isn't there yet (tests, the plugin sandbox): the default.
}
if (min !== undefined && !toastDurationAtLeast(value, min)) return min;
return value;
}
/**
* How long a toast that must not be missed stays: one with an action (Undo),
* an error, a reminder. The setting makes it stay longer, never shorter than
* `baseMs`, the time the caller picked; with "until closed" it stays until
* it is closed.
*/
export function lastingToastDuration(baseMs: number): number {
const seconds = userToastSeconds();
if (seconds === 0 || baseMs <= 0) return 0;
return Math.max(baseMs, seconds * 1000);
}
/**
* The duration a new toast gets (WCAG 2.2.1). A toast with an action or an
* error follows the setting (lastingToastDuration); a plain notice keeps
* its own time. Progress toasts stay until they are updated, and a toast
* whose action ends at a fixed time (Undo send, pauseOnHover: false) keeps
* that time, since its countdown has to match it.
*/
export function effectiveToastDuration(
toast: Pick<Toast, 'type' | 'duration' | 'action' | 'secondaryAction' | 'tertiaryAction' | 'pauseOnHover' | 'progress'>,
): number {
const base = toast.duration ?? (toast.type === 'error' ? DEFAULT_ERROR_TOAST_MS : DEFAULT_TOAST_MS);
if (base <= 0 || toast.progress !== undefined || toast.pauseOnHover === false) return base;
const lasting = toast.type === 'error' || !!(toast.action || toast.secondaryAction || toast.tertiaryAction);
return lasting ? lastingToastDuration(base) : base;
}
+4 -1
View File
@@ -4,6 +4,7 @@ import { generateUUID } from "@/lib/utils";
import { toastHooks } from "@/lib/plugin-hooks";
import { recordNotification } from "@/stores/notification-center-store";
import { announce } from "@/lib/announce";
import { effectiveToastDuration } from "@/lib/toast-duration";
/**
* What a screen reader hears when a toast appears: its title and message as
@@ -39,7 +40,9 @@ export const useToastStore = create<ToastStore>((set, get) => ({
const newToast: Toast = {
...toast,
id,
duration: toast.duration ?? 5000,
// The "Notification duration" setting, for toasts with an action
// and errors (lib/toast-duration).
duration: effectiveToastDuration(toast),
};
set((state) => ({
+10 -1
View File
@@ -1,7 +1,16 @@
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { cleanup, configure } from '@testing-library/react';
import { afterEach, beforeEach, vi } from 'vitest';
import { setNoThirdPartyTraffic } from '@/lib/third-party-mode';
import { LIVE_REGION_IDS } from '@/lib/announce';
// Every toast is also read out through the app-wide live regions
// (lib/announce), so its title is in the page twice for a few seconds.
// Text queries look at what is on screen; tests of the announcements read
// the regions by id.
configure({
defaultIgnore: `script, style, #${LIVE_REGION_IDS.polite}, #${LIVE_REGION_IDS.assertive}`,
});
// jsdom does not implement matchMedia; components that read media queries
// (e.g. responsive layout hooks) call it during render. Provide a minimal