mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 16:16:49 +00:00
A window snapped to half the screen (640-800 px) or a 1366x768 laptop at 150 % (911 px) got the phone and tablet layout: bottom tab bar, folders in a drawer, Settings as a phone list with a Back arrow. The layout switched at the 64rem (1024 px) breakpoint. Now the phone layout starts below 600 px. From 600 to 1024 px the app is a compact desktop: app rail at the side, folder sidebar folded to its icons (the existing collapsed state, opened per session with its toggle), Settings with its two panes and a narrower page list. Below 840 px the split layout opens a message in the list's place, as the focused layout does. This holds in the browser and the desktop app, in the Pro shell (which now runs from 600 px) and under Mountain View. - `hooks/use-media-query.ts`: classifyWidth, PHONE_BELOW 600, FULL_DESKTOP 1024; the tablet class (`isTablet`) is gone, the store has `isCompact` instead. Plugins still see "tablet" for it. - `app/globals.css`: a `desk:` breakpoint at 37.5rem for the layout classes, and a `tap:` variant for 44px rows on phones and touch screens below 1024 px (was every window below 1024 px). - Touch screens below 1024 px keep swiping list rows (was phones only). - The boot skeletons, boot script, mail app, sidebar, reader, admin shell and calendar, contacts and files follow the new widths. Tests changed with the behaviour: the tablet cases in pro-gate, mail-app-mv-composer-move, mail-app-compose-handoff, mail-app-thread-expand-mark-read, mobile-tour-end and thread-list-item-columns now run on a phone or a compact desktop, as those widths no longer exist as a tablet. mail-app-short-window-tab-bar is replaced by mail-app-compact-layout: a phone on its side is a compact desktop with the rail at the side, so the short-window tab bar rule is gone. Class and media-query assertions follow desk:/tap: and 37.5rem; the 32px-text test uses 1100 px, since 1440 px at 32px is now a compact desktop.
192 lines
7.9 KiB
TypeScript
192 lines
7.9 KiB
TypeScript
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, isCompact: 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);
|
|
// A compact desktop is "tablet" to plugins.
|
|
useUIStore.getState().setDeviceType(false, true, true);
|
|
|
|
expect(view).toHaveBeenCalledWith({ view: 'viewer' });
|
|
expect(toggle).toHaveBeenCalledWith({ open: true });
|
|
expect(collapse).toHaveBeenCalledWith({ collapsed: true });
|
|
expect(device.mock.calls).toEqual([[{ deviceType: 'desktop' }], [{ deviceType: 'tablet' }]]);
|
|
});
|
|
|
|
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(true),
|
|
refreshCurrentMailbox: vi.fn().mockResolvedValue(undefined),
|
|
setEmailKeywords: vi.fn().mockResolvedValue(true),
|
|
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('says nothing moved when a plugin stops the move', async () => {
|
|
useEmailStore.setState({ moveEmailsToMailbox: vi.fn().mockResolvedValue(false) } as never);
|
|
const dropped = listen(dragDropHooks.onEmailDrop);
|
|
const onSuccess = vi.fn();
|
|
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(dropped).not.toHaveBeenCalled();
|
|
expect(onSuccess).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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' });
|
|
});
|
|
|
|
it('says nothing was tagged when a plugin stops it', async () => {
|
|
useEmailStore.setState({ setEmailKeywords: vi.fn().mockResolvedValue(false) } as never);
|
|
const tagged = listen(dragDropHooks.onTagDrop);
|
|
const onSuccess = vi.fn();
|
|
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).not.toHaveBeenCalled();
|
|
expect(onSuccess).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
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' }]]);
|
|
});
|
|
});
|