From afe3fca08ad7a701614b21a44a62c39135e6de6e Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sun, 1 Nov 2026 19:33:35 +0100
Subject: [PATCH 1/7] feat: draw the mail page's folders, list and reader while
the app loads
---
app/boot-skeleton.css | 13 +
components/boot-skeleton/mail-skeleton.tsx | 585 +++++++++++++++++-
components/boot-skeleton/shell.tsx | 9 +-
.../__tests__/boot-script.test.ts | 14 +
lib/boot-skeleton/boot-script.ts | 3 +
5 files changed, 619 insertions(+), 5 deletions(-)
diff --git a/app/boot-skeleton.css b/app/boot-skeleton.css
index 7ce12389c..09908b5c4 100644
--- a/app/boot-skeleton.css
+++ b/app/boot-skeleton.css
@@ -91,3 +91,16 @@
animation: none;
}
}
+
+/* mail */
+
+/* List density (the density setting, data-boot-density): extra-compact rows
+ have no avatar, compact and extra-compact ones no preview. */
+@custom-variant boot-xc (&:where([data-boot-density="extra-compact"] *));
+@custom-variant boot-dense (&:where([data-boot-density="compact"] *, [data-boot-density="extra-compact"] *));
+/* Message previews turned off (showPreview). */
+@custom-variant boot-no-preview (&:where([data-boot-preview="off"] *));
+/* The default interface layout, the opposite of mv: (both can't be told apart by order). */
+@custom-variant boot-std (&:where(:root:not([data-interface-layout="mountain-view"]) *));
+/* Mountain View's side panel shows only with calendar or contacts. */
+@custom-variant boot-no-mv-side (&:where([data-boot-rail]:not([data-boot-rail~="calendar"]):not([data-boot-rail~="contacts"]) *));
diff --git a/components/boot-skeleton/mail-skeleton.tsx b/components/boot-skeleton/mail-skeleton.tsx
index f71310a14..9b0932865 100644
--- a/components/boot-skeleton/mail-skeleton.tsx
+++ b/components/boot-skeleton/mail-skeleton.tsx
@@ -1,3 +1,37 @@
+/**
+ * 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,
+ * 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.
+ *
+ * The pieces with a `density`/`shape` prop are shared with the loaded app's
+ * own loading states (the list's skeleton rows, the sidebar before the
+ * folders arrive, the viewer while a message loads). Without those props they
+ * pick their variant by CSS from the boot attributes, which is what the page
+ * skeleton needs; with them they draw exactly the one the app asks for.
+ */
+import type { ReactNode } from "react";
+import {
+ CalendarEvent,
+ ChevronDown,
+ ChevronsLeft,
+ Filter,
+ Mail,
+ Menu,
+ MoreVertical,
+ Pencil,
+ RefreshCw,
+ Search,
+ Settings,
+ Square,
+ Star,
+ Users,
+} from "@/components/icons";
+import { cn } from "@/lib/cn";
+import type { Density } from "@/lib/density";
+import { Bar, Block, Circle, SkeletonRegion, repeat, textWidth } from "./kit";
import { AppShellSkeleton, type RailLabels } from "./shell";
export interface MailSkeletonProps {
@@ -7,19 +41,562 @@ export interface MailSkeletonProps {
labels?: RailLabels;
}
+/** Folder row indent: the row's own start padding and the expand chevron's slot (sidebar.tsx). */
+export const FOLDER_ROW_PX_BASE = 8;
+export const FOLDER_CHEVRON_SLOT = 20;
+
+/**
+ * One line of placeholder text that is exactly as tall as a line of the real
+ * text (h-lh: one line-height of its own font size and leading), with the bar
+ * centred on it. `className` carries the font size and leading.
+ */
+function TextLine({ className, w, bar = "h-3" }: { className?: string; w: string; bar?: string }) {
+ return (
+
+
+
+ );
+}
+
+// --- Sidebar -----------------------------------------------------------------
+
+/**
+ * Folder rows as SidebarRow draws them (sidebar.tsx): text-sm, the density's
+ * block padding, a 2px leading stripe, the chevron slot and a 16px icon.
+ * Under Mountain View the rows are 32px pills with 20px icons, as
+ * globals.css makes the real ones.
+ */
+export function FolderRowsSkeleton({ count = 7 }: { count?: number }) {
+ return (
+
+ {repeat(count, (i) => (
+
+
+
+
+
+
+
+
+
+ ))}
+
+ );
+}
+
+/** The "Folders" section header (SidebarSectionHeader, first). */
+function FolderSectionHeaderSkeleton({ className }: { className?: string }) {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function SidebarSkeleton({ embedded }: { embedded: boolean }) {
+ return (
+
+ {/* Header: collapse button and account switcher. Hidden in a Pro pane and under Mountain View. */}
+ {!embedded && (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+ {/* Mountain View: the Compose button heads the navigation. */}
+ {!embedded && (
+
+ )}
+
+ {/* Mountain View lists the folders without a heading. */}
+
+
+
+
+ );
+}
+
+// --- Message list ------------------------------------------------------------
+
+/** How the list draws its rows (useListRowShape, plus Mountain View's own rows). */
+export type MailRowShape = "stacked" | "one-line" | "mv";
+
+interface RowOptions {
+ /** Live: the density setting. Left out, the boot attributes decide by CSS. */
+ density?: Density;
+ /** Live: whether rows show a preview (showPreview). */
+ preview?: boolean;
+}
+
+/** Classes for the avatar, or null where the density has none. */
+function avatarClass(o: RowOptions): string | null {
+ if (o.density === undefined) return "boot-xc:hidden";
+ return o.density === "extra-compact" ? null : "";
+}
+
+/** Classes for the two preview lines, or null where the row has none. */
+function previewClass(o: RowOptions): string | null {
+ if (o.density === undefined) return "boot-dense:hidden boot-no-preview:hidden";
+ return o.preview === false || o.density === "compact" || o.density === "extra-compact" ? null : "";
+}
+
+/** A stacked row (ThreadListItem on phones and in the split list). */
+function StackedRow({ i, o }: { i: number; o: RowOptions }) {
+ const avatar = avatarClass(o);
+ const preview = previewClass(o);
+ return (
+
+
+
+ {avatar !== null && (
+
+
+
+ )}
+
+
+
+
+
+
+ {preview !== null && (
+
+
+
+
+ )}
+
+
+
+
+ );
+}
+
+/** A one-line row (the focused list and the bottom reading pane on a desktop). */
+function OneLineRow({ i, o }: { i: number; o: RowOptions }) {
+ const avatar = avatarClass(o);
+ return (
+
+
+
+ {avatar !== null && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/** Mountain View's row on a tablet or desktop: check box and star, then one 40px line. */
+function MvRow({ i }: { i: number }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/** Enough rows of each shape to fill a tall viewport. */
+const ROWS: Record = { stacked: 9, "one-line": 24, mv: 22 };
+
+/**
+ * Message list rows. With `shape`, the rows the loaded list is about to draw
+ * (EmailList's loading state); without, every shape, shown by CSS from the
+ * boot attributes (one-line where useListRowShape says so, Mountain View's
+ * rows from a tablet up).
+ */
+export function MailListRowsSkeleton({
+ shape,
+ density,
+ preview,
+ className,
+}: { shape?: MailRowShape; className?: string } & RowOptions) {
+ const o: RowOptions = { density, preview };
+ if (shape) {
+ return (
+
+ {repeat(ROWS[shape], (i) =>
+ shape === "stacked" ? : shape === "one-line" ? : ,
+ )}
+
+ );
+ }
+ return (
+
+
+ {repeat(ROWS.stacked, (i) => )}
+
+
+ {repeat(ROWS["one-line"], (i) => )}
+
+
{repeat(ROWS.mv, (i) => )}
+
+ );
+}
+
+/** The list header row: select toggle, search field, folder menu (mail-app.tsx). */
+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 (
+
+
+
+
+
+
+
+
+
+
+
+ /
+
+
+
+
+
+
+
+
+
+
+
+ {/* Mountain View's list toolbar (MvListToolbar). */}
+ {!embedded && (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+}
+
+/**
+ * The phone and tablet header (MobileHeader): menu, the search pill on
+ * phones or the folder name on tablets, the ⋮ menu and the account.
+ */
+function MobileHeaderSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+// --- Reading pane --------------------------------------------------------------
+
+/**
+ * The viewer while a message loads (EmailViewer's loading state draws this
+ * same component): the toolbar, the subject block and the sender block of the
+ * default reader, then the message body's tinted sheet.
+ */
+export function MailMessageSkeleton({ className }: { className?: string }) {
+ const button = "h-8 w-8 sm:w-20 rounded-md";
+ return (
+
+ );
+}
+
+/** The empty reading pane: nothing open (EmailViewer without an email). */
+function EmptyReaderSkeleton({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+/** The reader's frame (EmailViewer's shell()). */
+function ReaderFrame({ children }: { children: ReactNode }) {
+ return (
+
+ );
+}
+
+/** Mountain View's side panel rail (MvSidePanel): calendar and contacts. */
+function MvSidePanelSkeleton() {
+ return (
+
+ );
+}
+
+// --- The page ------------------------------------------------------------------
+
/**
* The mail page's own content: everything in the row next to the rail.
* `embedded` is the variant drawn inside a Pro pane (useIsEmbedded()), which
* has no rail, no Mountain View shell and fills its pane (h-full).
*/
export function MailSkeletonBody({ embedded = false }: { embedded?: boolean }) {
- return ;
+ return (
+ <>
+ {/* Folders: in the flow on a desktop, a closed drawer below that. */}
+
+
+
+ {/* AppMain. Under Mountain View the rounded card inset from the canvas. */}
+
+
+ {/* The message list. */}
+
+ {!embedded &&
}
+
+
+ {/* The round Compose button (Mountain View's sits in its navigation). */}
+
+
+ {/* The reading pane. */}
+
+
+
+
+
+
+
+
+ {!embedded && }
+ >
+ );
}
-/** Skeleton of the mail page. Stub: filled in per surface. */
-export function MailSkeleton({ label, labels }: MailSkeletonProps) {
+/**
+ * Skeleton of the mail page. `embedded`: the mail app inside a Pro pane, which
+ * draws only its body at the pane's height.
+ */
+export function MailSkeleton({ label, labels, embedded = false }: MailSkeletonProps & { embedded?: boolean }) {
+ if (embedded) {
+ return (
+
+
+
+ );
+ }
return (
-
+
);
diff --git a/components/boot-skeleton/shell.tsx b/components/boot-skeleton/shell.tsx
index af71f393c..1b8e0b1de 100644
--- a/components/boot-skeleton/shell.tsx
+++ b/components/boot-skeleton/shell.tsx
@@ -182,6 +182,10 @@ export function MvTopBarSkeleton({ search = true, className }: { search?: boolea
+ {/* The account switcher. */}
+
+
+
);
@@ -203,6 +207,8 @@ interface AppShellSkeletonProps {
mvSearch?: 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. */
+ bottomBarClassName?: string;
children: ReactNode;
}
@@ -222,6 +228,7 @@ export function AppShellSkeleton({
labels,
mvSearch = false,
rowClassName,
+ bottomBarClassName,
children,
}: AppShellSkeletonProps) {
return (
@@ -245,7 +252,7 @@ export function AppShellSkeleton({
>
diff --git a/lib/boot-skeleton/__tests__/boot-script.test.ts b/lib/boot-skeleton/__tests__/boot-script.test.ts
index ee151e6bc..96c512fee 100644
--- a/lib/boot-skeleton/__tests__/boot-script.test.ts
+++ b/lib/boot-skeleton/__tests__/boot-script.test.ts
@@ -128,6 +128,20 @@ describe('boot script: layout settings', () => {
expect(cssVar('--density-sidebar-py')).toBe(sidebar);
});
+ it('names the density and whether previews are off, for the mail rows', () => {
+ boot('/en/mail');
+ expect(attr('density')).toBe('regular');
+ expect(attr('preview')).toBeNull();
+ settings({ density: 'extra-compact', showPreview: false });
+ boot('/en/mail');
+ expect(attr('density')).toBe('extra-compact');
+ expect(attr('preview')).toBe('off');
+ settings({ density: 'cosy', showPreview: true });
+ boot('/en/mail');
+ expect(attr('density')).toBe('regular');
+ expect(attr('preview')).toBeNull();
+ });
+
it('falls back to regular for an unknown density', () => {
settings({ density: 'cosy' });
boot('/en/mail');
diff --git a/lib/boot-skeleton/boot-script.ts b/lib/boot-skeleton/boot-script.ts
index fa40c8776..791d67da5 100644
--- a/lib/boot-skeleton/boot-script.ts
+++ b/lib/boot-skeleton/boot-script.ts
@@ -167,6 +167,9 @@ export function applyBootLayout(root: HTMLElement, c: BootLayoutConfig): void {
var density = typeof s.density === 'string' && c.density[s.density] ? s.density : 'regular';
var vars = (layout === 'mountain-view' ? c.mvDensity : c.density)[density];
for (var prop in vars) root.style.setProperty(prop, vars[prop]);
+ // Which rows the mail list draws: avatars and previews depend on these.
+ mark('density', density);
+ mark('preview', s.showPreview === false ? 'off' : null);
var mailLayout = s.mailLayout === 'focus' || s.mailLayout === 'horizontal' ? s.mailLayout : 'split';
if (layout === 'mountain-view' || s.simpleMode === true) mailLayout = 'focus';
From cdf661d320bcd14db3daf8b1d941a32a8b5f04d5 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sun, 1 Nov 2026 19:40:20 +0100
Subject: [PATCH 2/7] feat: keep the mail skeleton's shapes until the folders,
list and linked message arrive
---
.../__tests__/mail-skeleton.test.tsx | 52 ++++++
components/email/email-list.tsx | 36 ++--
components/email/email-viewer.tsx | 52 +-----
components/layout/sidebar.tsx | 19 +-
.../__tests__/mail-app-boot-skeleton.test.tsx | 171 ++++++++++++++++++
components/mail/mail-app.tsx | 46 +++--
6 files changed, 284 insertions(+), 92 deletions(-)
create mode 100644 components/boot-skeleton/__tests__/mail-skeleton.test.tsx
create mode 100644 components/mail/__tests__/mail-app-boot-skeleton.test.tsx
diff --git a/components/boot-skeleton/__tests__/mail-skeleton.test.tsx b/components/boot-skeleton/__tests__/mail-skeleton.test.tsx
new file mode 100644
index 000000000..1e7e565ff
--- /dev/null
+++ b/components/boot-skeleton/__tests__/mail-skeleton.test.tsx
@@ -0,0 +1,52 @@
+import { renderToString } from 'react-dom/server';
+import { describe, expect, it } from 'vitest';
+import { MailListRowsSkeleton, MailSkeleton } from '../mail-skeleton';
+
+function html(element: React.ReactElement): HTMLElement {
+ const container = document.createElement('div');
+ container.innerHTML = renderToString(element);
+ return container;
+}
+
+describe('mail skeleton', () => {
+ // The app hides its bottom bar while a message is open on a phone; the
+ // skeleton of /mail/message/ must not draw one either.
+ it('hides the phone bottom bar when a message or conversation is open', () => {
+ const page = html();
+ // The bar's items are its 64px-basis tabs.
+ const bar = page.querySelector('.basis-\\[64px\\]')?.parentElement as HTMLElement;
+ expect(bar).toBeTruthy();
+ expect(bar.className.split(' ')).toContain('max-md:boot-open:hidden');
+ });
+
+ it('draws only the body inside a Pro pane: no frame, no sidebar header', () => {
+ const pane = html();
+ const region = pane.querySelector('[data-boot-skeleton="mail"]')!;
+ expect(region.className).toContain('h-full');
+ expect(region.className).not.toContain('h-dvh');
+ expect(pane.querySelector('.boot-out\\:block')).toBeNull();
+ expect(pane.querySelector('.lg\\:mv\\:hidden.border-b')).toBeNull();
+ });
+
+ it.each([
+ ['extra-compact', 0, 0],
+ ['compact', 9, 0],
+ ['regular', 9, 9],
+ ] as const)('draws %s stacked rows with %i avatars and %i previews', (density, avatars, previews) => {
+ const rows = html();
+ expect(rows.querySelectorAll('.w-10.h-10').length).toBe(avatars);
+ expect(rows.querySelectorAll('.leading-relaxed').length).toBe(previews * 2);
+ });
+
+ it('leaves the preview out when previews are turned off', () => {
+ const rows = html();
+ expect(rows.querySelectorAll('.leading-relaxed').length).toBe(0);
+ });
+
+ it('draws every shape for the boot page and lets the boot attributes pick', () => {
+ const rows = html();
+ expect(rows.querySelector('.boot-xc\\:hidden')).not.toBeNull();
+ expect(rows.querySelector('.boot-dense\\:hidden')).not.toBeNull();
+ expect(rows.querySelector('.md\\:mv\\:block')).not.toBeNull();
+ });
+});
diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx
index 05def799e..bebd4f42c 100644
--- a/components/email/email-list.tsx
+++ b/components/email/email-list.tsx
@@ -29,6 +29,8 @@ import { useShallow } from "zustand/react/shallow";
import { useAuthStore } from "@/stores/auth-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useListRowShape } from "@/hooks/use-list-row-shape";
+import { MailListRowsSkeleton, type MailRowShape } from "@/components/boot-skeleton/mail-skeleton";
+import type { Density } from "@/lib/density";
import { useUIStore } from "@/stores/ui-store";
import { useMessageListTabsStore } from "@/stores/message-list-tabs-store";
import { emailKeyFor, groupEmailsByThread, sortThreadGroups, threadKeyFor, getEmailTagIds } from "@/lib/thread-utils";
@@ -124,27 +126,11 @@ const BATCH_TOOLBAR_WIDE = 480;
// Declared out here so re-renders of the list keep the same element. Inside
// EmailList every render made a new component, which remounted the skeleton
-// and replayed its fade-in each time the store changed while a list loaded.
-function LoadingSkeleton() {
- return (
-
- {[...Array(8)].map((_, i) => (
-
- ))}
-
- );
+// each time the store changed while a list loaded.
+function LoadingSkeleton({ shape, density, preview }: { shape: MailRowShape; density: Density; preview: boolean }) {
+ // The page skeleton's rows (components/boot-skeleton), so the list the app
+ // draws while it loads is the one the server painted before it started.
+ return ;
}
/** Rows per page in simple mode's paged list. */
@@ -1222,7 +1208,13 @@ export function EmailList({
)}
{isLoading && emails.length === 0 ? (
-
+
+
+
) : emails.length === 0 && !isLoading ? (
{/* R9: an empty search offers Clear search; Drafts, which the
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index 17f149483..ecfb817c7 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -114,6 +114,7 @@ import {
Pencil,
Paperclip,
} from "@/components/icons";
+import { MailMessageSkeleton } from "@/components/boot-skeleton/mail-skeleton";
import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/navigation";
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
@@ -3926,54 +3927,11 @@ ${printWatermark ? printWatermark.html : ''}
);
- // Show loading skeleton while email is being fetched
+ // Show loading skeleton while email is being fetched: the page skeleton's
+ // message pane, so a linked message keeps the shape it had before the app
+ // started.
if (isLoading && !email) {
- return shell(
-
- {/* Loading Header Skeleton - gentler animation */}
-
-
-
- {/* Loading Sender Info Skeleton */}
-
-
-
- {/* Loading Content Skeleton */}
-
-
- );
+ return shell();
}
if (!email) {
diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx
index a76ce0940..349afad95 100644
--- a/components/layout/sidebar.tsx
+++ b/components/layout/sidebar.tsx
@@ -44,6 +44,7 @@ import {
MoreVertical,
} from "@/components/icons";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
+import { FOLDER_CHEVRON_SLOT, FOLDER_ROW_PX_BASE, FolderRowsSkeleton } from "@/components/boot-skeleton/mail-skeleton";
import { localizeMailboxName } from "@/lib/mailbox-label";
import {
buildKeywordTree,
@@ -168,8 +169,8 @@ interface SidebarProps {
onLoginSharedMailboxSelect?: (loginId: string, mailboxId: string) => void;
}
-const ROW_PX_BASE = 8;
-const CHEVRON_SLOT = 20;
+const ROW_PX_BASE = FOLDER_ROW_PX_BASE;
+const CHEVRON_SLOT = FOLDER_CHEVRON_SLOT;
const INDENT_STEP = 12;
// Icons and colours follow the JMAP role only.
@@ -2054,8 +2055,11 @@ function SidebarImpl({
{((expanded && !isCollapsed) || isCollapsed) && (
<>
{tree.length === 0 ? (
-
- {!isCollapsed && t("loading_mailboxes")}
+ // The page skeleton's folder rows, so nothing moves when the
+ // folders arrive (components/boot-skeleton).
+
+ {t("loading_mailboxes")}
+ {!isCollapsed && }
) : (
<>
@@ -2120,8 +2124,11 @@ function SidebarImpl({
{(mvFoldersHeadless || (foldersExpanded && !isCollapsed) || isCollapsed) && (
<>
{mailboxes.length === 0 ? (
-
- {!isCollapsed && t("loading_mailboxes")}
+ // The page skeleton's folder rows, so nothing moves when the
+ // folders arrive (components/boot-skeleton).
+
+ {t("loading_mailboxes")}
+ {!isCollapsed && }
) : (
<>
diff --git a/components/mail/__tests__/mail-app-boot-skeleton.test.tsx b/components/mail/__tests__/mail-app-boot-skeleton.test.tsx
new file mode 100644
index 000000000..048718dc2
--- /dev/null
+++ b/components/mail/__tests__/mail-app-boot-skeleton.test.tsx
@@ -0,0 +1,171 @@
+import React, { Suspense, lazy } from 'react';
+import { act, render, waitFor } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { Mailbox } from '@/lib/jmap/types';
+import { useAuthStore } from '@/stores/auth-store';
+import { useEmailStore } from '@/stores/email-store';
+import { useSettingsStore } from '@/stores/settings-store';
+import { usePluginStore } from '@/stores/plugin-store';
+import { useThemeStore } from '@/stores/theme-store';
+import { useUIStore } from '@/stores/ui-store';
+import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
+
+// The mail page hands over from the page skeleton the server drew without
+// anything jumping: the auth gate draws that same skeleton, the list keeps
+// its skeleton rows until the folder's first page is in, and a linked message
+// keeps the reader open while it loads.
+
+const nav = vi.hoisted(() => ({ replace: vi.fn(), push: vi.fn(), back: vi.fn(), prefetch: vi.fn() }));
+vi.mock('@/i18n/navigation', () => ({
+ useRouter: () => nav,
+ usePathname: () => '/en',
+ Link: ({ children }: { children: React.ReactNode }) => children,
+}));
+vi.mock('next/dynamic', () => ({ default: (load: () => Promise
>>) => {
+ const Component = lazy(async () => ({ default: await load() }));
+ return function Dynamic(props: Record) {
+ return ;
+ };
+} }));
+const viewer = vi.hoisted(() => ({ isLoading: [] as boolean[] }));
+vi.mock('@/components/email/email-viewer', () => ({
+ EmailViewer: ({ isLoading }: { isLoading?: boolean }) => {
+ viewer.isLoading.push(!!isLoading);
+ return ;
+ },
+}));
+const list = vi.hoisted(() => ({ isLoading: [] as boolean[] }));
+vi.mock('@/components/email/email-list', () => ({
+ EmailList: ({ isLoading }: { isLoading?: boolean }) => {
+ list.isLoading.push(!!isLoading);
+ return ;
+ },
+}));
+vi.mock('@/components/email/email-composer', () => ({ EmailComposer: () => null }));
+vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null }));
+vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => }));
+vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null }));
+vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null }));
+vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null }));
+vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
+vi.mock('@/components/layout/mv-side-panel', () => ({ MvSidePanel: () => null }));
+vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), MessageListTabs: () => null }));
+vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
+vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) }));
+vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
+vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
+vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
+vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} }));
+vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] }));
+vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} }));
+vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
+vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
+const device = vi.hoisted(() => ({ isMobile: false }));
+vi.mock('@/hooks/use-media-query', () => ({
+ useDeviceDetection: () => ({ isMobile: device.isMobile, isTablet: false, isDesktop: !device.isMobile }),
+ useMediaQuery: () => false,
+ useIsDesktop: () => !device.isMobile,
+}));
+vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
+
+const { MailApp } = await import('../mail-app');
+
+const inbox = {
+ id: 'inbox', name: 'Inbox', role: 'inbox', sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true,
+} as Mailbox;
+
+/** A promise the test settles by hand. */
+function deferred() {
+ let resolve!: (value: T) => void;
+ const promise = new Promise((r) => { resolve = r; });
+ return { promise, resolve };
+}
+
+let fetchEmails: ReturnType;
+let fetchEmailContent: ReturnType;
+
+beforeEach(() => {
+ list.isLoading = [];
+ viewer.isLoading = [];
+ device.isMobile = false;
+ nav.replace.mockReset();
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
+ vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
+ usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
+ useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) });
+ useUIStore.setState({ activeView: 'list' });
+ const client = {
+ getAccountId: () => 'acc', getUsername: () => 'a@example.org',
+ getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null,
+ getMailboxes: vi.fn().mockResolvedValue([inbox]),
+ };
+ useAuthStore.setState({
+ isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [],
+ getClientForAccount: () => client as never,
+ getAllConnectedClients: () => new Map([['login-a', client]]) as never,
+ });
+ useSettingsStore.setState({ enableUnifiedMailbox: false, interfaceLayout: 'default', mailLayout: 'focus' } as never);
+ fetchEmails = vi.fn().mockResolvedValue(undefined);
+ fetchEmailContent = vi.fn().mockResolvedValue(null);
+ useEmailStore.setState({
+ emails: [], selectedEmail: null, selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: [inbox],
+ viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
+ isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '',
+ searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '',
+ selectedEmailKeys: new Set(), unreadSection: null,
+ fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails,
+ fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined),
+ refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined),
+ fetchEmailContent,
+ } as never);
+});
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+});
+
+describe('mail page hand-off from the boot skeleton', () => {
+ it('draws the mail skeleton, not a spinner, while the session is restored', async () => {
+ useAuthStore.setState({ isAuthenticated: false, client: null, checkAuth: vi.fn(() => new Promise(() => {})) } as never);
+ const { container } = render();
+ await act(async () => {});
+ expect(container.querySelector('[data-boot-skeleton="mail"]')).not.toBeNull();
+ expect(container.querySelector('[data-boot-skeleton="mail"] [role="status"]')?.textContent).toBeTruthy();
+ expect(container.querySelector('.animate-spin')).toBeNull();
+ });
+
+ it('keeps the list loading until the folder\'s first page is in, then shows an empty folder as empty', async () => {
+ const page = deferred();
+ fetchEmails.mockReturnValue(page.promise);
+ const { getByTestId } = render();
+ await act(async () => {});
+ // Nothing fetched yet: the store is not loading, but the list must not say "no emails".
+ expect(useEmailStore.getState().isLoading).toBe(false);
+ expect(getByTestId('list').hasAttribute('data-loading')).toBe(true);
+ await act(async () => { page.resolve(); });
+ await waitFor(() => expect(getByTestId('list').hasAttribute('data-loading')).toBe(false));
+ });
+
+ it('keeps the reader open in its loading state while a linked message loads (focus layout)', async () => {
+ const content = deferred();
+ fetchEmailContent.mockReturnValue(content.promise);
+ const { getByTestId, container } = render();
+ await act(async () => {});
+ const reader = container.querySelector('[data-pane="reader"]')!;
+ expect(reader.className).not.toMatch(/(^|\s)md: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|$)/));
+ });
+
+ it('opens a linked message over the list on a phone and hides the bottom bar meanwhile', async () => {
+ device.isMobile = true;
+ fetchEmailContent.mockReturnValue(new Promise(() => {}));
+ const { container, queryByTestId } = render();
+ await act(async () => {});
+ const reader = container.querySelector('[data-pane="reader"]')!;
+ expect(reader.className).not.toMatch(/(^|\s)max-md:hidden(\s|$)/);
+ expect(queryByTestId('bottom-nav')).toBeNull();
+ });
+});
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index 33ae06070..8eef489bb 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -176,6 +176,8 @@ import type { QuoteHeader } from "@/lib/plugin-types";
import { initialLinkSearch, useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
import { useMinimisedComposerFlush } from "@/hooks/use-minimised-composer-flush";
import { quickReplyEnvelope, saveQuickReplyDraft } from "@/lib/outgoing/quick-reply-envelope";
+import { MailSkeleton } from "@/components/boot-skeleton/mail-skeleton";
+import { useSkeletonLabels } from "@/components/boot-skeleton/use-skeleton-labels";
export interface MailAppProps {
/**
@@ -231,6 +233,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Static Lite build: the route params are empty, read the link from the URL.
const linkSegments = useLiteLinkSegments('mail', routeSegments);
const t = useTranslations();
+ // The page skeleton the gates below draw: the same one the server sent.
+ const skeletonLabels = useSkeletonLabels();
const tNotifications = useTranslations('notifications');
// The toasts' Undo after a delete to Trash, archive, move or spam report.
const undo = undoLabels(tNotifications);
@@ -367,6 +371,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Mobile/tablet responsive hooks
const { isMobile, isTablet } = 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
+ // applied. Otherwise the focused layout and phones showed the list alone
+ // while the session came up and only then opened the reader.
+ const [deepLinkPane, setDeepLinkPane] = useState<'message' | 'thread' | null>(() => {
+ if (isEmbedded || typeof window === 'undefined') return null;
+ const link = parseMailPath(linkSegments ?? [], new URLSearchParams(initialLinkSearch('mail')));
+ return link?.kind === 'message' || link?.kind === 'thread' ? link.kind : null;
+ });
/**
* Mountain View's list cursor. `j` / `k` there move a highlight without
* opening the conversation, marking it read or changing the URL - only
@@ -1960,6 +1973,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
if (!link && !openLatestUnread) {
deepLinkHandledRef.current = true;
+ setDeepLinkPane(null);
return;
}
@@ -1971,7 +1985,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
deepLinkHandledRef.current = true;
if (link) {
- void applyMailDeepLinkRef.current(link, { onLoad: true });
+ void applyMailDeepLinkRef.current(link, { onLoad: true }).finally(() => setDeepLinkPane(null));
return;
}
@@ -4472,14 +4486,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
], !isEmbedded);
if (!initialCheckDone || authLoading || (!isAuthenticated || !client)) {
- return (
-
-
-
-
{t("common.loading")}
-
-
- );
+ return ;
}
// Get current mailbox name for mobile header
@@ -4499,8 +4506,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
&& (pendingDraft?.mode ?? composerMode) !== 'compose' && Boolean(selectedEmail);
const floatingComposer = mvShell && showComposer && !isEmbedded && !mvInlineReply;
const hasViewerContent = (showComposer && !floatingComposer) || Boolean(conversationThread)
- || Boolean(selectedEmail && !(mvShell && cursorOnly));
- const shouldCollapseListPane = (isTablet && !tabletListVisible && !readingPaneEmpty) || (!isMobile && isFocusedMailLayout && hasViewerContent);
+ || 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 shouldHideViewerPane = !isMobile && !hasViewerContent && isFocusedMailLayout;
const shouldHideHorizontalViewerPane = isHorizontalMailLayout && !hasViewerContent;
@@ -4887,7 +4896,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
);
if (!isAuthenticated) {
- return null;
+ return ;
}
return (
@@ -5087,7 +5096,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
: "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 && activeView !== "list" && "max-md:hidden",
+ isMobile && mobileView !== "list" && "max-md:hidden",
// Pane-mobile (narrow Pro pane on a wide viewport): the
// `max-md:` viewport classes above never apply, so mirror
// them with pane-scoped equivalents.
@@ -5328,7 +5337,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
hideBatchToolbar={mvShell}
emails={activeEmails}
selectedEmailKey={selectedEmail ? emailKeyFor(selectedEmail) : undefined}
- isLoading={activeIsLoading}
+ // Until the first page of the folder has arrived an empty list
+ // is not an empty folder: it draws its skeleton rows, not "no
+ // emails".
+ isLoading={activeIsLoading || (!initialMailLoadDone && activeEmails.length === 0)}
hasMore={activeHasMore}
isLoadingMoreItems={isScheduledView ? isLoadingScheduled && activeEmails.length > 0 : undefined}
isScheduledView={isScheduledView}
@@ -5518,7 +5530,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Mobile: full screen overlay when active
"max-md:fixed max-md:inset-0 max-md:z-30",
"max-md:h-full max-md:pt-[env(safe-area-inset-top)]",
- isMobile && activeView !== "viewer" && "max-md:hidden",
+ isMobile && mobileView !== "viewer" && "max-md: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`
@@ -5623,7 +5635,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
{/* Bottom Navigation - mobile and tablet (hidden when embedded) */}
- {(isMobile || isTablet) && activeView !== "viewer" && !isEmbedded && (
+ {(isMobile || isTablet) && mobileView !== "viewer" && !isEmbedded && (
Date: Sun, 1 Nov 2026 19:40:21 +0100
Subject: [PATCH 3/7] feat: show the mail skeleton while a mailto link opens
---
app/(main)/protocol/mailto/page.tsx | 10 ++++++-
.../__tests__/mailto-protocol-client.test.tsx | 26 +++++++++++++++++++
.../protocol/mailto-protocol-client.tsx | 14 +++++-----
3 files changed, 43 insertions(+), 7 deletions(-)
create mode 100644 components/protocol/__tests__/mailto-protocol-client.test.tsx
diff --git a/app/(main)/protocol/mailto/page.tsx b/app/(main)/protocol/mailto/page.tsx
index f2797d699..bef4cbd4c 100644
--- a/app/(main)/protocol/mailto/page.tsx
+++ b/app/(main)/protocol/mailto/page.tsx
@@ -3,6 +3,14 @@ import { MailtoProtocolClient } from "@/components/protocol/mailto-protocol-clie
export default async function MailtoProtocolPage() {
const t = await getTranslations("protocol_handlers");
+ const tSidebar = await getTranslations("sidebar");
+ const labels = {
+ mail: tSidebar("mail"),
+ calendar: tSidebar("calendar"),
+ contacts: tSidebar("contacts"),
+ files: tSidebar("files"),
+ settings: tSidebar("settings"),
+ };
- return ;
+ return ;
}
diff --git a/components/protocol/__tests__/mailto-protocol-client.test.tsx b/components/protocol/__tests__/mailto-protocol-client.test.tsx
new file mode 100644
index 000000000..ef594d63d
--- /dev/null
+++ b/components/protocol/__tests__/mailto-protocol-client.test.tsx
@@ -0,0 +1,26 @@
+import { render } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('@/lib/protocol-handlers/session', () => ({
+ // Still asking an open tab whether it takes the message.
+ requestOpenMailtoInExistingClient: vi.fn(() => new Promise(() => {})),
+ savePendingMailto: vi.fn(),
+}));
+
+const { MailtoProtocolClient } = await import('../mailto-protocol-client');
+
+afterEach(() => {
+ window.history.replaceState(null, '', '/');
+});
+
+describe('mailto handler page', () => {
+ // It hands over to the mail page, where the composer opens: it draws that
+ // page's skeleton instead of a line of text on a blank page.
+ it('draws the mail skeleton with the opening text as its status', () => {
+ window.history.replaceState(null, '', '/protocol/mailto?url=mailto%3Aa%40example.com');
+ const { container } = render();
+ const region = container.querySelector('[data-boot-skeleton="mail"]');
+ expect(region).not.toBeNull();
+ expect(region!.querySelector('[role="status"]')?.textContent).toBe('Opening email…');
+ });
+});
diff --git a/components/protocol/mailto-protocol-client.tsx b/components/protocol/mailto-protocol-client.tsx
index e6b8e2b1d..70bd5ed54 100644
--- a/components/protocol/mailto-protocol-client.tsx
+++ b/components/protocol/mailto-protocol-client.tsx
@@ -1,6 +1,8 @@
"use client";
import { useEffect } from "react";
+import { MailSkeleton } from "@/components/boot-skeleton/mail-skeleton";
+import type { RailLabels } from "@/components/boot-skeleton/shell";
import { parseMailto } from "@/lib/protocol-handlers/mailto";
import { requestOpenMailtoInExistingClient, savePendingMailto } from "@/lib/protocol-handlers/session";
import { useSettingsStore } from "@/stores/settings-store";
@@ -51,9 +53,11 @@ async function focusExistingClient() {
interface MailtoProtocolClientProps {
openingText: string;
+ /** The phone bottom bar's labels for the skeleton. */
+ labels?: RailLabels;
}
-export function MailtoProtocolClient({ openingText }: MailtoProtocolClientProps) {
+export function MailtoProtocolClient({ openingText, labels }: MailtoProtocolClientProps) {
useEffect(() => {
let cancelled = false;
@@ -99,9 +103,7 @@ export function MailtoProtocolClient({ openingText }: MailtoProtocolClientProps)
};
}, []);
- return (
-
- {openingText}
-
- );
+ // The mail page this hands over to, where the composer opens: its skeleton,
+ // with the opening text as the status message.
+ return ;
}
From 3b99ce26ae023a998b0e0e5cfb3f45fe1d048635 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sun, 1 Nov 2026 19:52:36 +0100
Subject: [PATCH 4/7] feat: draw the reader's toolbar and a phone's
conversation view while a linked message loads
---
.../__tests__/mail-skeleton.test.tsx | 6 +-
components/boot-skeleton/mail-skeleton.tsx | 104 ++++++++++++++++--
components/email/thread-conversation-view.tsx | 11 +-
.../__tests__/mail-app-boot-skeleton.test.tsx | 11 ++
components/mail/mail-app.tsx | 11 +-
5 files changed, 127 insertions(+), 16 deletions(-)
diff --git a/components/boot-skeleton/__tests__/mail-skeleton.test.tsx b/components/boot-skeleton/__tests__/mail-skeleton.test.tsx
index 1e7e565ff..f99b30e85 100644
--- a/components/boot-skeleton/__tests__/mail-skeleton.test.tsx
+++ b/components/boot-skeleton/__tests__/mail-skeleton.test.tsx
@@ -13,8 +13,10 @@ describe('mail skeleton', () => {
// skeleton of /mail/message/ must not draw one either.
it('hides the phone bottom bar when a message or conversation is open', () => {
const page = html();
- // The bar's items are its 64px-basis tabs.
- const bar = page.querySelector('.basis-\\[64px\\]')?.parentElement as HTMLElement;
+ // The bar's items are its 64px-basis tabs; it is the last such row on the page.
+ const tabs = page.querySelectorAll('.basis-\\[64px\\]');
+ 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');
});
diff --git a/components/boot-skeleton/mail-skeleton.tsx b/components/boot-skeleton/mail-skeleton.tsx
index 9b0932865..38634255d 100644
--- a/components/boot-skeleton/mail-skeleton.tsx
+++ b/components/boot-skeleton/mail-skeleton.tsx
@@ -14,7 +14,13 @@
*/
import type { ReactNode } from "react";
import {
+ ArrowLeft,
CalendarEvent,
+ ChevronLeft,
+ ChevronRight,
+ Forward,
+ Reply,
+ ReplyAll,
ChevronDown,
ChevronsLeft,
Filter,
@@ -396,18 +402,34 @@ function MobileHeaderSkeleton() {
* default reader, then the message body's tinted sheet.
*/
export function MailMessageSkeleton({ className }: { className?: string }) {
- const button = "h-8 w-8 sm:w-20 rounded-md";
+ const button = "h-8 w-20 rounded-md";
return (
-
-
+ {/* Phones: back, then icon-over-label actions. */}
+
+
+
+
+ {repeat(6, (i) => (
+
+
+
+
+ ))}
+
+
+
+ {/* Back to the list, where the reader has the list's place (tablet, focus). */}
+
+
+
+
-
-
+
@@ -420,7 +442,11 @@ export function MailMessageSkeleton({ className }: { className?: string }) {
-
+ {/* Date and size. */}
+
+
+
+
@@ -429,7 +455,8 @@ export function MailMessageSkeleton({ className }: { className?: string }) {
-
+
+
@@ -444,6 +471,62 @@ export function MailMessageSkeleton({ className }: { className?: string }) {
+ {/* Phones: the reply bar along the bottom (previous, reply, reply all, forward, next). */}
+
+ {[ChevronLeft, Reply, ReplyAll, Forward, ChevronRight].map((Icon, i) => (
+
+
+
+
+ ))}
+
+
+ );
+}
+
+/**
+ * A conversation on a phone while it loads (ThreadConversationView): the
+ * header with back, subject and count, then the message cards.
+ */
+export function MailConversationSkeleton({ className }: { className?: string }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {repeat(4, () => )}
+
+
+
+
+ {repeat(3, (i) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
);
}
@@ -524,7 +607,8 @@ export function MailSkeletonBody({ embedded = false }: { embedded?: boolean }) {
-
+ {/* A conversation link opens the conversation view on a phone, its newest message elsewhere. */}
+
+
diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx
index 73f96e501..98e5b407c 100644
--- a/components/email/thread-conversation-view.tsx
+++ b/components/email/thread-conversation-view.tsx
@@ -35,7 +35,7 @@ import { brandIndicatorDomain } from "@/lib/email-headers";
import { Button } from "@/components/ui/button";
import { formatDate, formatFileSize, cn, cleanPreview } from "@/lib/utils";
import { IconButton } from "@/components/ui/icon-button";
-import { PaneSpinner } from "@/components/ui/spinner";
+import { MailConversationSkeleton } from "@/components/boot-skeleton/mail-skeleton";
import {
ArrowLeft,
ChevronDown,
@@ -245,8 +245,13 @@ export function ThreadConversationView({
};
if (isLoading) {
- // One centred spinner for a whole pane (R9).
- return ;
+ // The conversation's shape (the page skeleton's), with one status message.
+ return (
+
+ {t("threads.loading")}
+
+
+ );
}
return (
diff --git a/components/mail/__tests__/mail-app-boot-skeleton.test.tsx b/components/mail/__tests__/mail-app-boot-skeleton.test.tsx
index 048718dc2..920c50b20 100644
--- a/components/mail/__tests__/mail-app-boot-skeleton.test.tsx
+++ b/components/mail/__tests__/mail-app-boot-skeleton.test.tsx
@@ -168,4 +168,15 @@ describe('mail page hand-off from the boot skeleton', () => {
expect(reader.className).not.toMatch(/(^|\s)max-md:hidden(\s|$)/);
expect(queryByTestId('bottom-nav')).toBeNull();
});
+
+ it('draws a linked conversation on a phone as the conversation view, not the message viewer, while it loads', async () => {
+ device.isMobile = true;
+ const client = useAuthStore.getState().client as unknown as Record;
+ client.getThreadEmails = vi.fn(() => new Promise(() => {}));
+ const { container, queryByTestId } = render();
+ await act(async () => {});
+ const reader = container.querySelector('[data-pane="reader"]')!;
+ expect(reader.querySelector('[aria-busy="true"] [role="status"]')).not.toBeNull();
+ expect(queryByTestId('viewer')).toBeNull();
+ });
});
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index 8eef489bb..a19c3c670 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -176,7 +176,7 @@ import type { QuoteHeader } from "@/lib/plugin-types";
import { initialLinkSearch, useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
import { useMinimisedComposerFlush } from "@/hooks/use-minimised-composer-flush";
import { quickReplyEnvelope, saveQuickReplyDraft } from "@/lib/outgoing/quick-reply-envelope";
-import { MailSkeleton } from "@/components/boot-skeleton/mail-skeleton";
+import { MailConversationSkeleton, MailSkeleton } from "@/components/boot-skeleton/mail-skeleton";
import { useSkeletonLabels } from "@/components/boot-skeleton/use-skeleton-labels";
export interface MailAppProps {
@@ -5580,7 +5580,14 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
)}
{/* Mobile Conversation View - shown when thread is selected on mobile */}
- {isMobile && conversationThread ? (
+ {isMobile && !conversationThread && deepLinkPane === 'thread' ? (
+ // A linked conversation on a phone, still loading: the shape the
+ // page skeleton gave it.
+
+ {t('threads.loading')}
+
+
+ ) : isMobile && conversationThread ? (
Date: Sun, 1 Nov 2026 19:54:18 +0100
Subject: [PATCH 5/7] fix: let the reader go when a linked account has no
folders to open the link in
---
components/mail/mail-app.tsx | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index a19c3c670..b7a283e66 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -1961,7 +1961,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
useEffect(() => {
if (deepLinkHandledRef.current) return;
if (!isAuthenticated || !client) return;
- if (mailboxes.length === 0) return;
+ if (mailboxes.length === 0) {
+ // The account has no folders to open the link in: stop holding the reader open for it.
+ if (initialMailLoadDone) setDeepLinkPane(null);
+ return;
+ }
if (!initialMailLoadDone) return;
const params = new URLSearchParams(initialSearchRef.current ?? window.location.search);
From 78ebb62e40e6c8e14202adb35b7c65f7e869b890 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sun, 1 Nov 2026 20:00:48 +0100
Subject: [PATCH 6/7] fix: fill tall screens and dark sidebars in the mail
skeleton
---
.../boot-skeleton/__tests__/mail-skeleton.test.tsx | 4 ++--
components/boot-skeleton/mail-skeleton.tsx | 14 ++++++++++----
2 files changed, 12 insertions(+), 6 deletions(-)
diff --git a/components/boot-skeleton/__tests__/mail-skeleton.test.tsx b/components/boot-skeleton/__tests__/mail-skeleton.test.tsx
index f99b30e85..27d59123b 100644
--- a/components/boot-skeleton/__tests__/mail-skeleton.test.tsx
+++ b/components/boot-skeleton/__tests__/mail-skeleton.test.tsx
@@ -32,8 +32,8 @@ describe('mail skeleton', () => {
it.each([
['extra-compact', 0, 0],
- ['compact', 9, 0],
- ['regular', 9, 9],
+ ['compact', 16, 0],
+ ['regular', 16, 16],
] as const)('draws %s stacked rows with %i avatars and %i previews', (density, avatars, previews) => {
const rows = html();
expect(rows.querySelectorAll('.w-10.h-10').length).toBe(avatars);
diff --git a/components/boot-skeleton/mail-skeleton.tsx b/components/boot-skeleton/mail-skeleton.tsx
index 38634255d..9d270b954 100644
--- a/components/boot-skeleton/mail-skeleton.tsx
+++ b/components/boot-skeleton/mail-skeleton.tsx
@@ -74,7 +74,8 @@ function TextLine({ className, w, bar = "h-3" }: { className?: string; w: string
*/
export function FolderRowsSkeleton({ count = 7 }: { count?: number }) {
return (
-
+ // On the sidebar's panel colour, which dark themes share with the shapes.
+
{repeat(count, (i) => (
@@ -204,7 +207,8 @@ function StackedRow({ i, o }: { i: number; o: RowOptions }) {
{preview !== null && (
-
+ {/* Mountain View's phone rows keep the preview to one line. */}
+
)}
@@ -274,7 +278,7 @@ function MvRow({ i }: { i: number }) {
}
/** Enough rows of each shape to fill a tall viewport. */
-const ROWS: Record
= { stacked: 9, "one-line": 24, mv: 22 };
+const ROWS: Record = { stacked: 16, "one-line": 32, mv: 22 };
/**
* Message list rows. With `shape`, the rows the loaded list is about to draw
@@ -370,6 +374,8 @@ function MobileHeaderSkeleton() {
"lg: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.
+ "mv:[&_.sk-shape]:bg-foreground/10",
)}
>
@@ -378,7 +384,7 @@ function MobileHeaderSkeleton() {
-
+
From 90044b9d5a352e338e2536200636de194ef75fa5 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Mon, 2 Nov 2026 19:42:40 +0100
Subject: [PATCH 7/7] perf: draw the mail skeleton's rows with less markup
---
app/boot-skeleton.css | 56 +++++++-
.../__tests__/mail-skeleton.test.tsx | 2 +-
components/boot-skeleton/mail-skeleton.tsx | 135 +++++++++---------
components/boot-skeleton/shell.tsx | 4 -
4 files changed, 120 insertions(+), 77 deletions(-)
diff --git a/app/boot-skeleton.css b/app/boot-skeleton.css
index 09908b5c4..5254a5240 100644
--- a/app/boot-skeleton.css
+++ b/app/boot-skeleton.css
@@ -95,8 +95,9 @@
/* mail */
/* List density (the density setting, data-boot-density): extra-compact rows
+ (boot-density-xc, the same variant contacts uses: drop one copy on merging)
have no avatar, compact and extra-compact ones no preview. */
-@custom-variant boot-xc (&:where([data-boot-density="extra-compact"] *));
+@custom-variant boot-density-xc (&:where([data-boot-density="extra-compact"] *));
@custom-variant boot-dense (&:where([data-boot-density="compact"] *, [data-boot-density="extra-compact"] *));
/* Message previews turned off (showPreview). */
@custom-variant boot-no-preview (&:where([data-boot-preview="off"] *));
@@ -104,3 +105,56 @@
@custom-variant boot-std (&:where(:root:not([data-interface-layout="mountain-view"]) *));
/* Mountain View's side panel shows only with calendar or contacts. */
@custom-variant boot-no-mv-side (&:where([data-boot-rail]:not([data-boot-rail~="calendar"]):not([data-boot-rail~="contacts"]) *));
+
+/* Message list rows (MailListRowsSkeleton), in short classes because a
+ skeleton draws dozens of them. Geometry as ThreadListItem's: a 1px rule,
+ the cell's density padding and gap, its minimum height on stacked rows.
+ In the components layer, so utilities on the same element still win. */
+@layer components {
+ .sk-mrow {
+ border-bottom: 1px solid var(--color-border);
+ background-color: var(--color-background);
+ }
+ .sk-mcell {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--density-item-gap);
+ padding: var(--density-item-py) 0.75rem;
+ }
+ .sk-mcell-min {
+ min-height: var(--list-item-height);
+ }
+ /* The avatar's column: centred on the row. */
+ .sk-mav {
+ display: flex;
+ flex-shrink: 0;
+ align-self: stretch;
+ align-items: center;
+ }
+ /* A line of text as tall as the real one (font size and leading from its
+ classes), with its bar centred on it. */
+ .sk-ml {
+ position: relative;
+ display: block;
+ height: 1lh;
+ }
+ .sk-ml > .sk-shape {
+ position: absolute;
+ inset-block: 0;
+ inset-inline-start: 0;
+ height: 0.75rem;
+ margin-block: auto;
+ border-radius: 0.25rem;
+ }
+}
+
+/* Panels drawn in the shapes' own colour (the sidebar in dark themes, the
+ Mountain View phone header): lift the shapes off them. Below the contrast
+ rules' specificity, and not in forced colours. */
+@media not (forced-colors: active) {
+ :where(.dark) .sk-lift-dark .sk-shape,
+ :where([data-interface-layout="mountain-view"]) .sk-lift-mv .sk-shape,
+ .sk-lift .sk-shape {
+ background-color: color-mix(in oklab, var(--color-foreground) 10%, transparent);
+ }
+}
diff --git a/components/boot-skeleton/__tests__/mail-skeleton.test.tsx b/components/boot-skeleton/__tests__/mail-skeleton.test.tsx
index 27d59123b..784665b8c 100644
--- a/components/boot-skeleton/__tests__/mail-skeleton.test.tsx
+++ b/components/boot-skeleton/__tests__/mail-skeleton.test.tsx
@@ -47,7 +47,7 @@ describe('mail skeleton', () => {
it('draws every shape for the boot page and lets the boot attributes pick', () => {
const rows = html();
- expect(rows.querySelector('.boot-xc\\:hidden')).not.toBeNull();
+ expect(rows.querySelector('.boot-density-xc\\:hidden')).not.toBeNull();
expect(rows.querySelector('.boot-dense\\:hidden')).not.toBeNull();
expect(rows.querySelector('.md\\:mv\\:block')).not.toBeNull();
});
diff --git a/components/boot-skeleton/mail-skeleton.tsx b/components/boot-skeleton/mail-skeleton.tsx
index 9d270b954..ae430177e 100644
--- a/components/boot-skeleton/mail-skeleton.tsx
+++ b/components/boot-skeleton/mail-skeleton.tsx
@@ -32,7 +32,6 @@ import {
Search,
Settings,
Square,
- Star,
Users,
} from "@/components/icons";
import { cn } from "@/lib/cn";
@@ -75,7 +74,7 @@ function TextLine({ className, w, bar = "h-3" }: { className?: string; w: string
export function FolderRowsSkeleton({ count = 7 }: { count?: number }) {
return (
// On the sidebar's panel colour, which dark themes share with the shapes.
-
+
{repeat(count, (i) => (
@@ -172,7 +171,7 @@ interface RowOptions {
/** Classes for the avatar, or null where the density has none. */
function avatarClass(o: RowOptions): string | null {
- if (o.density === undefined) return "boot-xc:hidden";
+ if (o.density === undefined) return "boot-density-xc:hidden";
return o.density === "extra-compact" ? null : "";
}
@@ -182,37 +181,49 @@ function previewClass(o: RowOptions): string | null {
return o.preview === false || o.density === "compact" || o.density === "extra-compact" ? null : "";
}
+/*
+ * The rows below are drawn dozens of times, so they use the short classes in
+ * app/boot-skeleton.css (mail section) for what every row repeats: sk-mrow
+ * (the row and its rule), sk-mcell (the cell: the density's padding and gap,
+ * and its minimum height), sk-mav (the avatar's column) and sk-ml (a line of
+ * text, with its bar).
+ */
+
+/** One line of row text, as tall as the real line; `w` is the bar's width. */
+function L({ className, w }: { className?: string; w: string }) {
+ return (
+
+
+
+ );
+}
+
/** A stacked row (ThreadListItem on phones and in the split list). */
function StackedRow({ i, o }: { i: number; o: RowOptions }) {
const avatar = avatarClass(o);
const preview = previewClass(o);
return (
-
-
-
- {avatar !== null && (
-
-
+
+
+ {avatar !== null && (
+
+
+
+ )}
+
+
+
+
+
+
+ {preview !== null && (
+
+ {/* Mountain View's phone rows keep the preview to one line, beside a 32px star. */}
+
+
)}
-
-
-
-
-
-
- {preview !== null && (
-
-
- {/* Mountain View's phone rows keep the preview to one line. */}
-
-
- )}
-
-
+
);
@@ -222,25 +233,18 @@ function StackedRow({ i, o }: { i: number; o: RowOptions }) {
function OneLineRow({ i, o }: { i: number; o: RowOptions }) {
const avatar = avatarClass(o);
return (
-
-
-
- {avatar !== null && (
-
-
-
- )}
-
-
-
-
-
-
+
+
+ {avatar !== null && (
+
+
-
+ )}
+
+
+
+
+
);
@@ -249,29 +253,18 @@ function OneLineRow({ i, o }: { i: number; o: RowOptions }) {
/** Mountain View's row on a tablet or desktop: check box and star, then one 40px line. */
function MvRow({ i }: { i: number }) {
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+ {/* The check box and the star, 26px each (an 18px icon in p-1). */}
+
+
+
+
+
+
+
+
+
);
@@ -375,7 +368,7 @@ function MobileHeaderSkeleton() {
"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.
- "mv:[&_.sk-shape]:bg-foreground/10",
+ "sk-lift-mv",
)}
>
@@ -384,7 +377,7 @@ function MobileHeaderSkeleton() {
-
+
diff --git a/components/boot-skeleton/shell.tsx b/components/boot-skeleton/shell.tsx
index 1b8e0b1de..0c2369253 100644
--- a/components/boot-skeleton/shell.tsx
+++ b/components/boot-skeleton/shell.tsx
@@ -182,10 +182,6 @@ export function MvTopBarSkeleton({ search = true, className }: { search?: boolea
- {/* The account switcher. */}
-
-
-
);