diff --git a/contexts/drag-drop-context.tsx b/contexts/drag-drop-context.tsx index 97c498e34..465b90f80 100644 --- a/contexts/drag-drop-context.tsx +++ b/contexts/drag-drop-context.tsx @@ -1,7 +1,8 @@ "use client"; -import { createContext, useContext, useState, useCallback, ReactNode } from "react"; +import { createContext, useContext, useState, useCallback, useRef, ReactNode } from "react"; import { Email } from "@/lib/jmap/types"; +import { dragDropHooks } from "@/lib/plugin-hooks"; interface DragDropState { isDragging: boolean; @@ -13,6 +14,8 @@ interface DragDropState { interface DragDropContextValue extends DragDropState { startDrag: (emails: Email[], sourceMailboxId: string) => void; endDrag: () => void; + /** A drop target accepted the dragged messages. */ + markDropped: () => void; } const initialState: DragDropState = { @@ -26,8 +29,13 @@ const DragDropContext = createContext(null); export function DragDropProvider({ children }: { children: ReactNode }) { const [state, setState] = useState(initialState); + // The drag plugins were told about; endDrag runs more than once per drag + // (the drop target and the dragged row both call it). + const current = useRef<{ ids: string[]; dropped: boolean } | null>(null); const startDrag = useCallback((emails: Email[], sourceMailboxId: string) => { + current.current = { ids: emails.map((e) => e.id), dropped: false }; + void dragDropHooks.onDragStart.emit({ kind: "email", ids: current.current.ids }); setState({ isDragging: true, draggedEmails: emails, @@ -37,11 +45,18 @@ export function DragDropProvider({ children }: { children: ReactNode }) { }, []); const endDrag = useCallback(() => { + const drag = current.current; + current.current = null; + if (drag) void dragDropHooks.onDragEnd.emit({ kind: "email", ids: drag.ids, dropped: drag.dropped }); setState(initialState); }, []); + const markDropped = useCallback(() => { + if (current.current) current.current.dropped = true; + }, []); + return ( - + {children} ); diff --git a/hooks/__tests__/plugin-ui-hooks.test.tsx b/hooks/__tests__/plugin-ui-hooks.test.tsx new file mode 100644 index 000000000..60b7e7e33 --- /dev/null +++ b/hooks/__tests__/plugin-ui-hooks.test.tsx @@ -0,0 +1,156 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import type { ReactNode, DragEvent } from 'react'; +import { clearAllHooks, dragDropHooks, sidebarAppHooks, uiHooks } from '@/lib/plugin-hooks'; +import { useUIStore } from '@/stores/ui-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; +import { DragDropProvider, useDragDropContext } from '@/contexts/drag-drop-context'; +import { useMailboxDrop } from '../use-mailbox-drop'; +import { useTagDrop } from '../use-tag-drop'; +import { useSidebarApps } from '../use-sidebar-apps'; +import type { Email, Mailbox } from '@/lib/jmap/types'; + +function listen(bus: { register: (id: string, fn: (...a: unknown[]) => unknown) => unknown }) { + const fn = vi.fn(); + bus.register('test', fn); + return fn; +} + +const wrapper = ({ children }: { children: ReactNode }) => {children}; + +function dropEvent(ids: string[]): DragEvent { + return { + preventDefault() {}, stopPropagation() {}, + dataTransfer: { getData: () => JSON.stringify(ids) }, + } as unknown as DragEvent; +} + +const EMAIL = { id: 'm1', mailboxIds: { inbox: true }, keywords: {} } as unknown as Email; + +beforeEach(() => { + clearAllHooks(); + useUIStore.setState({ activeView: 'list', sidebarOpen: false, sidebarCollapsed: false, isMobile: true, isTablet: false, isDesktop: false }); +}); + +describe('layout hooks', () => { + it('reports view, sidebar and device changes', () => { + const view = listen(uiHooks.onViewChange); + const toggle = listen(uiHooks.onSidebarToggle); + const collapse = listen(uiHooks.onSidebarCollapse); + const device = listen(uiHooks.onDeviceTypeChange); + + useUIStore.getState().showEmailViewer(); + useUIStore.getState().toggleSidebar(); + useUIStore.getState().setSidebarCollapsed(true); + useUIStore.getState().setDeviceType(false, false, true); + useUIStore.getState().setDeviceType(false, false, true); + + expect(view).toHaveBeenCalledWith({ view: 'viewer' }); + expect(toggle).toHaveBeenCalledWith({ open: true }); + expect(collapse).toHaveBeenCalledWith({ collapsed: true }); + expect(device.mock.calls).toEqual([[{ deviceType: 'desktop' }]]); + }); + + it('reports Back on the phone', () => { + const back = listen(uiHooks.onMobileBack); + + useUIStore.getState().goBack(); + + expect(back).toHaveBeenCalledTimes(1); + }); + + it('reports a column once the resize ends, not during the drag', () => { + const resized = listen(uiHooks.onColumnResize); + const start = useUIStore.getState().sidebarWidth; + + useUIStore.getState().setSidebarWidth(start + 10); + useUIStore.getState().setSidebarWidth(start + 20); + expect(resized).not.toHaveBeenCalled(); + useUIStore.getState().persistColumnWidths(); + + expect(resized.mock.calls).toEqual([[{ column: 'sidebar', width: useUIStore.getState().sidebarWidth }]]); + }); +}); + +describe('drag and drop hooks', () => { + beforeEach(() => { + useAuthStore.setState({ client: {} as never }); + useEmailStore.setState({ + emails: [EMAIL], mailboxes: [], selectedEmailKeys: new Set(), + moveEmailsToMailbox: vi.fn().mockResolvedValue(undefined), + refreshCurrentMailbox: vi.fn().mockResolvedValue(undefined), + setEmailKeywords: vi.fn().mockResolvedValue(undefined), + fetchEmails: vi.fn().mockResolvedValue(undefined), + fetchTagCounts: vi.fn(), + clearSelection: vi.fn(), + } as never); + }); + + it('reports a drag of messages dropped on a folder', async () => { + const started = listen(dragDropHooks.onDragStart); + const ended = listen(dragDropHooks.onDragEnd); + const dropped = listen(dragDropHooks.onEmailDrop); + const target = { id: 'archive', myRights: { mayAddItems: true } } as unknown as Mailbox; + const { result } = renderHook( + () => ({ ctx: useDragDropContext(), drop: useMailboxDrop({ mailbox: target, onSuccess: () => {} }) }), + { wrapper }, + ); + + act(() => { result.current.ctx.startDrag([EMAIL], 'inbox'); }); + await act(async () => { await result.current.drop.dropHandlers.onDrop(dropEvent(['m1'])); }); + + expect(started).toHaveBeenCalledWith({ kind: 'email', ids: ['m1'] }); + expect(dropped).toHaveBeenCalledWith({ emailIds: ['m1'], targetMailboxId: 'archive' }); + expect(ended.mock.calls).toEqual([[{ kind: 'email', ids: ['m1'], dropped: true }]]); + }); + + it('reports a drag that ended without a drop', () => { + const ended = listen(dragDropHooks.onDragEnd); + const { result } = renderHook(() => useDragDropContext(), { wrapper }); + + act(() => { result.current.startDrag([EMAIL], 'inbox'); }); + act(() => { result.current.endDrag(); }); + act(() => { result.current.endDrag(); }); + + expect(ended.mock.calls).toEqual([[{ kind: 'email', ids: ['m1'], dropped: false }]]); + }); + + it('reports messages dropped on a tag', async () => { + const tagged = listen(dragDropHooks.onTagDrop); + const { result } = renderHook( + () => ({ ctx: useDragDropContext(), drop: useTagDrop({ tagId: 'work', onSuccess: () => {} }) }), + { wrapper }, + ); + + act(() => { result.current.ctx.startDrag([EMAIL], 'inbox'); }); + await act(async () => { await result.current.drop.dropHandlers.onDrop(dropEvent(['m1'])); }); + + expect(tagged).toHaveBeenCalledWith({ emailIds: ['m1'], keyword: '$label:work' }); + }); +}); + +describe('sidebar app hooks', () => { + it('reports an app opening, and closing when the page goes away', () => { + const opened = listen(sidebarAppHooks.onSidebarAppOpen); + const closed = listen(sidebarAppHooks.onSidebarAppClose); + // An app picked just before a reload opens on mount. + sessionStorage.setItem('bulwark:pending-inline-app', JSON.stringify({ id: 'wiki', url: 'https://wiki.example.org', name: 'Wiki' })); + const { unmount } = renderHook(() => useSidebarApps()); + + expect(opened).toHaveBeenCalledWith({ appId: 'wiki' }); + expect(closed).not.toHaveBeenCalled(); + unmount(); + expect(closed).toHaveBeenCalledWith({ appId: 'wiki' }); + }); + + it('reports an app the user closes', () => { + const closed = listen(sidebarAppHooks.onSidebarAppClose); + sessionStorage.setItem('bulwark:pending-inline-app', JSON.stringify({ id: 'wiki', url: 'https://wiki.example.org', name: 'Wiki' })); + const { result } = renderHook(() => useSidebarApps()); + + act(() => { result.current.closeInlineApp(); }); + + expect(closed.mock.calls).toEqual([[{ appId: 'wiki' }]]); + }); +}); diff --git a/hooks/use-mailbox-drop.ts b/hooks/use-mailbox-drop.ts index 61c6792f8..ebc443e15 100644 --- a/hooks/use-mailbox-drop.ts +++ b/hooks/use-mailbox-drop.ts @@ -6,6 +6,7 @@ import { useEmailStore } from "@/stores/email-store"; import { emailKeyFor } from "@/lib/thread-utils"; import { useAuthStore } from "@/stores/auth-store"; import { useDragDropContext } from "@/contexts/drag-drop-context"; +import { dragDropHooks } from "@/lib/plugin-hooks"; import { toast } from "@/stores/toast-store"; import { getMailboxPath } from "@/lib/utils"; @@ -67,7 +68,7 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: const [isOver, setIsOver] = useState(false); const { client } = useAuthStore(); const { moveEmailsToMailbox, crossAccountMoveEmails, selectedEmailKeys, clearSelection, refreshCurrentMailbox, mailboxes } = useEmailStore(); - const { isDragging, sourceMailboxId, draggedEmails, endDrag } = useDragDropContext(); + const { isDragging, sourceMailboxId, draggedEmails, endDrag, markDropped } = useDragDropContext(); // Determine if this is a valid drop target const isValidTarget = useCallback(() => { @@ -129,6 +130,7 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: } const emailIds: string[] = JSON.parse(emailIdsJson); + markDropped(); // Group dragged emails by source account. In single-account flows this // collapses to one bucket; in unified view or the Pro multi-account @@ -183,6 +185,7 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: // Single-account or same-account-shared move: bulk JMAP request. await moveEmailsToMailbox(client, emailIds, mailbox.id); } + void dragDropHooks.onEmailDrop.emit({ emailIds, targetMailboxId: mailbox.id }); // Clear selection if any selected emails were moved. The selection is // keyed by owning account, so compare the dragged emails, not their ids. @@ -224,7 +227,7 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: } finally { endDrag(); } - }, [client, mailbox, mailboxes, isValidTarget, moveEmailsToMailbox, crossAccountMoveEmails, draggedEmails, sourceMailboxId, selectedEmailKeys, clearSelection, refreshCurrentMailbox, endDrag, onDropComplete, onSuccess, onError]); + }, [client, mailbox, mailboxes, isValidTarget, moveEmailsToMailbox, crossAccountMoveEmails, draggedEmails, sourceMailboxId, selectedEmailKeys, clearSelection, refreshCurrentMailbox, endDrag, markDropped, onDropComplete, onSuccess, onError]); const valid = isValidTarget(); diff --git a/hooks/use-sidebar-apps.ts b/hooks/use-sidebar-apps.ts index b198a8f7a..5cdc9e816 100644 --- a/hooks/use-sidebar-apps.ts +++ b/hooks/use-sidebar-apps.ts @@ -3,6 +3,7 @@ import { useSettingsStore } from '@/stores/settings-store'; import { useManagedSidebarApps } from '@/hooks/use-resolved-sidebar-apps'; import { appUrlToFrameOrigin, inlineAppFrameOrigins } from '@/lib/security/app-frame-origins'; import { readAppFrameOrigins, writeAppFrameOrigins } from '@/lib/security/app-frame-origins-client'; +import { sidebarAppHooks } from '@/lib/plugin-hooks'; export interface InlineAppState { id: string; @@ -48,6 +49,15 @@ export function useSidebarApps() { // opening one must not trigger the reload below. const managedOrigins = useMemo(() => inlineAppFrameOrigins(managedApps), [managedApps]); + // Plugins hear when an app opens, and when it closes: closed by the user, + // replaced by another app, or gone because the user left the page. + const inlineAppId = inlineApp?.id ?? null; + useEffect(() => { + if (!inlineAppId) return; + void sidebarAppHooks.onSidebarAppOpen.emit({ appId: inlineAppId }); + return () => { void sidebarAppHooks.onSidebarAppClose.emit({ appId: inlineAppId }); }; + }, [inlineAppId]); + const openInlineApp = useCallback((app: InlineAppState) => { setInlineApp(app); setLoadedApps((prev) => (prev.some((a) => a.id === app.id) ? prev : [...prev, app])); diff --git a/hooks/use-tag-drop.ts b/hooks/use-tag-drop.ts index cb99edb8b..761f57617 100644 --- a/hooks/use-tag-drop.ts +++ b/hooks/use-tag-drop.ts @@ -4,6 +4,7 @@ import { useCallback, useState, DragEvent } from "react"; import { useEmailStore } from "@/stores/email-store"; import { useAuthStore } from "@/stores/auth-store"; import { useDragDropContext } from "@/contexts/drag-drop-context"; +import { dragDropHooks } from "@/lib/plugin-hooks"; interface UseTagDropOptions { @@ -27,7 +28,7 @@ export function useTagDrop({ tagId, onSuccess, onError }: UseTagDropOptions): Us const [isOver, setIsOver] = useState(false); const { client } = useAuthStore(); const { fetchEmails, fetchTagCounts, selectedMailbox } = useEmailStore(); - const { isDragging, endDrag } = useDragDropContext(); + const { isDragging, endDrag, markDropped } = useDragDropContext(); const handleDragOver = useCallback((e: DragEvent) => { e.preventDefault(); @@ -71,6 +72,7 @@ export function useTagDrop({ tagId, onSuccess, onError }: UseTagDropOptions): Us } const emailIds: string[] = JSON.parse(emailIdsJson); + markDropped(); for (const emailId of emailIds) { // Read fresh state to avoid stale closures @@ -87,6 +89,8 @@ export function useTagDrop({ tagId, onSuccess, onError }: UseTagDropOptions): Us await emailState.setEmailKeywords(client, emailId, keywords); } + void dragDropHooks.onTagDrop.emit({ emailIds, keyword: `$label:${tagId}` }); + // Refresh the email list await fetchEmails(client, selectedMailbox); @@ -100,7 +104,7 @@ export function useTagDrop({ tagId, onSuccess, onError }: UseTagDropOptions): Us } finally { endDrag(); } - }, [client, isDragging, tagId, fetchEmails, fetchTagCounts, selectedMailbox, endDrag, onSuccess, onError]); + }, [client, isDragging, tagId, fetchEmails, fetchTagCounts, selectedMailbox, endDrag, markDropped, onSuccess, onError]); return { dropHandlers: { diff --git a/lib/__tests__/plugin-hook-catalog.test.ts b/lib/__tests__/plugin-hook-catalog.test.ts index 5d5dc3173..a26f9e562 100644 --- a/lib/__tests__/plugin-hook-catalog.test.ts +++ b/lib/__tests__/plugin-hook-catalog.test.ts @@ -31,7 +31,7 @@ function sourceFiles(dir: string): string[] { return out; } -const sources = ['app', 'components', 'hooks', 'lib', 'stores'].flatMap((d) => sourceFiles(path.join(root, d))) +const sources = ['app', 'components', 'contexts', 'hooks', 'lib', 'stores'].flatMap((d) => sourceFiles(path.join(root, d))) .map((f) => fs.readFileSync(f, 'utf8')) .join('\n'); @@ -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', 'security', 'ui', 'keyboard', + 'filters', 'vacation', 'templates', 'security', 'keyboard', ]); describe('hook catalogue', () => { diff --git a/lib/plugin-hook-catalog.ts b/lib/plugin-hook-catalog.ts index d8d156a91..57e05fdc9 100644 --- a/lib/plugin-hook-catalog.ts +++ b/lib/plugin-hook-catalog.ts @@ -284,14 +284,14 @@ export const HOOK_CATALOG = { onInterval: H('app', 'observer', 'app:lifecycle', '{ now }', 'once a minute while the app is open'), // ─── Layout ──────────────────────────────────────────────── - onViewChange: H('ui', 'observer', 'ui:observe', '{ view }', 'when the main view changes (list, message, composer)'), + onViewChange: H('ui', 'observer', 'ui:observe', '{ view: "sidebar" | "list" | "viewer" }', 'when the phone layout switches between folders, message list and message'), onSidebarToggle: H('ui', 'observer', 'ui:observe', '{ open }', 'when the sidebar is opened or closed'), onSidebarCollapse: H('ui', 'observer', 'ui:observe', '{ collapsed }', 'when the sidebar is collapsed or expanded'), onDeviceTypeChange: H('ui', 'observer', 'ui:observe', '{ deviceType: "mobile" | "tablet" | "desktop" }', 'when the layout switches between phone, tablet and desktop'), - onColumnResize: H('ui', 'observer', 'ui:observe', '{ column, width }', 'after a column was resized'), + onColumnResize: H('ui', 'observer', 'ui:observe', '{ column: "sidebar" | "email-list" | "email-list-height", width }', 'after a column was resized or reset'), onMobileBack: H('ui', 'observer', 'ui:observe', '{}', 'when Back is used on the phone layout'), onTextSelectionChange: H('ui', 'observer', 'email:read', 'SelectionContext', 'when the user selects text (debounced)'), - onDragStart: H('ui', 'observer', 'email:read', '{ kind, ids }', 'when messages or tags start being dragged'), + onDragStart: H('ui', 'observer', 'email:read', '{ kind: "email", ids }', 'when messages start being dragged'), onDragEnd: H('ui', 'observer', 'email:read', '{ kind, ids, dropped }', 'when a drag ends'), onEmailDrop: H('ui', 'observer', 'email:read', '{ emailIds, targetMailboxId }', 'after messages were dropped on a folder'), onTagDrop: H('ui', 'observer', 'email:read', '{ emailIds, keyword }', 'after a tag was dropped on messages'), diff --git a/stores/ui-store.ts b/stores/ui-store.ts index 12b48a13f..fb0882a4b 100644 --- a/stores/ui-store.ts +++ b/stores/ui-store.ts @@ -1,6 +1,7 @@ "use client"; import { create } from "zustand"; +import { uiHooks } from "@/lib/plugin-hooks"; export type ActiveView = "sidebar" | "list" | "viewer"; @@ -97,6 +98,7 @@ export const useUIStore = create((set, get) => ({ resetSidebarWidth: () => { set({ sidebarWidth: SIDEBAR_DEFAULT }); + reportColumnWidths(get()); const { emailListWidth, emailListHeight } = get(); try { localStorage.setItem("column-widths", JSON.stringify({ sidebarWidth: SIDEBAR_DEFAULT, emailListWidth, emailListHeight })); @@ -105,6 +107,7 @@ export const useUIStore = create((set, get) => ({ resetEmailListWidth: () => { set({ emailListWidth: EMAIL_LIST_DEFAULT }); + reportColumnWidths(get()); const { sidebarWidth, emailListHeight } = get(); try { localStorage.setItem("column-widths", JSON.stringify({ sidebarWidth, emailListWidth: EMAIL_LIST_DEFAULT, emailListHeight })); @@ -113,6 +116,7 @@ export const useUIStore = create((set, get) => ({ resetEmailListHeight: () => { set({ emailListHeight: EMAIL_LIST_HEIGHT_DEFAULT }); + reportColumnWidths(get()); const { sidebarWidth, emailListWidth } = get(); try { localStorage.setItem("column-widths", JSON.stringify({ sidebarWidth, emailListWidth, emailListHeight: EMAIL_LIST_HEIGHT_DEFAULT })); @@ -121,6 +125,7 @@ export const useUIStore = create((set, get) => ({ persistColumnWidths: () => { const { sidebarWidth, emailListWidth, emailListHeight } = get(); + reportColumnWidths({ sidebarWidth, emailListWidth, emailListHeight }); try { localStorage.setItem("column-widths", JSON.stringify({ sidebarWidth, emailListWidth, emailListHeight })); } catch { /* localStorage may be unavailable */ } @@ -147,6 +152,7 @@ export const useUIStore = create((set, get) => ({ goBack: () => { const { activeView, isMobile } = get(); if (!isMobile) return; + void uiHooks.onMobileBack.emit({}); if (activeView === "viewer") { set({ activeView: "list" }); @@ -155,3 +161,41 @@ export const useUIStore = create((set, get) => ({ } }, })); + +type ColumnWidths = Pick; + +const COLUMN_NAMES: Record = { + sidebarWidth: "sidebar", + emailListWidth: "email-list", + emailListHeight: "email-list-height", +}; + +let reportedWidths: ColumnWidths | null = null; + +// Called when a resize ends (widths are saved then), not on every pixel of +// the drag. Reports only the columns that changed since the last report. +function reportColumnWidths(widths: ColumnWidths): void { + const before = reportedWidths; + reportedWidths = { sidebarWidth: widths.sidebarWidth, emailListWidth: widths.emailListWidth, emailListHeight: widths.emailListHeight }; + if (!before) return; + for (const key of Object.keys(COLUMN_NAMES) as (keyof ColumnWidths)[]) { + if (widths[key] !== before[key]) { + void uiHooks.onColumnResize.emit({ column: COLUMN_NAMES[key], width: widths[key] }); + } + } +} + +function deviceType(s: Pick): "mobile" | "tablet" | "desktop" { + return s.isMobile ? "mobile" : s.isTablet ? "tablet" : "desktop"; +} + +// Plugins hear about layout changes, whichever control or effect made them. +useUIStore.subscribe((state, prev) => { + if (!reportedWidths) { + reportedWidths = { sidebarWidth: prev.sidebarWidth, emailListWidth: prev.emailListWidth, emailListHeight: prev.emailListHeight }; + } + if (state.activeView !== prev.activeView) void uiHooks.onViewChange.emit({ view: state.activeView }); + if (state.sidebarOpen !== prev.sidebarOpen) void uiHooks.onSidebarToggle.emit({ open: state.sidebarOpen }); + if (state.sidebarCollapsed !== prev.sidebarCollapsed) void uiHooks.onSidebarCollapse.emit({ collapsed: state.sidebarCollapsed }); + if (deviceType(state) !== deviceType(prev)) void uiHooks.onDeviceTypeChange.emit({ deviceType: deviceType(state) }); +});