mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: keep undo toasts, errors and reminders as long as the notification duration says
This commit is contained in:
@@ -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`;
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user