From 8d2ebccf08459c6763a61739303141f2450f53b3 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:46:25 +0100 Subject: [PATCH] feat: keep undo toasts, errors and reminders as long as the notification duration says --- hooks/use-calendar-alerts.ts | 29 +++++- lib/__tests__/toast-duration.test.ts | 144 +++++++++++++++++++++++++++ lib/toast-duration.ts | 56 +++++++++++ stores/toast-store.ts | 5 +- vitest.setup.ts | 11 +- 5 files changed, 239 insertions(+), 6 deletions(-) create mode 100644 lib/__tests__/toast-duration.test.ts create mode 100644 lib/toast-duration.ts diff --git a/hooks/use-calendar-alerts.ts b/hooks/use-calendar-alerts.ts index 83397f293..580371a10 100644 --- a/hooks/use-calendar-alerts.ts +++ b/hooks/use-calendar-alerts.ts @@ -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`; }, diff --git a/lib/__tests__/toast-duration.test.ts b/lib/__tests__/toast-duration.test.ts new file mode 100644 index 000000000..4de9f10eb --- /dev/null +++ b/lib/__tests__/toast-duration.test.ts @@ -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(); + }); +}); diff --git a/lib/toast-duration.ts b/lib/toast-duration.ts new file mode 100644 index 000000000..439b34019 --- /dev/null +++ b/lib/toast-duration.ts @@ -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, +): 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; +} diff --git a/stores/toast-store.ts b/stores/toast-store.ts index be6e5b659..a0b0b71e8 100644 --- a/stores/toast-store.ts +++ b/stores/toast-store.ts @@ -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((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) => ({ diff --git a/vitest.setup.ts b/vitest.setup.ts index d34e96620..47d1af1c6 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -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