feat: keep the mail skeleton's shapes until the folders, list and linked message arrive

This commit is contained in:
Linus Rath
2026-11-01 19:40:20 +01:00
parent afe3fca08a
commit cdf661d320
6 changed files with 284 additions and 92 deletions
@@ -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/<id> must not draw one either.
it('hides the phone bottom bar when a message or conversation is open', () => {
const page = html(<MailSkeleton label="Loading" />);
// 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(<MailSkeleton label="Loading" embedded />);
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(<MailListRowsSkeleton shape="stacked" density={density} preview />);
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(<MailListRowsSkeleton shape="stacked" density="comfortable" preview={false} />);
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(<MailListRowsSkeleton />);
expect(rows.querySelector('.boot-xc\\:hidden')).not.toBeNull();
expect(rows.querySelector('.boot-dense\\:hidden')).not.toBeNull();
expect(rows.querySelector('.md\\:mv\\:block')).not.toBeNull();
});
});
+14 -22
View File
@@ -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 (
<div className="animate-in fade-in duration-200" data-testid="email-list-skeleton">
{[...Array(8)].map((_, i) => (
<div key={i} className="border-b border-border px-4 py-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-muted/50 rounded-full" />
<div className="flex-1">
<div className="flex items-center justify-between mb-2">
<div className="h-4 bg-muted/50 rounded w-32" />
<div className="h-3 bg-muted/50 rounded w-16" />
</div>
<div className="h-4 bg-muted/50 rounded w-3/4 mb-2" />
<div className="h-3 bg-muted/50 rounded w-full" />
</div>
</div>
</div>
))}
</div>
);
// 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 <MailListRowsSkeleton shape={shape} density={density} preview={preview} className="contents" />;
}
/** Rows per page in simple mode's paged list. */
@@ -1222,7 +1208,13 @@ export function EmailList({
)}
{isLoading && emails.length === 0 ? (
<LoadingSkeleton />
<div data-testid="email-list-skeleton">
<LoadingSkeleton
shape={isFocusedMailLayout ? (mountainViewList ? 'mv' : 'one-line') : 'stacked'}
density={density}
preview={showPreview}
/>
</div>
) : emails.length === 0 && !isLoading ? (
<div className="flex flex-col items-center justify-center h-full">
{/* R9: an empty search offers Clear search; Drafts, which the
+5 -47
View File
@@ -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 : ''}
</div>
);
// 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(
<div className="flex-1 min-w-0 flex flex-col h-full bg-background overflow-hidden animate-in fade-in duration-200">
{/* Loading Header Skeleton - gentler animation */}
<div className="bg-background border-b border-border">
<div className="px-4 lg:px-6 py-3 lg:py-4">
<div className="flex items-start justify-between gap-2 lg:gap-4">
<div className="flex-1 min-w-0 space-y-2 lg:space-y-3">
<div className="h-6 lg:h-8 bg-muted/60 rounded-md w-3/4"></div>
<div className="flex items-center gap-2 lg:gap-3">
<div className="h-3 lg:h-4 bg-muted/60 rounded w-24 lg:w-32"></div>
<div className="h-3 lg:h-4 bg-muted/60 rounded w-16 lg:w-24"></div>
</div>
</div>
<div className="flex items-center gap-1 lg:gap-2">
<div className="h-8 w-8 lg:w-20 bg-muted/60 rounded"></div>
<div className="h-8 w-8 bg-muted/60 rounded hidden lg:block"></div>
</div>
</div>
</div>
{/* Loading Sender Info Skeleton */}
<div className="px-4 lg:px-6 pb-3 lg:pb-4">
<div className="flex items-start gap-3 lg:gap-4">
<div className="w-10 h-10 lg:w-12 lg:h-12 bg-muted/60 rounded-full"></div>
<div className="flex-1 space-y-2">
<div className="h-4 bg-muted/60 rounded w-48"></div>
<div className="h-3 bg-muted/60 rounded w-64"></div>
</div>
</div>
</div>
</div>
{/* Loading Content Skeleton */}
<div className="flex-1 overflow-auto bg-muted/20">
<div className="px-6 pt-4 pb-6">
<div className="space-y-3">
<div className="h-4 bg-muted/60 rounded w-full"></div>
<div className="h-4 bg-muted/60 rounded w-5/6"></div>
<div className="h-4 bg-muted/60 rounded w-4/6"></div>
<div className="h-4 bg-muted/60 rounded w-full"></div>
<div className="h-4 bg-muted/60 rounded w-3/4"></div>
</div>
</div>
</div>
</div>
);
return shell(<MailMessageSkeleton />);
}
if (!email) {
+13 -6
View File
@@ -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 ? (
<div className="px-4 py-2 text-sm text-muted-foreground">
{!isCollapsed && t("loading_mailboxes")}
// The page skeleton's folder rows, so nothing moves when the
// folders arrive (components/boot-skeleton).
<div data-testid="folders-loading">
<span className="sr-only">{t("loading_mailboxes")}</span>
{!isCollapsed && <FolderRowsSkeleton />}
</div>
) : (
<>
@@ -2120,8 +2124,11 @@ function SidebarImpl({
{(mvFoldersHeadless || (foldersExpanded && !isCollapsed) || isCollapsed) && (
<>
{mailboxes.length === 0 ? (
<div className="px-4 py-2 text-sm text-muted-foreground">
{!isCollapsed && t("loading_mailboxes")}
// The page skeleton's folder rows, so nothing moves when the
// folders arrive (components/boot-skeleton).
<div data-testid="folders-loading">
<span className="sr-only">{t("loading_mailboxes")}</span>
{!isCollapsed && <FolderRowsSkeleton />}
</div>
) : (
<>
@@ -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<React.ComponentType<Record<string, unknown>>>) => {
const Component = lazy(async () => ({ default: await load() }));
return function Dynamic(props: Record<string, unknown>) {
return <Suspense fallback={null}><Component {...props} /></Suspense>;
};
} }));
const viewer = vi.hoisted(() => ({ isLoading: [] as boolean[] }));
vi.mock('@/components/email/email-viewer', () => ({
EmailViewer: ({ isLoading }: { isLoading?: boolean }) => {
viewer.isLoading.push(!!isLoading);
return <div data-testid="viewer" data-loading={isLoading ? '' : undefined} />;
},
}));
const list = vi.hoisted(() => ({ isLoading: [] as boolean[] }));
vi.mock('@/components/email/email-list', () => ({
EmailList: ({ isLoading }: { isLoading?: boolean }) => {
list.isLoading.push(!!isLoading);
return <div data-testid="list" data-loading={isLoading ? '' : undefined} />;
},
}));
vi.mock('@/components/email/email-composer', () => ({ EmailComposer: () => null }));
vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null }));
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => <div data-testid="bottom-nav" /> }));
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<T = void>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((r) => { resolve = r; });
return { promise, resolve };
}
let fetchEmails: ReturnType<typeof vi.fn>;
let fetchEmailContent: ReturnType<typeof vi.fn>;
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(<MailApp />);
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(<MailApp />);
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<null>();
fetchEmailContent.mockReturnValue(content.promise);
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(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(<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(queryByTestId('bottom-nav')).toBeNull();
});
});
+29 -17
View File
@@ -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 (
<div className="flex h-screen items-center justify-center bg-background">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-foreground mx-auto"></div>
<p className="mt-4 text-sm text-muted-foreground">{t("common.loading")}</p>
</div>
</div>
);
return <MailSkeleton label={skeletonLabels.label} labels={skeletonLabels.labels} embedded={isEmbedded} />;
}
// 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 <MailSkeleton label={skeletonLabels.label} labels={skeletonLabels.labels} embedded={isEmbedded} />;
}
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 = {}) {
<EmailViewer
email={selectedEmail}
blobSource={resolveEmailBlobContext(selectedEmail, client)}
isLoading={isLoadingEmail}
isLoading={isLoadingEmail || deepLinkPane !== null}
onReply={handleReply}
onReplyAll={handleReplyAll}
onForward={handleForward}
@@ -5749,7 +5761,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
</div>
{/* Bottom Navigation - mobile and tablet (hidden when embedded) */}
{(isMobile || isTablet) && activeView !== "viewer" && !isEmbedded && (
{(isMobile || isTablet) && mobileView !== "viewer" && !isEmbedded && (
<NavigationRail
orientation="horizontal"
onManageApps={handleManageApps}