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] 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 && (