feat: tell plugins about layout changes, drag and drop, and sidebar apps

This commit is contained in:
Linus Rath
2026-10-14 03:40:04 +02:00
parent e62553642f
commit 051e82b487
8 changed files with 243 additions and 11 deletions
+17 -2
View File
@@ -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<DragDropContextValue | null>(null);
export function DragDropProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<DragDropState>(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 (
<DragDropContext.Provider value={{ ...state, startDrag, endDrag }}>
<DragDropContext.Provider value={{ ...state, startDrag, endDrag, markDropped }}>
{children}
</DragDropContext.Provider>
);
+156
View File
@@ -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 }) => <DragDropProvider>{children}</DragDropProvider>;
function dropEvent(ids: string[]): DragEvent<HTMLDivElement> {
return {
preventDefault() {}, stopPropagation() {},
dataTransfer: { getData: () => JSON.stringify(ids) },
} as unknown as DragEvent<HTMLDivElement>;
}
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' }]]);
});
});
+5 -2
View File
@@ -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();
+10
View File
@@ -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]));
+6 -2
View File
@@ -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<HTMLDivElement>) => {
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: {
+2 -2
View File
@@ -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<HookGroup>([
'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', () => {
+3 -3
View File
@@ -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'),
+44
View File
@@ -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<UIState>((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<UIState>((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<UIState>((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<UIState>((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<UIState>((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<UIState>((set, get) => ({
}
},
}));
type ColumnWidths = Pick<UIState, "sidebarWidth" | "emailListWidth" | "emailListHeight">;
const COLUMN_NAMES: Record<keyof ColumnWidths, string> = {
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<UIState, "isMobile" | "isTablet">): "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) });
});