mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
Merge branch 'feat/desktop-offline' into feat/do-ci-fix
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
@@ -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']));
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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`);
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user