fix: keep the desktop layout in a half-screen window and at 150 % zoom

A window snapped to half the screen (640-800 px) or a 1366x768 laptop
at 150 % (911 px) got the phone and tablet layout: bottom tab bar,
folders in a drawer, Settings as a phone list with a Back arrow. The
layout switched at the 64rem (1024 px) breakpoint.

Now the phone layout starts below 600 px. From 600 to 1024 px the app
is a compact desktop: app rail at the side, folder sidebar folded to
its icons (the existing collapsed state, opened per session with its
toggle), Settings with its two panes and a narrower page list. Below
840 px the split layout opens a message in the list's place, as the
focused layout does. This holds in the browser and the desktop app,
in the Pro shell (which now runs from 600 px) and under Mountain View.

- `hooks/use-media-query.ts`: classifyWidth, PHONE_BELOW 600,
  FULL_DESKTOP 1024; the tablet class (`isTablet`) is gone, the store
  has `isCompact` instead. Plugins still see "tablet" for it.
- `app/globals.css`: a `desk:` breakpoint at 37.5rem for the layout
  classes, and a `tap:` variant for 44px rows on phones and touch
  screens below 1024 px (was every window below 1024 px).
- Touch screens below 1024 px keep swiping list rows (was phones only).
- The boot skeletons, boot script, mail app, sidebar, reader, admin
  shell and calendar, contacts and files follow the new widths.

