mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: tell plugins about layout changes, drag and drop, and sidebar apps
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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' }]]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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]));
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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) });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user