Merge branch 'feat/desktop-offline' into feat/do-ci-fix

This commit is contained in:
Linus Rath
2026-11-04 16:16:33 +01:00
9 changed files with 741 additions and 118 deletions
+12
View File
@@ -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. */
@@ -1587,6 +1590,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);
+17 -60
View File
@@ -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.
@@ -250,7 +248,6 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
const searchInputRef = useRef<HTMLInputElement>(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<ParsedWebcal | null>(null);
const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false);
const [editingSubscription, setEditingSubscription] = useState<string | null>(null);
@@ -354,10 +351,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) {
@@ -373,6 +370,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 {
@@ -380,15 +388,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();
@@ -407,27 +411,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;
@@ -1943,33 +1928,6 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
/>
) : null;
const renderWebcalActionChoice = () => showWebcalActionChoice && pendingSubscription ? (
<Dialog
isOpen
onClose={closeWebcalActionChoice}
title={tWebcalAction("title")}
description={tWebcalAction("description", { name: pendingSubscription.name })}
footer={<Button variant="outline" onClick={closeWebcalActionChoice}>{tWebcalAction("cancel")}</Button>}
>
<div className="pt-4 space-y-3">
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleImportWebcal}>
<span className="text-start">
<span className="block font-medium">{tWebcalAction("import_title")}</span>
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("import_description")}</span>
</span>
</Button>
{!IS_LITE && (
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleSubscribeWebcal}>
<span className="text-start">
<span className="block font-medium">{tWebcalAction("subscribe_title")}</span>
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("subscribe_description")}</span>
</span>
</Button>
)}
</div>
</Dialog>
) : 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.
@@ -2729,7 +2687,6 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
{renderWebcalAccountPicker()}
{renderWebcalActionChoice()}
<RecurrenceScopeDialog
isOpen={!!pendingScopeAction}
actionType={pendingScopeAction?.type || "edit"}
@@ -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<Mailbox> = {}) => ({
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<HTMLElement>('[data-folder-drawer]');
const nextFrame = () => act(() => new Promise<void>((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(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
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(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" onDrawerChange={onDrawerChange} />);
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(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
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(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
openFromAllFolders();
const inDrawer = screen.getByTestId('sidebar-collapse').className;
cleanup();
useUIStore.setState({ sidebarCollapsed: false } as never);
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
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(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
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(<><Sidebar mailboxes={mailboxes} selectedMailbox="inbox" /><button type="button">after</button></>);
openFromAllFolders();
const root = drawer()!;
const focusable = Array.from(root.querySelectorAll<HTMLElement>('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(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" onDrawerChange={onDrawerChange} />);
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(<><Sidebar mailboxes={mailboxes} selectedMailbox="inbox" /><div data-drawer-scrim="" data-testid="scrim" /></>);
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(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
openFromAllFolders();
rerender(<Sidebar mailboxes={mailboxes} selectedMailbox="news" />);
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(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
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(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" mvShell onDrawerChange={onDrawerChange} />);
fireEvent.mouseEnter(container.firstElementChild!);
const peek = document.querySelector<HTMLElement>('[data-mv-rail-peek]')!;
expect(peek.className).toContain('desk:absolute');
expect(drawer()).toBeNull();
expect(onDrawerChange).not.toHaveBeenCalled();
});
});
+112 -14
View File
@@ -169,6 +169,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;
@@ -176,6 +182,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<HTMLElement>(
'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) => {
@@ -1201,6 +1233,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
@@ -1224,6 +1257,12 @@ function SidebarImpl({
const allFoldersRef = useRef<HTMLButtonElement>(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;
@@ -1231,6 +1270,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<Set<string>>(new Set());
const [expandedTags, setExpandedTags] = useState<Set<string>>(new Set());
@@ -1349,6 +1431,9 @@ function SidebarImpl({
?? root?.querySelector<HTMLElement>('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);
@@ -1982,18 +2067,19 @@ function SidebarImpl({
<div
ref={rootRef}
// The peek of the collapsed rail; not with hover opening turned off.
onMouseEnter={sidebarCollapsed && !isDrawer ? () => {
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);
@@ -2005,7 +2091,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
@@ -2014,11 +2101,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
)}
>
@@ -2027,13 +2121,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.
<div className="border-b border-border">
<div className="border-b border-border" data-sidebar-head="">
<div className={cn("flex items-center h-14", isCollapsed ? "justify-center px-2" : "gap-1 px-2")}>
<Button
variant="ghost"
size="icon"
onClick={onSidebarClose}
className="desk:hidden h-9 w-9 flex-shrink-0"
className={cn("desk:hidden h-9 w-9 flex-shrink-0", SIDEBAR_HEAD_BUTTON)}
aria-label={t("close")}
>
<X className="w-5 h-5" />
@@ -2042,11 +2136,15 @@ function SidebarImpl({
<Button
variant="ghost"
size="icon"
onClick={toggleSidebarCollapsed}
className="hidden desk:flex h-8 w-8 flex-shrink-0"
// In the drawer it closes the drawer, as the phone's X does;
// docking the folders is the folded column's expand button.
onClick={isFolderDrawer ? closeRailPeek : toggleSidebarCollapsed}
className={cn("hidden desk:flex h-8 w-8 flex-shrink-0", SIDEBAR_HEAD_BUTTON)}
data-testid="sidebar-collapse"
title={isCollapsed ? t("expand_tooltip") : t("collapse_tooltip")}
>
{isCollapsed ? <ChevronsRight className="w-4 h-4" /> : <ChevronsLeft className="w-4 h-4" />}
{/* The arrows point the way the column goes: mirrored in RTL, where it sits at the right. */}
{isCollapsed ? <ChevronsRight className="w-4 h-4 rtl:-scale-x-100" /> : <ChevronsLeft className="w-4 h-4 rtl:-scale-x-100" />}
</Button>
{/* The account switcher heads the folders by default, with the
@@ -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<typeof import('@/hooks/use-media-query')>()),
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 }) => <nav data-rail={orientation}>rail</nav>,
}));
vi.mock('@/components/layout/sidebar', () => ({
Sidebar: ({ onDrawerChange }: { onDrawerChange?: (close: (() => void) | null) => void }) => {
device.drawerChange = onDrawerChange ?? null;
return <div><button type="button">first folder</button><button type="button">last folder</button></div>;
},
}));
vi.mock('@/components/layout/mobile-header', async () => {
const { useUIStore: store } = await import('@/stores/ui-store');
return {
MobileHeader: () => (
<button type="button" data-sidebar-toggle="" onClick={() => store.getState().setSidebarOpen(true)}>menu</button>
),
};
});
vi.mock('@/components/email/email-composer', () => ({ EmailComposer: () => <p>stub composer</p> }));
vi.mock('@/components/email/email-viewer', () => ({ EmailViewer: () => <div>message viewer</div> }));
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: () => <div>message list</div> }));
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<HTMLElement>('[id]')!;
describe('the compact desktop\'s folder drawer', () => {
it('dims the whole page while the folded column\'s drawer is open', () => {
render(<MailApp />);
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(<MailApp />);
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(<MailApp />);
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(<MailApp />);
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']));
});
});
+44 -24
View File
@@ -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";
@@ -2620,28 +2621,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<HTMLDivElement>(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<HTMLElement>(
'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<HTMLElement>('[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
@@ -5881,13 +5889,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 && (
<div
aria-hidden="true"
data-testid="folder-drawer-scrim"
data-drawer-scrim=""
className={cn(
"inset-0 bg-black/50 z-40",
isEmbedded ? "absolute" : "fixed desk:hidden"
// The folded column's drawer opens on a desktop, so only the
// phone's scrim goes with the phone layout.
isEmbedded ? "absolute" : cn("fixed", !closeFolderDrawer && "desk:hidden")
)}
onClick={() => setSidebarOpen(false)}
onClick={() => {
if (closeFolderDrawer) closeFolderDrawer();
else setSidebarOpen(false);
}}
/>
)}
@@ -5901,9 +5917,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
`inert` it stays in the accessibility tree and screen readers can
reach a menu the Toggle menu button reports as collapsed (#721). */
inert={isMobile && !sidebarOpen}
onKeyDown={isMobile && sidebarOpen ? (e) => {
if (e.key === 'Escape') setSidebarOpen(false);
} : undefined}
className={cn(
"flex-shrink-0 h-full z-50",
// The z-index is for the phone drawer. On a desktop under Mountain
@@ -5920,16 +5933,22 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// sign or the drawer slides off the wrong edge.
!sidebarOpen && "-translate-x-full rtl:translate-x-full"
)
: "relative translate-x-0")
// No translate here: it would make this column the box the
// folded column's drawer is placed in.
: "relative")
: cn(
// Mobile/Tablet: fixed overlay
// Phone: a drawer from the window's start edge, from under
// the top bar and the window's controls (--drawer-top).
// start-0 rather than left-0: in RTL the drawer belongs on
// the right, matching the hamburger that opens it.
"max-desk:fixed max-desk:inset-y-0 max-desk:start-0 max-desk:w-72 max-desk:pt-[var(--app-top-inset)]",
"max-desk:fixed max-desk:start-0 max-desk:top-[var(--drawer-top)] max-desk:bottom-0 max-desk:h-auto max-desk:w-72",
"max-desk:transform max-desk:transition-transform max-desk:duration-300 max-desk:ease-in-out",
!sidebarOpen && "max-desk:-translate-x-full max-desk:rtl:translate-x-full",
// Desktop: normal flow
"desk:relative desk:translate-x-0"
// Desktop: normal flow. translate-none, not translate-x-0:
// any translate makes this column the containing block of
// the folded column's fixed drawer, which then opens beside
// the app rail instead of at the window's edge.
"desk:relative desk:translate-none"
),
inlineApp && "hidden",
fullscreenReading && "hidden"
@@ -5956,6 +5975,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
accountMailboxes={accountMailboxes}
accountSharedMailboxes={accountSharedMailboxes}
viewingAccountId={viewingAccountId}
onDrawerChange={handleFolderDrawerChange}
/>
</ErrorBoundary>
</div>
+161
View File
@@ -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 <worktree> 3241 <jmap-url> links-1.0
* bash desktop-offline-rig/run-spec.sh <worktree> 3241 <jmap-url> 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<Page> {
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<void> {
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<Page> {
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();
});
+20
View File
@@ -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 <j@example.com>"],
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`);
+19 -20
View File
@@ -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),