- {!isCollapsed && t("loading_mailboxes")}
+ // The page skeleton's folder rows, so nothing moves when the
+ // folders arrive (components/boot-skeleton).
+
) : (
<>
@@ -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..920c50b20
--- /dev/null
+++ b/components/mail/__tests__/mail-app-boot-skeleton.test.tsx
@@ -0,0 +1,182 @@
+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();
+ });
+
+ 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 33ae06070..b7a283e66 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 { MailConversationSkeleton, 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
@@ -1948,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);
@@ -1960,6 +1977,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
if (!link && !openLatestUnread) {
deepLinkHandledRef.current = true;
+ setDeepLinkPane(null);
return;
}
@@ -1971,7 +1989,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 +4490,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 +4510,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 +4900,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
);
if (!isAuthenticated) {
- return null;
+ return ;
}
return (
@@ -5087,7 +5100,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 +5341,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 +5534,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`
@@ -5568,7 +5584,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 ? (
{/* Bottom Navigation - mobile and tablet (hidden when embedded) */}
- {(isMobile || isTablet) && activeView !== "viewer" && !isEmbedded && (
+ {(isMobile || isTablet) && mobileView !== "viewer" && !isEmbedded && (
({
+ // 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 ;
}
diff --git a/lib/boot-skeleton/__tests__/boot-script.test.ts b/lib/boot-skeleton/__tests__/boot-script.test.ts
index d5ec0703c..9e0bcef60 100644
--- a/lib/boot-skeleton/__tests__/boot-script.test.ts
+++ b/lib/boot-skeleton/__tests__/boot-script.test.ts
@@ -129,6 +129,20 @@ describe('boot script: layout settings', () => {
expect(attr('density')).toBe(density);
});
+ 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 1a4ad5df8..e6f49a2a1 100644
--- a/lib/boot-skeleton/boot-script.ts
+++ b/lib/boot-skeleton/boot-script.ts
@@ -167,8 +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]);
- // Rows that drop their avatar or second line at some densities (contact rows).
+ // 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';