mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: tell plugins when an event reminder fires and when it is dismissed
This commit is contained in:
+17
-3
@@ -19,6 +19,11 @@ export interface Toast {
|
||||
message?: string;
|
||||
duration?: number;
|
||||
onClick?: () => void;
|
||||
/**
|
||||
* Called when the user closes the toast: the close button, a click on the
|
||||
* toast or on one of its actions. Not called when it times out.
|
||||
*/
|
||||
onDismiss?: () => void;
|
||||
icon?: React.ReactNode;
|
||||
action?: ToastAction;
|
||||
secondaryAction?: ToastAction;
|
||||
@@ -90,6 +95,15 @@ export function ToastItem({ toast, onClose }: ToastProps) {
|
||||
setTimeout(() => onClose(toast.id), 280);
|
||||
}, [onClose, toast.id]);
|
||||
|
||||
const dismissByUser = useCallback(() => {
|
||||
try {
|
||||
toast.onDismiss?.();
|
||||
} catch {
|
||||
// A failing listener must not keep the toast on screen.
|
||||
}
|
||||
dismiss();
|
||||
}, [toast, dismiss]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!toast.duration || toast.duration <= 0 || isProgress) return;
|
||||
|
||||
@@ -112,7 +126,7 @@ export function ToastItem({ toast, onClose }: ToastProps) {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
action.onClick();
|
||||
dismiss();
|
||||
dismissByUser();
|
||||
} catch {
|
||||
// Don't close toast on error so user can retry
|
||||
}
|
||||
@@ -141,7 +155,7 @@ export function ToastItem({ toast, onClose }: ToastProps) {
|
||||
onClick={() => {
|
||||
if (toast.onClick && !toast.action) {
|
||||
toast.onClick();
|
||||
dismiss();
|
||||
dismissByUser();
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -180,7 +194,7 @@ export function ToastItem({ toast, onClose }: ToastProps) {
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
dismiss();
|
||||
dismissByUser();
|
||||
}}
|
||||
className={cn(
|
||||
"flex-shrink-0 p-1 rounded-md transition-all",
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react';
|
||||
import { useCalendarAlerts } from '../use-calendar-alerts';
|
||||
import { ToastContainer } from '@/components/ui/toast';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useCalendarStore } from '@/stores/calendar-store';
|
||||
import { useCalendarNotificationStore } from '@/stores/calendar-notification-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useToastStore } from '@/stores/toast-store';
|
||||
import { calendarHooks, clearAllHooks } from '@/lib/plugin-hooks';
|
||||
import type { CalendarEvent } from '@/lib/jmap/types';
|
||||
|
||||
// A reminder that fires tells plugins (onCalendarAlert); the user closing
|
||||
// its toast tells them it was dismissed (onCalendarAlertAcknowledge). A
|
||||
// toast that just times out is not a dismissal.
|
||||
|
||||
const fireTime = new Date(Date.now() - 60_000).toISOString();
|
||||
|
||||
const event = {
|
||||
id: 'e1',
|
||||
uid: 'uid-1',
|
||||
title: 'Dentist',
|
||||
start: new Date(Date.now() + 10 * 60_000).toISOString().slice(0, 19),
|
||||
utcStart: new Date(Date.now() + 10 * 60_000).toISOString(),
|
||||
duration: 'PT1H',
|
||||
calendarIds: { c1: true },
|
||||
useDefaultAlerts: false,
|
||||
alerts: {
|
||||
a1: { '@type': 'Alert', action: 'display', trigger: { '@type': 'AbsoluteTrigger', when: fireTime } },
|
||||
},
|
||||
} as unknown as CalendarEvent;
|
||||
|
||||
function Toasts() {
|
||||
const toasts = useToastStore((s) => s.toasts);
|
||||
const removeToast = useToastStore((s) => s.removeToast);
|
||||
return <ToastContainer toasts={toasts} onClose={removeToast} />;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllHooks();
|
||||
useToastStore.setState({ toasts: [] });
|
||||
useCalendarNotificationStore.getState().clearAll();
|
||||
useAuthStore.setState({ isAuthenticated: true, client: null });
|
||||
useSettingsStore.setState({ calendarNotificationsEnabled: true, calendarNotificationSound: false, enableCalendarTasks: false });
|
||||
useCalendarStore.setState({ events: [event], calendars: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('reminder hooks', () => {
|
||||
it('reports a fired reminder once, and its dismissal when the user closes it', async () => {
|
||||
const fired = vi.fn();
|
||||
const acknowledged = vi.fn();
|
||||
calendarHooks.onCalendarAlert.register('t', fired);
|
||||
calendarHooks.onCalendarAlertAcknowledge.register('t', acknowledged);
|
||||
|
||||
renderHook(() => useCalendarAlerts());
|
||||
render(<Toasts />);
|
||||
|
||||
await waitFor(() => expect(fired).toHaveBeenCalledTimes(1));
|
||||
expect(fired).toHaveBeenCalledWith({
|
||||
id: 'a1', eventId: 'e1', eventTitle: 'Dentist', triggerTime: new Date(fireTime).toISOString(),
|
||||
});
|
||||
expect(acknowledged).not.toHaveBeenCalled();
|
||||
|
||||
const close = screen.getByText('Dentist').closest('.toast-item')!.querySelector('button:last-of-type')!;
|
||||
fireEvent.click(close);
|
||||
expect(acknowledged).toHaveBeenCalledTimes(1);
|
||||
expect(acknowledged).toHaveBeenCalledWith({ alertId: 'a1', eventId: 'e1' });
|
||||
|
||||
// Re-checking does not fire the same reminder again.
|
||||
act(() => { useCalendarStore.setState({ events: [{ ...event }] }); });
|
||||
expect(fired).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toast onDismiss', () => {
|
||||
it('runs when the user closes the toast, not when it times out', () => {
|
||||
vi.useFakeTimers();
|
||||
const onDismiss = vi.fn();
|
||||
render(<Toasts />);
|
||||
|
||||
act(() => { useToastStore.getState().addToast({ type: 'info', title: 'Times out', duration: 1000, onDismiss }); });
|
||||
act(() => { vi.advanceTimersByTime(2000); });
|
||||
expect(screen.queryByText('Times out')).toBeNull();
|
||||
expect(onDismiss).not.toHaveBeenCalled();
|
||||
|
||||
act(() => { useToastStore.getState().addToast({ type: 'info', title: 'Closed', duration: 10_000, onDismiss }); });
|
||||
const close = screen.getByText('Closed').closest('.toast-item')!.querySelector('button:last-of-type')!;
|
||||
fireEvent.click(close);
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import { getPendingAlerts, getPendingTaskAlerts, buildAlertKey } from '@/lib/cal
|
||||
import { playNotificationSound } from '@/lib/notification-sound';
|
||||
import { getPathPrefix } from '@/lib/browser-navigation';
|
||||
import type { CalendarEvent } from '@/lib/jmap/types';
|
||||
import { calendarHooks } from '@/lib/plugin-hooks';
|
||||
|
||||
const CHECK_INTERVAL_MS = 60 * 1000;
|
||||
const PROACTIVE_FETCH_HOURS = 24;
|
||||
@@ -55,6 +56,13 @@ export function useCalendarAlerts() {
|
||||
const diffMs = (alert.event.utcStart ? new Date(alert.event.utcStart).getTime() : parseISO(alert.event.start).getTime()) - now;
|
||||
const diffMin = Math.round(diffMs / 60000);
|
||||
|
||||
void calendarHooks.onCalendarAlert.emit({
|
||||
id: alert.alertId,
|
||||
eventId: alert.eventId,
|
||||
eventTitle: alert.event.title || '',
|
||||
triggerTime: new Date(alert.fireTimeMs).toISOString(),
|
||||
});
|
||||
|
||||
const timeLabel = diffMin <= 0
|
||||
? t('alert_now')
|
||||
: t('alert_in_minutes', { count: diffMin });
|
||||
@@ -71,6 +79,9 @@ export function useCalendarAlerts() {
|
||||
onClick: () => {
|
||||
window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`;
|
||||
},
|
||||
onDismiss: () => {
|
||||
void calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: alert.alertId, eventId: alert.eventId });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user