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 ( + + ); +} + +/** 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 ( + + ); + } + return ( + + ); +} + +/** 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 ( +
+
{children}
+
+ ); +} + +/** Mountain View's side panel rail (MvSidePanel): calendar and contacts. */ +function MvSidePanelSkeleton() { + return ( + ); @@ -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 ( 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 ( -