From b3ad80de700457c2ea59adca4e302a33e0ba866b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:52:58 +0100 Subject: [PATCH 1/4] fix: open the folder list in a narrow window as a drawer from the window's edge, with the page dimmed behind it --- app/globals.css | 12 ++ .../__tests__/sidebar-folder-drawer.test.tsx | 180 ++++++++++++++++++ components/layout/sidebar.tsx | 126 ++++++++++-- .../__tests__/mail-app-folder-drawer.test.tsx | 176 +++++++++++++++++ components/mail/mail-app.tsx | 68 ++++--- 5 files changed, 524 insertions(+), 38 deletions(-) create mode 100644 components/layout/__tests__/sidebar-folder-drawer.test.tsx create mode 100644 components/mail/__tests__/mail-app-folder-drawer.test.tsx diff --git a/app/globals.css b/app/globals.css index 315f59981..7a0579c93 100644 --- a/app/globals.css +++ b/app/globals.css @@ -617,6 +617,9 @@ optgroup { --titlebar-right-inset: calc(100vw - env(titlebar-area-x, 0px) - env(titlebar-area-width, 100vw)); --titlebar-left-inset: env(titlebar-area-x, 0px); --titlebar-row-height: env(titlebar-area-height, 0px); + /* Where a drawer from the window's edge starts (the folder drawer): under + the top bar, and under the window's controls when they are over the page. */ + --drawer-top: max(var(--app-top-inset), var(--titlebar-row-height)); } /* A skeleton's row that will hold the controls has them from the first frame. */ @@ -1579,6 +1582,15 @@ html[data-window-controls-left] [data-window-rail] { } } +/* The folded folder column's drawer (components/layout/sidebar.tsx) is at its + full width from the first frame, Reduce motion included, whose rule makes + everything transition for 0.01ms. Grown from the column's 48px at the + window's edge, it left the pointer that opened it outside for a frame, and + the leave closed it again. Specificity (0,3,0) beats that rule's (0,2,0). */ +:root [data-folder-drawer][data-folder-drawer] { + transition: none !important; +} + /* A list row while its right-click menu is open (components/ui/context-menu.tsx). */ [data-menu-row][data-menu-opener] { outline: 2px solid var(--color-ring); diff --git a/components/layout/__tests__/sidebar-folder-drawer.test.tsx b/components/layout/__tests__/sidebar-folder-drawer.test.tsx new file mode 100644 index 000000000..413d7316b --- /dev/null +++ b/components/layout/__tests__/sidebar-folder-drawer.test.tsx @@ -0,0 +1,180 @@ +import { act, render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import type { Mailbox } from '@/lib/jmap/types'; + +// The folded folder column (600 to 1024 px, or folded by hand) floated its +// tree out beside the app rail, with nothing dimming the list it covered: the +// rail showed at its side, the list was cut off at its edge, and the collapse +// button in it lit up blue under the pointer that opened it. It is a drawer +// now, as in Outlook and Thunderbird: from the window's edge, over the rail, +// the page dimmed by the caller (mail-app), the focus kept inside. + +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/components/tour/tour-provider', () => ({ + useTour: () => ({ startTour: vi.fn(), resetTourCompletion: vi.fn(), activeTour: null, endTour: vi.fn(), registerStep: vi.fn() }), +})); +vi.mock('@/contexts/drag-drop-context', () => ({ + useDragDropContext: () => ({ isDragging: false }), +})); +vi.mock('@/hooks/use-mailbox-drop', () => ({ + useMailboxDrop: () => ({ dropHandlers: {}, isValidDropTarget: false, isInvalidDropTarget: false }), +})); +vi.mock('@/hooks/use-tag-drop', () => ({ + useTagDrop: () => ({ dropHandlers: {}, isValidDropTarget: false }), +})); +vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null })); + +const { Sidebar } = await import('../sidebar'); +const { useAuthStore } = await import('@/stores/auth-store'); +const { useSettingsStore } = await import('@/stores/settings-store'); +const { useUIStore } = await import('@/stores/ui-store'); + +const box = (id: string, name: string, role: string | null, extra: Partial = {}) => ({ + id, name, role, parentId: null, totalEmails: 0, unreadEmails: 0, myRights: { mayAddItems: true }, ...extra, +}) as unknown as Mailbox; + +const mailboxes: Mailbox[] = [ + box('inbox', 'Inbox', 'inbox', { totalEmails: 120, unreadEmails: 4 }), + box('trash', 'Trash', 'trash'), + box('news', 'Newsletters', null, { totalEmails: 30, unreadEmails: 2 }), +]; + +const drawer = () => document.querySelector('[data-folder-drawer]'); +const nextFrame = () => act(() => new Promise((resolve) => requestAnimationFrame(() => resolve()))); +const openFromAllFolders = () => fireEvent.click(screen.getByTestId('folded-all-folders')); + +describe('the folded column\'s drawer (default layout)', () => { + beforeEach(() => { + localStorage.clear(); + useAuthStore.setState({ activeAccountId: 'login-a', client: { getAccountId: () => 'me', getCapabilities: () => ({}) } } as never); + useSettingsStore.setState({ hideAccountSwitcher: true, interfaceLayout: 'default', openOnHover: true, unreadCountStyle: 'count' } as never); + useUIStore.setState({ sidebarCollapsed: true, compactSidebarExpanded: false } as never); + }); + afterEach(() => { + cleanup(); + vi.useRealTimers(); + useUIStore.setState({ sidebarCollapsed: false } as never); + }); + + it('opens at the window\'s start edge, from under the top bar to the bottom, over the app rail', () => { + render(); + openFromAllFolders(); + const classes = drawer()!.className.split(/\s+/); + // Fixed to the window, not placed in the folded column beside the rail. + expect(classes).toEqual(expect.arrayContaining(['fixed', 'start-0', 'bottom-0', 'top-[var(--drawer-top)]', 'z-50'])); + expect(classes).not.toContain('desk:absolute'); + expect(classes).not.toContain('h-full'); + }); + + it('asks the caller to dim the page while it is open, and closes from there', () => { + const onDrawerChange = vi.fn(); + render(); + expect(onDrawerChange).not.toHaveBeenCalled(); + openFromAllFolders(); + const close = onDrawerChange.mock.calls.at(-1)![0]; + expect(typeof close).toBe('function'); + act(() => close()); + expect(drawer()).toBeNull(); + expect(onDrawerChange).toHaveBeenLastCalledWith(null); + }); + + it('closes on the collapse button, without docking the folders', () => { + render(); + openFromAllFolders(); + fireEvent.click(screen.getByTestId('sidebar-collapse')); + expect(drawer()).toBeNull(); + expect(useUIStore.getState().sidebarCollapsed).toBe(true); + expect(useUIStore.getState().compactSidebarExpanded).toBe(false); + }); + + it('draws the collapse button with the sidebar\'s grey hover, in the drawer and docked alike', () => { + render(); + openFromAllFolders(); + const inDrawer = screen.getByTestId('sidebar-collapse').className; + cleanup(); + useUIStore.setState({ sidebarCollapsed: false } as never); + render(); + const docked = screen.getByTestId('sidebar-collapse').className; + for (const classes of [inDrawer, docked]) { + expect(classes).toContain('hover:bg-muted'); + expect(classes).not.toContain('hover:bg-accent'); + } + expect(inDrawer).toBe(docked); + }); + + it('mirrors the collapse arrows in RTL, where the drawer comes from the right', () => { + render(); + expect(screen.getByTestId('sidebar-collapse').querySelector('svg')!.getAttribute('class')).toContain('rtl:-scale-x-100'); + openFromAllFolders(); + expect(screen.getByTestId('sidebar-collapse').querySelector('svg')!.getAttribute('class')).toContain('rtl:-scale-x-100'); + }); + + it('keeps Tab and Shift+Tab inside while open', () => { + render(<>); + openFromAllFolders(); + const root = drawer()!; + const focusable = Array.from(root.querySelectorAll('button:not(:disabled)')); + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + last.focus(); + fireEvent.keyDown(last, { key: 'Tab' }); + expect(document.activeElement).toBe(first); + fireEvent.keyDown(first, { key: 'Tab', shiftKey: true }); + expect(document.activeElement).toBe(last); + }); + + it('gives the focus back to All folders when the scrim closes it', async () => { + const onDrawerChange = vi.fn(); + render(); + openFromAllFolders(); + expect(drawer()!.contains(document.activeElement)).toBe(true); + // A press on the scrim drops the focus to the page; its click closes. + (document.activeElement as HTMLElement | null)?.blur(); + act(() => onDrawerChange.mock.calls.at(-1)![0]()); + await nextFrame(); + expect(drawer()).toBeNull(); + expect(document.activeElement).toBe(screen.getByTestId('folded-all-folders')); + }); + + it('stays open on the press on the scrim, so its click doesn\'t reach the message under it', () => { + render(<>
); + openFromAllFolders(); + fireEvent.pointerDown(screen.getByTestId('scrim')); + expect(drawer()).not.toBeNull(); + }); + + it('gives the focus back to All folders after a pick', async () => { + const { rerender } = render(); + openFromAllFolders(); + rerender(); + await nextFrame(); + expect(drawer()).toBeNull(); + expect(document.activeElement).toBe(screen.getByTestId('folded-all-folders')); + }); + + it('does not float out while the pointer is on the header\'s buttons', () => { + vi.useFakeTimers(); + const { container } = render(); + const root = container.firstElementChild!; + fireEvent.mouseEnter(root); + fireEvent.mouseMove(screen.getByTestId('sidebar-collapse')); + act(() => { vi.advanceTimersByTime(400); }); + expect(drawer()).toBeNull(); + // On the folders it does. + fireEvent.mouseMove(document.querySelector('[data-mailbox-id="inbox"]')!); + act(() => { vi.advanceTimersByTime(400); }); + expect(drawer()).not.toBeNull(); + }); + + it('keeps Mountain View\'s peek beside its rail', () => { + vi.useFakeTimers(); + useSettingsStore.setState({ interfaceLayout: 'mountain-view' } as never); + const onDrawerChange = vi.fn(); + const { container } = render(); + fireEvent.mouseEnter(container.firstElementChild!); + const peek = document.querySelector('[data-mv-rail-peek]')!; + expect(peek.className).toContain('desk:absolute'); + expect(drawer()).toBeNull(); + expect(onDrawerChange).not.toHaveBeenCalled(); + }); +}); diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index b07adb2d4..42de678cd 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -168,6 +168,12 @@ interface SidebarProps { * to that login and opens it, since shared mail is reached through the * active login. */ onLoginSharedMailboxSelect?: (loginId: string, mailboxId: string) => void; + /** + * The folded column's tree is open over the page as a drawer (default + * layout): called with a function that closes it, and with null once it is + * closed. The caller dims the page behind it. + */ + onDrawerChange?: (close: (() => void) | null) => void; } const ROW_PX_BASE = FOLDER_ROW_PX_BASE; @@ -175,6 +181,32 @@ const CHEVRON_SLOT = FOLDER_CHEVRON_SLOT; const INDENT_STEP = 12; /** How long the pointer rests on the folded column before the tree floats out. */ const RAIL_HOVER_DELAY_MS = 350; +/** + * The header's collapse and close buttons: muted, with the grey hover of the + * other icon buttons in the sidebar. The ghost button's accent hover is a + * strong blue in the dark theme, and a drawer that opens under the pointer + * showed it as if the button were switched on. + */ +const SIDEBAR_HEAD_BUTTON = "text-muted-foreground hover:bg-muted hover:text-foreground"; + +/** Keeps Tab and Shift+Tab inside `root`, wrapping at either end. */ +function trapTab(e: React.KeyboardEvent, root: HTMLElement | null) { + if (!root) return; + const focusable = Array.from(root.querySelectorAll( + 'button:not(:disabled), [href], input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]' + )).filter((el) => el.getAttribute('tabindex') !== '-1' && !el.closest('[inert]')); + if (focusable.length === 0) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const active = document.activeElement; + if (e.shiftKey && (active === first || active === root)) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && active === last) { + e.preventDefault(); + first.focus(); + } +} // Icons and colours follow the JMAP role only. const getIconForMailbox = (role?: string, hasChildren?: boolean, isExpanded?: boolean, id?: string) => { @@ -1200,6 +1232,7 @@ function SidebarImpl({ accountSharedMailboxes, onLoginSharedMailboxSelect, mvShell = false, + onDrawerChange, }: SidebarProps) { const router = useRouter(); // The rail is a desktop thing. Below that the sidebar is a drawer, opened to @@ -1223,6 +1256,12 @@ function SidebarImpl({ const allFoldersRef = useRef(null); const hoverExpanded = sidebarCollapsed && !isDrawer && (railHovered || railOpen); const isCollapsed = sidebarCollapsed && !hoverExpanded && !isDrawer; + // Outside Mountain View the floated tree is a drawer, as in Outlook and + // Thunderbird: from the window's start edge, over the app rail and the + // folded column, with the page dimmed behind it (the caller draws that). + // Floating beside the rail with nothing behind it, it looked like a second + // sidebar cut into the list. Mountain View keeps Gmail's peek beside its rail. + const isFolderDrawer = hoverExpanded && !mvShell; const closeRailPeek = useCallback(() => { if (railHoverTimer.current) clearTimeout(railHoverTimer.current); railHoverTimer.current = null; @@ -1230,6 +1269,49 @@ function SidebarImpl({ setRailOpen(false); }, []); useEffect(() => () => { if (railHoverTimer.current) clearTimeout(railHoverTimer.current); }, []); + // Unfolded or turned into the phone drawer meanwhile: nothing floats any more. + useEffect(() => { + if (!sidebarCollapsed || isDrawer) closeRailPeek(); + }, [sidebarCollapsed, isDrawer, closeRailPeek]); + const onDrawerChangeRef = useRef(onDrawerChange); + useEffect(() => { onDrawerChangeRef.current = onDrawerChange; }); + useEffect(() => { + if (!isFolderDrawer) return; + onDrawerChangeRef.current?.(closeRailPeek); + return () => onDrawerChangeRef.current?.(null); + }, [isFolderDrawer, closeRailPeek]); + // Opened from All folders, the drawer gives the focus back there however it + // closes: Escape, the collapse button, a pick, a press on the dimmed page. + const railWasOpen = useRef(false); + useEffect(() => { + if (railOpen) { railWasOpen.current = true; return; } + if (!railWasOpen.current) return; + railWasOpen.current = false; + // A frame later: a press on the scrim closes it on pointerdown, and the + // press itself then drops the focus to the page. + const frame = requestAnimationFrame(() => { + const active = document.activeElement; + if (active && active !== document.body && !rootRef.current?.contains(active)) return; + allFoldersRef.current?.focus(); + }); + return () => cancelAnimationFrame(frame); + }, [railOpen]); + // The pointer resting on the folders floats the tree out; on the header + // (expand, compose) it doesn't, so the expand button can still be clicked. + const armRailHover = useCallback((target: EventTarget | null) => { + if (!hoverOpens()) return; + if (mvShell) { setRailHovered(true); return; } + if (target instanceof Element && target.closest('[data-sidebar-head]')) { + if (railHoverTimer.current) clearTimeout(railHoverTimer.current); + railHoverTimer.current = null; + return; + } + if (railHoverTimer.current) return; + railHoverTimer.current = setTimeout(() => { + railHoverTimer.current = null; + setRailHovered(true); + }, RAIL_HOVER_DELAY_MS); + }, [mvShell]); const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore(useShallow((state) => ({ primaryIdentity: state.primaryIdentity, activeAccountId: state.activeAccountId }))); const [expandedFolders, setExpandedFolders] = useState>(new Set()); const [expandedTags, setExpandedTags] = useState>(new Set()); @@ -1347,6 +1429,9 @@ function SidebarImpl({ ?? root?.querySelector('nav button:not([disabled])'); target?.focus(); const onPointerDown = (e: PointerEvent) => { + // The scrim behind the drawer closes it on its own click: gone at the + // press, it would let that click through to the message under it. + if (e.target instanceof Element && e.target.closest('[data-drawer-scrim]')) return; if (root && !root.contains(e.target as Node)) closeRailPeek(); }; document.addEventListener('pointerdown', onPointerDown, true); @@ -1980,18 +2065,19 @@ function SidebarImpl({
{ - if (!hoverOpens()) return; - if (mvShell) { setRailHovered(true); return; } - if (railHoverTimer.current) clearTimeout(railHoverTimer.current); - railHoverTimer.current = setTimeout(() => setRailHovered(true), RAIL_HOVER_DELAY_MS); - } : undefined} + onMouseEnter={sidebarCollapsed && !isDrawer ? (e) => armRailHover(e.target) : undefined} + onMouseMove={sidebarCollapsed && !isDrawer && !mvShell && !railHovered ? (e) => armRailHover(e.target) : undefined} onMouseLeave={sidebarCollapsed && !isDrawer ? () => { if (railHoverTimer.current) clearTimeout(railHoverTimer.current); railHoverTimer.current = null; setRailHovered(false); } : undefined} onKeyDown={railOpen || railHovered ? (e) => { + // Opened from All folders, the drawer keeps the focus until it closes. + if (e.key === 'Tab' && railOpen && !mvShell) { + trapTab(e, rootRef.current); + return; + } if (e.key !== 'Escape' || e.defaultPrevented) return; e.preventDefault(); const hadFocus = rootRef.current?.contains(document.activeElement); @@ -2003,7 +2089,8 @@ function SidebarImpl({ if (next && !e.currentTarget.contains(next)) closeRailPeek(); } : undefined} data-mv-rail-peek={hoverExpanded && mvShell ? "" : undefined} - data-folder-peek={hoverExpanded && !mvShell ? "" : undefined} + data-folder-peek={isFolderDrawer ? "" : undefined} + data-folder-drawer={isFolderDrawer ? "" : undefined} className={cn( "relative flex flex-col h-full border-e transition-all duration-300 overflow-hidden", // Mountain View's navigation sits on the canvas, with no rule between @@ -2012,11 +2099,18 @@ function SidebarImpl({ "max-desk:w-full", // Mountain View's collapsed column is 72px (--mv-rail-w), wide enough // for its round compose button; the default layout's rail is 48px. - isCollapsed && !mvShell ? "desk:w-12" : hoverExpanded && !mvShell ? "desk:w-64" : "desk:w-full", + isCollapsed && !mvShell ? "desk:w-12" : isFolderDrawer ? "desk:w-72" : "desk:w-full", // Floats out of the rail's own column rather than widening it, so the // message list underneath never moves while the pointer passes over. // Width comes from --mv-rail-peek-w, keyed on data-mv-rail-peek. - hoverExpanded && "desk:absolute desk:inset-y-0 desk:start-0 desk:z-40 desk:shadow-xl", + hoverExpanded && mvShell && "desk:absolute desk:inset-y-0 desk:start-0 desk:z-40 desk:shadow-xl", + // The drawer: at the window's start edge, from under the top bar and + // the window's controls (--drawer-top) to the bottom; in a Pro pane, at + // the pane's. At its full width at once: grown from the folded + // column's 48px at the edge, it would leave the pointer that opened it + // outside, which closes it again. + isFolderDrawer && "transition-none shadow-xl z-50", + isFolderDrawer && (isEmbedded ? "absolute inset-y-0 start-0" : "fixed start-0 bottom-0 top-[var(--drawer-top)] h-auto"), className )} > @@ -2025,13 +2119,13 @@ function SidebarImpl({ {!isEmbedded && !mvShell && ( // Border lives on the wrapper (outside the h-14 box) so the bar's total // height matches the search/reply toolbars, which border-b their wrapper too. -
+
{/* Compose at the head of the folders on a desktop, as in Gmail diff --git a/components/mail/__tests__/mail-app-folder-drawer.test.tsx b/components/mail/__tests__/mail-app-folder-drawer.test.tsx new file mode 100644 index 000000000..43b611a84 --- /dev/null +++ b/components/mail/__tests__/mail-app-folder-drawer.test.tsx @@ -0,0 +1,176 @@ +import React from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { usePluginStore } from '@/stores/plugin-store'; +import { useThemeStore } from '@/stores/theme-store'; +import { useUIStore } from '@/stores/ui-store'; +import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; + +// The folder list over the page (the folded column's drawer on a compact +// desktop, the phone's drawer) dims the page behind it, and the phone's keeps +// the focus until it closes and then hands it back to the menu button. The +// folded column's drawer opened beside the app rail with nothing dimming the +// list (usability report 2026-11-04, screenshots at 525 and 587 px). + +const device = vi.hoisted(() => ({ + current: { isMobile: false, isCompact: true, isDesktop: true }, + drawerChange: null as null | ((close: (() => void) | null) => void), +})); + +vi.mock('@/hooks/use-media-query', async (importOriginal) => ({ + ...(await importOriginal()), + useDeviceDetection: () => device.current, + useMediaQuery: () => false, + useIsDesktop: () => device.current.isDesktop, + useIsMobile: () => device.current.isMobile, +})); +vi.mock('@/hooks/use-split-panes-fit', () => ({ useSplitPanesFit: () => true })); +vi.mock('@/components/layout/navigation-rail', () => ({ + NavigationRail: ({ orientation = 'vertical' }: { orientation?: string }) => , +})); +vi.mock('@/components/layout/sidebar', () => ({ + Sidebar: ({ onDrawerChange }: { onDrawerChange?: (close: (() => void) | null) => void }) => { + device.drawerChange = onDrawerChange ?? null; + return
; + }, +})); +vi.mock('@/components/layout/mobile-header', async () => { + const { useUIStore: store } = await import('@/stores/ui-store'); + return { + MobileHeader: () => ( + + ), + }; +}); +vi.mock('@/components/email/email-composer', () => ({ EmailComposer: () =>

stub composer

})); +vi.mock('@/components/email/email-viewer', () => ({ EmailViewer: () =>
message viewer
})); +vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null })); +vi.mock('@/components/search/mv-search-form', () => ({ MvSearchForm: () => null })); +vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null })); +vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null })); +vi.mock('@/components/layout/mv-side-panel', () => ({ MvSidePanel: () => null })); +vi.mock('@/components/plugins/plugin-side-panels', () => ({ PluginSidePanel: () => null })); +vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), MessageListTabs: () => null })); +vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null })); +vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) })); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] })); +vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) })); +vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} })); +vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] })); +vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} })); +vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false })); +vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} })); +vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} })); +vi.mock('@/components/email/email-list', () => ({ EmailList: () =>
message list
})); + +const { MailApp } = await import('../mail-app'); + +const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 1, unreadEmails: 0 } as Mailbox; +const message = { id: 'm1', threadId: 't-m1', subject: 'm1', receivedAt: '2026-10-31T10:00:00Z', keywords: { $seen: true }, mailboxIds: { inbox: true } } as unknown as Email; + +const compact = { isMobile: false, isCompact: true, isDesktop: true }; +const phone = { isMobile: true, isCompact: false, isDesktop: false }; + +beforeEach(() => { + device.current = compact; + device.drawerChange = null; + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request'))); + vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) }); + useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) }); + const client = { + getAccountId: () => 'acc', getUsername: () => 'a@example.org', + getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null, + }; + useAuthStore.setState({ + isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [], + getClientForAccount: () => client as never, + getAllConnectedClients: () => new Map([['login-a', client]]) as never, + }); + useSettingsStore.setState({ enableUnifiedMailbox: false, interfaceLayout: 'default', mailLayout: 'split', simpleMode: false } as never); + useUIStore.setState({ + activeView: 'list', sidebarOpen: false, sidebarWidth: 256, + sidebarCollapsed: false, compactSidebarExpanded: false, + isMobile: false, isCompact: true, isDesktop: true, + }); + useEmailStore.setState({ + emails: [message], selectedEmail: null, selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: [inbox], + viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null, + isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '', + searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', isAdvancedSearchOpen: false, + selectedEmailKeys: new Set(), unreadSection: null, + fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined), + fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined), + refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined), + fetchEmailContent: vi.fn().mockResolvedValue(undefined), + markAsRead: vi.fn().mockResolvedValue(undefined), + } as never); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +const scrim = () => screen.queryByTestId('folder-drawer-scrim'); +/** The element holding the folder sidebar (the one with its id). */ +const folders = () => screen.getByText('first folder').closest('[id]')!; + +describe('the compact desktop\'s folder drawer', () => { + it('dims the whole page while the folded column\'s drawer is open', () => { + render(); + expect(scrim()).toBeNull(); + const close = vi.fn(); + act(() => device.drawerChange!(close)); + const classes = scrim()!.className.split(/\s+/); + expect(classes).toEqual(expect.arrayContaining(['fixed', 'inset-0'])); + // Not the phone's scrim, which the desktop breakpoint hides. + expect(classes).not.toContain('desk:hidden'); + fireEvent.click(scrim()!); + expect(close).toHaveBeenCalledTimes(1); + act(() => device.drawerChange!(null)); + expect(scrim()).toBeNull(); + }); + + it('leaves the drawer the window to be placed in, not the folder column', () => { + render(); + const classes = folders().className.split(/\s+/); + // A translate, even translate-x-0, makes the column the drawer's + // containing block, and the drawer opened beside the app rail. + expect(classes).toContain('desk:translate-none'); + expect(classes.some((c) => /^desk:-?translate-x-/.test(c))).toBe(false); + }); +}); + +describe('the phone\'s folder drawer', () => { + beforeEach(() => { + device.current = phone; + useUIStore.setState({ isMobile: true, isCompact: false, isDesktop: false }); + }); + + it('keeps Tab inside and gives the focus back to the menu button on Escape', () => { + render(); + const toggle = screen.getByText('menu'); + toggle.focus(); + fireEvent.click(toggle); + const first = screen.getByText('first folder'); + const last = screen.getByText('last folder'); + expect(document.activeElement).toBe(first); + last.focus(); + fireEvent.keyDown(last, { key: 'Tab' }); + expect(document.activeElement).toBe(first); + fireEvent.keyDown(first, { key: 'Escape' }); + expect(useUIStore.getState().sidebarOpen).toBe(false); + expect(document.activeElement).toBe(toggle); + }); + + it('starts under the top bar and the window\'s controls', () => { + render(); + const classes = folders().className.split(/\s+/); + expect(classes).toEqual(expect.arrayContaining(['max-desk:fixed', 'max-desk:start-0', 'max-desk:top-[var(--drawer-top)]', 'max-desk:bottom-0'])); + }); +}); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 7833a66e2..8ac8ecbe9 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -79,6 +79,7 @@ import { useContactStore } from "@/stores/contact-store"; import { useIdentityStore } from "@/stores/identity-store"; import { useUIStore, sidebarFolded, listPaneCssSize, SIDEBAR_MIN, SIDEBAR_MAX, EMAIL_LIST_MIN, EMAIL_LIST_MAX, EMAIL_LIST_HEIGHT_MIN, EMAIL_LIST_HEIGHT_MAX, READING_PANE_MIN_WIDTH, READING_PANE_MIN_HEIGHT, COMPOSER_PANE_MIN_HEIGHT } from "@/stores/ui-store"; import { atLeast, PHONE_BELOW, useDeviceDetection } from "@/hooks/use-media-query"; +import { useFocusTrap } from "@/hooks/use-focus-trap"; import { usePaneSize } from "@/hooks/use-pane-size"; import { useIsFocusedProTab, usePaneId } from "@/hooks/use-pane-context"; import { useKeyboardShortcuts, type GoToMailboxTarget } from "@/hooks/use-keyboard-shortcuts"; @@ -2593,28 +2594,35 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // Pro can mount two mail panes at once, so the sidebar id must be per-instance. const sidebarId = useId(); const listHeadingId = useId(); - const sidebarContainerRef = useRef(null); + // The phone drawer keeps Tab inside until it closes, and Escape closes it. + const phoneDrawerOpen = isMobile && sidebarOpen; + const sidebarContainerRef = useFocusTrap({ + isActive: phoneDrawerOpen, + onEscape: () => setSidebarOpen(false), + restoreFocus: false, + }); const sidebarWasOpen = useRef(false); useEffect(() => { - const isOverlay = isMobile; - if (isOverlay && sidebarOpen) { + if (phoneDrawerOpen) { sidebarWasOpen.current = true; - const frame = requestAnimationFrame(() => { - const first = sidebarContainerRef.current?.querySelector( - 'button:not(:disabled),[href],input:not(:disabled),[tabindex]:not([tabindex="-1"])' - ); - first?.focus(); - }); - return () => cancelAnimationFrame(frame); + return; } if (!sidebarWasOpen.current) return; sidebarWasOpen.current = false; - // The toggle lives in whichever MobileHeader the current view renders, so + // Back to the toggle however it closed (Escape, X, a pick, the scrim), + // unless the focus has moved on to something outside the drawer. The + // toggle lives in whichever MobileHeader the current view renders, so // look it up inside this app instance rather than threading a ref through. const active = document.activeElement; - if (active && active !== document.body) return; + if (active && active !== document.body && !sidebarContainerRef.current?.contains(active)) return; appRootRef.current?.querySelector('[data-sidebar-toggle]')?.focus(); - }, [isMobile, sidebarOpen]); + }, [phoneDrawerOpen, sidebarContainerRef]); + // The folded column's tree open over the page as a drawer (Sidebar's + // onDrawerChange): the function that closes it, for the scrim. + const [closeFolderDrawer, setCloseFolderDrawer] = useState<(() => void) | null>(null); + const handleFolderDrawerChange = useCallback((close: (() => void) | null) => { + setCloseFolderDrawer(() => close); + }, []); // The message a composer session answers, pinned when the session starts. // Mountain View keeps its compose window open while other mail is read @@ -5841,13 +5849,21 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { `desk:hidden` viewport-variant alone wouldn't gate this overlay; scope to the pane via `absolute` so the backdrop stays inside the pane instead of covering the whole window. */} - {isMobile && sidebarOpen && !inlineApp && ( + {((isMobile && sidebarOpen) || closeFolderDrawer) && !inlineApp && ( From d6d8f638f88e87cf01b2d0e3edbe641542dba626 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:59:22 +0100 Subject: [PATCH 2/4] fix: open mail links that carry a stray percent sign --- lib/__tests__/protocol-handlers.test.ts | 20 +++++++++++++ lib/protocol-handlers/mailto.ts | 39 ++++++++++++------------- 2 files changed, 39 insertions(+), 20 deletions(-) diff --git a/lib/__tests__/protocol-handlers.test.ts b/lib/__tests__/protocol-handlers.test.ts index ba83f2bc8..5a473250a 100644 --- a/lib/__tests__/protocol-handlers.test.ts +++ b/lib/__tests__/protocol-handlers.test.ts @@ -97,6 +97,26 @@ describe("protocol handlers", () => { }); }); + it("keeps a link with a stray percent sign, decoding what can be decoded", () => { + // Links written by hand or by older programs: Outlook and Thunderbird open these. + expect(parseMailto("mailto:alice@example.com?subject=50% off&body=Caf%C3%A9 %ZZ %C3")).toMatchObject({ + to: ["alice@example.com"], + subject: "50% off", + body: "Café %ZZ �", + }); + expect(parseMailto("mailto:bob%40example.com,100%@example.com")?.to).toEqual(["bob@example.com", "100%@example.com"]); + }); + + it("decodes UTF-8 and CRLF line breaks in every field", () => { + expect(parseMailto("mailto:J%C3%BCrgen%20%3Cj%40example.com%3E?cc=a%40example.com,b%40example.com&subject=Gr%C3%BC%C3%9Fe%20%F0%9F%98%80&body=Zeile%201%0D%0AZeile%202%0D%0A%0D%0A%E2%82%AC%2610")).toEqual({ + to: ["Jürgen "], + cc: ["a@example.com", "b@example.com"], + bcc: [], + subject: "Grüße 😀", + body: "Zeile 1\nZeile 2\n\n€&10", + }); + }); + it("removes control characters and caps recipients", () => { const recipients = Array.from({ length: 250 }, (_, index) => `user${index}@example.com`).join(","); const parsed = parseMailto(`mailto:${recipients}?subject=Hi%0ABcc:evil@example.com`); diff --git a/lib/protocol-handlers/mailto.ts b/lib/protocol-handlers/mailto.ts index fcc60a363..124af2faf 100644 --- a/lib/protocol-handlers/mailto.ts +++ b/lib/protocol-handlers/mailto.ts @@ -78,25 +78,29 @@ function getQueryValue(searchParams: QueryParam[], key: string): string { return values.join(","); } -function decodePathname(pathname: string): string | null { +/** + * Percent-decoding that never gives up on a link. Links written by hand or by older + * programs carry a stray "%" ("50% off") or a cut-off UTF-8 sequence; Outlook and + * Thunderbird open those, so each run of valid escapes is decoded and anything else + * stays as it is. Bytes that aren't UTF-8 become U+FFFD. RFC 6068 uses + * percent-encoding, not form encoding: a literal '+' stays a '+'. + */ +function decodeLenient(value: string): string { try { - return decodeURIComponent(pathname || ""); - } catch { - return null; - } -} - -function decodeQueryPart(value: string): string | null { - try { - // RFC 6068 uses percent-encoding for mailto query fields; unlike form - // encoding, a literal '+' is part of the value and must not become space. return decodeURIComponent(value); } catch { - return null; + return value.replace(/(?:%[0-9a-f]{2})+/gi, (run) => { + try { + return decodeURIComponent(run); + } catch { + const bytes = Uint8Array.from(run.slice(1).split("%"), (hex) => parseInt(hex, 16)); + return new TextDecoder().decode(bytes); + } + }); } } -function parseQuery(query: string): QueryParam[] | null { +function parseQuery(query: string): QueryParam[] { if (!query) return []; const params: QueryParam[] = []; @@ -105,10 +109,7 @@ function parseQuery(query: string): QueryParam[] | null { const separatorIndex = part.indexOf("="); const rawKey = separatorIndex >= 0 ? part.slice(0, separatorIndex) : part; const rawValue = separatorIndex >= 0 ? part.slice(separatorIndex + 1) : ""; - const key = decodeQueryPart(rawKey); - const value = decodeQueryPart(rawValue); - if (key === null || value === null) return null; - params.push({ key, value }); + params.push({ key: decodeLenient(rawKey), value: decodeLenient(rawValue) }); } return params; @@ -122,10 +123,8 @@ export function parseMailto(raw: string): ParsedMailto | null { const rawPathname = queryIndex >= 0 ? addressAndQuery.slice(0, queryIndex) : addressAndQuery; const rawQuery = queryIndex >= 0 ? addressAndQuery.slice(queryIndex + 1) : ""; - const decodedPathname = decodePathname(rawPathname); - if (decodedPathname === null) return null; + const decodedPathname = decodeLenient(rawPathname); const searchParams = parseQuery(rawQuery); - if (searchParams === null) return null; const to = [ ...splitRecipients(decodedPathname), From c4ab6d250763613c686aa7222ac35c21b400222c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:59:23 +0100 Subject: [PATCH 3/4] fix: open the subscription straight away for a calendar link --- components/calendar/calendar-app.tsx | 77 ++++++---------------------- 1 file changed, 17 insertions(+), 60 deletions(-) diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 3ce9695bb..340712727 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -78,7 +78,6 @@ import { cn, generateUUID } from "@/lib/utils"; import type { Calendar, CalendarEvent, CalendarParticipant, CalendarRights, CalendarTask } from "@/lib/jmap/types"; import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; -import { Dialog } from "@/components/ui/dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { SchedulingDeniedError } from "@/lib/jmap/scheduling-error"; import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal"; @@ -166,7 +165,6 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = useDocumentTitle(tSidebar("calendar")); useDesktopWindowName(tSidebar("calendar"), { withAccount: true }); const skeletonLabels = useSkeletonLabels(); - const tWebcalAction = useTranslations("calendar.webcal_action"); const { demoMode } = useConfig(); // Subscriptions fetch the feed through /api/fetch-ical, which the demo has no // mail server for and the Lite static build does not have at all. @@ -246,7 +244,6 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const searchInputRef = useRef(null); const [showSubscriptionModal, setShowSubscriptionModal] = useState(false); const [pendingSubscription, setPendingSubscription] = useState<{ url: string; name: string } | null>(null); - const [showWebcalActionChoice, setShowWebcalActionChoice] = useState(false); const [pendingWebcalAccountChoice, setPendingWebcalAccountChoice] = useState(null); const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false); const [editingSubscription, setEditingSubscription] = useState(null); @@ -345,10 +342,10 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = } else if (client && !calendarEnabled) { // Calendar disabled by admin policy - send the user back to mail. router.push("/"); - } else if (client && !supportsCalendar && !pendingWebcalAccountChoice && !isProtocolAccountSwitching && !pendingSubscription && !showWebcalActionChoice && !hasPendingWebcal()) { + } else if (client && !supportsCalendar && !pendingWebcalAccountChoice && !isProtocolAccountSwitching && !pendingSubscription && !hasPendingWebcal()) { router.push("/"); } - }, [initialCheckDone, isAuthenticated, authLoading, client, calendarEnabled, supportsCalendar, pendingWebcalAccountChoice, isProtocolAccountSwitching, pendingSubscription, showWebcalActionChoice, router]); + }, [initialCheckDone, isAuthenticated, authLoading, client, calendarEnabled, supportsCalendar, pendingWebcalAccountChoice, isProtocolAccountSwitching, pendingSubscription, router]); useEffect(() => { if (error) { @@ -364,6 +361,17 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = }); }, []); + // A webcal: link asks to subscribe, so that dialog opens with the address and name filled + // in and is the one question. Where subscribing isn't offered (Lite, demo) it's the import. + const openWebcalDialog = useCallback((pending: ParsedWebcal) => { + setPendingSubscription({ + url: pending.subscriptionUrl, + name: pending.suggestedName, + }); + if (canSubscribe) setShowSubscriptionModal(true); + else setShowImportModal(true); + }, [canSubscribe]); + const openWebcalForAccount = useCallback(async (pending: ParsedWebcal, accountId: string) => { setIsProtocolAccountSwitching(true); try { @@ -371,15 +379,11 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = await switchAccount(accountId); } setPendingWebcalAccountChoice(null); - setPendingSubscription({ - url: pending.subscriptionUrl, - name: pending.suggestedName, - }); - setShowWebcalActionChoice(true); + openWebcalDialog(pending); } finally { setIsProtocolAccountSwitching(false); } - }, [switchAccount]); + }, [switchAccount, openWebcalDialog]); const handleWebcalProtocolRequest = useCallback((pending: ParsedWebcal) => { const protocolAccounts = getWebcalProtocolAccounts(); @@ -398,27 +402,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = return; } - setPendingSubscription({ - url: pending.subscriptionUrl, - name: pending.suggestedName, - }); - setShowWebcalActionChoice(true); - }, [activeAccountId, getWebcalProtocolAccounts, openWebcalForAccount, supportsCalendar]); - - const closeWebcalActionChoice = useCallback(() => { - setShowWebcalActionChoice(false); - setPendingSubscription(null); - }, []); - - const handleImportWebcal = useCallback(() => { - setShowWebcalActionChoice(false); - setShowImportModal(true); - }, []); - - const handleSubscribeWebcal = useCallback(() => { - setShowWebcalActionChoice(false); - setShowSubscriptionModal(true); - }, []); + openWebcalDialog(pending); + }, [activeAccountId, getWebcalProtocolAccounts, openWebcalForAccount, openWebcalDialog, supportsCalendar]); useEffect(() => { if (!isAuthenticated || !client) return; @@ -1907,33 +1892,6 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = /> ) : null; - const renderWebcalActionChoice = () => showWebcalActionChoice && pendingSubscription ? ( - {tWebcalAction("cancel")}} - > -
- - {!IS_LITE && ( - - )} -
-
- ) : null; - // Until the session says what the account has, the page is drawn as the // skeleton the server sent (components/boot-skeleton): the same markup, so // nothing moves while the sign-in is restored or the redirects decide. @@ -2687,7 +2645,6 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = {renderWebcalAccountPicker()} - {renderWebcalActionChoice()} Date: Wed, 4 Nov 2026 15:59:23 +0100 Subject: [PATCH 4/4] test: check the browser's mail and calendar link handlers end to end --- integration/tests/26-do-links.spec.ts | 161 ++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 integration/tests/26-do-links.spec.ts diff --git a/integration/tests/26-do-links.spec.ts b/integration/tests/26-do-links.spec.ts new file mode 100644 index 000000000..ce9681bb7 --- /dev/null +++ b/integration/tests/26-do-links.spec.ts @@ -0,0 +1,161 @@ +import { test, expect, type Browser, type Page } from '@playwright/test'; +import { accountSwitcher, neutralizeDevOverlay } from './helpers/app'; +import { ENT, ENT_PASSWORD, contextOptions, jmapAs, requireEntEnv, shot } from './helpers/ent'; + +/** + * The browser webmail as the mail and calendar app of the browser: Settings registers + * navigator.registerProtocolHandler for mailto: and webcal:, and the address the browser + * then opens fills the composer or the subscription. + * + * Chromium can't accept its own permission prompt from a test, so a stand-in records what the + * page registers, and the spec opens that address the way the browser would, %s replaced by the + * encoded link. A second context checks the real call is accepted without an error. + * + * BUILD=1 bash desktop-offline-rig/start-prod.sh 3241 links-1.0 + * bash desktop-offline-rig/run-spec.sh 3241 1.0 integration/tests/26-do-links.spec.ts + */ + +test.describe.configure({ mode: 'serial' }); +test.setTimeout(180_000); + +const RUN = `lkb${Date.now().toString(36)}`; +const composer = (page: Page) => page.locator('[data-testid="email-composer"]').first(); + +/** Records navigator.registerProtocolHandler instead of asking Chromium. */ +function recordRegistrations() { + const calls: [string, string][] = []; + (window as unknown as { __registered: [string, string][] }).__registered = calls; + Object.defineProperty(Navigator.prototype, 'registerProtocolHandler', { + configurable: true, + value(scheme: string, url: string) { + calls.push([scheme, url]); + }, + }); +} + +async function signedIn(browser: Browser, record: boolean): Promise { + const context = await browser.newContext(contextOptions()); + if (record) await context.addInitScript(recordRegistrations); + const page = await context.newPage(); + await neutralizeDevOverlay(page); + await page.goto('/'); + await page.fill('#username', ENT.alice); + await page.fill('#password', ENT_PASSWORD); + await page.click('button[type="submit"]'); + await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60_000 }); + return page; +} + +async function openDefaultApps(page: Page): Promise { + await page.goto('/settings/general'); + await expect(page.getByRole('heading', { name: 'Default apps' })).toBeVisible({ timeout: 60_000 }); +} + +/** + * The tab the composer or calendar ended up in. With "Always open a new tab" a handler tab that + * has history of its own hands the link to a fresh tab and closes, so it may not be this one. + */ +async function tabWith(page: Page, selector: string): Promise { + let found: Page | undefined; + await expect.poll(async () => { + for (const candidate of page.context().pages()) { + if (!candidate.isClosed() && await candidate.locator(selector).first().isVisible().catch(() => false)) { + found = candidate; + return true; + } + } + return false; + }, { timeout: 60_000 }).toBe(true); + return found!; +} + +/** The address the browser opens for a link, as Chromium fills in %s. */ +const handlerAddress = (template: string, link: string) => template.replace('%s', encodeURIComponent(link)); + +let mailtoTemplate = ''; +let webcalTemplate = ''; + +test.beforeAll(requireEntEnv); + +test.afterAll(async () => { + // Only this run's: a draft a composer may have saved. + const client = await jmapAs(ENT.alice); + const found = await client.request([['Email/query', { accountId: client.accountId, filter: { subject: RUN }, limit: 50 }, 'q']]); + const ids = (found.methodResponses[0][1] as { ids: string[] }).ids; + if (ids.length) await client.request([['Email/set', { accountId: client.accountId, destroy: ids }, 'd']]); +}); + +test('Settings registers both handlers with this app\'s own address', async ({ browser }) => { + const page = await signedIn(browser, true); + await openDefaultApps(page); + await page.getByRole('button', { name: /for email links/ }).click(); + await expect(page.getByText('Email handler registration requested')).toBeVisible(); + await page.getByRole('button', { name: /for calendar links/ }).click(); + await expect(page.getByText('Calendar handler registration requested')).toBeVisible(); + const origin = new URL(page.url()).origin; + const calls = await page.evaluate(() => (window as unknown as { __registered: [string, string][] }).__registered); + expect(calls).toEqual([ + ['mailto', `${origin}/protocol/mailto?url=%s`], + ['webcal', `${origin}/protocol/webcal?url=%s`], + ]); + [, mailtoTemplate] = calls[0]; + [, webcalTemplate] = calls[1]; + await shot(page, 'links-browser-default-apps'); + await page.context().close(); +}); + +test('the real browser call takes the address without an error', async ({ browser }) => { + const page = await signedIn(browser, false); + await openDefaultApps(page); + await page.getByRole('button', { name: /for email links/ }).click(); + await expect(page.getByText('Email handler registration requested')).toBeVisible(); + await expect(page.getByText('Protocol handler registration failed')).toHaveCount(0); + await page.context().close(); +}); + +test('a mail link opened by the browser fills the composer: every field, UTF-8 and line breaks', async ({ browser }) => { + const page = await signedIn(browser, false); + // The browser opens the handler in a tab of its own. + const tab = await page.context().newPage(); + const subject = `${RUN} Grüße 😀`; + const link = `mailto:J%C3%BCrgen%20%3Cjuergen%40example.org%3E,second%40example.org?cc=cc%40example.org&bcc=bcc%40example.org&subject=${encodeURIComponent(subject)}&body=${encodeURIComponent('Zeile 1\r\nZeile 2\r\n€ 10 & more')}`; + await tab.goto(handlerAddress(mailtoTemplate, link)); + const shown = await tabWith(page, '[data-testid="email-composer"]'); + expect(shown).not.toBe(page); + await expect(shown.getByTestId('composer-subject')).toHaveValue(subject); + const to = await shown.locator('[data-testid="composer-to"] [data-recipient-chip]').allTextContents(); + expect(to.join(' ')).toContain('Jürgen'); + expect(to.join(' ')).toContain('second@example.org'); + await expect(composer(shown)).toContainText('cc@example.org'); + await expect(composer(shown)).toContainText('bcc@example.org'); + const body = composer(shown).locator('[contenteditable="true"], textarea').last(); + await expect(body).toContainText('Zeile 1'); + await expect(body).toContainText('Zeile 2'); + await expect(body).toContainText('€ 10 & more'); + await shot(shown, 'links-browser-mailto'); + await page.context().close(); +}); + +test('a link with a stray percent sign still opens the composer', async ({ browser }) => { + const page = await signedIn(browser, false); + const tab = await page.context().newPage(); + await tab.goto(handlerAddress(mailtoTemplate, `mailto:someone@example.org?subject=${RUN}%2050% off`)); + const shown = await tabWith(page, '[data-testid="composer-subject"]'); + await expect(shown.getByTestId('composer-subject')).toHaveValue(`${RUN} 50% off`); + await page.context().close(); +}); + +test('a calendar link opened by the browser goes to the subscription, filled in', async ({ browser }) => { + const page = await signedIn(browser, false); + const tab = await page.context().newPage(); + await tab.goto(handlerAddress(webcalTemplate, `webcal://feeds.example.org/${RUN}.ics`)); + const shown = await tabWith(page, '[role="dialog"]'); + await expect(shown).toHaveURL(/\/calendar/); + const dialog = shown.getByRole('dialog', { name: 'Subscribe to a calendar' }); + await expect(dialog).toBeVisible({ timeout: 60_000 }); + await expect(shown.getByRole('dialog', { name: 'Open calendar link' })).toHaveCount(0); + await expect(dialog.getByRole('textbox').first()).toHaveValue(`https://feeds.example.org/${RUN}.ics`); + await expect(dialog.getByRole('textbox').nth(1)).toHaveValue(RUN); + await shot(shown, 'links-browser-webcal'); + await page.context().close(); +});