diff --git a/lib/__tests__/plugin-hook-catalog.test.ts b/lib/__tests__/plugin-hook-catalog.test.ts index f27f7c0b3..b3ce7ee0a 100644 --- a/lib/__tests__/plugin-hook-catalog.test.ts +++ b/lib/__tests__/plugin-hook-catalog.test.ts @@ -47,7 +47,7 @@ function isFired(hookName: string, groupName: string): boolean { const NOT_YET_WIRED = new Set([ 'mail-list', 'mail-read', 'mail-compose', 'mail-send', 'mail-organize', 'mail-mailboxes', 'mail-search', 'mail-sync', 'calendar', 'contacts', 'tasks', 'files', 'settings', 'identities', - 'filters', 'vacation', 'templates', 'auth', 'security', 'notifications', 'ui', 'keyboard', + 'filters', 'vacation', 'templates', 'auth', 'security', 'ui', 'keyboard', ]); describe('hook catalogue', () => { diff --git a/lib/plugin-hook-catalog.ts b/lib/plugin-hook-catalog.ts index 689d15778..d8d156a91 100644 --- a/lib/plugin-hook-catalog.ts +++ b/lib/plugin-hook-catalog.ts @@ -268,9 +268,8 @@ export const HOOK_CATALOG = { onLocaleChange: H('theme', 'observer', 'ui:observe', '{ locale }', 'after the app language changed'), // ─── Notifications ───────────────────────────────────────── - onToastShow: H('notifications', 'observer', 'ui:observe', '{ type, message }', 'when the app shows a toast'), + onToastShow: H('notifications', 'observer', 'ui:observe', '{ id, type, title, message }', 'when the app shows a toast'), onToastDismiss: H('notifications', 'observer', 'ui:observe', '{ id }', 'when a toast is dismissed'), - onBrowserNotification: H('notifications', 'transform', 'email:read', '{ title, body, tag } | false, { kind: "mail" | "calendar" | "plugin" }', 'before a system notification is shown; return false to suppress it'), onNotificationClick: H('notifications', 'observer', 'email:read', '{ tag, data }', 'when a system notification is clicked'), // ─── App lifecycle ───────────────────────────────────────── diff --git a/lib/plugin-hooks.ts b/lib/plugin-hooks.ts index af19b455f..6667c1e91 100644 --- a/lib/plugin-hooks.ts +++ b/lib/plugin-hooks.ts @@ -685,7 +685,6 @@ export const themeHooks = { export const toastHooks = { onToastShow: new HookBus(), onToastDismiss: new HookBus(), - onBrowserNotification: new HookBus(), // Observer fired when the user clicks an OS-level browser notification // dispatched by the host. Handler receives { tag: string, data?: unknown } // matching the original notification options. diff --git a/stores/__tests__/toast-store-hooks.test.ts b/stores/__tests__/toast-store-hooks.test.ts new file mode 100644 index 000000000..777d45d05 --- /dev/null +++ b/stores/__tests__/toast-store-hooks.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { clearAllHooks, toastHooks } from '@/lib/plugin-hooks'; +import { useToastStore } from '../toast-store'; + +beforeEach(() => { + clearAllHooks(); + useToastStore.setState({ toasts: [] }); +}); + +describe('toast hooks', () => { + it('tells plugins about a toast when it is shown and when it goes away', () => { + const shown = vi.fn(); + const dismissed = vi.fn(); + toastHooks.onToastShow.register('t', shown); + toastHooks.onToastDismiss.register('t', dismissed); + + const id = useToastStore.getState().addToast({ type: 'success', title: 'Moved', message: '3 messages' }); + useToastStore.getState().removeToast(id); + + expect(shown).toHaveBeenCalledWith({ id, type: 'success', title: 'Moved', message: '3 messages' }); + expect(dismissed).toHaveBeenCalledWith({ id }); + }); + + it('does not report a dismissal for a toast that is not there', () => { + const dismissed = vi.fn(); + toastHooks.onToastDismiss.register('t', dismissed); + + useToastStore.getState().removeToast('gone'); + + expect(dismissed).not.toHaveBeenCalled(); + }); +}); diff --git a/stores/toast-store.ts b/stores/toast-store.ts index 65b18f287..8c114c3c4 100644 --- a/stores/toast-store.ts +++ b/stores/toast-store.ts @@ -1,6 +1,7 @@ import { create } from "zustand"; import { Toast, ToastAction } from "@/components/ui/toast"; import { generateUUID } from "@/lib/utils"; +import { toastHooks } from "@/lib/plugin-hooks"; interface ToastStore { toasts: Toast[]; @@ -11,7 +12,7 @@ interface ToastStore { clearToasts: () => void; } -export const useToastStore = create((set) => ({ +export const useToastStore = create((set, get) => ({ toasts: [], addToast: (toast) => { @@ -29,6 +30,9 @@ export const useToastStore = create((set) => ({ set((state) => ({ toasts: [...state.toasts, newToast], })); + void toastHooks.onToastShow.emit({ + id, type: toast.type, title: toast.title, message: toast.message ?? "", + }); return id; }, @@ -39,9 +43,11 @@ export const useToastStore = create((set) => ({ }, removeToast: (id) => { + if (!get().toasts.some((t) => t.id === id)) return; set((state) => ({ toasts: state.toasts.filter((toast) => toast.id !== id), })); + void toastHooks.onToastDismiss.emit({ id }); }, clearToasts: () => {