Tests changed with the behaviour: the tablet cases in pro-gate,
mail-app-mv-composer-move, mail-app-compose-handoff,
mail-app-thread-expand-mark-read, mobile-tour-end and
thread-list-item-columns now run on a phone or a compact desktop, as
those widths no longer exist as a tablet. mail-app-short-window-tab-bar
is replaced by mail-app-compact-layout: a phone on its side is a
compact desktop with the rail at the side, so the short-window tab bar
rule is gone. Class and media-query assertions follow desk:/tap: and
37.5rem; the 32px-text test uses 1100 px, since 1440 px at 32px is now
a compact desktop.
This commit is contained in:
Linus Rath
2026-11-04 09:07:06 +01:00
parent 1e51ff6e69
commit 044647ff34
69 changed files with 805 additions and 487 deletions
@@ -9,8 +9,8 @@ vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => (key === 'common.loading' ? 'Loading…' : key),
}));
const device = vi.hoisted(() => ({ isMobile: false, isTablet: false, isDesktop: true }));
vi.mock('@/hooks/use-media-query', () => ({ useDeviceDetection: () => device, atLeast: (px: number) => `(width >= ${px}px)` }));
const device = vi.hoisted(() => ({ isMobile: false, isCompact: false, isDesktop: true }));
vi.mock('@/hooks/use-media-query', () => ({ useDeviceDetection: () => device, atLeast: (px: number) => `(width >= ${px}px)`, PHONE_BELOW: 600 }));
const routerReplace = vi.hoisted(() => vi.fn());
vi.mock('@/i18n/navigation', () => ({ useRouter: () => ({ replace: routerReplace }) }));
@@ -72,7 +72,7 @@ beforeEach(() => {
auth.state.client = null;
auth.state.isLoading = false;
useSettingsStore.setState({ proInterface: true });
Object.assign(device, { isMobile: false, isTablet: false, isDesktop: true });
Object.assign(device, { isMobile: false, isCompact: false, isDesktop: true });
Object.defineProperty(window, 'location', {
configurable: true,
value: { ...window.location, replace, pathname: '/pro', search: '' },
@@ -98,13 +98,25 @@ describe('Pro page gate', () => {
it('keeps the skeleton up, not a blank page, below desktop width', async () => {
auth.state.isAuthenticated = true;
auth.state.client = {};
Object.assign(device, { isMobile: false, isTablet: true, isDesktop: false });
Object.assign(device, { isMobile: true, isCompact: false, isDesktop: false });
const { container } = render(<ProHome />);
await act(async () => {});
expect(container.querySelector('[data-boot-skeleton="pro"]')).not.toBeNull();
expect(container.querySelector('[data-testid="mail-app"]')).toBeNull();
});
// A half-screen window or a laptop at 150 % is a compact desktop, which
// keeps the Pro shell (usability test 2026-11-04, mail-28).
it('runs on a compact desktop', async () => {
auth.state.isAuthenticated = true;
auth.state.client = {};
Object.assign(device, { isMobile: false, isCompact: true, isDesktop: true });
const { container } = render(<ProHome />);
await act(async () => {});
expect(container.querySelector('[data-pro-shell] [data-testid="mail-app"]')).not.toBeNull();
expect(routerReplace).not.toHaveBeenCalled();
});
it('marks the loaded shell so a skeleton inside a pane keeps its own frame', async () => {
auth.state.isAuthenticated = true;
auth.state.client = {};
@@ -126,7 +138,7 @@ describe('Pro page gate', () => {
beforeEach(() => {
auth.state.isAuthenticated = true;
auth.state.client = {};
Object.assign(device, { isMobile: false, isTablet: true, isDesktop: false });
Object.assign(device, { isMobile: true, isCompact: false, isDesktop: false });
const compose = { id: 'c1', kind: 'compose' as const, labelKey: '', closeable: true, paneId: 'main' as const, composeData: { sessionId: 1, mode: 'compose' as const, title: 'x', initialData: { subject: 'Typed', mode: 'compose' } as never } };
useProTabStore.setState({
tabs: [{ id: 'home-mail', kind: 'mail', labelKey: 'mail', closeable: false, paneId: 'main' }, compose],
@@ -151,6 +163,16 @@ describe('Pro page gate', () => {
expect(routerReplace).not.toHaveBeenCalled();
expect(takeHandedOffComposeDraft()).toBeNull();
});
it('stays in a window as wide as a compact desktop (800px)', async () => {
window.matchMedia = vi.fn((query: string) => {
const px = Number(/\(width >= (\d+)px\)/.exec(query)?.[1] ?? NaN);
return { matches: 800 >= px, addEventListener: () => {}, removeEventListener: () => {} };
}) as never;
render(<ProHome />);
await act(async () => {});
expect(routerReplace).not.toHaveBeenCalled();
});
});
it('moves a tab dropped on a surface that stops every drop', async () => {
+6 -5
View File
@@ -10,7 +10,7 @@ import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { atLeast, useDeviceDetection } from "@/hooks/use-media-query";
import { atLeast, PHONE_BELOW, useDeviceDetection } from "@/hooks/use-media-query";
import { EmbeddedContext } from "@/hooks/use-is-embedded";
import { OrgAnnouncement } from "@/components/org/org-announcement";
import { PaneSizeContext } from "@/hooks/use-pane-size";
@@ -49,7 +49,8 @@ import { ProComposeTabBody } from "@/components/pro/pro-compose-tab-body";
import { ProEmailTabBody } from "@/components/pro/pro-email-tab-body";
import { ProFolderTabBody } from "@/components/pro/pro-folder-tab-body";
const DESKTOP_MIN_WIDTH = 1024;
// Pro runs wherever the desktop layout does, the compact one included.
const DESKTOP_MIN_WIDTH = PHONE_BELOW;
// Each surface renders the same component the standard routes do. Deep links
// reach them through the handoff ProInterfaceRedirect parks (#733), not as
@@ -195,7 +196,7 @@ function Pane({ paneId, tabs, activeTabId, loadedTabIds, onPaneFocus, isFocused
export default function ProHome() {
const t = useTranslations();
const { label: skeletonLabel } = useSkeletonLabels();
const { isMobile, isTablet, isDesktop } = useDeviceDetection();
const { isMobile, isDesktop } = useDeviceDetection();
const router = useRouter();
const [initialCheckDone, setInitialCheckDone] = useState(
@@ -263,7 +264,7 @@ export default function ProHome() {
// switching Pro off from Settings lands back in Settings rather than
// on the mail list. Read imperatively: the focused tab must not retrigger
// this effect, it only matters at the moment a precondition flips.
if (isMobile || isTablet || !proInterface) {
if (isMobile || !proInterface) {
const { tabs: allTabs, focusedPaneId: pane, activeTabId, activeSplitTabId } = useProTabStore.getState();
const focusedId = pane === 'split' ? activeSplitTabId : activeTabId;
const kind = allTabs.find((tab) => tab.id === focusedId)?.kind;
@@ -284,7 +285,7 @@ export default function ProHome() {
if (kind === 'compose' && focusedId) handOffComposeTab(focusedId);
router.replace(surface);
}
}, [initialCheckDone, isMobile, isTablet, proInterface, router]);
}, [initialCheckDone, isMobile, proInterface, router]);
const mainTabs = useMemo(() => tabs.filter((t) => t.paneId === 'main'), [tabs]);
const splitTabs = useMemo(() => tabs.filter((t) => t.paneId === 'split'), [tabs]);
+14 -1
View File
@@ -8,7 +8,7 @@
*/
/* Mountain View shell. Its geometry only applies on desktop, so pair it with
lg: in markup (lg:mv:hidden), except the phone bottom bar, which follows
desk: in markup (desk:mv:hidden), except the phone bottom bar, which follows
the layout at every width. */
@custom-variant mv (&:where([data-interface-layout="mountain-view"] *));
@@ -16,6 +16,19 @@
@custom-variant boot-split (&:where([data-boot-mail-layout="split"] *));
@custom-variant boot-horizontal (&:where([data-boot-mail-layout="horizontal"] *));
@custom-variant boot-focus (&:where([data-boot-mail-layout="focus"] *));
/* One pane at a time: the focused layout, and the split layout in a window
too narrow for the list and the reader side by side (SPLIT_PANES_MIN,
useDrawnMailLayout). The rows keep the split layout's shape (boot-focus). */
@custom-variant boot-onepane {
&:where([data-boot-mail-layout="focus"] *) {
@slot;
}
@media (width < 52.5rem) {
&:where([data-boot-mail-layout="split"] *) {
@slot;
}
}
}
/* A message, thread, contact, settings page or admin page is open. */
@custom-variant boot-open (&:where([data-boot-pane] *));
+27 -6
View File
@@ -8,6 +8,27 @@
/* The app's font stack replaces Tailwind's default --font-sans, so the
font-sans utility and body use the same stack, and a theme's
typography.fontSans (compiled to --font-sans on :root) restyles both. */
/* The layout breakpoint (PHONE_BELOW in hooks/use-media-query.ts): below
37.5rem (600px) the phone layout, from there up the desktop layout, compact
up to lg. `desk:` and `max-desk:` decide what is drawn (rail or bottom bar,
sidebar in the flow or as a drawer, panes side by side or one at a time);
md: and lg: stay for spacing and type sizes. */
@theme {
--breakpoint-desk: 37.5rem;
}
/* Rows and buttons sized for a finger (44px): on phones, and on touch screens
below the full desktop, which get the compact desktop layout. A mouse in a
narrow window keeps the desktop's row heights. */
@custom-variant tap {
@media (width < 37.5rem) {
@slot;
}
@media (width < 64rem) and (pointer: coarse) {
@slot;
}
}
@theme {
--font-sans:
-apple-system,
@@ -90,13 +111,13 @@
--density-card-p: 16px;
--density-sidebar-py: 4px;
/* Minimum height of a tappable list row below the lg breakpoint, where
* folder rows use max-lg:min-h-[44px]. Plugin frames get it as a variable
* because their own viewport is the frame, not the page. */
/* Minimum height of a tappable list row where folder rows use
* tap:min-h-[44px]. Plugin frames get it as a variable because their own
* viewport is the frame, not the page. */
--touch-row-min-h: 0px;
}
@media (width < 64rem) {
@media (width < 37.5rem), (width < 64rem) and (pointer: coarse) {
:root {
--touch-row-min-h: 44px;
}
@@ -1522,9 +1543,9 @@ html[data-window-controls-left] [data-window-rail] {
/* The collapsed rail's hover peek. Only on desktop: below that breakpoint the
sidebar is already a drawer, opened by a tap rather than by a pointer that
does not exist. The width beats Tailwind's own `lg:w-full` on specificity,
does not exist. The width beats Tailwind's own `desk:w-full` on specificity,
which is what keeps the floating panel from filling the 72px column. */
@media (min-width: 1024px) {
@media (width >= 37.5rem) {
[data-interface-layout="mountain-view"] [data-mv-rail-peek] {
width: var(--mv-rail-peek-w);
}
@@ -21,7 +21,7 @@ describe('AdminShell across the desktop breakpoint', () => {
let desktop = initial;
const listeners = new Set<() => void>();
window.matchMedia = vi.fn((query: string) => ({
get matches() { return desktop && query === '(width >= 64rem)'; },
get matches() { return desktop && query === '(width >= 37.5rem)'; },
media: query,
addEventListener: (_: string, cb: () => void) => listeners.add(cb),
removeEventListener: (_: string, cb: () => void) => listeners.delete(cb),
@@ -16,7 +16,7 @@ import { useUpdateStore } from '@/stores/update-store';
function setDesktop(desktop: boolean) {
window.matchMedia = vi.fn((query: string) => ({
matches: desktop && query === '(width >= 64rem)',
matches: desktop && query === '(width >= 37.5rem)',
media: query,
addEventListener: () => {},
removeEventListener: () => {},
+2 -2
View File
@@ -29,7 +29,7 @@ export function AdminPageHeader({ title, description, actions, back, leading, cl
<AdminLink
href={back.href}
// The phone shell's top bar already has the back arrow.
className="hidden lg:inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
className="hidden desk:inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="w-4 h-4" />
{back.label}
@@ -39,7 +39,7 @@ export function AdminPageHeader({ title, description, actions, back, leading, cl
<div className="flex items-start gap-3 min-w-0">
{leading}
<div className="min-w-0">
<h1 className={cn('text-xl font-semibold text-foreground leading-tight', !back && 'hidden lg:block')}>{title}</h1>
<h1 className={cn('text-xl font-semibold text-foreground leading-tight', !back && 'hidden desk:block')}>{title}</h1>
{description && <p className="text-sm text-muted-foreground mt-1">{description}</p>}
</div>
</div>
@@ -61,7 +61,7 @@ describe('admin skeleton', () => {
it('shows the phone list at /admin and the page below it', () => {
const index = dom(renderToString(<AdminSkeleton page={null} />));
const detail = dom(renderToString(<AdminSkeleton page="logs" />));
const phone = (c: HTMLElement) => c.querySelector('.lg\\:hidden')!;
const phone = (c: HTMLElement) => c.querySelector('.desk\\:hidden')!;
// Index: the list (with its chevrons) is shown, the page column hidden.
expect(phone(index).querySelector('.p-4')!.className).toContain('hidden');
expect(phone(detail).querySelector('.p-4')!.className).not.toContain('hidden');
@@ -18,7 +18,7 @@ describe('mail skeleton', () => {
const bar = tabs[tabs.length - 1]?.parentElement as HTMLElement;
expect(bar.querySelector('svg')).not.toBeNull();
expect(bar).toBeTruthy();
expect(bar.className.split(' ')).toContain('max-md:boot-open:hidden');
expect(bar.className.split(' ')).toContain('max-desk:boot-open:hidden');
});
it('draws only the body inside a Pro pane: no frame, no sidebar header', () => {
@@ -52,6 +52,6 @@ describe('mail skeleton', () => {
expect(rows.querySelector('.boot-density-xc\\:hidden')).not.toBeNull();
expect(rows.querySelector('.boot-dense\\:hidden')).not.toBeNull();
expect(rows.querySelector('.boot-one-preview-line\\:hidden')).not.toBeNull();
expect(rows.querySelector('.md\\:mv\\:block')).not.toBeNull();
expect(rows.querySelector('.desk\\:mv\\:block')).not.toBeNull();
});
});
@@ -21,7 +21,7 @@ const classes = (el: Element | null | undefined) => (el?.getAttribute('class') ?
describe('AppShellSkeleton under the Pro interface', () => {
const page = () =>
html(
<AppShellSkeleton surface="calendar" label="Loading" railBelow="md" active="calendar">
<AppShellSkeleton surface="calendar" label="Loading" active="calendar">
<div data-testid="calendar-body" />
</AppShellSkeleton>,
);
+4 -4
View File
@@ -93,12 +93,12 @@ export function AdminContentSkeleton({ page, status }: { page?: AdminPageId | nu
<div className="min-w-0">
{def && page !== undefined ? (
<>
<span className="hidden lg:block text-xl font-semibold text-foreground leading-tight">{def.label}</span>
<span className="hidden desk:block text-xl font-semibold text-foreground leading-tight">{def.label}</span>
<span className="block text-sm text-muted-foreground mt-1">{def.description}</span>
</>
) : (
<>
<TextLine className="text-xl font-semibold leading-tight hidden lg:block" w="10rem" />
<TextLine className="text-xl font-semibold leading-tight hidden desk:block" w="10rem" />
<TextLine className="text-sm mt-1" w="22rem" />
</>
)}
@@ -174,7 +174,7 @@ export function AdminSkeleton({ label, page, backLabel, searchLabel, labels, cla
className={cn("h-dvh bg-background pt-[var(--app-top-inset)]", className)}
>
{/* Desktop */}
<div className="hidden lg:flex h-full">
<div className="hidden desk:flex h-full">
<RailSkeleton active="admin" className="flex" />
<div aria-hidden="true" className="flex w-64 shrink-0 flex-col border-e border-border bg-secondary">
@@ -239,7 +239,7 @@ export function AdminSkeleton({ label, page, backLabel, searchLabel, labels, cla
</div>
{/* Phone and tablet */}
<div className="flex lg:hidden h-full flex-col">
<div className="flex desk:hidden h-full flex-col">
<div aria-hidden="true" className="flex h-14 shrink-0 items-center gap-1 border-b border-border bg-background px-2">
<span className="flex h-10 w-10 items-center justify-center rounded-md text-foreground">
<ArrowLeft className="h-5 w-5" />
@@ -176,7 +176,7 @@ function ToolbarSkeleton({ text }: { text?: CalendarSkeletonText }) {
const views = text?.views;
return (
<>
<div data-window-row-right="" className="hidden md:flex items-center gap-1 px-3 h-14 box-content border-b border-border flex-shrink-0">
<div data-window-row-right="" className="hidden desk:flex items-center gap-1 px-3 h-14 box-content border-b border-border flex-shrink-0">
<ToolbarIcon className="me-1">
<Menu />
</ToolbarIcon>
@@ -222,7 +222,7 @@ function ToolbarSkeleton({ text }: { text?: CalendarSkeletonText }) {
</OutlineButton>
</div>
<div className="md:hidden border-b border-border">
<div className="desk:hidden border-b border-border">
<div className="flex flex-col gap-1 px-2 py-2">
<div className="flex flex-wrap items-center gap-1">
<PhoneIcon className="-ms-1">
@@ -607,7 +607,7 @@ export function CalendarSkeletonBody({ embedded = false, text }: { embedded?: bo
<CalendarViewSkeleton text={text} />
</div>
{/* The floating create button of a phone. */}
<span className="md:hidden absolute bottom-4 end-4 z-40 flex items-center justify-center h-14 w-14 rounded-full shadow-lg bg-primary text-primary-foreground">
<span className="desk:hidden absolute bottom-4 end-4 z-40 flex items-center justify-center h-14 w-14 rounded-full shadow-lg bg-primary text-primary-foreground">
<Plus className="h-6 w-6" />
</span>
</div>
@@ -624,10 +624,9 @@ export function CalendarSkeleton({ label, labels, text }: CalendarSkeletonProps)
surface="calendar"
label={label}
labels={labels}
railBelow="md"
active="calendar"
mvMenu={false}
rowClassName="lg:mv:mb-4 lg:mv:mx-4 lg:mv:rounded-[var(--mv-card-radius)] lg:mv:bg-background"
rowClassName="desk:mv:mb-4 desk:mv:mx-4 desk:mv:rounded-[var(--mv-card-radius)] desk:mv:bg-background"
>
<CalendarSkeletonBody text={text} />
</AppShellSkeleton>
@@ -16,7 +16,7 @@ export interface ContactsSkeletonProps {
* Mountain View's content card on a desktop: the data-mv-card row of the
* contacts, files and plugin pages, inset 16px from the canvas.
*/
export const MV_CARD_ROW = "lg:mv:mb-4 lg:mv:mx-4 lg:mv:rounded-[var(--mv-card-radius)] lg:mv:bg-background";
export const MV_CARD_ROW = "desk:mv:mb-4 desk:mv:mx-4 desk:mv:rounded-[var(--mv-card-radius)] desk:mv:bg-background";
const SIDEBAR_ROW_PY = { paddingBlock: "var(--density-sidebar-py, 4px)" } as const;
const CONTACT_ROW_STYLE = {
@@ -121,7 +121,7 @@ function ContactListToolbarSkeleton({ embedded }: { embedded: boolean }) {
<ToolIcon icon={Square} />
<SearchFieldSkeleton trailing={Filter} className="flex-1 min-w-0" />
<ToolIcon icon={Printer} className={embedded ? undefined : "hidden md:grid"} />
{embedded ? null : <PhoneActionsSkeleton className="flex md:hidden" />}
{embedded ? null : <PhoneActionsSkeleton className="flex desk:hidden" />}
</div>
</div>
</div>
@@ -213,7 +213,7 @@ export function ContactsSkeletonBody({ embedded = false }: { embedded?: boolean
"relative border-e border-border bg-background flex flex-col flex-shrink-0",
embedded
? "w-[var(--boot-contacts-list-w,384px)]"
: "max-md:w-full max-md:boot-open:hidden md:w-[var(--boot-contacts-list-w,384px)]",
: "max-desk:w-full max-desk:boot-open:hidden desk:w-[var(--boot-contacts-list-w,384px)]",
)}
>
<ContactListToolbarSkeleton embedded={embedded} />
@@ -221,14 +221,14 @@ export function ContactsSkeletonBody({ embedded = false }: { embedded?: boolean
<ContactRowsSkeleton />
</div>
{embedded ? null : (
<span className="md:hidden absolute bottom-4 end-4 z-20 grid place-items-center w-11 h-11 rounded-full shadow-lg bg-primary text-primary-foreground">
<span className="desk:hidden absolute bottom-4 end-4 z-20 grid place-items-center w-11 h-11 rounded-full shadow-lg bg-primary text-primary-foreground">
<Plus className="w-5 h-5" />
</span>
)}
</div>
<div className={cn("flex-1 min-w-0 flex flex-col", !embedded && "max-md:[:root:not([data-boot-pane])_&]:hidden")}>
<div className={cn("flex-1 min-w-0 flex flex-col", !embedded && "max-desk:[:root:not([data-boot-pane])_&]:hidden")}>
{embedded ? null : (
<div className="hidden max-md:block px-3 py-2 border-b border-border">
<div className="hidden max-desk:block px-3 py-2 border-b border-border">
<span className="flex items-center h-9 px-3">
<ArrowLeft className="w-4 h-4 me-2" />
<Bar className="h-3.5" w="7rem" />
@@ -257,7 +257,6 @@ export function ContactsSkeleton({ label, labels }: ContactsSkeletonProps) {
surface="contacts"
label={label}
labels={labels}
railBelow="md"
active="contacts"
mvMenu={false}
rowClassName={MV_CARD_ROW}
+1 -2
View File
@@ -221,7 +221,7 @@ function FilesHeaderSkeleton({ picker }: { picker: boolean }) {
</span>
<ToolIcon icon={RefreshCw} className="hidden md:grid" />
<ToolIcon icon={PanelRight} className="boot-files-details:bg-primary/10 boot-files-details:text-primary" />
<PhoneActionsSkeleton className="flex md:hidden" />
<PhoneActionsSkeleton className="flex desk:hidden" />
</div>
)}
</div>
@@ -349,7 +349,6 @@ export function FilesSkeleton({ label, labels }: FilesSkeletonProps) {
surface="files"
label={label}
labels={labels}
railBelow="md"
active="files"
mvMenu={false}
rowClassName={MV_CARD_ROW}
+66 -57
View File
@@ -1,7 +1,7 @@
/**
* The mail page as a skeleton: folder sidebar, message list and reading pane,
* in each layout the app has (split, horizontal, focus, Mountain View, tablet,
* phone). Class lists follow the real components (components/mail/mail-app.tsx,
* in each layout the app has (split, horizontal, focus, Mountain View, the
* compact desktop, phone). Class lists follow the real components (components/mail/mail-app.tsx,
* components/layout/sidebar.tsx, components/layout/mobile-header.tsx,
* components/email/thread-list-item.tsx, components/email/email-viewer.tsx),
* so the page fills in over it without anything moving.
@@ -23,6 +23,7 @@ import {
ReplyAll,
ChevronDown,
ChevronsLeft,
ChevronsRight,
Filter,
Mail,
Menu,
@@ -78,17 +79,17 @@ export function FolderRowsSkeleton({ count = 7 }: { count?: number }) {
{repeat(count, (i) => (
<div
className={cn(
"w-full flex items-center max-lg:min-h-[44px] text-sm pe-2 border-s-2 border-transparent",
"w-full flex items-center tap:min-h-[44px] text-sm pe-2 border-s-2 border-transparent",
"mv:min-h-[var(--mv-nav-row-h)] mv:me-3 mv:pe-4 mv:border-s-0",
)}
style={{ paddingBlock: "var(--density-sidebar-py)" }}
>
<span className="flex-shrink-0" style={{ width: FOLDER_ROW_PX_BASE + FOLDER_CHEVRON_SLOT }} />
<span className="flex items-center gap-2 mv:gap-[18px] min-w-0 flex-1">
<span className="flex-shrink-0 desk:max-lg:hidden" style={{ width: FOLDER_ROW_PX_BASE + FOLDER_CHEVRON_SLOT }} />
<span className="flex items-center gap-2 mv:gap-[18px] min-w-0 flex-1 desk:max-lg:justify-center">
<span className="flex items-center justify-center flex-shrink-0 w-4 h-4 mv:w-5 mv:h-5">
<Block className="w-full h-full rounded" />
</span>
<TextLine className="flex-1" w={textWidth(i, 35, 30)} />
<TextLine className="flex-1 desk:max-lg:hidden" w={textWidth(i, 35, 30)} />
</span>
</div>
))}
@@ -118,17 +119,19 @@ function SidebarSkeleton({ embedded }: { embedded: boolean }) {
"relative flex flex-col h-full border-e overflow-hidden bg-secondary border-border",
// Dark themes draw the panel in the shapes' own colour: lift them off it.
"sk-lift-dark",
!embedded && "lg:mv:bg-[var(--mv-canvas)] lg:mv:border-transparent",
!embedded && "desk:mv:bg-[var(--mv-canvas)] desk:mv:border-transparent",
)}
>
{/* Header: collapse button and account switcher. Hidden in a Pro pane and under Mountain View. */}
{!embedded && (
<div className="border-b border-border lg:mv:hidden">
<div className="flex items-center h-14 gap-1 px-2">
<span className="hidden lg:flex items-center justify-center h-8 w-8 flex-shrink-0 text-muted-foreground">
<ChevronsLeft className="w-4 h-4" />
<div className="border-b border-border desk:mv:hidden">
<div className="flex items-center h-14 gap-1 px-2 desk:max-lg:justify-center">
<span className="hidden desk:flex items-center justify-center h-8 w-8 flex-shrink-0 text-muted-foreground">
<ChevronsLeft className="w-4 h-4 desk:max-lg:hidden" />
{/* A compact desktop folds the sidebar to its icons (sidebarFolded). */}
<ChevronsRight className="w-4 h-4 hidden desk:max-lg:block" />
</span>
<span className="flex flex-1 min-w-0 items-center gap-2 px-2 py-1.5">
<span className="flex flex-1 min-w-0 items-center gap-2 px-2 py-1.5 desk:max-lg:hidden">
<Circle className="w-9 h-9" />
<span className="min-w-0 flex-1">
<TextLine className="text-sm" w="70%" />
@@ -137,20 +140,24 @@ function SidebarSkeleton({ embedded }: { embedded: boolean }) {
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
</span>
</div>
{/* The folded sidebar's Compose button. */}
<div className="hidden desk:max-lg:flex justify-center pb-2">
<Block className="w-10 h-10 rounded-md" />
</div>
</div>
)}
{/* Mountain View: the Compose button heads the navigation. */}
{!embedded && (
<div className="hidden lg:mv:block pt-2 pb-3 px-3">
<div className="hidden desk:mv:block pt-2 pb-3 px-3">
<span className="flex w-fit items-center gap-3 ps-4 pe-6 h-[var(--mv-compose-h,56px)] rounded-2xl bg-[var(--mv-compose-bg)] text-[var(--mv-compose-fg)]">
<Pencil className="w-6 h-6 flex-shrink-0" />
<TextLine className="text-sm w-14" w="100%" />
<TextLine className="text-sm w-14 desk:max-lg:hidden" w="100%" />
</span>
</div>
)}
<div className="flex-1 overflow-hidden">
{/* Mountain View lists the folders without a heading. */}
<FolderSectionHeaderSkeleton className={embedded ? undefined : "lg:mv:hidden"} />
<FolderSectionHeaderSkeleton className={cn("desk:max-lg:hidden", !embedded && "desk:mv:hidden")} />
<FolderRowsSkeleton />
</div>
</div>
@@ -210,7 +217,7 @@ function StackedRow({ i, o }: { i: number; o: RowOptions }) {
const preview = previewClass(o);
const secondLine = secondPreviewLineClass(o);
return (
<div className="sk-mrow max-md:mv:border-b-transparent">
<div className="sk-mrow max-desk:mv:border-b-transparent">
<div className="sk-mcell sk-mcell-min">
{avatar !== null && (
<span className={cn("sk-mav", avatar)}>
@@ -226,9 +233,9 @@ function StackedRow({ i, o }: { i: number; o: RowOptions }) {
{preview !== null && (
<span className={cn("block", preview)}>
{/* Mountain View's phone rows keep the preview to one line, beside a 32px star. */}
<L className="text-sm leading-relaxed max-md:mv:h-8" w="95%" />
<L className="text-sm leading-relaxed max-desk:mv:h-8" w="95%" />
{secondLine !== null && (
<L className={cn("text-sm leading-relaxed max-md:mv:hidden", secondLine)} w={textWidth(i + 5, 45, 40)} />
<L className={cn("text-sm leading-relaxed max-desk:mv:hidden", secondLine)} w={textWidth(i + 5, 45, 40)} />
)}
</span>
)}
@@ -306,13 +313,13 @@ export function MailListRowsSkeleton({
}
return (
<div aria-hidden="true" className={className}>
<div className="md:boot-focus:hidden lg:boot-horizontal:hidden md:mv:hidden">
<div className="desk:boot-focus:hidden desk:boot-horizontal:hidden desk:mv:hidden">
{repeat(ROWS.stacked, (i) => <StackedRow i={i} o={o} />)}
</div>
<div className="hidden md:boot-std:boot-focus:block lg:boot-std:boot-horizontal:block">
<div className="hidden desk:boot-std:boot-focus:block desk:boot-std:boot-horizontal:block">
{repeat(ROWS["one-line"], (i) => <OneLineRow i={i} o={o} />)}
</div>
<div className="hidden md:mv:block">{repeat(ROWS.mv, (i) => <MvRow i={i} />)}</div>
<div className="hidden desk:mv:block">{repeat(ROWS.mv, (i) => <MvRow i={i} />)}</div>
</div>
);
}
@@ -322,8 +329,8 @@ function ListToolbarSkeleton({ embedded }: { embedded: boolean }) {
const icon = "flex items-center justify-center h-8 w-8 shrink-0 text-muted-foreground";
const round = "grid place-items-center w-10 h-10 rounded-full shrink-0 text-muted-foreground";
return (
<div className="border-b border-border bg-background max-md:hidden">
<div className={cn("px-3 h-14 flex items-center", !embedded && "lg:mv:hidden")}>
<div className="border-b border-border bg-background max-desk:hidden">
<div className={cn("px-3 h-14 flex items-center", !embedded && "desk:mv:hidden")}>
<div className="flex items-center gap-1 w-full">
<span className={icon}>
<Square className="w-4 h-4" />
@@ -347,7 +354,7 @@ function ListToolbarSkeleton({ embedded }: { embedded: boolean }) {
</div>
{/* Mountain View's list toolbar (MvListToolbar). */}
{!embedded && (
<div className="hidden lg:mv:flex items-center gap-1 h-12 ps-[5px] pe-2 shrink-0">
<div className="hidden desk:mv:flex items-center gap-1 h-12 ps-[5px] pe-2 shrink-0">
<span className={round}>
<Square className="w-[18px] h-[18px]" />
</span>
@@ -373,7 +380,7 @@ function MobileHeaderSkeleton() {
return (
<div
className={cn(
"lg:hidden flex items-center justify-between h-14 shrink-0",
"desk:hidden flex items-center justify-between h-14 shrink-0",
"border-b border-border bg-background px-4",
"mv:mx-2 mv:mt-2 mv:mb-1 mv:rounded-full mv:border-0 mv:bg-muted mv:px-2 mv:shadow-sm",
// Mountain View's pill is the shapes' own colour: lift them off it.
@@ -384,9 +391,9 @@ function MobileHeaderSkeleton() {
<span className="flex items-center justify-center h-11 w-11">
<Menu className="h-5 w-5" />
</span>
<TextLine className="max-md:hidden w-28 font-semibold text-lg" w="100%" bar="h-4" />
<TextLine className="max-desk:hidden w-28 font-semibold text-lg" w="100%" bar="h-4" />
</span>
<span className="md:hidden flex-1 min-w-0 h-10 flex items-center gap-2 rounded-full pe-1 mx-1 bg-muted ps-3 mv:mx-0 mv:bg-transparent mv:ps-2 text-muted-foreground sk-lift">
<span className="desk:hidden flex-1 min-w-0 h-10 flex items-center gap-2 rounded-full pe-1 mx-1 bg-muted ps-3 mv:mx-0 mv:bg-transparent mv:ps-2 text-muted-foreground sk-lift">
<Search className="h-4 w-4 flex-shrink-0" />
<TextLine className="flex-1 text-sm" w="7rem" />
</span>
@@ -435,8 +442,8 @@ export function MailMessageToolbarSkeleton({ end }: { end?: ReactNode } = {}) {
</div>
<div className="max-sm:hidden flex items-center justify-between gap-2 w-full">
<div className="flex items-center gap-2">
{/* Back to the list, where the reader has the list's place (tablet, focus). */}
<span className="hidden md:max-lg:flex md:boot-focus:flex items-center justify-center h-9 w-9 -ms-1 flex-shrink-0">
{/* Back to the list, where the reader has the list's place (focus, or a window too narrow for two panes). */}
<span className="hidden desk:boot-onepane:flex items-center justify-center h-9 w-9 -ms-1 flex-shrink-0">
<ArrowLeft className="w-5 h-5" />
</span>
<Block className={button} />
@@ -446,7 +453,7 @@ export function MailMessageToolbarSkeleton({ end }: { end?: ReactNode } = {}) {
<div className="flex items-center gap-2">
<Block className={button} />
<Block className={button} />
<Block className={cn(button, "max-md:hidden")} />
<Block className={cn(button, "max-desk:hidden")} />
<Block className="h-8 w-8 rounded-md" />
{end}
</div>
@@ -587,7 +594,7 @@ function ReaderFrame({ children }: { children: ReactNode }) {
/** Mountain View's side panel rail (MvSidePanel): calendar and contacts. */
function MvSidePanelSkeleton() {
return (
<div aria-hidden="true" className="hidden lg:mv:flex boot-no-mv-side:hidden w-14 shrink-0 flex-col items-center gap-2 pt-3">
<div aria-hidden="true" className="hidden desk:mv:flex boot-no-mv-side:hidden w-14 shrink-0 flex-col items-center gap-2 pt-3">
<span className="grid h-10 w-10 place-items-center rounded-full boot-no-calendar:hidden">
<CalendarEvent className="h-5 w-5 text-[#1a73e8]" />
</span>
@@ -609,10 +616,11 @@ function MvSidePanelSkeleton() {
export function MailSkeletonBody({ embedded = false }: { embedded?: boolean }) {
return (
<>
{/* Folders: in the flow on a desktop, a closed drawer below that. */}
{/* Folders: in the flow on a desktop (folded to icons on a compact one),
a closed drawer on a phone. */}
<div
aria-hidden="true"
className="flex-shrink-0 h-full max-lg:hidden"
className="flex-shrink-0 h-full max-desk:hidden max-lg:w-12! max-lg:mv:w-[72px]!"
style={{ width: "var(--boot-mail-sidebar-w, 256px)" }}
>
<SidebarSkeleton embedded={embedded} />
@@ -621,59 +629,61 @@ export function MailSkeletonBody({ embedded = false }: { embedded?: boolean }) {
<div
className={cn(
"flex flex-col flex-1 min-w-0 h-full",
!embedded && "lg:mv:h-auto lg:mv:mb-4 lg:mv:me-4 lg:mv:rounded-[var(--mv-card-radius)] lg:mv:bg-background lg:mv:overflow-hidden",
!embedded && "desk:mv:h-auto desk:mv:mb-4 desk:mv:me-4 desk:mv:rounded-[var(--mv-card-radius)] desk:mv:bg-background desk:mv:overflow-hidden",
)}
>
<div className={cn("relative flex flex-1 min-h-0", !embedded && "lg:boot-horizontal:flex-col")}>
<div className={cn("relative flex flex-1 min-h-0", !embedded && "desk:boot-horizontal:flex-col")}>
{/* The message list. */}
<div
className={cn(
"relative flex flex-col bg-background min-w-0 h-full border-e border-border",
// Phones and tablets give an open message the whole width.
"max-md:flex-1 max-md:border-e-0 max-lg:boot-open:hidden",
"md:flex-shrink-0 md:shadow-sm md:w-[var(--boot-mail-list-w,384px)]",
embedded && "md:max-w-[max(220px,55%)]",
// Focus: the list takes the width while nothing is open and makes way for the reader.
"md:boot-focus:w-auto md:boot-focus:flex-1 md:boot-focus:border-e-0 md:boot-focus:boot-open:hidden",
// Phones give an open message the whole width.
"max-desk:flex-1 max-desk:border-e-0 max-desk:boot-open:hidden",
"desk:flex-shrink-0 desk:shadow-sm desk:w-[var(--boot-mail-list-w,384px)]",
embedded && "desk:max-w-[max(220px,55%)]",
// Focus (and split in a window too narrow for two panes): the
// list takes the width while nothing is open and makes way for the reader.
"desk:boot-onepane:w-auto desk:boot-onepane:flex-1 desk:boot-onepane:border-e-0 desk:boot-onepane:boot-open:hidden",
// Horizontal (desktop only): the list on top, full width.
!embedded && [
"lg:boot-horizontal:w-full lg:boot-horizontal:h-auto lg:boot-horizontal:border-e-0 lg:boot-horizontal:shadow-none",
"lg:boot-horizontal:flex-1 lg:boot-horizontal:min-h-0",
"lg:boot-horizontal:boot-open:flex-none lg:boot-horizontal:boot-open:h-[var(--boot-mail-list-h,320px)]",
"lg:boot-horizontal:boot-open:shadow-[0_8px_12px_-6px_rgba(0,0,0,0.18)] dark:lg:boot-horizontal:boot-open:shadow-[0_8px_14px_-6px_rgba(0,0,0,0.55)]",
"desk:boot-horizontal:w-full desk:boot-horizontal:h-auto desk:boot-horizontal:border-e-0 desk:boot-horizontal:shadow-none",
"desk:boot-horizontal:flex-1 desk:boot-horizontal:min-h-0",
"desk:boot-horizontal:boot-open:flex-none desk:boot-horizontal:boot-open:h-[var(--boot-mail-list-h,320px)]",
"desk:boot-horizontal:boot-open:shadow-[0_8px_12px_-6px_rgba(0,0,0,0.18)] dark:desk:boot-horizontal:boot-open:shadow-[0_8px_14px_-6px_rgba(0,0,0,0.55)]",
],
)}
>
{!embedded && <MobileHeaderSkeleton />}
<ListToolbarSkeleton embedded={embedded} />
<div className="flex-1 min-h-0 flex flex-col">
<div className={cn("flex-1 overflow-hidden bg-background relative pb-20", !embedded && "lg:mv:pb-0")}>
<div className={cn("flex-1 overflow-hidden bg-background relative pb-20", !embedded && "desk:mv:pb-0")}>
<MailListRowsSkeleton />
</div>
</div>
{/* The round Compose button (Mountain View's sits in its navigation). */}
<Circle
className={cn(
"absolute z-40 shadow-lg bottom-4 end-4 h-12 w-12 max-md:h-14 max-md:w-14",
"max-md:mv:w-36 max-md:mv:rounded-2xl",
!embedded && "lg:mv:hidden",
"absolute z-40 shadow-lg bottom-4 end-4 h-12 w-12 max-desk:h-14 max-desk:w-14",
"max-desk:mv:w-36 max-desk:mv:rounded-2xl",
// A desktop has Compose in the folder sidebar; a Pro pane keeps the round one.
!embedded && "desk:hidden",
)}
/>
</div>
{/* The reading pane. */}
<div
className={cn(
"flex flex-col bg-background flex-1 min-w-0 h-full md:relative",
"max-md:hidden max-md:boot-open:flex",
"md:boot-focus:hidden md:boot-focus:boot-open:flex",
!embedded && "lg:boot-horizontal:hidden lg:boot-horizontal:boot-open:flex lg:boot-horizontal:h-auto lg:boot-horizontal:min-h-0",
"flex flex-col bg-background flex-1 min-w-0 h-full desk:relative",
"max-desk:hidden max-desk:boot-open:flex",
"desk:boot-onepane:hidden desk:boot-onepane:boot-open:flex",
!embedded && "desk:boot-horizontal:hidden desk:boot-horizontal:boot-open:flex desk:boot-horizontal:h-auto desk:boot-horizontal:min-h-0",
)}
>
<ReaderFrame>
<EmptyReaderSkeleton className="boot-open:hidden" />
{/* A conversation link opens the conversation view on a phone, its newest message elsewhere. */}
<MailMessageSkeleton className="hidden boot-open:flex max-md:boot-thread:hidden" />
<MailConversationSkeleton className="hidden max-md:boot-thread:flex" />
<MailMessageSkeleton className="hidden boot-open:flex max-desk:boot-thread:hidden" />
<MailConversationSkeleton className="hidden max-desk:boot-thread:flex" />
</ReaderFrame>
</div>
</div>
@@ -700,10 +710,9 @@ export function MailSkeleton({ label, labels, embedded = false }: MailSkeletonPr
surface="mail"
label={label}
labels={labels}
railBelow="lg"
active="mail"
mvSearch
bottomBarClassName="max-md:boot-open:hidden"
bottomBarClassName="max-desk:boot-open:hidden"
>
<MailSkeletonBody />
</AppShellSkeleton>
@@ -22,7 +22,7 @@ export function PluginPageSkeletonBody({ embedded = false }: { embedded?: boolea
return (
<div aria-hidden="true" className={cn("flex flex-col flex-1 min-w-0", embedded && "h-full")} data-embedded={embedded ? "" : undefined}>
{embedded ? null : (
<div className="flex md:hidden items-center gap-2 h-14 shrink-0 border-b border-border bg-background px-4">
<div className="flex desk:hidden items-center gap-2 h-14 shrink-0 border-b border-border bg-background px-4">
<span className="flex items-center h-7 min-w-0">
<Bar className="h-4" w="7rem" />
</span>
@@ -37,7 +37,7 @@ export function PluginPageSkeletonBody({ embedded = false }: { embedded?: boolea
/** Skeleton of a plugin page (/apps/<plugin>/<page>). */
export function PluginPageSkeleton({ label, labels }: PluginPageSkeletonProps) {
return (
<AppShellSkeleton surface="apps" label={label} labels={labels} railBelow="md" mvMenu={false} rowClassName={MV_CARD_ROW}>
<AppShellSkeleton surface="apps" label={label} labels={labels} mvMenu={false} rowClassName={MV_CARD_ROW}>
<PluginPageSkeletonBody />
</AppShellSkeleton>
);
@@ -65,7 +65,7 @@ function SettingsNav({ embedded }: { embedded: boolean }) {
return (
<div
aria-hidden="true"
className="border-e border-border bg-secondary flex flex-col shrink-0"
className="border-e border-border bg-secondary flex flex-col shrink-0 max-lg:max-w-52"
style={{ width: "var(--boot-settings-nav-w, 256px)" }}
>
{embedded ? null : (
@@ -264,10 +264,10 @@ function PhonePage() {
export function SettingsSkeletonBody({ embedded = false }: { embedded?: boolean }) {
return (
<div
className={cn("flex-1 min-w-0 min-h-0 flex", embedded ? "h-full" : "flex-col lg:flex-row")}
className={cn("flex-1 min-w-0 min-h-0 flex", embedded ? "h-full" : "flex-col desk:flex-row")}
data-embedded={embedded ? "" : undefined}
>
<div className={cn("flex-1 min-w-0 min-h-0", embedded ? "flex" : "hidden lg:flex")}>
<div className={cn("flex-1 min-w-0 min-h-0", embedded ? "flex" : "hidden desk:flex")}>
<SettingsNav embedded={embedded} />
<div aria-hidden="true" className="flex-1 min-w-0 overflow-hidden">
{embedded ? null : (
@@ -281,7 +281,7 @@ export function SettingsSkeletonBody({ embedded = false }: { embedded?: boolean
</div>
</div>
{embedded ? null : (
<div className="flex flex-col flex-1 min-h-0 lg:hidden">
<div className="flex flex-col flex-1 min-h-0 desk:hidden">
<PhoneList />
<PhonePage />
</div>
@@ -297,10 +297,9 @@ export function SettingsSkeleton({ label, labels }: SettingsSkeletonProps) {
surface="settings"
label={label}
labels={labels}
railBelow="lg"
active="settings"
// Mountain View: the page sits on a card inset from the canvas.
rowClassName="lg:mv:mb-4 lg:mv:mx-4 lg:mv:rounded-[var(--mv-card-radius)] lg:mv:bg-background"
rowClassName="desk:mv:mb-4 desk:mv:mx-4 desk:mv:rounded-[var(--mv-card-radius)] desk:mv:bg-background"
>
<SettingsSkeletonBody />
</AppShellSkeleton>
+8 -20
View File
@@ -70,7 +70,7 @@ export function RailSkeleton({
}) {
const item = (on: boolean) =>
cn(
"relative flex items-center justify-center w-10 h-10 rounded-md max-lg:min-h-[44px]",
"relative flex items-center justify-center w-10 h-10 rounded-md tap:min-h-[44px]",
on ? "bg-primary/10 text-primary" : "text-muted-foreground",
);
const footerItem = "flex items-center justify-center w-10 h-10 rounded-md";
@@ -125,7 +125,7 @@ export function RailSkeleton({
}
/**
* The phone/tablet bottom bar (NavigationRail horizontal). `className` decides
* The phone bottom bar (NavigationRail horizontal). `className` decides
* at which widths it shows.
*/
export function BottomBarSkeleton({
@@ -239,11 +239,6 @@ interface AppShellSkeletonProps {
label?: string;
/** Rail item drawn as current. */
active?: RailApp;
/**
* Below which width the vertical rail gives way to the bottom bar: mail and
* settings switch at 64rem, calendar, contacts, files and plugin pages at 48rem.
*/
railBelow: "md" | "lg";
labels?: RailLabels;
/** Whether the Mountain View top bar holds the search field (mail does). */
mvSearch?: boolean;
@@ -251,7 +246,7 @@ interface AppShellSkeletonProps {
mvMenu?: boolean;
/** Extra classes for the row under the top bar (Mountain View card margins etc.). */
rowClassName?: string;
/** Extra classes for the phone/tablet bottom bar, e.g. to hide it while a message is open. */
/** Extra classes for the phone bottom bar, e.g. to hide it while a message is open. */
bottomBarClassName?: string;
children: ReactNode;
}
@@ -273,7 +268,6 @@ export function AppShellSkeleton({
surface,
label,
active,
railBelow,
labels,
mvSearch = false,
mvMenu = true,
@@ -294,21 +288,15 @@ export function AppShellSkeleton({
<SkeletonRegion
surface={surface}
label={label}
className="flex flex-col overflow-hidden pt-[var(--app-top-inset)] h-dvh bg-background lg:mv:bg-[var(--mv-canvas)] boot-out:hidden boot-pro-frame:hidden"
className="flex flex-col overflow-hidden pt-[var(--app-top-inset)] h-dvh bg-background desk:mv:bg-[var(--mv-canvas)] boot-out:hidden boot-pro-frame:hidden"
>
<MvTopBarSkeleton search={mvSearch} menu={mvMenu} className="hidden lg:mv:flex" />
<MvTopBarSkeleton search={mvSearch} menu={mvMenu} className="hidden desk:mv:flex" />
{/* The rail from the phone breakpoint up (PHONE_BELOW), the bottom bar below it. */}
<div className={cn("flex flex-1 min-h-0 overflow-hidden", rowClassName)}>
<RailSkeleton
active={active}
className={railBelow === "lg" ? "hidden lg:flex lg:mv:hidden" : "hidden md:flex lg:mv:hidden"}
/>
<RailSkeleton active={active} className="hidden desk:flex desk:mv:hidden" />
{children}
</div>
<BottomBarSkeleton
active={active}
labels={labels}
className={cn(railBelow === "lg" ? "flex lg:hidden" : "flex md:hidden", bottomBarClassName)}
/>
<BottomBarSkeleton active={active} labels={labels} className={cn("flex desk:hidden", bottomBarClassName)} />
</SkeletonRegion>
</>
);
@@ -12,6 +12,7 @@ import { useSettingsStore } from '@/stores/settings-store';
vi.mock('@/hooks/use-media-query', () => ({
useIsMobile: () => false,
useIsDesktop: () => true,
useIsFullDesktop: () => true,
useIsTablet: () => false,
useMediaQuery: () => false,
}));
@@ -15,7 +15,9 @@ import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
const media = vi.hoisted(() => ({ mobile: false, desktop: true }));
vi.mock('@/hooks/use-media-query', () => ({
useIsMobile: () => media.mobile,
useIsDesktop: () => media.desktop,
useIsDesktop: () => !media.mobile,
// `desktop` is the full desktop: below it the sidebar opens over the page.
useIsFullDesktop: () => media.desktop,
useIsTablet: () => !media.mobile && !media.desktop,
useMediaQuery: () => false,
}));
@@ -16,7 +16,9 @@ vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/pl
const media = vi.hoisted(() => ({ mobile: false, desktop: true }));
vi.mock('@/hooks/use-media-query', () => ({
useIsMobile: () => media.mobile,
useIsDesktop: () => media.desktop,
useIsDesktop: () => !media.mobile,
// `desktop` is the full desktop: below it the sidebar opens over the page.
useIsFullDesktop: () => media.desktop,
useIsTablet: () => !media.mobile && !media.desktop,
useMediaQuery: () => false,
}));
+4 -3
View File
@@ -18,7 +18,7 @@ import { useIdentityStore } from "@/stores/identity-store";
import { useAccountStore } from "@/stores/account-store";
import { usePolicyStore } from "@/stores/policy-store";
import { toast } from "@/stores/toast-store";
import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
import { useIsFullDesktop, useIsMobile } from "@/hooks/use-media-query";
import { useAccountPrincipalEmails } from "@/hooks/use-account-principal-emails";
import { Button } from "@/components/ui/button";
import { CalendarSkeleton, CalendarSkeletonBody, CalendarViewSkeleton, calendarSkeletonText } from "@/components/boot-skeleton/calendar-skeleton";
@@ -166,7 +166,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
const canSubscribe = !demoMode && !IS_LITE;
const tDeepLink = useTranslations("deep_link");
const isMobile = useIsMobile();
const isDesktop = useIsDesktop();
// Below the full desktop the sidebar opens over the page.
const isFullDesktop = useIsFullDesktop();
const isEmbedded = useIsEmbedded();
// The Pro interface takes over this route; the Pro tab loads the data then.
const proInterfaceActive = useProInterfaceActive();
@@ -174,7 +175,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
const mvShell = useMountainViewShell(isEmbedded);
// When the pane (Pro shell) or window is narrower than `lg`, the sidebar
// collapses into a burger-toggled overlay instead of taking inline space.
const isNarrow = !isDesktop;
const isNarrow = !isFullDesktop;
const [narrowSidebarOpen, setNarrowSidebarOpen] = useState(false);
useEffect(() => { if (!isNarrow) setNarrowSidebarOpen(false); }, [isNarrow]);
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
+4 -3
View File
@@ -46,7 +46,7 @@ import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { useIsFocusedProTab } from "@/hooks/use-pane-context";
import { useProMultiAccountContacts } from "@/hooks/use-pro-multi-account-contacts";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
import { useIsFullDesktop, useIsMobile } from "@/hooks/use-media-query";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/types";
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
@@ -174,7 +174,8 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
const hasFetched = useRef(false);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const isMobile = useIsMobile();
const isDesktop = useIsDesktop();
// Below the full desktop the sidebar opens over the page.
const isFullDesktop = useIsFullDesktop();
const locale = useLocale();
usePrintListener();
const printing = usePrinting();
@@ -190,7 +191,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
const intentAppliedRef = useRef(false);
// Narrow pane (Pro split or small window): the categories sidebar collapses
// into a burger-toggled overlay.
const isNarrow = !isDesktop;
const isNarrow = !isFullDesktop;
const [narrowSidebarOpen, setNarrowSidebarOpen] = useState(false);
useEffect(() => { if (!isNarrow) setNarrowSidebarOpen(false); }, [isNarrow]);
// A new view (a form, a group, a contact) replaces whatever the drawer was for.
@@ -44,7 +44,7 @@ function renderRow(emails: Email[]) {
}
beforeEach(() => {
useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true });
useUIStore.setState({ isMobile: false, isCompact: false, isDesktop: true });
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: true,
@@ -42,7 +42,7 @@ function renderRow(emails: Email[]) {
}
beforeEach(() => {
useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true });
useUIStore.setState({ isMobile: false, isCompact: false, isDesktop: true });
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
@@ -71,7 +71,7 @@ beforeEach(() => {
setActionDispatcher(dispatch);
useEmailStore.setState(initialEmailState, true);
useEmailStore.setState({ selectedMailbox: 'inbox', mailboxes, emails: [], selectedEmailKeys: new Set<string>() });
useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true });
useUIStore.setState({ isMobile: false, isCompact: false, isDesktop: true });
useSettingsStore.setState({ interfaceLayout: 'default', hoverActions: ['delete', 'star'], hoverActionsMode: 'inline' } as never);
});
@@ -58,7 +58,7 @@ beforeEach(() => {
clearAllHooks();
clearRowBadgeCache();
resetRowBadgeRefresh();
useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true });
useUIStore.setState({ isMobile: false, isCompact: false, isDesktop: true });
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
@@ -120,7 +120,7 @@ describe('plugin badges in message-list rows', () => {
});
it('shows badges on a conversation row and on the phone layout', async () => {
useUIStore.setState({ isMobile: true, isTablet: false, isDesktop: false });
useUIStore.setState({ isMobile: true, isCompact: false, isDesktop: false });
renderHooks.onEmailListItemRender.register('test', async () => [{ key: 'k', label: 'Phone badge' }]);
render(row([
@@ -68,7 +68,7 @@ const accountDot = (c: HTMLElement) =>
describe('ThreadListItem account dot (#912)', () => {
beforeEach(() => {
accountState.accounts = [ACCOUNT_TEAL, ACCOUNT_OTHER];
useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true });
useUIStore.setState({ isMobile: false, isCompact: false, isDesktop: true });
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
@@ -55,7 +55,7 @@ const sizeCells = (c: HTMLElement) => Array.from(c.querySelectorAll('[data-list-
describe('message list rows under the bottom reading pane (#262)', () => {
beforeEach(() => {
useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true });
useUIStore.setState({ isMobile: false, isCompact: false, isDesktop: true });
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: true,
@@ -78,8 +78,14 @@ describe('message list rows under the bottom reading pane (#262)', () => {
expect(screen.getByText(/Preview text/)).toBeInTheDocument();
});
it('keeps stacked rows on a tablet, which shows the split layout', () => {
useUIStore.setState({ isMobile: false, isTablet: true, isDesktop: false });
it('draws one line on a compact desktop too, which shows the bottom pane', () => {
useUIStore.setState({ isMobile: false, isCompact: true, isDesktop: true });
const { container } = renderEmails([makeEmail()]);
expect(container.querySelector('p')).toBeNull();
});
it('keeps stacked rows on a phone', () => {
useUIStore.setState({ isMobile: true, isCompact: false, isDesktop: false });
const { container } = renderEmails([makeEmail()]);
expect(container.querySelector('p')).not.toBeNull();
});
@@ -52,7 +52,7 @@ function renderRow(conversationInReader: boolean) {
}
beforeEach(() => {
useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true });
useUIStore.setState({ isMobile: false, isCompact: false, isDesktop: true });
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS], showPreview: true, mailLayout: 'split',
interfaceLayout: 'default', listColumns: false,
@@ -67,7 +67,7 @@ const conversation = () => [
describe('ThreadListItem folder chip in search results (#1172)', () => {
beforeEach(() => {
useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true });
useUIStore.setState({ isMobile: false, isCompact: false, isDesktop: true });
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
@@ -165,7 +165,7 @@ describe('ThreadListItem sender line in a narrow list', () => {
};
beforeEach(() => {
useUIStore.setState({ isMobile: true, isTablet: false, isDesktop: false });
useUIStore.setState({ isMobile: true, isCompact: false, isDesktop: false });
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], showPreview: false, mailLayout: 'split', interfaceLayout: 'default', showColorNames: false });
useEmailStore.setState({ selectedEmailKeys: new Set<string>(), selectedMailbox: 'inbox', isUnifiedView: false, selectedKeyword: null, searchQuery: '', searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: 'archive' });
});
@@ -66,7 +66,7 @@ function renderRow(email: Email) {
}
beforeEach(() => {
useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true });
useUIStore.setState({ isMobile: false, isCompact: false, isDesktop: true });
});
describe('ThreadListItem tag badge', () => {
@@ -238,7 +238,7 @@ describe('ThreadListItem row content', () => {
it('puts the star at the trailing edge in the Mountain View phone row', () => {
useSettingsStore.setState({ interfaceLayout: 'mountain-view', mailLayout: 'split' });
useUIStore.setState({ isMobile: true, isTablet: false, isDesktop: false });
useUIStore.setState({ isMobile: true, isCompact: false, isDesktop: false });
const onToggleStar = vi.fn();
const [thread] = groupEmailsByThread([makeEmail()]);
const { container } = render(
@@ -268,7 +268,7 @@ describe('ThreadListItem opening a thread on the phone', () => {
function tap(interfaceLayout: 'mountain-view' | 'default') {
useSettingsStore.setState({ interfaceLayout, mailLayout: 'split' });
useUIStore.setState({ isMobile: true, isTablet: false, isDesktop: false });
useUIStore.setState({ isMobile: true, isCompact: false, isDesktop: false });
useEmailStore.setState({ selectedEmailKeys: new Set<string>(), selectedMailbox: 'inbox' });
const onEmailSelect = vi.fn();
const onOpenConversation = vi.fn();
@@ -305,7 +305,7 @@ describe('ThreadListItem opening a thread on the phone', () => {
describe('ThreadListItem keeping the selection when a message opens', () => {
function open(interfaceLayout: 'mountain-view' | 'default', emails: Email[]) {
useSettingsStore.setState({ interfaceLayout, mailLayout: 'split' });
useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true });
useUIStore.setState({ isMobile: false, isCompact: false, isDesktop: true });
useEmailStore.setState({ selectedEmailKeys: new Set(['other']), selectedMailbox: 'inbox' });
const onEmailSelect = vi.fn();
const [thread] = groupEmailsByThread(emails);
@@ -348,7 +348,7 @@ describe('ThreadListItem whole row toggles in selection mode', () => {
} = {},
) {
useSettingsStore.setState({ interfaceLayout: layout, mailLayout: 'split', density: 'regular' } as never);
useUIStore.setState({ isMobile: mobile, isTablet: false, isDesktop: !mobile });
useUIStore.setState({ isMobile: mobile, isCompact: false, isDesktop: !mobile });
useEmailStore.setState({ selectedEmailKeys: new Set(selected), selectedMailbox: 'inbox' });
const onEmailSelect = vi.fn();
const onEmailDoubleClick = vi.fn();
@@ -499,7 +499,7 @@ describe('ThreadListItem avatar gutter (#1381)', () => {
describe('ThreadListItem Mountain View phone row layout', () => {
it('sets the star at the end of the preview line, not under the time', () => {
useSettingsStore.setState({ interfaceLayout: 'mountain-view', mailLayout: 'split', showPreview: true, density: 'regular' } as never);
useUIStore.setState({ isMobile: true, isTablet: false, isDesktop: false });
useUIStore.setState({ isMobile: true, isCompact: false, isDesktop: false });
const [thread] = groupEmailsByThread([makeEmail()]);
const { container } = render(
<ThreadListItem
@@ -939,7 +939,7 @@ describe('ThreadListItem fallbacks', () => {
describe('ThreadListItem swipe on a conversation', () => {
it('archives the conversation\'s row like a single message\'s', () => {
useUIStore.setState({ isMobile: true, isTablet: false, isDesktop: false });
useUIStore.setState({ isMobile: true, isCompact: false, isDesktop: false });
useSettingsStore.setState({ swipeRightAction: 'archive', swipeLeftAction: 'delete' } as never);
const first = makeEmail({ id: 'a', threadId: 't', receivedAt: '2024-01-15T09:00:00Z' });
const second = makeEmail({ id: 'b', threadId: 't', receivedAt: '2024-01-15T10:00:00Z' });
@@ -954,6 +954,37 @@ describe('ThreadListItem swipe on a conversation', () => {
fireEvent.touchEnd(row);
expect(onArchive).toHaveBeenCalledWith(expect.objectContaining({ id: 'b' }));
});
// A tablet draws the compact desktop layout (600-1024px) and keeps the swipe;
// a mouse in a window that narrow does not get it.
it.each([
['swipes on a touch screen of a compact desktop', true, 1],
['does not swipe with a mouse', false, 0],
])('%s', (_name, coarse, calls) => {
useUIStore.setState({ isMobile: false, isCompact: true, isDesktop: true });
useSettingsStore.setState({ swipeRightAction: 'archive', swipeLeftAction: 'delete' } as never);
const spy = vi.spyOn(window, 'matchMedia').mockImplementation((query: string) => ({
matches: coarse && query === '(pointer: coarse) and (width < 64rem)', media: query, onchange: null,
addEventListener: () => {}, removeEventListener: () => {}, addListener: () => {}, removeListener: () => {},
dispatchEvent: () => false,
}) as MediaQueryList);
try {
const first = makeEmail({ id: 'a', threadId: 't', receivedAt: '2024-01-15T09:00:00Z' });
const second = makeEmail({ id: 'b', threadId: 't', receivedAt: '2024-01-15T10:00:00Z' });
const [thread] = groupEmailsByThread([first, second]);
const onArchive = vi.fn();
const { container } = render(
<ThreadListItem thread={thread} isExpanded={false} onToggleExpand={() => {}} onEmailSelect={() => {}} onArchive={onArchive} onDelete={vi.fn()} />,
);
const row = container.querySelector('[data-unread]')!;
fireEvent.touchStart(row, { touches: [{ clientX: 10, clientY: 10 }] });
fireEvent.touchMove(row, { touches: [{ clientX: 160, clientY: 12 }] });
fireEvent.touchEnd(row);
expect(onArchive).toHaveBeenCalledTimes(calls);
} finally {
spy.mockRestore();
}
});
});
// R11: in an Arabic interface an English subject was cut on the wrong side.
+15 -19
View File
@@ -130,11 +130,10 @@ import { useRouter } from "@/i18n/navigation";
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
import { useSettingsStore } from "@/stores/settings-store";
import { useShallow } from "zustand/react/shallow";
import { useEffectiveMailLayout } from "@/hooks/use-effective-mail-layout";
import { useDrawnMailLayout } from "@/hooks/use-effective-mail-layout";
import { pluginRenderKey } from "@/hooks/use-plugin-rendered-body";
import { registerComposeFlush } from "@/lib/compose-flush-registry";
import { useExternalRecipientConfirmStore } from "@/stores/external-recipient-confirm-store";
import { useUIStore } from "@/stores/ui-store";
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
import { toast } from "@/stores/toast-store";
import { rescheduleFailureToast, sendFailureMessage } from "@/lib/send-failure-message";
@@ -1452,7 +1451,7 @@ export function EmailViewer({
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
const showToolbarLabelsSetting = useSettingsStore((state) => state.showToolbarLabels);
const interfaceLayout = useSettingsStore((state) => state.interfaceLayout);
const mailLayout = useEffectiveMailLayout();
const mailLayout = useDrawnMailLayout();
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
const readReceiptResponse = useSettingsStore((state) => state.readReceiptResponse);
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
@@ -1503,8 +1502,7 @@ export function EmailViewer({
const canCancelScheduled = isScheduled && email?.scheduledUndoStatus === 'pending';
// Tablet list visibility
const { isTablet, isMobile } = useDeviceDetection();
const { isCompact, isMobile } = useDeviceDetection();
// The top toolbar leaves the message column on desktops and tablets, so
// it runs over the side panels to the right edge.
const liftToolbar = !isMobile;
@@ -1525,8 +1523,8 @@ export function EmailViewer({
const mvAfter = mvOpenIndex >= 0 ? mvConversation!.slice(mvOpenIndex + 1) : [];
const mvConversationIds = mvOpenIndex >= 0 ? mvConversation!.map((e) => e.id).join(',') : '';
const threadSummaryProps = useMemo(
() => ({ threadId: email?.threadId ?? null, emailIds: mvConversationIds ? mvConversationIds.split(',') : [], deviceType: isMobile ? 'mobile' : isTablet ? 'tablet' : 'desktop' }),
[email?.threadId, mvConversationIds, isMobile, isTablet],
() => ({ threadId: email?.threadId ?? null, emailIds: mvConversationIds ? mvConversationIds.split(',') : [], deviceType: isMobile ? 'mobile' : isCompact ? 'tablet' : 'desktop' }),
[email?.threadId, mvConversationIds, isMobile, isCompact],
);
// Mountain View: which other messages of the conversation are unfolded. A
// conversation opens with its unread messages unfolded, as Gmail does, so
@@ -1594,7 +1592,6 @@ export function EmailViewer({
// `sm:hidden` must not be trusted - the viewport may be desktop-sized
// while the pane is phone-sized.
const isPaneScoped = useIsPaneScoped();
const { tabletListVisible } = useUIStore(useShallow((state) => ({ tabletListVisible: state.tabletListVisible })));
const { identities, client, isDemoMode, activeAccountId } = useAuthStore(useShallow((state) => ({ identities: state.identities, client: state.client, isDemoMode: state.isDemoMode, activeAccountId: state.activeAccountId })));
const activeAccount = useAccountStore((s) => s.accounts.find((a) => a.id === activeAccountId));
// The standard mail UI supplies message routing for preview/download,
@@ -1909,7 +1906,7 @@ export function EmailViewer({
);
// Reader frames learn the layout: the bar a plugin shows on phones has no
// place next to a side panel on a desktop.
const pluginDeviceType = isMobile ? 'mobile' : isTablet ? 'tablet' : 'desktop';
const pluginDeviceType = isMobile ? 'mobile' : isCompact ? 'tablet' : 'desktop';
const pluginEmailProps = useMemo(() => ({ email: pluginEmailView, deviceType: pluginDeviceType }), [pluginEmailView, pluginDeviceType]);
// The text selected in the body, for the side panel (a plugin working on
// what the user highlights) and the right-click menu on it. Reset per
@@ -2089,7 +2086,7 @@ export function EmailViewer({
// Everything the decision depends on that can be read without a layout.
const layoutKey = (items: HTMLElement[]) => [
toolbarWidthRef.current, toolbarPosition, showToolbarLabels, isTablet, tabletListVisible,
toolbarWidthRef.current, toolbarPosition, showToolbarLabels, isFocusedMailLayout,
...items.map((item) => `${item.dataset.overflowPriority}:${item.className}:${item.textContent}`),
].join('|');
@@ -2223,8 +2220,7 @@ export function EmailViewer({
emailKeywords.length,
currentColor,
isInJunkFolder,
isTablet,
tabletListVisible,
isFocusedMailLayout,
onBack,
onMarkAsSpam,
onUndoSpam,
@@ -4369,7 +4365,7 @@ ${printWatermark ? printWatermark.html : ''}
<>
{/* Left: Reply actions */}
<div className={cn("flex items-center gap-0", showBackButton ? "sm:gap-1" : "sm:gap-0.5")}>
{showBackButton && (isMobile || (isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
{showBackButton && (isMobile || isFocusedMailLayout) && onBack && (
<Button
variant="ghost"
size="icon"
@@ -4831,7 +4827,7 @@ ${printWatermark ? printWatermark.html : ''}
className={cn(
"bg-background flex-shrink-0",
!mvViewer && "border-b border-border",
"max-lg:sticky max-lg:top-0 max-lg:z-10"
"max-desk:sticky max-desk:top-0 max-desk:z-10"
)}
>
<div className={cn("px-2 sm:px-4 lg:px-6 flex items-center", mvViewer ? "h-12 lg:ps-4" : "h-14")}>
@@ -4963,14 +4959,14 @@ ${printWatermark ? printWatermark.html : ''}
<div className={cn(
"bg-background",
!mvViewer && "border-b border-border",
toolbarPosition === 'below-subject' && "max-lg:sticky max-lg:top-0 max-lg:z-10"
toolbarPosition === 'below-subject' && "max-desk:sticky max-desk:top-0 max-desk:z-10"
)}>
{/* Mountain View lines the subject up with the sender's name below it. */}
<div className={cn("px-4 lg:px-6", mvViewer && "lg:ps-[80px] pt-3 pb-1")} style={mvViewer ? undefined : { paddingBlock: 'var(--density-header-py)' }}>
{/* In a narrow pane the date takes its own line instead of squeezing the subject to a column. */}
<div className="flex flex-wrap items-start justify-between gap-x-2 gap-y-1 lg:gap-x-4">
{/* Back button (for below-subject mode on tablet) */}
{toolbarPosition === 'below-subject' && (isMobile || (isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
{toolbarPosition === 'below-subject' && (isMobile || isFocusedMailLayout) && onBack && (
<Button
variant="ghost"
size="icon"
@@ -5042,7 +5038,7 @@ ${printWatermark ? printWatermark.html : ''}
</div>
{/* Mountain View: print and full screen at the end of the subject line. */}
{mvViewer && (
<div className="hidden lg:flex items-center flex-shrink-0 -me-2" data-mv-subject-actions="">
<div className="hidden desk:flex items-center flex-shrink-0 -me-2" data-mv-subject-actions="">
{!devices.noPrint && (
<button
type="button"
@@ -5137,7 +5133,7 @@ ${printWatermark ? printWatermark.html : ''}
) : (<>
{/* === SENDER INFO (Desktop) === */}
<div
className={cn("hidden lg:block bg-background px-6", !mvViewer && "border-b border-border", (mvViewer || conversationPane) && mvBefore.length > 0 && "border-t border-border", mvOpenIndex >= 0 && "cursor-pointer")}
className={cn("hidden desk:block bg-background px-6", !mvViewer && "border-b border-border", (mvViewer || conversationPane) && mvBefore.length > 0 && "border-t border-border", mvOpenIndex >= 0 && "cursor-pointer")}
style={mvViewer ? { paddingBlock: '0.75rem 0.5rem' } : { paddingBlock: 'var(--density-header-py)' }}
data-mv-open-header={mvOpenIndex >= 0 ? "" : undefined}
onClick={mvOpenIndex >= 0 ? (e) => {
@@ -5328,7 +5324,7 @@ ${printWatermark ? printWatermark.html : ''}
{/* Mobile/Tablet Sender Info - scrolls with content */}
<div
className={cn("lg:hidden bg-background border-b border-border px-4", mvOpenIndex >= 0 && "cursor-pointer", mvOpenIndex >= 0 && mvBefore.length > 0 && "border-t")}
className={cn("desk:hidden bg-background border-b border-border px-4", mvOpenIndex >= 0 && "cursor-pointer", mvOpenIndex >= 0 && mvBefore.length > 0 && "border-t")}
style={{ paddingBlock: 'var(--density-header-py)' }}
data-mv-open-header-mobile={mvOpenIndex >= 0 ? "" : undefined}
onClick={mvOpenIndex >= 0 ? (e) => {
@@ -205,7 +205,7 @@ export function ThreadConversationView({
const t = useTranslations();
const externalContent = useConversationExternalContent();
const emailIdList = emails.map((email) => email.id).join(',');
const { isMobile: summaryOnPhone, isTablet: summaryOnTablet } = useDeviceDetection();
const { isMobile: summaryOnPhone, isCompact: summaryOnTablet } = useDeviceDetection();
const threadSummaryProps = useMemo(
() => ({ threadId: thread.latestEmail.threadId ?? null, emailIds: emailIdList ? emailIdList.split(',') : [], deviceType: summaryOnPhone ? 'mobile' : summaryOnTablet ? 'tablet' : 'desktop' }),
[thread.latestEmail.threadId, emailIdList, summaryOnPhone, summaryOnTablet],
@@ -336,8 +336,8 @@ function useConversationPluginProps(email: Email) {
const belowSubjectOffers = usePluginSlotOffers('reader-below-subject').length;
const bannerOffers = usePluginSlotOffers('email-banner').length;
const wantsView = headerOffers + belowSubjectOffers + bannerOffers > 0;
const { isMobile, isTablet } = useDeviceDetection();
const deviceType = isMobile ? 'mobile' : isTablet ? 'tablet' : 'desktop';
const { isMobile, isCompact } = useDeviceDetection();
const deviceType = isMobile ? 'mobile' : isCompact ? 'tablet' : 'desktop';
const emailProps = useMemo(() => ({ email: wantsView ? emailToReadView(email) : null, deviceType }), [email, wantsView, deviceType]);
const idProps = useMemo(() => ({ emailId: email.id }), [email.id]);
const actionTarget = useMessageActionTarget(email);
+15 -2
View File
@@ -20,6 +20,7 @@ import { useListRowShape, LIST_COLUMN_CLASS } from "@/hooks/use-list-row-shape";
import { SizeCell } from "./list-columns";
import { accountTintKey, generateAvatarColor } from "@/lib/account-utils";
import { useUIStore } from "@/stores/ui-store";
import { useMediaQuery } from "@/hooks/use-media-query";
import { useEmailStore, isUnscopedSearchActive } from "@/stores/email-store";
import { useAccountStore, type AccountEntry } from "@/stores/account-store";
import { emailKeyFor, getThreadTagIds, getEmailTagIds } from "@/lib/thread-utils";
@@ -95,6 +96,16 @@ function useShowAccountDot(tintListRowsByAccount: boolean): boolean {
return enabled && severalAccounts && !tintListRowsByAccount;
}
/**
* Rows swipe on a phone, and on a touch screen below the full desktop: a
* tablet draws the compact desktop layout and keeps the phone's gesture.
*/
function useSwipeRows(): boolean {
const isMobile = useUIStore((state) => state.isMobile);
const touchCompact = useMediaQuery("(pointer: coarse) and (width < 64rem)");
return isMobile || touchCompact;
}
/** Hover actions mount on the first pointerover or keyboard focus in the row. */
function useArmOnPointerOver(): [boolean, () => void] {
const [armed, setArmed] = React.useState(false);
@@ -478,6 +489,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const announceRowPreview = useSettingsStore((state) => state.announceRowPreview);
const showRowActionButton = useSettingsStore((state) => state.showRowActionButton);
const isMobile = useUIStore((state) => state.isMobile);
const swipeRows = useSwipeRows();
// One line per row where the list has the full width; stacked on phones.
const { oneLine: oneLineRow, columns } = useListRowShape();
// Mountain View leads its rows with a checkbox and a clickable star
@@ -557,7 +569,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
};
const hasRight = isActionable(swipeRightAction);
const hasLeft = isActionable(swipeLeftAction);
const swipeEnabled = isMobile && (hasLeft || hasRight);
const swipeEnabled = swipeRows && (hasLeft || hasRight);
const { swipeHandlers, offsetX, side: swipeSide, willCommit, consumeTap } = useSwipeActions({
enabled: swipeEnabled,
hasLeft,
@@ -1066,6 +1078,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
const interfaceLayout = useSettingsStore((state) => state.interfaceLayout);
const isMobile = useUIStore((state) => state.isMobile);
const swipeRows = useSwipeRows();
const { latestEmail, participantNames, hasUnread, hasStarred, hasPinned, hasAnswered, hasForwarded, emailCount } = thread;
// Only the latest email's parts are loaded; an older one with the flag keeps the paperclip.
const latestHasAttachment = useListHasAttachment(latestEmail, loadAttachments);
@@ -1165,7 +1178,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
};
const threadHasRight = threadActionable(threadSwipeRightAction);
const threadHasLeft = threadActionable(threadSwipeLeftAction);
const threadSwipeEnabled = isMobile && (threadHasLeft || threadHasRight);
const threadSwipeEnabled = swipeRows && (threadHasLeft || threadHasRight);
const {
swipeHandlers: threadSwipeHandlers, offsetX: threadOffsetX, side: threadSwipeSide,
willCommit: threadWillCommit, consumeTap: threadConsumeTap,
+4 -3
View File
@@ -7,7 +7,7 @@ import {
FilePlus, CopyPlus, AlertCircle, FolderUp, Share2, SquarePen, Eye, FolderOpen,
RefreshCw, Star, StarOff, Plus, CheckSquare, File, RotateCcw, FolderInput,
} from "@/components/icons";
import { useIsDesktop, useIsMobile, useMediaQuery } from "@/hooks/use-media-query";
import { useIsFullDesktop, useIsMobile, useMediaQuery } from "@/hooks/use-media-query";
import { useContextMenu } from "@/hooks/use-context-menu";
import { useKeepFileOffline } from "@/hooks/use-keep-file-offline";
import { useRegisterCommands } from "@/lib/command-registry";
@@ -270,8 +270,9 @@ export function FileBrowser({
}), [settings.showIcons, settings.coloredIcons, settings.showThumbnails]);
// ---- Layout ----------------------------------------------------------------
const isDesktop = useIsDesktop();
const isNarrow = !isDesktop;
// Below the full desktop the sidebar opens over the page.
const isFullDesktop = useIsFullDesktop();
const isNarrow = !isFullDesktop;
const isMobile = useIsMobile();
const touch = useMediaQuery("(pointer: coarse)") && isMobile;
const [narrowSidebarOpen, setNarrowSidebarOpen] = useState(false);
+1 -1
View File
@@ -40,7 +40,7 @@ function FacetButton({ active, label, count, onClick }: { active: boolean; label
style={{ paddingBlock: 'var(--density-sidebar-py)' }}
className={cn(
"w-full flex items-center gap-2 ps-3 pe-2 text-sm text-left transition-colors duration-150 cursor-pointer",
"max-lg:min-h-[44px] border-s-2",
"tap:min-h-[44px] border-s-2",
active
? "bg-accent text-accent-foreground font-semibold border-primary"
: "hover:bg-muted/50 text-foreground border-transparent",
@@ -31,7 +31,7 @@ const mailboxes = [
const realMatchMedia = window.matchMedia;
function narrowWindow(narrow: boolean) {
window.matchMedia = ((query: string) => ({
matches: narrow && query.includes('64rem') && query.includes('<'),
matches: narrow && query === '(width < 37.5rem)',
media: query,
onchange: null,
addListener: () => {},
@@ -54,7 +54,7 @@ describe('Mountain View rail peek', () => {
it('shows folder names in the drawer of a narrow window, even when collapsed on a wider one', () => {
const matchMedia = window.matchMedia;
window.matchMedia = ((query: string) => ({
matches: query.includes('64rem'), media: query, addEventListener() {}, removeEventListener() {},
matches: query === '(width < 37.5rem)', media: query, addEventListener() {}, removeEventListener() {},
})) as never;
try {
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
+1 -1
View File
@@ -61,7 +61,7 @@ export function MobileHeader({
const { toggleSidebar, goBack, sidebarOpen } = useUIStore(useShallow((state) => ({ toggleSidebar: state.toggleSidebar, goBack: state.goBack, sidebarOpen: state.sidebarOpen })));
const mountainView = useSettingsStore((state) => state.interfaceLayout === 'mountain-view');
// Pane-aware: in Pro split mode the viewport is desktop-wide while the
// pane is narrow. The Tailwind `lg:hidden` variant alone would never fire
// pane is narrow. The Tailwind `desk:hidden` variant alone would never fire
// there, so we additionally hide via JS when the surrounding pane is
// desktop-sized. Outside of Pro this still returns the viewport value.
const isPaneDesktop = useIsDesktop();
+5 -5
View File
@@ -658,7 +658,7 @@ export function NavigationRail({
className={cn(
"relative flex items-center gap-2.5 rounded-md transition-colors duration-150 cursor-pointer",
collapsed ? "justify-center w-10 h-10" : "px-2.5 text-sm",
"max-lg:min-h-[44px]",
"tap:min-h-[44px]",
"text-muted-foreground hover:bg-muted hover:text-foreground"
)}
title={collapsed ? tGlobalSearch("title") : undefined}
@@ -682,7 +682,7 @@ export function NavigationRail({
collapsed
? "justify-center w-10 h-10"
: "px-2.5 text-sm",
"max-lg:min-h-[44px]",
"tap:min-h-[44px]",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
@@ -726,7 +726,7 @@ export function NavigationRail({
collapsed
? "justify-center w-10 h-10"
: "px-2.5 text-sm",
"max-lg:min-h-[44px]",
"tap:min-h-[44px]",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
@@ -772,7 +772,7 @@ export function NavigationRail({
collapsed
? "justify-center w-10 h-10"
: "px-2.5 text-sm",
"max-lg:min-h-[44px]",
"tap:min-h-[44px]",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
@@ -795,7 +795,7 @@ export function NavigationRail({
collapsed
? "justify-center w-10 h-10"
: "px-2.5 text-sm",
"max-lg:min-h-[44px]",
"tap:min-h-[44px]",
"text-muted-foreground hover:bg-muted hover:text-foreground"
)}
title={collapsed ? t("add_app") : undefined}
+13 -15
View File
@@ -61,8 +61,7 @@ import { isAppShortcutBlocked, isEventInside } from "@/lib/keyboard";
import { Mailbox } from "@/lib/jmap/types";
import { useContextMenu } from "@/hooks/use-context-menu";
import { useLongPress } from "@/hooks/use-long-press";
import { useMediaQuery } from "@/hooks/use-media-query";
import { usePaneSize } from "@/hooks/use-pane-size";
import { useDeviceDetection, useMediaQuery } from "@/hooks/use-media-query";
import { IconButton } from "@/components/ui/icon-button";
import { MailboxContextMenu, type MailboxContextTarget, type MailboxNotificationActions } from "./mailbox-context-menu";
import { useAccountStore } from '@/stores/account-store';
@@ -73,7 +72,7 @@ import { useDragDropContext } from "@/contexts/drag-drop-context";
import { useMailboxDrop } from "@/hooks/use-mailbox-drop";
import { MAILBOX_DRAG_MIME } from "@/components/pro/pro-shell-drop";
import { useTagDrop } from "@/hooks/use-tag-drop";
import { useUIStore } from "@/stores/ui-store";
import { sidebarFolded, useUIStore } from "@/stores/ui-store";
import { useAuthStore } from "@/stores/auth-store";
import { unifiedLoadProgress } from "@/lib/unified-mailbox";
import { useVacationStore } from "@/stores/vacation-store";
@@ -441,7 +440,7 @@ function SidebarRow({
data-has-unread={(unread ?? 0) > 0 && testRole !== 'drafts' ? '' : undefined}
style={{ paddingBlock: 'var(--density-sidebar-py)' }}
className={cn(
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-colors duration-150",
"group w-full flex items-center tap:min-h-[44px] text-sm transition-colors duration-150",
isCollapsed ? "justify-center px-1" : "pe-2",
openFromRow && "cursor-pointer select-none",
isVirtual
@@ -1135,7 +1134,11 @@ function SidebarImpl({
mvShell = false,
}: SidebarProps) {
const router = useRouter();
const { sidebarCollapsed, toggleSidebarCollapsed } = useUIStore(useShallow((state) => ({ sidebarCollapsed: state.sidebarCollapsed, toggleSidebarCollapsed: state.toggleSidebarCollapsed })));
// The rail is a desktop thing. Below that the sidebar is a drawer, opened to
// read the folder names, whatever the collapse button last said on a wider
// window. A compact desktop (or Pro pane) starts with the rail.
const { isMobile: isDrawer, isCompact } = useDeviceDetection();
const { sidebarCollapsed, toggleSidebarCollapsed } = useUIStore(useShallow((state) => ({ sidebarCollapsed: sidebarFolded(state, isCompact), toggleSidebarCollapsed: state.toggleSidebarCollapsed })));
// Mountain View's collapsed rail is a peek, not a commitment: putting the pointer on
// it slides the full list back out over the message list and takes it away
// again on leaving, so a folder two clicks deep costs no clicks at all. It
@@ -1143,11 +1146,6 @@ function SidebarImpl({
// list behind it.
const [railHovered, setRailHovered] = useState(false);
const hoverExpanded = mvShell && sidebarCollapsed && railHovered;
// The rail is a desktop thing. Below that the sidebar is a drawer, opened to
// read the folder names, whatever the collapse button last said on a wider window.
const paneWidth = usePaneSize();
const narrowWindow = useMediaQuery('(width < 64rem)');
const isDrawer = paneWidth !== null ? paneWidth < 1024 : narrowWindow;
const isCollapsed = sidebarCollapsed && !hoverExpanded && !isDrawer;
const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore(useShallow((state) => ({ primaryIdentity: state.primaryIdentity, activeAccountId: state.activeAccountId })));
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
@@ -1881,14 +1879,14 @@ function SidebarImpl({
// Mountain View's navigation sits on the canvas, with no rule between
// it and the content card.
mvShell ? "bg-[var(--mv-canvas)] border-transparent" : "bg-secondary border-border",
"max-lg:w-full",
"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 ? "lg:w-12" : "lg:w-full",
isCollapsed && !mvShell ? "desk:w-12" : "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 && "lg:absolute lg:inset-y-0 lg:start-0 lg:z-40 lg:shadow-xl",
hoverExpanded && "desk:absolute desk:inset-y-0 desk:start-0 desk:z-40 desk:shadow-xl",
className
)}
>
@@ -1903,7 +1901,7 @@ function SidebarImpl({
variant="ghost"
size="icon"
onClick={onSidebarClose}
className="lg:hidden h-9 w-9 flex-shrink-0"
className="desk:hidden h-9 w-9 flex-shrink-0"
aria-label={t("close")}
>
<X className="w-5 h-5" />
@@ -1913,7 +1911,7 @@ function SidebarImpl({
variant="ghost"
size="icon"
onClick={toggleSidebarCollapsed}
className="hidden lg:flex h-8 w-8 flex-shrink-0"
className="hidden desk:flex h-8 w-8 flex-shrink-0"
title={isCollapsed ? t("expand_tooltip") : t("collapse_tooltip")}
>
{isCollapsed ? <ChevronsRight className="w-4 h-4" /> : <ChevronsLeft className="w-4 h-4" />}
@@ -152,11 +152,11 @@ describe('mail page hand-off from the boot skeleton', () => {
const { getByTestId, container } = render(<MailApp linkSegments={['message', 'm9']} />);
await act(async () => {});
const reader = container.querySelector('[data-pane="reader"]')!;
expect(reader.className).not.toMatch(/(^|\s)md:hidden(\s|$)/);
expect(reader.className).not.toMatch(/(^|\s)desk:hidden(\s|$)/);
expect(getByTestId('viewer').hasAttribute('data-loading')).toBe(true);
// Not found: the reader goes away again and the list takes the page.
await act(async () => { content.resolve(null); });
await waitFor(() => expect(container.querySelector('[data-pane="reader"]')!.className).toMatch(/(^|\s)md:hidden(\s|$)/));
await waitFor(() => expect(container.querySelector('[data-pane="reader"]')!.className).toMatch(/(^|\s)desk:hidden(\s|$)/));
});
it('opens a linked message over the list on a phone and hides the bottom bar meanwhile', async () => {
@@ -165,7 +165,7 @@ describe('mail page hand-off from the boot skeleton', () => {
const { container, queryByTestId } = render(<MailApp linkSegments={['message', 'm9']} />);
await act(async () => {});
const reader = container.querySelector('[data-pane="reader"]')!;
expect(reader.className).not.toMatch(/(^|\s)max-md:hidden(\s|$)/);
expect(reader.className).not.toMatch(/(^|\s)max-desk:hidden(\s|$)/);
expect(queryByTestId('bottom-nav')).toBeNull();
});
@@ -1,5 +1,5 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { 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';
@@ -10,20 +10,29 @@ import { useThemeStore } from '@/stores/theme-store';
import { useUIStore } from '@/stores/ui-store';
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
// A phone on its side: the bottom bar takes a sixth of the window, so it goes
// over what fills the window. It stays wherever the list shows, or there is no
// way left to the other apps.
// A window between the phone and the full desktop (a half-screen window, a
// laptop at 150 %) is a compact desktop: the app rail at the side, the folder
// sidebar folded to its icons, and no phone tab bar (usability test
// 2026-11-04, mail-28 and settings-16).
const device = vi.hoisted(() => ({ current: { isMobile: false, isTablet: true, isDesktop: false }, short: true }));
const device = vi.hoisted(() => ({
current: { isMobile: false, isCompact: true, isDesktop: true },
splitFits: true,
}));
vi.mock('@/hooks/use-media-query', () => ({
vi.mock('@/hooks/use-media-query', async (importOriginal) => ({
...(await importOriginal<typeof import('@/hooks/use-media-query')>()),
useDeviceDetection: () => device.current,
useMediaQuery: (query: string) => query.includes('max-height') && device.short,
useIsDesktop: () => false,
useMediaQuery: () => false,
useIsDesktop: () => device.current.isDesktop,
useIsMobile: () => device.current.isMobile,
}));
vi.mock('@/hooks/use-split-panes-fit', () => ({ useSplitPanesFit: () => device.splitFits }));
vi.mock('@/components/layout/navigation-rail', () => ({
NavigationRail: ({ orientation = 'vertical' }: { orientation?: string }) => <nav data-rail={orientation}>rail</nav>,
}));
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => <nav>tab bar</nav> }));
vi.mock('@/components/layout/sidebar', () => ({
Sidebar: ({ onCompose }: { onCompose: () => void }) => <button onClick={onCompose}>stub compose</button>,
Sidebar: () => <button>stub folders</button>,
}));
vi.mock('@/components/email/email-composer', () => ({ EmailComposer: () => <p>stub composer</p> }));
vi.mock('@/components/email/email-viewer', () => ({ EmailViewer: () => <div>message viewer</div> }));
@@ -33,6 +42,7 @@ vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ Cale
vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null }));
vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => 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() }) }));
@@ -52,9 +62,13 @@ 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 full = { isMobile: false, isCompact: false, isDesktop: true };
const phone = { isMobile: true, isCompact: false, isDesktop: false };
beforeEach(() => {
device.current = { isMobile: false, isTablet: true, isDesktop: false };
device.short = true;
device.current = compact;
device.splitFits = true;
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) });
@@ -68,8 +82,12 @@ beforeEach(() => {
getClientForAccount: () => client as never,
getAllConnectedClients: () => new Map([['login-a', client]]) as never,
});
useSettingsStore.setState({ enableUnifiedMailbox: false, interfaceLayout: 'default', mailLayout: 'split' } as never);
useUIStore.setState({ activeView: 'list', tabletListVisible: true, sidebarOpen: false });
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,
@@ -88,42 +106,60 @@ afterEach(() => {
vi.unstubAllGlobals();
});
const tabBar = () => screen.queryByText('tab bar');
/** The element holding the folder sidebar (the one with its width). */
const folders = () => screen.getByText('stub folders').closest<HTMLElement>('[id]')!;
const rails = () => Array.from(document.querySelectorAll<HTMLElement>('[data-rail]')).map((el) => el.dataset.rail);
const listPane = (c: HTMLElement) => c.querySelector<HTMLElement>('section[aria-labelledby]')!;
describe('the bottom bar in a short window', () => {
it('stays with the list beside an open message', () => {
useEmailStore.setState({ selectedEmail: message } as never);
describe('the compact desktop', () => {
it('has the app rail at the side and no phone tab bar', () => {
render(<MailApp />);
expect(tabBar()).not.toBeNull();
expect(rails()).toEqual(['vertical']);
});
it('goes when the message fills the window', () => {
useUIStore.setState({ tabletListVisible: false });
useEmailStore.setState({ selectedEmail: message } as never);
it('folds the folder sidebar to its icons, in the flow', () => {
render(<MailApp />);
expect(tabBar()).toBeNull();
expect(folders().style.width).toBe('48px');
expect(folders().hasAttribute('inert')).toBe(false);
});
it('goes over a composer, and comes back without one', async () => {
it('opens the folders at their width when asked, without touching the full desktop choice', () => {
useUIStore.getState().toggleSidebarCollapsed();
render(<MailApp />);
expect(tabBar()).not.toBeNull();
fireEvent.click(screen.getByText('stub compose'));
await screen.findByText('stub composer', {}, { timeout: 5000 });
expect(tabBar()).toBeNull();
expect(folders().style.width).toBe('256px');
expect(useUIStore.getState().sidebarCollapsed).toBe(false);
});
it('stays in a tall window whatever is open', () => {
device.short = false;
useUIStore.setState({ tabletListVisible: false });
it('opens a message in the list\'s place when the window is too narrow for both', () => {
device.splitFits = false;
useEmailStore.setState({ selectedEmail: message } as never);
render(<MailApp />);
expect(tabBar()).not.toBeNull();
const { container } = render(<MailApp />);
expect(listPane(container).hasAttribute('inert')).toBe(true);
});
it('stays on a phone\'s list with a message kept selected behind it', () => {
device.current = { isMobile: true, isTablet: false, isDesktop: false };
it('keeps the list beside the message where both fit', () => {
useEmailStore.setState({ selectedEmail: message } as never);
const { container } = render(<MailApp />);
expect(listPane(container).hasAttribute('inert')).toBe(false);
});
});
describe('the full desktop', () => {
it('keeps the folders at their width', () => {
device.current = full;
useUIStore.setState({ isCompact: false });
render(<MailApp />);
expect(tabBar()).not.toBeNull();
expect(folders().style.width).toBe('256px');
expect(rails()).toEqual(['vertical']);
});
});
describe('the phone', () => {
it('keeps the tab bar and the folders in a closed drawer', () => {
device.current = phone;
useUIStore.setState({ isMobile: true, isCompact: false, isDesktop: false });
render(<MailApp />);
expect(rails()).toEqual(['horizontal']);
expect(folders().hasAttribute('inert')).toBe(true);
});
});
@@ -69,11 +69,14 @@ vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountI
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: () => {} }));
// A compact desktop: rail and folders beside the list, the composer in the pane.
const device = vi.hoisted(() => ({ current: { isMobile: false, isCompact: true, isDesktop: true } }));
vi.mock('@/hooks/use-media-query', () => ({
useDeviceDetection: () => ({ isMobile: false, isTablet: true, isDesktop: false }),
useDeviceDetection: () => device.current,
useMediaQuery: () => false,
useIsDesktop: () => false,
useIsDesktop: () => true,
atLeast: (px: number) => `(width >= ${px}px)`,
PHONE_BELOW: 600,
}));
vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
vi.mock('@/components/email/email-list', () => ({ EmailList: () => <div>message list</div> }));
@@ -208,8 +211,11 @@ describe('a composer coming back from its own window', () => {
expect(seen.sourceEmailId).toBe('a');
});
// Mountain View docks the composer of a desktop in its own window; on a
// phone it fills the reading pane as in the default layout.
it('under Mountain View too', async () => {
useSettingsStore.setState({ interfaceLayout: 'mountain-view' } as never);
device.current = { isMobile: true, isCompact: false, isDesktop: false };
render(<MailApp />);
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 50)); });
const answer = comeBack('r1');
@@ -217,6 +223,7 @@ describe('a composer coming back from its own window', () => {
await waitFor(() => expect(answer).toHaveBeenCalledWith('shown'));
expect(seen.sourceEmailId).toBe('a');
useSettingsStore.setState({ interfaceLayout: 'default' } as never);
device.current = { isMobile: false, isCompact: true, isDesktop: true };
});
it('under Pro it becomes a tab that answers its own message, not the one selected in the list', async () => {
@@ -14,7 +14,7 @@ import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
// and, with messages selected, the selection's count and buttons (mail-10).
// The New folder dialogs are titled without the menu item's "…" (mail-29).
const device = vi.hoisted(() => ({ current: { isMobile: false, isTablet: false, isDesktop: true } }));
const device = vi.hoisted(() => ({ current: { isMobile: false, isCompact: false, isDesktop: true } }));
const listProps = vi.hoisted(() => ({ host: undefined as HTMLElement | null | undefined }));
vi.mock('@/hooks/use-media-query', () => ({
@@ -71,7 +71,7 @@ const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 1, unrea
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;
beforeEach(() => {
device.current = { isMobile: false, isTablet: false, isDesktop: true };
device.current = { isMobile: false, isCompact: false, isDesktop: true };
listProps.host = undefined;
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
@@ -87,7 +87,7 @@ beforeEach(() => {
getAllConnectedClients: () => new Map([['login-a', client]]) as never,
});
useSettingsStore.setState({ enableUnifiedMailbox: false, interfaceLayout: 'default', mailLayout: 'split' } as never);
useUIStore.setState({ activeView: 'list', tabletListVisible: true, sidebarOpen: false });
useUIStore.setState({ activeView: 'list', sidebarOpen: false });
useEmailStore.setState({
emails: [message], selectedEmail: null, selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: [inbox],
viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
@@ -121,7 +121,7 @@ describe('the first-run tips', () => {
});
it('stay above the list on a phone', () => {
device.current = { isMobile: true, isTablet: false, isDesktop: false };
device.current = { isMobile: true, isCompact: false, isDesktop: false };
render(<MailApp />);
expect(screen.getByTestId('welcome-list')).toBeTruthy();
expect(screen.queryByTestId('welcome-pane')).toBeNull();
@@ -14,7 +14,7 @@ import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
// depending on the width, and React mounts it anew in each. What was typed has
// to come along, or a narrowed window silently empties the message.
const device = vi.hoisted(() => ({ current: { isMobile: false, isTablet: false, isDesktop: true } }));
const device = vi.hoisted(() => ({ current: { isMobile: false, isCompact: false, isDesktop: true } }));
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
vi.mock('@/components/layout/sidebar', () => ({
@@ -76,7 +76,7 @@ const { MailApp } = await import('../mail-app');
const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 0, unreadEmails: 0 } as Mailbox;
beforeEach(() => {
device.current = { isMobile: false, isTablet: false, isDesktop: true };
device.current = { isMobile: false, isCompact: false, isDesktop: true };
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) });
@@ -113,17 +113,19 @@ describe('moving the Mountain View composer between layouts', () => {
fireEvent.click(screen.getByText('stub compose'));
fireEvent.change(await screen.findByLabelText('stub subject'), { target: { value: 'Still here' } });
device.current = { isMobile: false, isTablet: true, isDesktop: false };
device.current = { isMobile: true, isCompact: false, isDesktop: false };
rerender(<MailApp />);
await waitFor(() => expect(screen.getByLabelText<HTMLInputElement>('stub subject').value).toBe('Still here'));
device.current = { isMobile: false, isTablet: false, isDesktop: true };
device.current = { isMobile: false, isCompact: false, isDesktop: true };
rerender(<MailApp />);
await waitFor(() => expect(screen.getByLabelText<HTMLInputElement>('stub subject').value).toBe('Still here'));
});
const setWidth = (rerender: (ui: React.ReactElement) => void, width: 'desktop' | 'tablet') => {
device.current = { isMobile: false, isTablet: width === 'tablet', isDesktop: width === 'desktop' };
// Mountain View floats its composer on every desktop, compact included; a
// phone has it in the reading pane.
const setWidth = (rerender: (ui: React.ReactElement) => void, width: 'desktop' | 'phone') => {
device.current = { isMobile: width === 'phone', isCompact: false, isDesktop: width === 'desktop' };
rerender(<MailApp />);
};
const subject = () => screen.getByLabelText<HTMLInputElement>('stub subject');
@@ -134,13 +136,13 @@ describe('moving the Mountain View composer between layouts', () => {
await screen.findByLabelText('stub subject');
// Clean, so this move hands nothing over.
setWidth(rerender, 'tablet');
setWidth(rerender, 'phone');
fireEvent.change(await screen.findByLabelText('stub subject'), { target: { value: 'Typed in the pane' } });
setWidth(rerender, 'desktop');
await waitFor(() => expect(subject().value).toBe('Typed in the pane'));
setWidth(rerender, 'tablet');
setWidth(rerender, 'phone');
await waitFor(() => expect(subject().value).toBe('Typed in the pane'));
});
@@ -149,25 +151,25 @@ describe('moving the Mountain View composer between layouts', () => {
fireEvent.click(screen.getByText('stub compose'));
fireEvent.change(await screen.findByLabelText('stub subject'), { target: { value: 'First' } });
setWidth(rerender, 'tablet');
setWidth(rerender, 'phone');
await waitFor(() => expect(subject().value).toBe('First'));
setWidth(rerender, 'desktop');
await waitFor(() => expect(subject().value).toBe('First'));
fireEvent.change(subject(), { target: { value: 'First and more' } });
setWidth(rerender, 'tablet');
setWidth(rerender, 'phone');
await waitFor(() => expect(subject().value).toBe('First and more'));
});
});
describe('a composer opened from the phone and tablet drawer', () => {
describe('a composer opened from the phone drawer', () => {
const openFromInsideDrawer = () => window.dispatchEvent(new CustomEvent('bulwark:mailto', {
detail: { to: ['bob@example.org'], cc: [], bcc: [], subject: '', body: '' },
cancelable: true,
}));
beforeEach(() => {
device.current = { isMobile: false, isTablet: true, isDesktop: false };
device.current = { isMobile: true, isCompact: false, isDesktop: false };
useSettingsStore.setState({ interfaceLayout: 'default' } as never);
useUIStore.setState({ sidebarOpen: true });
});
@@ -12,8 +12,9 @@ import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
// The `x` shortcut expands the selected conversation and marks it read. It has
// to follow markAsReadDelay like the reading pane, and a delayed mark must not
// land once the user has moved on (`j`/`k`) to a message outside it. The
// default layout shows the conversation in the reading pane instead, so this
// runs where the list still unfolds it: Mountain View on a tablet.
// default layout shows the conversation in the reading pane instead, and
// Mountain View's desktop shell has Gmail's `x` (select), so this runs where
// the list still unfolds it: Mountain View on a phone with a keyboard.
vi.mock('@/components/email/email-composer', () => ({ EmailComposer: () => null }));
vi.mock('@/components/email/email-viewer', () => ({ EmailViewer: () => null }));
@@ -34,10 +35,10 @@ vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMa
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
vi.mock('@/hooks/use-media-query', () => ({
useDeviceDetection: () => ({ isMobile: false, isTablet: true, isDesktop: false }),
useDeviceDetection: () => ({ isMobile: true, isCompact: false, isDesktop: false }),
useMediaQuery: () => false,
useIsDesktop: () => true,
useIsMobile: () => false,
useIsDesktop: () => false,
useIsMobile: () => true,
}));
vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
vi.mock('@/components/email/email-list', () => ({ EmailList: () => <div>message list</div> }));
@@ -43,7 +43,7 @@ vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMa
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
vi.mock('@/hooks/use-media-query', () => ({
useDeviceDetection: () => ({ isMobile: true, isTablet: false, isDesktop: false }),
useDeviceDetection: () => ({ isMobile: true, isCompact: false, isDesktop: false }),
useMediaQuery: () => false,
useIsDesktop: () => false,
}));
@@ -89,7 +89,7 @@ afterEach(() => {
});
/** On a phone only one of list and reading pane shows; the other gets max-md:hidden. */
const listHidden = () => screen.getByText('message list').closest('.max-md\\:hidden') !== null;
const listHidden = () => screen.getByText('message list').closest('.max-desk\\:hidden') !== null;
describe('sending from a phone', () => {
it('goes back to the message list after sending a new message', async () => {
@@ -67,9 +67,9 @@ vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountI
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: () => {} }));
const device = vi.hoisted(() => ({ isMobile: true, isTablet: false }));
const device = vi.hoisted(() => ({ isMobile: true, isCompact: false }));
vi.mock('@/hooks/use-media-query', () => ({
useDeviceDetection: () => ({ isMobile: device.isMobile, isTablet: device.isTablet, isDesktop: !device.isMobile && !device.isTablet }),
useDeviceDetection: () => ({ isMobile: device.isMobile, isCompact: device.isCompact, isDesktop: !device.isMobile }),
useMediaQuery: () => false,
useIsDesktop: () => false,
}));
@@ -112,13 +112,13 @@ beforeEach(() => {
});
afterEach(() => {
device.isMobile = true;
device.isTablet = false;
device.isCompact = false;
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
/** On a phone only one of list and reading pane shows; the other gets max-md:hidden. */
const listHidden = () => screen.getByText('message list').closest('.max-md\\:hidden') !== null;
/** On a phone only one of list and reading pane shows; the other gets max-desk:hidden. */
const listHidden = () => screen.getByText('message list').closest('.max-desk\\:hidden') !== null;
describe('Mail after the tour on a phone', () => {
it('shows the list when it opens with the reader left on and nothing to read', () => {
@@ -138,16 +138,17 @@ describe('Mail after the tour on a phone', () => {
expect(document.querySelector('[data-tour="compose-button"]')).toBeNull();
});
// mail-27: a tablet drew the header's ⋮ and the list's ⋮ one above the
// other, with the same entries; the header's one has them all now.
it('draws the list ⋮ on a desktop but not on a tablet, under the header ⋮', () => {
// mail-27 kept the tablet from drawing the header's ⋮ and the list's ⋮ one
// above the other. The tablet widths are a compact desktop now, which has
// no phone header: the list's ⋮ is the only one there.
it('draws the list ⋮ on a desktop, compact or full', () => {
device.isMobile = false;
const view = render(<MailApp />);
expect(screen.queryByTestId('mail-folder-menu')).not.toBeNull();
device.isTablet = true;
device.isCompact = true;
view.rerender(<MailApp />);
expect(screen.queryByTestId('mail-folder-menu')).toBeNull();
expect(screen.queryByTestId('mail-folder-menu')).not.toBeNull();
});
it('keeps an open composer in view when the window narrows to a phone', async () => {
+65 -125
View File
@@ -54,12 +54,12 @@ import { CalendarEventNotificationToaster } from "@/components/layout/calendar-e
import { useAuthStore, redirectToLogin, saveRedirectAfterLogin, canEnterLoginInPlace } from '@/stores/auth-store';
import { useRouter } from '@/i18n/navigation';
import { useSettingsStore } from "@/stores/settings-store";
import { useEffectiveMailLayout } from "@/hooks/use-effective-mail-layout";
import { useDrawnMailLayout } from "@/hooks/use-effective-mail-layout";
import { useOfflineStore } from "@/lib/offline/state";
import { useContactStore } from "@/stores/contact-store";
import { useIdentityStore } from "@/stores/identity-store";
import { useUIStore, 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, useDeviceDetection, useMediaQuery } from "@/hooks/use-media-query";
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 { usePaneSize } from "@/hooks/use-pane-size";
import { useIsFocusedProTab, usePaneId } from "@/hooks/use-pane-context";
import { useKeyboardShortcuts, type GoToMailboxTarget } from "@/hooks/use-keyboard-shortcuts";
@@ -92,7 +92,7 @@ import {
} from "@/components/error";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { ScheduleSendDialog } from "@/components/email/schedule-send-dialog";
import { MailFolderMenu, MailFolderMenuItems, MailSortSubMenu } from "@/components/email/mail-list-menus";
import { MailFolderMenu, MailFolderMenuItems } from "@/components/email/mail-list-menus";
import { ContextMenuItem } from "@/components/ui/context-menu";
import { PromptDialog } from "@/components/ui/prompt-dialog";
import { DragDropProvider } from "@/contexts/drag-drop-context";
@@ -272,7 +272,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// The list is the category tabs' panel while the tabs are shown.
const messageListPanelId = useId();
const messageListPanelProps = useMessageListPanelProps(messageListPanelId);
const mailLayout = useEffectiveMailLayout();
const mailLayout = useDrawnMailLayout();
const interfaceLayout = useSettingsStore((state) => state.interfaceLayout);
// Phones present search full-screen from the header field rather than
// giving it a permanent second bar under the header.
@@ -413,9 +413,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Window, selection and navigation events for plugins are fired app-wide
// by components/providers/plugin-app-events.tsx.
// Mobile/tablet responsive hooks
const { isMobile, isTablet } = useDeviceDetection();
const isShortWindow = useMediaQuery('(max-height: 32rem)');
// Phone, compact desktop or desktop (classifyWidth). A phone on its side is
// a compact desktop: the rail at the side costs no height.
const { isMobile, isCompact } = useDeviceDetection();
const isEmbedded = useIsEmbedded();
// A message or conversation link shows the reader from the first frame, as
// the page skeleton draws it, in its loading state until the link has been
@@ -436,7 +436,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const [cursorOnly, setCursorOnly] = useState(false);
// Desktop lifts search into a global header. Mobile keeps its native
// one-pane flow, with Mountain View's own app chrome.
const mvShell = interfaceLayout === 'mountain-view' && !isMobile && !isTablet && !isEmbedded;
const mvShell = interfaceLayout === 'mountain-view' && !isMobile && !isEmbedded;
const mvMobile = interfaceLayout === 'mountain-view' && isMobile && !isEmbedded;
// Mountain View reads a thread as one conversation in the viewer, on the
// phone as on the desktop, so both show the same order and details. (#1224)
@@ -454,13 +454,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// after a split closes and reopens at a different size.
const clampEmailListWidth = (width: number) =>
paneWidth !== null ? Math.min(width, Math.max(220, Math.floor(paneWidth * 0.55))) : width;
const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, emailListHeight, setSidebarWidth, setEmailListWidth, setEmailListHeight, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth, resetEmailListHeight } = useUIStore(useShallow((state) => ({
const { activeView, sidebarOpen, setSidebarOpen, setActiveView, sidebarWidth, emailListWidth, emailListHeight, setSidebarWidth, setEmailListWidth, setEmailListHeight, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth, resetEmailListHeight } = useUIStore(useShallow((state) => ({
activeView: state.activeView,
sidebarOpen: state.sidebarOpen,
setSidebarOpen: state.setSidebarOpen,
setActiveView: state.setActiveView,
tabletListVisible: state.tabletListVisible,
setTabletListVisible: state.setTabletListVisible,
sidebarWidth: state.sidebarWidth,
emailListWidth: state.emailListWidth,
emailListHeight: state.emailListHeight,
@@ -468,7 +466,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
setEmailListWidth: state.setEmailListWidth,
setEmailListHeight: state.setEmailListHeight,
persistColumnWidths: state.persistColumnWidths,
sidebarCollapsed: state.sidebarCollapsed,
// On a compact desktop the folders show as icons until opened.
sidebarCollapsed: sidebarFolded(state, isCompact),
resetSidebarWidth: state.resetSidebarWidth,
resetEmailListWidth: state.resetEmailListWidth,
resetEmailListHeight: state.resetEmailListHeight,
@@ -1034,9 +1033,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
if (isMobile) {
setActiveView("list");
}
if (isTablet) {
setTabletListVisible(true);
}
},
onReply: () => {
if (isScheduledView) return;
@@ -1280,7 +1276,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
}), [activeEmails, selectedEmail, client, selectedMailbox, isMobile, isTablet, selectedEmailKeys, mailboxes, isScheduledView, mvShell, cursorOnly, showCrossStarred, isUnifiedView, unifiedRole, archiveActionEnabled, deepLinkPane]);
}), [activeEmails, selectedEmail, client, selectedMailbox, isMobile, selectedEmailKeys, mailboxes, isScheduledView, mvShell, cursorOnly, showCrossStarred, isUnifiedView, unifiedRole, archiveActionEnabled, deepLinkPane]);
// Initialize keyboard shortcuts
// Mountain View keeps the ticked rows while a message is read (#1231). The
@@ -2170,7 +2166,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
setFullscreenReading(true);
}
if (isMobile) setActiveView('viewer');
if (isTablet) setTabletListVisible(false);
if (link.reply) {
// A notification's Reply. The branch above may have skipped
// selecting, and the reply has to wait for the message to be open.
@@ -2210,7 +2205,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// A shared account's conversation needs its folder open: the open
// message is acted on in the account of the folder on screen.
if (email && link.ownerAccountId) await showInOwningFolder(activeClient, email);
if (isTablet) setTabletListVisible(false);
}
} finally {
setLoadingEmail(false);
@@ -2341,12 +2335,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedEmail?.id, selectedEmail?.sourceClientAccountId, selectedEmail?.sourceAccountId]);
// Opening a message hides the list on tablet, but plenty of paths clear the
// reading pane without going through a "back" handler (delete/move/archive,
// a failed fetch, history restore, sending the open draft). Bring the list
// back once there is nothing left to read, so tablet never ends up on an
// empty reading pane with only the bottom bar.
const readingPaneEmpty = !showComposer && !conversationThread && !selectedEmail && !isLoadingEmail;
// The phone conversation's Report spam / Not spam, as in the list: not on
// your own outgoing mail.
const conversationRole = mailboxes.find(m => m.id === selectedMailbox)?.role ?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
@@ -2365,9 +2353,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const folderMenuMarkRead = useCallback(() => folderMenuLatest.current.markRead(), []);
const folderMenuMarkAllRead = useCallback(() => folderMenuLatest.current.markAllRead(), []);
const folderMenuEmpty = useCallback(() => folderMenuLatest.current.empty(), []);
useEffect(() => {
if (readingPaneEmpty && !tabletListVisible) setTabletListVisible(true);
}, [readingPaneEmpty, tabletListVisible, setTabletListVisible]);
// Handle mark-as-read with delay based on settings
useEffect(() => {
@@ -2437,14 +2422,14 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// A composer opened from the drawer (a plugin's birthday greeting, a contact)
// must not stay under it.
useEffect(() => {
if (showComposer && (isMobile || isTablet)) setSidebarOpen(false);
if (showComposer && isMobile) setSidebarOpen(false);
// Only when a composer opens: the drawer may be opened over one later.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showComposer]);
// Lock body scroll when sidebar is open on mobile/tablet
// Lock body scroll when sidebar is open on a phone
useEffect(() => {
if ((isMobile || isTablet) && sidebarOpen) {
if (isMobile && sidebarOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
@@ -2452,7 +2437,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
return () => {
document.body.style.overflow = '';
};
}, [isMobile, isTablet, sidebarOpen]);
}, [isMobile, sidebarOpen]);
// Move focus into the sidebar when it opens as an overlay, and back to the
// toggle when it closes, so keyboard and screen reader users end up where the
@@ -2464,7 +2449,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const sidebarContainerRef = useRef<HTMLDivElement>(null);
const sidebarWasOpen = useRef(false);
useEffect(() => {
const isOverlay = isMobile || isTablet;
const isOverlay = isMobile;
if (isOverlay && sidebarOpen) {
sidebarWasOpen.current = true;
const frame = requestAnimationFrame(() => {
@@ -2482,7 +2467,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const active = document.activeElement;
if (active && active !== document.body) return;
appRootRef.current?.querySelector<HTMLElement>('[data-sidebar-toggle]')?.focus();
}, [isMobile, isTablet, sidebarOpen]);
}, [isMobile, sidebarOpen]);
// The message a composer session answers, pinned when the session starts.
// Mountain View keeps its compose window open while other mail is read
@@ -2933,8 +2918,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const wasOpen = useEmailStore.getState().selectedEmail?.id === email.id;
const restored = await cancelScheduledForEdit(email);
if (restored === undefined) return;
// The store closed it in the reader; on a phone or tablet the list comes back.
if (wasOpen && (isMobile || isTablet)) handleMobileBack();
// The store closed it in the reader; on a phone the list comes back.
if (wasOpen && isMobile) handleMobileBack();
toast.success(
tViewer('send_cancelled'),
t('notifications.moved_to_mailbox', { mailbox: t('sidebar.mailboxes.drafts') }),
@@ -3356,7 +3341,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Outlook. With the conversation in the reading pane that is the next
// conversation down the list (the one above at the end), as its row opens it.
const returnsToList = () => returnToListAfterAction(
!fullscreenReading && listBesideReader({ mailLayout, interfaceLayout, isMobile, isTablet }),
!fullscreenReading && listBesideReader({ mailLayout, interfaceLayout, isMobile }),
);
const leaveIfOpen = (email: Email) => {
const { selectedEmail: open, emails: rows, listRows } = useEmailStore.getState();
@@ -3755,10 +3740,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
setSidebarOpen(false);
setActiveView("list");
}
if (isTablet) {
setTabletListVisible(true);
setSidebarOpen(false);
}
if (viewingClient) {
// Keep an active search applied when switching folders (#553), unless
// the user opted to reset search on folder change. The store actions
@@ -3809,10 +3790,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
setSidebarOpen(false);
setActiveView("list");
}
if (isTablet) {
setTabletListVisible(true);
setSidebarOpen(false);
}
if (client) await fetchScheduledEmails(client);
return;
}
@@ -3829,10 +3806,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
setSidebarOpen(false);
setActiveView("list");
}
if (isTablet) {
setTabletListVisible(true);
setSidebarOpen(false);
}
const isSameView = captureViewToken();
const populated = await buildPopulatedUnifiedAccounts();
@@ -3868,10 +3841,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
setSidebarOpen(false);
setActiveView("list");
}
if (isTablet) {
setTabletListVisible(true);
setSidebarOpen(false);
}
const isSameView = captureViewToken();
const populated = await buildPopulatedUnifiedAccounts();
@@ -3907,12 +3876,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
setActiveView("list");
}
// On tablet, show the list again
if (isTablet) {
setTabletListVisible(true);
setSidebarOpen(false);
}
if (client) {
// If there's an active search, re-run it in the new mailbox unless the
// user opted to reset search on folder change. Advanced filters must go
@@ -3940,12 +3903,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
setActiveView("list");
}
// On tablet, show the list again
if (isTablet) {
setTabletListVisible(true);
setSidebarOpen(false);
}
if (client) {
await fetchEmails(client);
}
@@ -4869,7 +4826,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
: "Inbox";
})();
const isFocusedMailLayout = mailLayout === 'focus';
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile;
// Under Mountain View on desktop the composer floats over the list in a
// docked window instead of taking the reading pane.
const mvInlineReply = mvShell && showComposer && !isEmbedded && !mvReplyPoppedOut
@@ -4881,13 +4838,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|| Boolean(selectedEmail && !(mvShell && cursorOnly)) || deepLinkPane !== null;
// A linked message on a phone opens over the list, as one picked there does.
const mobileView = deepLinkPane !== null && isMobile ? 'viewer' : activeView;
const shouldCollapseListPane = (isTablet && ((!tabletListVisible && !readingPaneEmpty) || deepLinkPane !== null)) || (!isMobile && isFocusedMailLayout && hasViewerContent);
const shouldCollapseListPane = !isMobile && isFocusedMailLayout && hasViewerContent;
const shouldHideViewerPane = !isMobile && !hasViewerContent && isFocusedMailLayout;
// A phone on its side counts as a tablet, which keeps the bottom bar. In a window
// this short the bar takes a sixth of it and leaves a few lines, so it goes where
// a phone held upright hides it too: over a composer, or a message filling the
// window. With the list beside the message it is the only way to the other apps.
const shortWindowHidesTabBar = isShortWindow && ((showComposer && !floatingComposer) || (hasViewerContent && shouldCollapseListPane));
const shouldHideHorizontalViewerPane = isHorizontalMailLayout && !hasViewerContent;
// Where the empty reading pane shows beside the list, it holds the
// first-run tips (mail-15) and a selection's actions (mail-10).
@@ -4965,11 +4917,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
setActiveView("viewer");
}
// On tablet, hide the list to maximize viewer space
if (isTablet) {
setTabletListVisible(false);
}
// Fetch the full content
try {
const source = resolveEmailBlobContext(listEmail ?? null, client);
@@ -5060,9 +5007,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
setConversationEmails([]);
}
selectEmail(null);
if (isTablet) {
setTabletListVisible(true);
}
setActiveView("list");
};
@@ -5243,7 +5187,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
// The window widened into Pro's width, which takes this page over:
// the draft goes on in a compose tab.
if (!isEmbedded && useSettingsStore.getState().proInterface && window.matchMedia(atLeast(1024)).matches) {
if (!isEmbedded && useSettingsStore.getState().proInterface && window.matchMedia(atLeast(PHONE_BELOW)).matches) {
useProTabStore.getState().openComposeTab({
sessionId: composerSessionId + 1,
mode: data.mode,
@@ -5360,7 +5304,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
<div className="flex flex-1 overflow-hidden">
{/* Desktop Navigation Rail (hidden when embedded inside Pro shell, and
under Mountain View, whose header carries the app grid instead) */}
{!isMobile && !isTablet && !isEmbedded && !mvShell && (
{!isMobile && !isEmbedded && !mvShell && (
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
<NavigationRail
collapsed
@@ -5382,14 +5326,14 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
{/* Mobile/Tablet Sidebar Overlay Backdrop.
When embedded in a Pro pane the viewport is desktop-wide, so the
`lg:hidden` viewport-variant alone wouldn't gate this overlay;
`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 || isTablet) && sidebarOpen && !inlineApp && (
{isMobile && sidebarOpen && !inlineApp && (
<div
className={cn(
"inset-0 bg-black/50 z-40",
isEmbedded ? "absolute" : "fixed lg:hidden"
isEmbedded ? "absolute" : "fixed desk:hidden"
)}
onClick={() => setSidebarOpen(false)}
/>
@@ -5404,8 +5348,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
/* On mobile/tablet the sidebar is only slid off-screen, so without
`inert` it stays in the accessibility tree and screen readers can
reach a menu the Toggle menu button reports as collapsed (#721). */
inert={(isMobile || isTablet) && !sidebarOpen}
onKeyDown={(isMobile || isTablet) && sidebarOpen ? (e) => {
inert={isMobile && !sidebarOpen}
onKeyDown={isMobile && sidebarOpen ? (e) => {
if (e.key === 'Escape') setSidebarOpen(false);
} : undefined}
className={cn(
@@ -5413,10 +5357,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// The z-index is for the phone drawer. On a desktop under Mountain
// View it only has to lift the hover peek over the list: at 40 the
// compose window, drawn later at the same level, stays on top.
mvShell && "lg:z-40",
mvShell && "desk:z-40",
!isResizing && "transition-[width] duration-300",
isEmbedded
? (isMobile || isTablet
? (isMobile
? cn(
"absolute inset-y-0 start-0 w-72 pt-[var(--app-top-inset)]",
"transform transition-transform duration-300 ease-in-out",
@@ -5429,16 +5373,16 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Mobile/Tablet: fixed overlay
// start-0 rather than left-0: in RTL the drawer belongs on
// the right, matching the hamburger that opens it.
"max-lg:fixed max-lg:inset-y-0 max-lg:start-0 max-lg:w-72 max-lg:pt-[var(--app-top-inset)]",
"max-lg:transform max-lg:transition-transform max-lg:duration-300 max-lg:ease-in-out",
!sidebarOpen && "max-lg:-translate-x-full max-lg:rtl:translate-x-full",
"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: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
"lg:relative lg:translate-x-0"
"desk:relative desk:translate-x-0"
),
inlineApp && "hidden",
fullscreenReading && "hidden"
)}
style={!isMobile && !isTablet ? { width: sidebarCollapsed ? (mvShell ? 72 : 48) : sidebarWidth } : undefined}
style={!isMobile ? { width: sidebarCollapsed ? (mvShell ? 72 : 48) : sidebarWidth } : undefined}
>
<ErrorBoundary fallback={SidebarErrorFallback}>
<Sidebar
@@ -5466,7 +5410,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
{/* Sidebar resize handle (desktop only, hidden when collapsed, and
absent under Mountain View, whose navigation has a fixed width) */}
{!isMobile && !isTablet && !sidebarCollapsed && !inlineApp && !fullscreenReading && !mvShell && (
{!isMobile && !sidebarCollapsed && !inlineApp && !fullscreenReading && !mvShell && (
<ResizeHandle
pane="sidebar"
value={sidebarWidth}
@@ -5489,7 +5433,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
data-mv-card={mvShell ? "" : undefined}
className={cn("flex flex-col flex-1 min-w-0", mvShell ? "mb-4 me-4" : "h-full", inlineApp && "hidden")}
>
<div className={cn("relative flex flex-1 min-h-0", isHorizontalMailLayout && "md:flex-col")}>
<div className={cn("relative flex flex-1 min-h-0", isHorizontalMailLayout && "desk:flex-col")}>
{/* Email List - full width on mobile, fixed width/height on tablet/desktop */}
<section
ref={listPaneRef}
@@ -5500,23 +5444,23 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
className={cn(
"relative flex flex-col bg-background min-w-0",
isHorizontalMailLayout
? (shouldHideHorizontalViewerPane ? "md:w-full md:min-h-0" : "md:w-full md:h-auto")
? (shouldHideHorizontalViewerPane ? "desk:w-full desk:min-h-0" : "desk:w-full desk:h-auto")
: "h-full border-e border-border",
// Mobile: full width, hidden when viewing email
"max-md:flex-1 max-md:border-e-0 max-md:border-b-0",
isMobile && mobileView !== "list" && "max-md:hidden",
"max-desk:flex-1 max-desk:border-e-0 max-desk:border-b-0",
isMobile && mobileView !== "list" && "max-desk:hidden",
// Pane-mobile (narrow Pro pane on a wide viewport): the
// `max-md:` viewport classes above never apply, so mirror
// `max-desk:` viewport classes above never apply, so mirror
// them with pane-scoped equivalents.
paneMobile && "flex-1 border-e-0 border-b-0",
paneMobile && activeView !== "list" && "hidden",
// Tablet/Desktop: fixed width with collapse animation
!isHorizontalMailLayout && (shouldHideViewerPane ? "md:flex-1 md:border-e-0" : "md:flex-shrink-0"),
isHorizontalMailLayout && (shouldHideHorizontalViewerPane ? "md:flex-1" : "md:flex-shrink-0"),
isHorizontalMailLayout && !shouldHideHorizontalViewerPane && "md:shadow-[0_8px_12px_-6px_rgba(0,0,0,0.18)] dark:md:shadow-[0_8px_14px_-6px_rgba(0,0,0,0.55)]",
!isHorizontalMailLayout && "md:shadow-sm",
!isHorizontalMailLayout && (shouldHideViewerPane ? "desk:flex-1 desk:border-e-0" : "desk:flex-shrink-0"),
isHorizontalMailLayout && (shouldHideHorizontalViewerPane ? "desk:flex-1" : "desk:flex-shrink-0"),
isHorizontalMailLayout && !shouldHideHorizontalViewerPane && "desk:shadow-[0_8px_12px_-6px_rgba(0,0,0,0.18)] dark:desk:shadow-[0_8px_14px_-6px_rgba(0,0,0,0.55)]",
!isHorizontalMailLayout && "desk:shadow-sm",
!isResizing && "transition-all duration-200 ease-out",
shouldCollapseListPane && "md:w-0 md:opacity-0 md:overflow-hidden md:border-e-0",
shouldCollapseListPane && "desk:w-0 desk:opacity-0 desk:overflow-hidden desk:border-e-0",
fullscreenReading && "hidden"
)}
style={
@@ -5557,8 +5501,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
disabled={!client || isManualRefreshing}
onClick={() => { close(); void handleManualRefresh(); }}
/>
{/* A tablet shows the list's own ⋮ nowhere else (see below). */}
{isTablet && <MailSortSubMenu folderRole={conversationRole} onPicked={close} />}
<MailFolderMenuItems
onMarkFolderRead={selectedMailbox ? () => handleMarkFolderRead(selectedMailbox) : undefined}
onMarkAllFoldersRead={handleMarkAllFoldersRead}
@@ -5664,10 +5606,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
</ToolbarSearchAction>
}
/>
{/* The folder's actions, as the Mountain View list ⋮ has them. A
tablet has them in the header's ⋮ right above, and two ⋮
stacked read as one menu twice (mail-27). */}
{!isTablet && <MailFolderMenu
{/* The folder's actions, as the Mountain View list ⋮ has them. */}
<MailFolderMenu
disabled={isScheduledView}
folderRole={conversationRole}
onRefresh={handleManualRefresh}
@@ -5675,7 +5615,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
onMarkFolderRead={selectedMailbox ? folderMenuMarkRead : undefined}
onMarkAllFoldersRead={folderMenuMarkAllRead}
onEmptyFolder={folderIsEmptyable && selectedMailbox ? folderMenuEmpty : undefined}
/>}
/>
{/* Plugin buttons (top-bar-actions). On phones they sit in
the header's overflow menu instead (MobileHeader). */}
{!isMobile && (
@@ -5886,7 +5826,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
{/* Floating Compose Button - on desktop the folder sidebar has
Compose at its top instead (and Mountain View its rail), so the
button no longer sits on the list's rows. */}
{!mvShell && (isMobile || isTablet || isEmbedded) && <Button
{!mvShell && (isMobile || isEmbedded) && <Button
onClick={() => {
void withComposeGate('compose', {}, () => {
startFreshComposerSession();
@@ -5914,7 +5854,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
</section>
{/* Email list resize handle (desktop only) */}
{!isMobile && !isTablet && !isFocusedMailLayout && !isHorizontalMailLayout && !shouldHideViewerPane && !fullscreenReading && (
{!isMobile && !isFocusedMailLayout && !isHorizontalMailLayout && !shouldHideViewerPane && !fullscreenReading && (
<ResizeHandle
pane="messageList"
value={emailListWidth}
@@ -5926,7 +5866,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
onDoubleClick={resetEmailListWidth}
/>
)}
{!isMobile && !isTablet && isHorizontalMailLayout && !shouldHideHorizontalViewerPane && !fullscreenReading && (
{!isMobile && isHorizontalMailLayout && !shouldHideHorizontalViewerPane && !fullscreenReading && (
<ResizeHandle
orientation="horizontal"
pane="messageList"
@@ -5948,17 +5888,17 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
"flex flex-col bg-background flex-1 min-w-0",
isHorizontalMailLayout ? "min-h-0" : "h-full",
// Mobile: full screen overlay when active
"max-md:fixed max-md:inset-0 max-md:z-30",
"max-md:h-full max-md:pt-[var(--app-top-inset)]",
isMobile && mobileView !== "viewer" && "max-md:hidden",
"max-desk:fixed max-desk:inset-0 max-desk:z-30",
"max-desk:h-full max-desk:pt-[var(--app-top-inset)]",
isMobile && mobileView !== "viewer" && "max-desk:hidden",
// Tablet/Desktop: relative. Pane-mobile instead overlays the
// pane (absolute within the relative content row above) - the
// viewport `max-md:` classes never fire there, and `fixed`
// viewport `max-desk:` classes never fire there, and `fixed`
// would escape the pane.
!paneMobile && "md:relative",
!paneMobile && "desk:relative",
paneMobile && (activeView === "viewer" ? "absolute inset-0 z-30 h-full" : "hidden"),
shouldHideViewerPane && "md:hidden",
shouldHideHorizontalViewerPane && "md:hidden"
shouldHideViewerPane && "desk:hidden",
shouldHideHorizontalViewerPane && "desk:hidden"
)}
>
{/* Inline Composer - shown in viewer pane.
@@ -6196,8 +6136,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
</section>
</div>
{/* Bottom Navigation - mobile and tablet (hidden when embedded) */}
{(isMobile || isTablet) && mobileView !== "viewer" && !shortWindowHidesTabBar && !isEmbedded && (
{/* Bottom Navigation - phones (hidden when embedded) */}
{isMobile && mobileView !== "viewer" && !isEmbedded && (
<NavigationRail
orientation="horizontal"
onManageApps={handleManageApps}
@@ -6213,7 +6153,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
{/* The default layout: plugins' panels beside the mail (the
side-panel slot), with their buttons in a rail at the right edge.
Desktop only; Mountain View has them in its side panel. */}
{!mvShell && !isMobile && !isTablet && !isEmbedded && !inlineApp && !fullscreenReading && <PluginSidePanel />}
{!mvShell && !isMobile && !isEmbedded && !inlineApp && !fullscreenReading && <PluginSidePanel />}
</div>
{/* Keyboard Shortcuts Modal */}
@@ -17,10 +17,11 @@ vi.mock('next-intl', () => ({
}));
vi.mock('@/hooks/use-media-query', async (importOriginal) => ({
atLeast: (await importOriginal<typeof import('@/hooks/use-media-query')>()).atLeast,
PHONE_BELOW: (await importOriginal<typeof import('@/hooks/use-media-query')>()).PHONE_BELOW,
useIsDesktop: () => media.desktop,
useIsMobile: () => true,
useMediaQuery: () => false,
useDeviceDetection: () => ({ isMobile: true, isTablet: false, isDesktop: false }),
useDeviceDetection: () => ({ isMobile: true, isCompact: false, isDesktop: false }),
}));
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail', stalwartFeaturesEnabled: false }) }));
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
+4 -2
View File
@@ -87,7 +87,7 @@ import {
ARCHIVE_ACTION_SEARCH_PATHS,
} from '@/lib/settings-search';
import { useAccountStore } from '@/stores/account-store';
import { useMediaQuery, atLeast } from '@/hooks/use-media-query';
import { useMediaQuery, atLeast, PHONE_BELOW } from '@/hooks/use-media-query';
import { useLiteLinkSegments } from '@/hooks/use-lite-link-segments';
import { useDocumentTitle } from '@/hooks/use-document-title';
import { MvTopBar } from "@/components/layout/mv-top-bar";
@@ -505,7 +505,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
if (typeof window === 'undefined') return;
if (!mobileShowContent) return;
// The first hydration pass reports a phone layout on every screen.
if (!isEmbedded && window.matchMedia(atLeast(1024)).matches) return;
if (!isEmbedded && window.matchMedia(atLeast(PHONE_BELOW)).matches) return;
// A page opened straight from a link has no list below it yet: turn the
// entry it arrived on into the list, then push the page on top. Inside
@@ -1120,6 +1120,8 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
data-pane="sidebar"
className={cn(
"border-e border-border bg-secondary flex flex-col",
// A compact desktop keeps more of its width for the page.
"max-lg:max-w-52",
!isResizing && "transition-[width] duration-300"
)}
style={{ width: `${settingsSidebarWidth}px` }}
+1 -1
View File
@@ -235,7 +235,7 @@ describe('tour text on a phone', () => {
const step = tour.steps[0] as { phoneDescriptionKey?: string };
step.phoneDescriptionKey = 'one_desc_phone';
const spy = vi.spyOn(window, 'matchMedia').mockImplementation((query: string) => ({
matches: query === '(width < 48rem)', media: query, onchange: null,
matches: query === '(width < 37.5rem)', media: query, onchange: null,
addEventListener: () => {}, removeEventListener: () => {}, addListener: () => {}, removeListener: () => {},
dispatchEvent: () => false,
}) as MediaQueryList);
+4 -2
View File
@@ -30,7 +30,7 @@ const EMAIL = { id: 'm1', mailboxIds: { inbox: true }, keywords: {} } as unknown
beforeEach(() => {
clearAllHooks();
useUIStore.setState({ activeView: 'list', sidebarOpen: false, sidebarCollapsed: false, isMobile: true, isTablet: false, isDesktop: false });
useUIStore.setState({ activeView: 'list', sidebarOpen: false, sidebarCollapsed: false, isMobile: true, isCompact: false, isDesktop: false });
});
describe('layout hooks', () => {
@@ -45,11 +45,13 @@ describe('layout hooks', () => {
useUIStore.getState().setSidebarCollapsed(true);
useUIStore.getState().setDeviceType(false, false, true);
useUIStore.getState().setDeviceType(false, false, true);
// A compact desktop is "tablet" to plugins.
useUIStore.getState().setDeviceType(false, true, true);
expect(view).toHaveBeenCalledWith({ view: 'viewer' });
expect(toggle).toHaveBeenCalledWith({ open: true });
expect(collapse).toHaveBeenCalledWith({ collapsed: true });
expect(device.mock.calls).toEqual([[{ deviceType: 'desktop' }]]);
expect(device.mock.calls).toEqual([[{ deviceType: 'desktop' }], [{ deviceType: 'tablet' }]]);
});
it('reports Back on the phone', () => {
@@ -648,7 +648,7 @@ describe('ZIP failure handling and retry', () => {
describe('message row drag hover wiring', () => {
beforeEach(() => {
useSettingsStore.setState({ interfaceLayout: 'default', mailLayout: 'split', density: 'regular', hoverActions: ['delete'] });
useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true });
useUIStore.setState({ isMobile: false, isCompact: false, isDesktop: true });
});
function renderRows(emails: Email[]) {
@@ -1,15 +1,26 @@
/**
* The scripts' breakpoints follow the browser's default text size, as the
* CSS breakpoints (Tailwind's rem) do. With a 32px default a 1440px window is
* 45rem wide: the CSS draws the phone layout, so the scripts must pick it too,
* or the mail screen showed only the reading pane with no list (LV-11).
* CSS breakpoints (Tailwind's rem) do. With a 32px default a 1100px window is
* about 34rem wide: the CSS draws the phone layout, so the scripts must pick
* it too, or the mail screen showed only the reading pane with no list (LV-11).
*
* The layout classes: a phone below 600px, a compact desktop (the folder
* sidebar folded to icons) up to 1024px, the full desktop above. A window
* snapped to half a screen and a laptop at 150 % (911px) are desktops
* (usability test 2026-11-04, mail-28 and settings-16).
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { createElement, type ReactNode } from 'react';
import { renderHook } from '@testing-library/react';
import { useIsMobile, useIsDesktop, narrowerThan, atLeast } from '../use-media-query';
import {
useIsMobile, useIsDesktop, useIsFullDesktop, useDeviceDetection,
classifyWidth, narrowerThan, atLeast,
} from '../use-media-query';
import { useSplitPanesFit } from '../use-split-panes-fit';
import { PaneSizeContext } from '../use-pane-size';
/** A window `px` wide whose default text size is `rootPx`. */
function viewport(px: number, rootPx: number) {
function viewport(px: number, rootPx = 16) {
vi.stubGlobal('matchMedia', (query: string) => {
const m = /\(width (<|>=) ([\d.]+)rem\)/.exec(query);
const matches = m ? (m[1] === '<' ? px < Number(m[2]) * rootPx : px >= Number(m[2]) * rootPx) : false;
@@ -17,23 +28,87 @@ function viewport(px: number, rootPx: number) {
});
}
/** Renders inside a Pro pane `width` px wide. */
const inPane = (width: number) => ({ children }: { children: ReactNode }) =>
createElement(PaneSizeContext.Provider, { value: width }, children);
afterEach(() => vi.unstubAllGlobals());
describe('breakpoints', () => {
it('writes the queries in rem, as Tailwind does', () => {
expect(narrowerThan(768)).toBe('(width < 48rem)');
expect(narrowerThan(600)).toBe('(width < 37.5rem)');
expect(atLeast(1024)).toBe('(width >= 64rem)');
});
it('is a desktop at 1440px with the default 16px text', () => {
viewport(1440, 16);
viewport(1440);
expect(renderHook(() => useIsDesktop()).result.current).toBe(true);
expect(renderHook(() => useIsMobile()).result.current).toBe(false);
});
it('is a phone at 1440px when the browser text size is 32px', () => {
viewport(1440, 32);
it('is a phone at 1100px when the browser text size is 32px', () => {
viewport(1100, 32);
expect(renderHook(() => useIsDesktop()).result.current).toBe(false);
expect(renderHook(() => useIsMobile()).result.current).toBe(true);
});
});
describe('classifyWidth', () => {
it('draws the phone layout only below 600px', () => {
expect(classifyWidth(390)).toEqual({ isMobile: true, isCompact: false, isDesktop: false });
expect(classifyWidth(599)).toEqual({ isMobile: true, isCompact: false, isDesktop: false });
expect(classifyWidth(600)).toEqual({ isMobile: false, isCompact: true, isDesktop: true });
});
it('draws a compact desktop up to the full desktop at 1024px', () => {
for (const w of [640, 800, 853, 911, 1023]) {
expect(classifyWidth(w)).toEqual({ isMobile: false, isCompact: true, isDesktop: true });
}
expect(classifyWidth(1024)).toEqual({ isMobile: false, isCompact: false, isDesktop: true });
expect(classifyWidth(1366)).toEqual({ isMobile: false, isCompact: false, isDesktop: true });
});
});
describe('the window', () => {
it('is a desktop at half a screen and at 150 % on a 1366px laptop', () => {
for (const w of [640, 800, 911]) {
viewport(w);
expect(renderHook(() => useIsDesktop()).result.current).toBe(true);
expect(renderHook(() => useIsMobile()).result.current).toBe(false);
expect(renderHook(() => useIsFullDesktop()).result.current).toBe(false);
}
});
it('is a phone below 600px', () => {
viewport(599);
expect(renderHook(() => useIsMobile()).result.current).toBe(true);
expect(renderHook(() => useIsDesktop()).result.current).toBe(false);
});
it('puts the list beside the reader from 840px', () => {
viewport(839);
expect(renderHook(() => useSplitPanesFit()).result.current).toBe(false);
viewport(911);
expect(renderHook(() => useSplitPanesFit()).result.current).toBe(true);
});
});
describe('a Pro pane', () => {
it('classifies by its own width, not the window', () => {
viewport(1920);
expect(renderHook(() => useDeviceDetection(), { wrapper: inPane(500) }).result.current)
.toEqual({ isMobile: true, isCompact: false, isDesktop: false });
expect(renderHook(() => useDeviceDetection(), { wrapper: inPane(700) }).result.current)
.toEqual({ isMobile: false, isCompact: true, isDesktop: true });
expect(renderHook(() => useDeviceDetection(), { wrapper: inPane(1100) }).result.current)
.toEqual({ isMobile: false, isCompact: false, isDesktop: true });
});
it('answers the single hooks by its width too', () => {
viewport(1920);
expect(renderHook(() => useIsMobile(), { wrapper: inPane(599) }).result.current).toBe(true);
expect(renderHook(() => useIsDesktop(), { wrapper: inPane(600) }).result.current).toBe(true);
expect(renderHook(() => useIsFullDesktop(), { wrapper: inPane(900) }).result.current).toBe(false);
expect(renderHook(() => useSplitPanesFit(), { wrapper: inPane(800) }).result.current).toBe(false);
});
});
+15
View File
@@ -1,8 +1,23 @@
"use client";
import { effectiveMailLayout, useSettingsStore, type MailLayout } from "@/stores/settings-store";
import { useSplitPanesFit } from "@/hooks/use-split-panes-fit";
/** The mail layout in force: the saved one, or `focus` under Mountain View. */
export function useEffectiveMailLayout(): MailLayout {
return useSettingsStore((state) => effectiveMailLayout(state.mailLayout, state.interfaceLayout, state.simpleMode));
}
/**
* The layout the mail screen draws now: the one in force, except that a
* window (or Pro pane) too narrow for the list and the reading pane side by
* side opens a message in the list's place, as the focused layout does. The
* list keeps its rows as the chosen layout draws them (useListRowShape).
*/
export function drawnMailLayout(layout: MailLayout, splitPanesFit: boolean): MailLayout {
return layout === 'split' && !splitPanesFit ? 'focus' : layout;
}
export function useDrawnMailLayout(): MailLayout {
return drawnMailLayout(useEffectiveMailLayout(), useSplitPanesFit());
}
+4 -4
View File
@@ -12,8 +12,9 @@ export interface ListRowShape {
* How the message list draws its rows.
*
* The focused list and the bottom reading pane both give the list the full
* width, so their rows run on one line. Phones fall back to stacked rows, and
* so do tablets under the bottom pane, which they draw as the split layout.
* width, so their rows run on one line. Phones fall back to stacked rows.
* A window too narrow for the split layout's two panes keeps its stacked rows
* (useDrawnMailLayout): the list is as narrow as the window.
* Columns are a table on top of the one-line rows (#262); Mountain View rows
* are shaped after Gmail and never take them.
*/
@@ -22,8 +23,7 @@ export function useListRowShape(): ListRowShape {
const mountainView = useSettingsStore((state) => state.interfaceLayout === 'mountain-view');
const listColumns = useSettingsStore((state) => state.listColumns);
const isMobile = useUIStore((state) => state.isMobile);
const isTablet = useUIStore((state) => state.isTablet);
const oneLine = !isMobile && (mailLayout === 'focus' || (mailLayout === 'horizontal' && !isTablet));
const oneLine = !isMobile && (mailLayout === 'focus' || mailLayout === 'horizontal');
return { oneLine, columns: oneLine && listColumns && !mountainView };
}
+64 -27
View File
@@ -14,6 +14,35 @@ const BREAKPOINTS = {
"2xl": 1536,
} as const;
/**
* The widths that decide the layout (`desk:` in app/globals.css is the first).
* Below PHONE_BELOW the phone layout: one pane at a time, a bottom tab bar and
* the folders in a drawer. From there up the desktop layout, with the app rail
* and the folder sidebar beside the list. Below FULL_DESKTOP that desktop is
* compact: the folder sidebar folds to its icons, and calendar, contacts and
* files open their sidebars over the page. A half-screen window and a laptop
* at 150 % (911px) are desktops, not tablets (usability test 2026-11-04).
*/
export const PHONE_BELOW = 600;
export const FULL_DESKTOP = BREAKPOINTS.lg;
export interface DeviceClass {
/** Phone layout. */
isMobile: boolean;
/** Desktop layout narrower than the full desktop: the sidebar folds to icons. */
isCompact: boolean;
/** Desktop layout, compact or full. */
isDesktop: boolean;
}
/** The layout a window or pane `width` CSS pixels wide gets. */
export function classifyWidth(width: number): DeviceClass {
return {
isMobile: width < PHONE_BELOW,
isCompact: width >= PHONE_BELOW && width < FULL_DESKTOP,
isDesktop: width >= PHONE_BELOW,
};
}
const getMediaQueryServerSnapshot = () => false;
/**
@@ -61,23 +90,18 @@ export function useMediaQuery(query: string): boolean {
* When the Pro shell renders a page inside a (possibly split) pane, that pane
* publishes its measured width via `PaneSizeContext`. Inner pages should
* branch their layout against the pane width - not the full viewport - so a
* narrow pane gets the mobile/tablet layout instead of overflowing.
* narrow pane gets the phone or compact layout instead of overflowing.
*
* Returns `null` when no pane size is published, signalling the caller to
* fall back to `window.matchMedia`.
*/
function classifyPane(paneWidth: number | null) {
if (paneWidth === null) return null;
return {
isMobile: paneWidth < BREAKPOINTS.md,
isTablet: paneWidth >= BREAKPOINTS.md && paneWidth < BREAKPOINTS.lg,
isDesktop: paneWidth >= BREAKPOINTS.lg,
};
function classifyPane(paneWidth: number | null): DeviceClass | null {
return paneWidth === null ? null : classifyWidth(paneWidth);
}
/**
* Hook to detect device type and sync with UI store
* Uses Tailwind breakpoints: mobile < 768px, tablet 768-1024px, desktop > 1024px
* Hook to detect the layout class (classifyWidth) and sync it with the UI
* store: phone below 600px, compact desktop to 1024px, full desktop above.
*
* When invoked inside a Pro pane, the returned values reflect the pane's
* width instead of the window's. The global UI store is NOT updated in that
@@ -85,35 +109,48 @@ function classifyPane(paneWidth: number | null) {
* and the store is meant to mirror the actual viewport for callers that read
* it directly (mobile navigation helpers etc.).
*/
export function useDeviceDetection() {
const { setDeviceType, isMobile, isTablet, isDesktop } = useUIStore(useShallow((state) => ({ setDeviceType: state.setDeviceType, isMobile: state.isMobile, isTablet: state.isTablet, isDesktop: state.isDesktop })));
export function useDeviceDetection(): DeviceClass {
const { setDeviceType, isMobile, isCompact, isDesktop } = useUIStore(useShallow((state) => ({ setDeviceType: state.setDeviceType, isMobile: state.isMobile, isCompact: state.isCompact, isDesktop: state.isDesktop })));
const paneWidth = usePaneSize();
const paneClassification = classifyPane(paneWidth);
const isMobileQuery = useMediaQuery(narrowerThan(BREAKPOINTS.md));
const isTabletQuery = useMediaQuery(`${atLeast(BREAKPOINTS.md)} and ${narrowerThan(BREAKPOINTS.lg)}`);
const isDesktopQuery = useMediaQuery(atLeast(BREAKPOINTS.lg));
// Each class asks its own query, so the first hydration pass (every query
// false) reads as the full desktop and draws no folded sidebar for a frame.
const isMobileQuery = useMediaQuery(narrowerThan(PHONE_BELOW));
const isCompactQuery = useMediaQuery(`${atLeast(PHONE_BELOW)} and ${narrowerThan(FULL_DESKTOP)}`);
const isDesktopQuery = useMediaQuery(atLeast(PHONE_BELOW));
useEffect(() => {
if (paneClassification) return;
setDeviceType(isMobileQuery, isTabletQuery, isDesktopQuery);
}, [isMobileQuery, isTabletQuery, isDesktopQuery, setDeviceType, paneClassification]);
setDeviceType(isMobileQuery, isCompactQuery, isDesktopQuery);
}, [isMobileQuery, isCompactQuery, isDesktopQuery, setDeviceType, paneClassification]);
if (paneClassification) return paneClassification;
return { isMobile, isTablet, isDesktop };
return { isMobile, isCompact, isDesktop };
}
/** The phone layout (below PHONE_BELOW), for the window or the Pro pane. */
export function useIsMobile() {
const paneWidth = usePaneSize();
const viewport = useMediaQuery(narrowerThan(PHONE_BELOW));
return paneWidth !== null ? paneWidth < PHONE_BELOW : viewport;
}
/** The desktop layout, compact or full (from PHONE_BELOW up). */
export function useIsDesktop() {
const paneWidth = usePaneSize();
const viewport = useMediaQuery(atLeast(PHONE_BELOW));
return paneWidth !== null ? paneWidth >= PHONE_BELOW : viewport;
}
/**
* Convenience hooks for specific breakpoints
* The full desktop (from FULL_DESKTOP up), where sidebars sit beside the page
* at their own width. Below it calendar, contacts and files open theirs over
* the page.
*/
export function useIsMobile() {
export function useIsFullDesktop() {
const paneWidth = usePaneSize();
const viewport = useMediaQuery(narrowerThan(BREAKPOINTS.md));
return paneWidth !== null ? paneWidth < BREAKPOINTS.md : viewport;
const viewport = useMediaQuery(atLeast(FULL_DESKTOP));
return paneWidth !== null ? paneWidth >= FULL_DESKTOP : viewport;
}
export function useIsDesktop() {
const paneWidth = usePaneSize();
const viewport = useMediaQuery(atLeast(BREAKPOINTS.lg));
return paneWidth !== null ? paneWidth >= BREAKPOINTS.lg : viewport;
}
+4 -3
View File
@@ -3,6 +3,7 @@
import { useSettingsStore } from "@/stores/settings-store";
import { usePolicyStore } from "@/stores/policy-store";
import { useDeviceDetection } from "@/hooks/use-media-query";
import { useDrawnMailLayout } from "@/hooks/use-effective-mail-layout";
import { effectiveReturnToList, listBesideReader, returnToListSetByPolicy } from "@/lib/return-to-list";
/**
@@ -11,16 +12,16 @@ import { effectiveReturnToList, listBesideReader, returnToListSetByPolicy } from
* nobody has set it yet.
*/
export function useReturnToListAfterAction(): { checked: boolean; followsLayout: boolean } {
const { isMobile, isTablet } = useDeviceDetection();
const { isMobile } = useDeviceDetection();
const value = useSettingsStore((s) => s.returnToListAfterAction);
const explicitSettings = useSettingsStore((s) => s.explicitSettings);
const mailLayout = useSettingsStore((s) => s.mailLayout);
const mailLayout = useDrawnMailLayout();
const interfaceLayout = useSettingsStore((s) => s.interfaceLayout);
const simpleMode = useSettingsStore((s) => s.simpleMode);
// Re-read when the policy arrives or changes.
usePolicyStore((s) => s.policy);
const policySet = returnToListSetByPolicy();
const besideReader = listBesideReader({ mailLayout, interfaceLayout, simpleMode, isMobile, isTablet });
const besideReader = listBesideReader({ mailLayout, interfaceLayout, simpleMode, isMobile });
return {
checked: effectiveReturnToList({ returnToListAfterAction: value, explicitSettings }, besideReader, policySet),
followsLayout: !policySet && !explicitSettings.includes("returnToListAfterAction"),
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { useCallback, useSyncExternalStore } from "react";
import { usePaneSize } from "@/hooks/use-pane-size";
/**
* The narrowest window (or Pro pane) that puts the message list and the
* reading pane side by side, in CSS pixels at the default text size. Narrower,
* the split layout opens a message in the list's place, as the focused layout
* does (useDrawnMailLayout). The page skeleton's `boot-onepane` variant
* (app/boot-skeleton.css) uses the same width, 52.5rem.
*/
export const SPLIT_PANES_MIN = 840;
const QUERY = `(width < ${SPLIT_PANES_MIN / 16}rem)`;
const subscribe = (callback: () => void) => {
const mq = window.matchMedia(QUERY);
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
};
const getServerSnapshot = () => false;
/**
* Whether the message list and the reading pane fit side by side. Asks for
* "too narrow", so a server render and the first hydration pass keep the
* two panes a desktop draws.
*/
export function useSplitPanesFit(): boolean {
const paneWidth = usePaneSize();
const getSnapshot = useCallback(() => window.matchMedia(QUERY).matches, []);
const tooNarrow = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
return paneWidth !== null ? paneWidth >= SPLIT_PANES_MIN : !tooNarrow;
}
+5 -4
View File
@@ -157,10 +157,11 @@ describe('plugin UI kit components', () => {
});
it('makes sidebar rows as tall as folder rows on touch screens', () => {
// The page sets --touch-row-min-h to 44px below lg, where folder rows
// are max-lg:min-h-[44px]; the frame can't see the page's width itself.
expect(source('components/layout/sidebar.tsx')).toContain('max-lg:min-h-[44px]');
expect(source('app/globals.css')).toMatch(/@media \(width < 64rem\) \{\s*:root \{\s*--touch-row-min-h: 44px;/);
// The page sets --touch-row-min-h to 44px where folder rows are
// tap:min-h-[44px] (phones, touch screens below the full desktop); the
// frame can't see the page's width itself.
expect(source('components/layout/sidebar.tsx')).toContain('tap:min-h-[44px]');
expect(source('app/globals.css')).toMatch(/@media \(width < 37\.5rem\), \(width < 64rem\) and \(pointer: coarse\) \{\s*:root \{\s*--touch-row-min-h: 44px;/);
render(<kit.SidebarRow label="Anna" onClick={() => {}} />);
expect(screen.getByRole('button', { name: 'Anna' }).style.minHeight).toBe('var(--touch-row-min-h, 0px)');
});
+1 -2
View File
@@ -9,7 +9,7 @@ const untouched = { returnToListAfterAction: true, explicitSettings: [] as strin
const chosen = (value: boolean) => ({ returnToListAfterAction: value, explicitSettings: ['returnToListAfterAction'] });
describe('listBesideReader', () => {
const desktop = { isMobile: false, isTablet: false, interfaceLayout: 'default' as const };
const desktop = { isMobile: false, interfaceLayout: 'default' as const };
it('is true for the split and horizontal layouts on a desktop', () => {
expect(listBesideReader({ ...desktop, mailLayout: 'split' })).toBe(true);
expect(listBesideReader({ ...desktop, mailLayout: 'horizontal' })).toBe(true);
@@ -19,7 +19,6 @@ describe('listBesideReader', () => {
expect(listBesideReader({ ...desktop, mailLayout: 'split', interfaceLayout: 'mountain-view' })).toBe(false);
expect(listBesideReader({ ...desktop, mailLayout: 'split', simpleMode: true })).toBe(false);
expect(listBesideReader({ ...desktop, mailLayout: 'split', isMobile: true })).toBe(false);
expect(listBesideReader({ ...desktop, mailLayout: 'split', isTablet: true })).toBe(false);
});
});
@@ -22,7 +22,7 @@ const signedIn = () =>
function desktop(matches: boolean) {
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: matches && query.includes('64rem'),
matches: matches && query.includes('37.5rem'),
media: query,
addEventListener: () => {},
removeEventListener: () => {},
+2 -1
View File
@@ -176,7 +176,8 @@ export function applyBootLayout(root: HTMLElement, c: BootLayoutConfig): void {
mark('mail-layout', mailLayout);
var desktop = false;
try { desktop = w.matchMedia('(min-width: 64rem)').matches; } catch { /* assume not */ }
// The desktop layout, compact included (PHONE_BELOW in hooks/use-media-query).
try { desktop = w.matchMedia('(min-width: 37.5rem)').matches; } catch { /* assume not */ }
var proOn = s.proInterface === true && desktop && (surface === 'pro' || has(c.pro, surface));
mark('pro', proOn ? 'on' : null);
+5 -4
View File
@@ -5,17 +5,18 @@ const KEY = 'returnToListAfterAction';
/**
* Whether the message list and the reading pane are on screen side by side:
* the split and horizontal layouts on a desktop. Phones, tablets, the focused
* layout, Simple mode and Mountain View put the message in the list's place.
* the split and horizontal layouts on a desktop. Phones, the focused layout,
* Simple mode and Mountain View put the message in the list's place, and so
* does the split layout in a window too narrow for both (pass the layout as
* drawn, useDrawnMailLayout).
*/
export function listBesideReader(opts: {
mailLayout: MailLayout;
interfaceLayout: InterfaceLayout;
simpleMode?: boolean;
isMobile: boolean;
isTablet: boolean;
}): boolean {
if (opts.isMobile || opts.isTablet) return false;
if (opts.isMobile) return false;
return effectiveMailLayout(opts.mailLayout, opts.interfaceLayout, opts.simpleMode) !== 'focus';
}
+48 -3
View File
@@ -1,14 +1,14 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useUIStore, listPaneCssSize, COMPOSER_PANE_MIN_HEIGHT } from '../ui-store';
import { useUIStore, listPaneCssSize, sidebarFolded, pluginDeviceType, COMPOSER_PANE_MIN_HEIGHT } from '../ui-store';
import { drawnMailLayout } from '@/hooks/use-effective-mail-layout';
describe('ui-store', () => {
beforeEach(() => {
useUIStore.setState({
activeView: 'list',
sidebarOpen: false,
tabletListVisible: true,
isMobile: false,
isTablet: false,
isCompact: false,
isDesktop: true,
sidebarWidth: 256,
emailListWidth: 384,
@@ -124,4 +124,49 @@ describe('ui-store', () => {
expect(useUIStore.getState().sidebarCollapsed).toBe(true);
});
});
// A compact desktop (600-1024px) folds the folders to icons until opened,
// and leaves the full desktop's own choice alone.
describe('the folder sidebar on a compact desktop', () => {
beforeEach(() => {
useUIStore.setState({ isMobile: false, isCompact: true, isDesktop: true, compactSidebarExpanded: false });
});
it('starts folded, whatever the full desktop chose', () => {
expect(sidebarFolded(useUIStore.getState())).toBe(true);
useUIStore.setState({ isCompact: false });
expect(sidebarFolded(useUIStore.getState())).toBe(false);
});
it('opens and folds again with the toggle, without changing the full desktop choice', () => {
useUIStore.getState().toggleSidebarCollapsed();
expect(sidebarFolded(useUIStore.getState())).toBe(false);
expect(useUIStore.getState().sidebarCollapsed).toBe(false);
useUIStore.getState().toggleSidebarCollapsed();
expect(sidebarFolded(useUIStore.getState())).toBe(true);
expect(useUIStore.getState().sidebarCollapsed).toBe(false);
});
it('follows a Pro pane\'s own class when given', () => {
useUIStore.setState({ isCompact: false, sidebarCollapsed: false });
expect(sidebarFolded(useUIStore.getState(), true)).toBe(true);
});
});
describe('pluginDeviceType', () => {
it('calls the compact desktop a tablet', () => {
expect(pluginDeviceType({ isMobile: true, isCompact: false })).toBe('mobile');
expect(pluginDeviceType({ isMobile: false, isCompact: true })).toBe('tablet');
expect(pluginDeviceType({ isMobile: false, isCompact: false })).toBe('desktop');
});
});
describe('drawnMailLayout', () => {
it('opens messages in the list\'s place when two panes do not fit', () => {
expect(drawnMailLayout('split', false)).toBe('focus');
expect(drawnMailLayout('split', true)).toBe('split');
expect(drawnMailLayout('horizontal', false)).toBe('horizontal');
expect(drawnMailLayout('focus', true)).toBe('focus');
});
});
});
+33 -17
View File
@@ -38,12 +38,10 @@ interface UIState {
activeView: ActiveView;
sidebarOpen: boolean;
// Tablet list visibility (auto-hide when email selected)
tabletListVisible: boolean;
// Device detection (hydrated client-side)
// Device detection (hydrated client-side), see classifyWidth in
// hooks/use-media-query: phone, compact desktop, desktop.
isMobile: boolean;
isTablet: boolean;
isCompact: boolean;
isDesktop: boolean;
// Resizable column widths (desktop only)
@@ -53,6 +51,10 @@ interface UIState {
// Sidebar collapsed state (desktop)
sidebarCollapsed: boolean;
// A compact desktop shows the folder sidebar as icons until it is opened;
// this is whether it was, for this session. The full desktop's choice
// (sidebarCollapsed) stays as it was.
compactSidebarExpanded: boolean;
// Open composers and expanded quick replies. Floating cards such as the
// PWA install prompt stay hidden while any is open and toasts lift clear,
@@ -63,8 +65,7 @@ interface UIState {
setActiveView: (view: ActiveView) => void;
setSidebarOpen: (open: boolean) => void;
toggleSidebar: () => void;
setTabletListVisible: (visible: boolean) => void;
setDeviceType: (isMobile: boolean, isTablet: boolean, isDesktop: boolean) => void;
setDeviceType: (isMobile: boolean, isCompact: boolean, isDesktop: boolean) => void;
setSidebarWidth: (width: number) => void;
setEmailListWidth: (width: number) => void;
setEmailListHeight: (height: number) => void;
@@ -73,6 +74,7 @@ interface UIState {
resetEmailListHeight: () => void;
persistColumnWidths: () => void;
setSidebarCollapsed: (collapsed: boolean) => void;
/** Folds or unfolds the folder sidebar in the layout drawn now (compact or full). */
toggleSidebarCollapsed: () => void;
beginComposing: () => void;
endComposing: () => void;
@@ -89,14 +91,14 @@ export const useUIStore = create<UIState>((set, get) => ({
// Initial state (SSR-safe defaults)
activeView: "list",
sidebarOpen: false,
tabletListVisible: true,
isMobile: false,
isTablet: false,
isCompact: false,
isDesktop: true,
sidebarWidth: SIDEBAR_DEFAULT,
emailListWidth: EMAIL_LIST_DEFAULT,
emailListHeight: EMAIL_LIST_HEIGHT_DEFAULT,
sidebarCollapsed: false,
compactSidebarExpanded: false,
composingCount: 0,
// Actions
@@ -106,10 +108,8 @@ export const useUIStore = create<UIState>((set, get) => ({
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
setTabletListVisible: (visible) => set({ tabletListVisible: visible }),
setDeviceType: (isMobile, isTablet, isDesktop) =>
set({ isMobile, isTablet, isDesktop }),
setDeviceType: (isMobile, isCompact, isDesktop) =>
set({ isMobile, isCompact, isDesktop }),
setSidebarWidth: (width) =>
set({ sidebarWidth: Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, width)) }),
@@ -156,7 +156,9 @@ export const useUIStore = create<UIState>((set, get) => ({
},
setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }),
toggleSidebarCollapsed: () => set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
toggleSidebarCollapsed: () => set((state) => (state.isCompact
? { compactSidebarExpanded: !state.compactSidebarExpanded }
: { sidebarCollapsed: !state.sidebarCollapsed })),
beginComposing: () => set((state) => ({ composingCount: state.composingCount + 1 })),
endComposing: () => set((state) => ({ composingCount: Math.max(0, state.composingCount - 1) })),
@@ -211,8 +213,22 @@ function reportColumnWidths(widths: ColumnWidths): void {
}
}
function deviceType(s: Pick<UIState, "isMobile" | "isTablet">): "mobile" | "tablet" | "desktop" {
return s.isMobile ? "mobile" : s.isTablet ? "tablet" : "desktop";
/**
* The folder sidebar shows only its icons: on a compact desktop until opened,
* on the full desktop when the user folded it. `isCompact` is the pane's own
* class inside a Pro pane.
*/
export function sidebarFolded(s: Pick<UIState, "isCompact" | "sidebarCollapsed" | "compactSidebarExpanded">, isCompact = s.isCompact): boolean {
return isCompact ? !s.compactSidebarExpanded : s.sidebarCollapsed;
}
/**
* The device type plugins see. The compact desktop keeps the name "tablet":
* it covers the widths that were the tablet layout, and a plugin uses it to
* draw something narrower than on the full desktop.
*/
export function pluginDeviceType(s: Pick<UIState, "isMobile" | "isCompact">): "mobile" | "tablet" | "desktop" {
return s.isMobile ? "mobile" : s.isCompact ? "tablet" : "desktop";
}
// Mirrored on <html> for CSS: toasts lift clear of Send (see toast.tsx).
@@ -231,5 +247,5 @@ useUIStore.subscribe((state, prev) => {
if (state.activeView !== prev.activeView) void uiHooks.onViewChange.emit({ view: state.activeView });
if (state.sidebarOpen !== prev.sidebarOpen) void uiHooks.onSidebarToggle.emit({ open: state.sidebarOpen });
if (state.sidebarCollapsed !== prev.sidebarCollapsed) void uiHooks.onSidebarCollapse.emit({ collapsed: state.sidebarCollapsed });
if (deviceType(state) !== deviceType(prev)) void uiHooks.onDeviceTypeChange.emit({ deviceType: deviceType(state) });
if (pluginDeviceType(state) !== pluginDeviceType(prev)) void uiHooks.onDeviceTypeChange.emit({ deviceType: pluginDeviceType(state) });
});