mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 08:15:38 +00:00
Merge branch 'feat/boot-skeleton-mail' into feat/boot-skeleton
# Conflicts: # app/boot-skeleton.css # lib/boot-skeleton/boot-script.ts
This commit is contained in:
@@ -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 <MailtoProtocolClient openingText={t("opening_mailto")} />;
|
||||
return <MailtoProtocolClient openingText={t("opening_mailto")} labels={labels} />;
|
||||
}
|
||||
|
||||
@@ -351,5 +351,69 @@
|
||||
@media (width < 48rem) {
|
||||
.sk-cal-cols {
|
||||
--sk-col: 120px;
|
||||
|
||||
/* mail */
|
||||
|
||||
/* List density (the density setting, data-boot-density): extra-compact rows
|
||||
(boot-density-xc, defined with contacts above) have no avatar, compact and
|
||||
extra-compact ones no preview. */
|
||||
@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"]) *));
|
||||
|
||||
/* Message list rows (MailListRowsSkeleton), in short classes because a
|
||||
skeleton draws dozens of them. Geometry as ThreadListItem's: a 1px rule,
|
||||
the cell's density padding and gap, its minimum height on stacked rows.
|
||||
In the components layer, so utilities on the same element still win. */
|
||||
@layer components {
|
||||
.sk-mrow {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background-color: var(--color-background);
|
||||
}
|
||||
.sk-mcell {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--density-item-gap);
|
||||
padding: var(--density-item-py) 0.75rem;
|
||||
}
|
||||
.sk-mcell-min {
|
||||
min-height: var(--list-item-height);
|
||||
}
|
||||
/* The avatar's column: centred on the row. */
|
||||
.sk-mav {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-self: stretch;
|
||||
align-items: center;
|
||||
}
|
||||
/* A line of text as tall as the real one (font size and leading from its
|
||||
classes), with its bar centred on it. */
|
||||
.sk-ml {
|
||||
position: relative;
|
||||
display: block;
|
||||
height: 1lh;
|
||||
}
|
||||
.sk-ml > .sk-shape {
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
inset-inline-start: 0;
|
||||
height: 0.75rem;
|
||||
margin-block: auto;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Panels drawn in the shapes' own colour (the sidebar in dark themes, the
|
||||
Mountain View phone header): lift the shapes off them. Below the contrast
|
||||
rules' specificity, and not in forced colours. */
|
||||
@media not (forced-colors: active) {
|
||||
:where(.dark) .sk-lift-dark .sk-shape,
|
||||
:where([data-interface-layout="mountain-view"]) .sk-lift-mv .sk-shape,
|
||||
.sk-lift .sk-shape {
|
||||
background-color: color-mix(in oklab, var(--color-foreground) 10%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
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; 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');
|
||||
});
|
||||
|
||||
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', 16, 0],
|
||||
['regular', 16, 16],
|
||||
] 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-density-xc\\:hidden')).not.toBeNull();
|
||||
expect(rows.querySelector('.boot-dense\\:hidden')).not.toBeNull();
|
||||
expect(rows.querySelector('.md\\:mv\\:block')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,42 @@
|
||||
/**
|
||||
* 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 {
|
||||
ArrowLeft,
|
||||
CalendarEvent,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Forward,
|
||||
Reply,
|
||||
ReplyAll,
|
||||
ChevronDown,
|
||||
ChevronsLeft,
|
||||
Filter,
|
||||
Mail,
|
||||
Menu,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings,
|
||||
Square,
|
||||
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 +46,642 @@ 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 (
|
||||
<span aria-hidden="true" className={cn("relative block h-lh", className)}>
|
||||
<Bar className={cn("absolute inset-y-0 start-0 my-auto", bar)} w={w} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// --- 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 (
|
||||
// On the sidebar's panel colour, which dark themes share with the shapes.
|
||||
<div aria-hidden="true" className="sk-lift-dark">
|
||||
{repeat(count, (i) => (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full flex items-center max-lg:min-h-[44px] text-sm pe-2 border-s-2 border-transparent",
|
||||
"mv:min-h-[var(--mv-nav-row-h)] mv:me-3 mv:pe-4 mv:border-s-0",
|
||||
)}
|
||||
style={{ paddingBlock: "var(--density-sidebar-py)" }}
|
||||
>
|
||||
<span className="flex-shrink-0" style={{ width: FOLDER_ROW_PX_BASE + FOLDER_CHEVRON_SLOT }} />
|
||||
<span className="flex items-center gap-2 mv:gap-[18px] min-w-0 flex-1">
|
||||
<span className="flex items-center justify-center flex-shrink-0 w-4 h-4 mv:w-5 mv:h-5">
|
||||
<Block className="w-full h-full rounded" />
|
||||
</span>
|
||||
<TextLine className="flex-1" w={textWidth(i, 35, 30)} />
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The "Folders" section header (SidebarSectionHeader, first). */
|
||||
function FolderSectionHeaderSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div aria-hidden="true" className={cn("w-full flex items-center pb-1 px-3 pt-3", className)}>
|
||||
<span className="flex flex-1 min-w-0 items-center">
|
||||
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||
<TextLine className="ms-1.5 text-sm font-semibold w-16" w="100%" />
|
||||
</span>
|
||||
<span className="-my-0.5 h-6 w-6 grid place-items-center text-muted-foreground/70">
|
||||
<Settings className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarSkeleton({ embedded }: { embedded: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col h-full border-e overflow-hidden bg-secondary border-border",
|
||||
// Dark themes draw the panel in the shapes' own colour: lift them off it.
|
||||
"sk-lift-dark",
|
||||
!embedded && "lg:mv:bg-[var(--mv-canvas)] lg:mv:border-transparent",
|
||||
)}
|
||||
>
|
||||
{/* Header: collapse button and account switcher. Hidden in a Pro pane and under Mountain View. */}
|
||||
{!embedded && (
|
||||
<div className="border-b border-border lg:mv:hidden">
|
||||
<div className="flex items-center h-14 gap-1 px-2">
|
||||
<span className="hidden lg:flex items-center justify-center h-8 w-8 flex-shrink-0 text-muted-foreground">
|
||||
<ChevronsLeft className="w-4 h-4" />
|
||||
</span>
|
||||
<span className="flex flex-1 min-w-0 items-center gap-2 px-2 py-1.5">
|
||||
<Circle className="w-9 h-9" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<TextLine className="text-sm" w="70%" />
|
||||
<TextLine className="text-xs" w="85%" bar="h-2.5" />
|
||||
</span>
|
||||
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Mountain View: the Compose button heads the navigation. */}
|
||||
{!embedded && (
|
||||
<div className="hidden lg:mv:block pt-2 pb-3 px-3">
|
||||
<span className="flex w-fit items-center gap-3 ps-4 pe-6 h-[var(--mv-compose-h,56px)] rounded-2xl bg-[var(--mv-compose-bg)] text-[var(--mv-compose-fg)]">
|
||||
<Pencil className="w-6 h-6 flex-shrink-0" />
|
||||
<TextLine className="text-sm w-14" w="100%" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{/* Mountain View lists the folders without a heading. */}
|
||||
<FolderSectionHeaderSkeleton className={embedded ? undefined : "lg:mv:hidden"} />
|
||||
<FolderRowsSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- 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-density-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 : "";
|
||||
}
|
||||
|
||||
/*
|
||||
* The rows below are drawn dozens of times, so they use the short classes in
|
||||
* app/boot-skeleton.css (mail section) for what every row repeats: sk-mrow
|
||||
* (the row and its rule), sk-mcell (the cell: the density's padding and gap,
|
||||
* and its minimum height), sk-mav (the avatar's column) and sk-ml (a line of
|
||||
* text, with its bar).
|
||||
*/
|
||||
|
||||
/** One line of row text, as tall as the real line; `w` is the bar's width. */
|
||||
function L({ className, w }: { className?: string; w: string }) {
|
||||
return (
|
||||
<span className={cn("sk-ml", className)}>
|
||||
<span className="sk-shape" style={{ width: w }} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="sk-mrow max-md:mv:border-b-transparent">
|
||||
<div className="sk-mcell sk-mcell-min">
|
||||
{avatar !== null && (
|
||||
<span className={cn("sk-mav", avatar)}>
|
||||
<span className="sk-shape block rounded-full w-10 h-10" />
|
||||
</span>
|
||||
)}
|
||||
<span className="block flex-1 min-w-0">
|
||||
<span className="flex items-center justify-between gap-2 mb-1">
|
||||
<L className="text-sm flex-1" w={textWidth(i, 30, 30)} />
|
||||
<L className="text-xs w-10" w="100%" />
|
||||
</span>
|
||||
<L className="mb-1 text-sm" w={textWidth(i + 3, 55, 35)} />
|
||||
{preview !== null && (
|
||||
<span className={cn("block", preview)}>
|
||||
{/* Mountain View's phone rows keep the preview to one line, beside a 32px star. */}
|
||||
<L className="text-sm leading-relaxed max-md:mv:h-8" w="95%" />
|
||||
<L className="text-sm leading-relaxed max-md:mv:hidden" w={textWidth(i + 5, 45, 40)} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="sk-mrow">
|
||||
<div className="sk-mcell items-center">
|
||||
{avatar !== null && (
|
||||
<span className={cn("sk-mav", avatar)}>
|
||||
<span className="sk-shape block rounded-full w-8 h-8" />
|
||||
</span>
|
||||
)}
|
||||
<span className="flex flex-1 min-w-0 gap-3">
|
||||
<L className="w-32 lg:w-40 text-sm" w={textWidth(i, 50, 40)} />
|
||||
<L className="flex-1 text-sm" w={textWidth(i + 2, 35, 45)} />
|
||||
<L className="w-10 text-xs self-center" w="100%" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Mountain View's row on a tablet or desktop: check box and star, then one 40px line. */
|
||||
function MvRow({ i }: { i: number }) {
|
||||
return (
|
||||
<div className="sk-mrow">
|
||||
<div className="sk-mcell items-center py-0 min-h-10">
|
||||
{/* The check box and the star, 26px each (an 18px icon in p-1). */}
|
||||
<span className="flex flex-shrink-0 items-center gap-[2px]">
|
||||
<span className="block w-3.5 h-3.5 m-[6px] rounded-[3px] border-2 border-muted-foreground/70" />
|
||||
<span className="sk-shape block w-3.5 h-3.5 m-[6px] rounded-full" />
|
||||
</span>
|
||||
<span className="flex flex-1 min-w-0 gap-3">
|
||||
<L className="w-32 lg:w-40 text-sm" w={textWidth(i, 45, 40)} />
|
||||
<L className="flex-1 text-sm" w={textWidth(i + 2, 35, 45)} />
|
||||
<L className="w-10 text-xs self-center" w="100%" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Enough rows of each shape to fill a tall viewport. */
|
||||
const ROWS: Record<MailRowShape, number> = { stacked: 16, "one-line": 32, 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 (
|
||||
<div aria-hidden="true" className={className} data-mail-row-skeleton={shape}>
|
||||
{repeat(ROWS[shape], (i) =>
|
||||
shape === "stacked" ? <StackedRow i={i} o={o} /> : shape === "one-line" ? <OneLineRow i={i} o={o} /> : <MvRow i={i} />,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div aria-hidden="true" className={className}>
|
||||
<div className="md:boot-focus:hidden lg:boot-horizontal:hidden md:mv:hidden">
|
||||
{repeat(ROWS.stacked, (i) => <StackedRow i={i} o={o} />)}
|
||||
</div>
|
||||
<div className="hidden md:boot-std:boot-focus:block lg:boot-std:boot-horizontal:block">
|
||||
{repeat(ROWS["one-line"], (i) => <OneLineRow i={i} o={o} />)}
|
||||
</div>
|
||||
<div className="hidden md:mv:block">{repeat(ROWS.mv, (i) => <MvRow i={i} />)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="border-b border-border bg-background max-md:hidden">
|
||||
<div className={cn("px-3 h-14 flex items-center", !embedded && "lg:mv:hidden")}>
|
||||
<div className="flex items-center gap-1 w-full">
|
||||
<span className={icon}>
|
||||
<Square className="w-4 h-4" />
|
||||
</span>
|
||||
<span className="relative flex-1 flex items-center h-8 rounded-md border border-input bg-background ps-8 pe-16">
|
||||
<Search className="absolute start-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<TextLine className="flex-1 text-sm" w="6rem" />
|
||||
<span className="absolute end-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 text-muted-foreground">
|
||||
<span className="pointer-coarse:hidden mx-1 rounded border border-border px-1.5 font-sans text-[0.6875rem] leading-4">
|
||||
/
|
||||
</span>
|
||||
<span className="p-1">
|
||||
<Filter className="w-4 h-4" />
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className={icon}>
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Mountain View's list toolbar (MvListToolbar). */}
|
||||
{!embedded && (
|
||||
<div className="hidden lg:mv:flex items-center gap-1 h-12 ps-[5px] pe-2 shrink-0">
|
||||
<span className={round}>
|
||||
<Square className="w-[18px] h-[18px]" />
|
||||
</span>
|
||||
<span className={round}>
|
||||
<RefreshCw className="w-[18px] h-[18px]" />
|
||||
</span>
|
||||
<span className={round}>
|
||||
<MoreVertical className="w-[18px] h-[18px]" />
|
||||
</span>
|
||||
<span className="flex-1 min-w-0" />
|
||||
<TextLine className="w-24 text-xs pe-2 shrink-0" w="100%" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div
|
||||
className={cn(
|
||||
"lg:hidden flex items-center justify-between h-14 shrink-0",
|
||||
"border-b border-border bg-background px-4",
|
||||
"mv:mx-2 mv:mt-2 mv:mb-1 mv:rounded-full mv:border-0 mv:bg-muted mv:px-2 mv:shadow-sm",
|
||||
// Mountain View's pill is the shapes' own colour: lift them off it.
|
||||
"sk-lift-mv",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0 flex-shrink-0">
|
||||
<span className="flex items-center justify-center h-11 w-11">
|
||||
<Menu className="h-5 w-5" />
|
||||
</span>
|
||||
<TextLine className="max-md:hidden w-28 font-semibold text-lg" w="100%" bar="h-4" />
|
||||
</span>
|
||||
<span className="md:hidden flex-1 min-w-0 h-10 flex items-center gap-2 rounded-full pe-1 mx-1 bg-muted ps-3 mv:mx-0 mv:bg-transparent mv:ps-2 text-muted-foreground sk-lift">
|
||||
<Search className="h-4 w-4 flex-shrink-0" />
|
||||
<TextLine className="flex-1 text-sm" w="7rem" />
|
||||
</span>
|
||||
<span className="flex items-center gap-1 flex-shrink-0">
|
||||
<span className="flex items-center justify-center h-11 w-11">
|
||||
<MoreVertical className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="flex items-center justify-center w-10 h-10 pointer-coarse:w-11 pointer-coarse:h-11">
|
||||
<Circle className="w-8 h-8" />
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- 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-20 rounded-md";
|
||||
return (
|
||||
<div aria-hidden="true" className={cn("flex-1 min-w-0 flex flex-col h-full bg-background overflow-hidden", className)}>
|
||||
<div className="bg-background flex-shrink-0 border-b border-border">
|
||||
<div className="px-2 sm:px-4 lg:px-6 flex items-center h-14">
|
||||
{/* Phones: back, then icon-over-label actions. */}
|
||||
<div className="sm:hidden flex items-center justify-between gap-0.5 w-full">
|
||||
<span className="flex items-center justify-center h-9 w-9 -ms-1 flex-shrink-0">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</span>
|
||||
{repeat(6, (i) => (
|
||||
<span className="flex flex-col items-center gap-1 px-1 flex-1">
|
||||
<Block className="w-5 h-5 rounded" />
|
||||
<Bar className="h-2" w={i === 5 ? "3.5rem" : "2.25rem"} />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="max-sm:hidden flex items-center justify-between gap-2 w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Back to the list, where the reader has the list's place (tablet, focus). */}
|
||||
<span className="hidden md:max-lg:flex md:boot-focus:flex items-center justify-center h-9 w-9 -ms-1 flex-shrink-0">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</span>
|
||||
<Block className={button} />
|
||||
<Block className={button} />
|
||||
<Block className={button} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Block className={button} />
|
||||
<Block className={button} />
|
||||
<Block className={cn(button, "max-md:hidden")} />
|
||||
<Block className="h-8 w-8 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-background border-b border-border">
|
||||
<div className="px-4 lg:px-6" style={{ paddingBlock: "var(--density-header-py)" }}>
|
||||
<div className="flex items-start justify-between gap-2 lg:gap-4">
|
||||
<TextLine className="flex-1 min-w-0 text-lg lg:text-xl font-bold" w="70%" bar="h-4 lg:h-5" />
|
||||
{/* Date and size. */}
|
||||
<span className="w-28 max-sm:hidden flex flex-col items-end">
|
||||
<TextLine className="w-full text-sm" w="100%" />
|
||||
<TextLine className="w-8 text-xs" w="100%" bar="h-2.5" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-background px-4 lg:px-6 border-b border-border" style={{ paddingBlock: "var(--density-header-py)" }}>
|
||||
<div className="flex items-start" style={{ gap: "var(--density-item-gap)" }}>
|
||||
<Circle className="w-10 h-10" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<TextLine className="font-semibold" w="10rem" />
|
||||
<TextLine className="text-sm leading-6" w="13rem" />
|
||||
<TextLine className="text-sm leading-6" w="16rem" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden bg-muted/30">
|
||||
<div className="px-6 pt-4 pb-6">
|
||||
<div className="space-y-3">
|
||||
<Bar className="h-4 w-full" />
|
||||
<Bar className="h-4 w-5/6" />
|
||||
<Bar className="h-4 w-4/6" />
|
||||
<Bar className="h-4 w-full" />
|
||||
<Bar className="h-4 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Phones: the reply bar along the bottom (previous, reply, reply all, forward, next). */}
|
||||
<div className="sm:hidden flex items-center flex-shrink-0 bg-background border-t border-border pb-[calc(env(safe-area-inset-bottom)/2)] text-muted-foreground">
|
||||
{[ChevronLeft, Reply, ReplyAll, Forward, ChevronRight].map((Icon, i) => (
|
||||
<span key={i} className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]">
|
||||
<Icon className="w-5 h-5" />
|
||||
<Bar className="h-2 my-px" w="2.25rem" />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A conversation on a phone while it loads (ThreadConversationView): the
|
||||
* header with back, subject and count, then the message cards.
|
||||
*/
|
||||
export function MailConversationSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div aria-hidden="true" className={cn("flex-1 min-w-0 flex flex-col h-full bg-background overflow-hidden", className)}>
|
||||
<div
|
||||
className="flex items-center px-4 border-b border-border bg-background"
|
||||
style={{ gap: "var(--density-item-gap)", paddingBlock: "var(--density-header-py)" }}
|
||||
>
|
||||
<span className="flex items-center justify-center h-11 w-11 -ms-2 flex-shrink-0">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<TextLine className="font-semibold" w="80%" />
|
||||
<TextLine className="font-semibold" w="45%" />
|
||||
<TextLine className="text-sm" w="5rem" />
|
||||
</span>
|
||||
<span className="flex items-center gap-1 flex-shrink-0">
|
||||
{repeat(4, () => <Block className="h-11 w-11 rounded-md" />)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="space-y-3" style={{ padding: "var(--density-card-p)" }}>
|
||||
{repeat(3, (i) => (
|
||||
<div className="rounded-lg border border-border overflow-hidden bg-background shadow-sm">
|
||||
<div className="w-full flex items-start" style={{ gap: "var(--density-item-gap)", padding: "var(--density-card-p)" }}>
|
||||
<Circle className="w-10 h-10" />
|
||||
<span className="flex-1 min-w-0">
|
||||
<TextLine className="mb-0.5" w={textWidth(i, 35, 25)} />
|
||||
<TextLine className="text-sm" w="3rem" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="border-t border-border px-4 py-4 space-y-3">
|
||||
<Bar className="h-3.5 w-full" />
|
||||
<Bar className="h-3.5 w-5/6" />
|
||||
<Bar className="h-3.5" w={textWidth(i + 2, 40, 40)} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The empty reading pane: nothing open (EmailViewer without an email). */
|
||||
function EmptyReaderSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"flex-1 min-w-0 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center text-center p-8">
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-background shadow-lg flex items-center justify-center">
|
||||
<Mail className="w-10 h-10 text-muted-foreground" />
|
||||
</div>
|
||||
<TextLine className="text-xl font-semibold mb-2 w-56" w="100%" bar="h-4" />
|
||||
<TextLine className="w-72" w="100%" />
|
||||
<Block className="mt-6 h-10 w-28" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The reader's frame (EmailViewer's shell()). */
|
||||
function ReaderFrame({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full bg-background overflow-hidden relative">
|
||||
<div className="flex-1 min-h-0 flex flex-row overflow-hidden relative">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Mountain View's side panel rail (MvSidePanel): calendar and contacts. */
|
||||
function MvSidePanelSkeleton() {
|
||||
return (
|
||||
<div aria-hidden="true" className="hidden lg:mv:flex boot-no-mv-side:hidden w-14 shrink-0 flex-col items-center gap-2 pt-3">
|
||||
<span className="grid h-10 w-10 place-items-center rounded-full boot-no-calendar:hidden">
|
||||
<CalendarEvent className="h-5 w-5 text-[#1a73e8]" />
|
||||
</span>
|
||||
<span className="grid h-10 w-10 place-items-center rounded-full boot-no-contacts:hidden">
|
||||
<Users className="h-5 w-5 text-[#1a73e8]" />
|
||||
</span>
|
||||
<div className="my-1 h-px w-5 bg-border" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- The page ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The mail page's own content: everything in the row next to the rail.
|
||||
* `embedded` is the variant drawn inside a Pro pane (useIsEmbedded()), which
|
||||
* has no rail, no Mountain View shell and fills its pane (h-full).
|
||||
*/
|
||||
export function MailSkeletonBody({ embedded = false }: { embedded?: boolean }) {
|
||||
return <div className="flex-1 min-w-0" data-embedded={embedded ? "" : undefined} />;
|
||||
return (
|
||||
<>
|
||||
{/* Folders: in the flow on a desktop, a closed drawer below that. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="flex-shrink-0 h-full max-lg:hidden"
|
||||
style={{ width: "var(--boot-mail-sidebar-w, 256px)" }}
|
||||
>
|
||||
<SidebarSkeleton embedded={embedded} />
|
||||
</div>
|
||||
{/* AppMain. Under Mountain View the rounded card inset from the canvas. */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col flex-1 min-w-0 h-full",
|
||||
!embedded && "lg:mv:h-auto lg:mv:mb-4 lg:mv:me-4 lg:mv:rounded-[var(--mv-card-radius)] lg:mv:bg-background lg:mv:overflow-hidden",
|
||||
)}
|
||||
>
|
||||
<div className={cn("relative flex flex-1 min-h-0", !embedded && "lg:boot-horizontal:flex-col")}>
|
||||
{/* The message list. */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col bg-background min-w-0 h-full border-e border-border",
|
||||
// Phones and tablets give an open message the whole width.
|
||||
"max-md:flex-1 max-md:border-e-0 max-lg:boot-open:hidden",
|
||||
"md:flex-shrink-0 md:shadow-sm md:w-[var(--boot-mail-list-w,384px)]",
|
||||
embedded && "md:max-w-[max(220px,55%)]",
|
||||
// Focus: the list takes the width while nothing is open and makes way for the reader.
|
||||
"md:boot-focus:w-auto md:boot-focus:flex-1 md:boot-focus:border-e-0 md:boot-focus:boot-open:hidden",
|
||||
// Horizontal (desktop only): the list on top, full width.
|
||||
!embedded && [
|
||||
"lg:boot-horizontal:w-full lg:boot-horizontal:h-auto lg:boot-horizontal:border-e-0 lg:boot-horizontal:shadow-none",
|
||||
"lg:boot-horizontal:flex-1 lg:boot-horizontal:min-h-0",
|
||||
"lg:boot-horizontal:boot-open:flex-none lg:boot-horizontal:boot-open:h-[var(--boot-mail-list-h,320px)]",
|
||||
"lg:boot-horizontal:boot-open:shadow-[0_8px_12px_-6px_rgba(0,0,0,0.18)] dark:lg:boot-horizontal:boot-open:shadow-[0_8px_14px_-6px_rgba(0,0,0,0.55)]",
|
||||
],
|
||||
)}
|
||||
>
|
||||
{!embedded && <MobileHeaderSkeleton />}
|
||||
<ListToolbarSkeleton embedded={embedded} />
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div className={cn("flex-1 overflow-hidden bg-background relative pb-20", !embedded && "lg:mv:pb-0")}>
|
||||
<MailListRowsSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
{/* The round Compose button (Mountain View's sits in its navigation). */}
|
||||
<Circle
|
||||
className={cn(
|
||||
"absolute z-40 shadow-lg bottom-4 end-4 h-12 w-12 max-md:h-14 max-md:w-14",
|
||||
"max-md:mv:w-36 max-md:mv:rounded-2xl",
|
||||
!embedded && "lg:mv:hidden",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{/* The reading pane. */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col bg-background flex-1 min-w-0 h-full md:relative",
|
||||
"max-md:hidden max-md:boot-open:flex",
|
||||
"md:boot-focus:hidden md:boot-focus:boot-open:flex",
|
||||
!embedded && "lg:boot-horizontal:hidden lg:boot-horizontal:boot-open:flex lg:boot-horizontal:h-auto lg:boot-horizontal:min-h-0",
|
||||
)}
|
||||
>
|
||||
<ReaderFrame>
|
||||
<EmptyReaderSkeleton className="boot-open:hidden" />
|
||||
{/* A conversation link opens the conversation view on a phone, its newest message elsewhere. */}
|
||||
<MailMessageSkeleton className="hidden boot-open:flex max-md:boot-thread:hidden" />
|
||||
<MailConversationSkeleton className="hidden max-md:boot-thread:flex" />
|
||||
</ReaderFrame>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!embedded && <MvSidePanelSkeleton />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Skeleton of the mail page. Stub: filled in per surface. */
|
||||
export function MailSkeleton({ label, labels }: MailSkeletonProps) {
|
||||
/**
|
||||
* Skeleton of the mail page. `embedded`: the mail app inside a Pro pane, which
|
||||
* draws only its body at the pane's height.
|
||||
*/
|
||||
export function MailSkeleton({ label, labels, embedded = false }: MailSkeletonProps & { embedded?: boolean }) {
|
||||
if (embedded) {
|
||||
return (
|
||||
<SkeletonRegion surface="mail" label={label} className="flex h-full overflow-hidden bg-background">
|
||||
<MailSkeletonBody embedded />
|
||||
</SkeletonRegion>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<AppShellSkeleton surface="mail" label={label} labels={labels} railBelow="lg" active="mail" mvSearch>
|
||||
<AppShellSkeleton
|
||||
surface="mail"
|
||||
label={label}
|
||||
labels={labels}
|
||||
railBelow="lg"
|
||||
active="mail"
|
||||
mvSearch
|
||||
bottomBarClassName="max-md:boot-open:hidden"
|
||||
>
|
||||
<MailSkeletonBody />
|
||||
</AppShellSkeleton>
|
||||
);
|
||||
|
||||
@@ -251,6 +251,8 @@ interface AppShellSkeletonProps {
|
||||
mvMenu?: 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;
|
||||
}
|
||||
|
||||
@@ -276,6 +278,7 @@ export function AppShellSkeleton({
|
||||
mvSearch = false,
|
||||
mvMenu = true,
|
||||
rowClassName,
|
||||
bottomBarClassName,
|
||||
children,
|
||||
}: AppShellSkeletonProps) {
|
||||
return (
|
||||
@@ -304,7 +307,7 @@ export function AppShellSkeleton({
|
||||
<BottomBarSkeleton
|
||||
active={active}
|
||||
labels={labels}
|
||||
className={railBelow === "lg" ? "flex lg:hidden" : "flex md:hidden"}
|
||||
className={cn(railBelow === "lg" ? "flex lg:hidden" : "flex md:hidden", bottomBarClassName)}
|
||||
/>
|
||||
</SkeletonRegion>
|
||||
</>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 <PaneSpinner label={t("threads.loading")} className="bg-background" />;
|
||||
// The conversation's shape (the page skeleton's), with one status message.
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background" aria-busy="true">
|
||||
<span role="status" className="sr-only">{t("threads.loading")}</span>
|
||||
<MailConversationSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -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,182 @@
|
||||
import React, { Suspense, lazy } from 'react';
|
||||
import { act, render, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Mailbox } from '@/lib/jmap/types';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { usePluginStore } from '@/stores/plugin-store';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { useUIStore } from '@/stores/ui-store';
|
||||
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
|
||||
|
||||
// The mail page hands over from the page skeleton the server drew without
|
||||
// anything jumping: the auth gate draws that same skeleton, the list keeps
|
||||
// its skeleton rows until the folder's first page is in, and a linked message
|
||||
// keeps the reader open while it loads.
|
||||
|
||||
const nav = vi.hoisted(() => ({ replace: vi.fn(), push: vi.fn(), back: vi.fn(), prefetch: vi.fn() }));
|
||||
vi.mock('@/i18n/navigation', () => ({
|
||||
useRouter: () => nav,
|
||||
usePathname: () => '/en',
|
||||
Link: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
vi.mock('next/dynamic', () => ({ default: (load: () => Promise<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();
|
||||
});
|
||||
|
||||
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<string, unknown>;
|
||||
client.getThreadEmails = vi.fn(() => new Promise(() => {}));
|
||||
const { container, queryByTestId } = render(<MailApp linkSegments={['thread', 't9']} />);
|
||||
await act(async () => {});
|
||||
const reader = container.querySelector('[data-pane="reader"]')!;
|
||||
expect(reader.querySelector('[aria-busy="true"] [role="status"]')).not.toBeNull();
|
||||
expect(queryByTestId('viewer')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -176,6 +176,8 @@ import type { QuoteHeader } from "@/lib/plugin-types";
|
||||
import { initialLinkSearch, useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
|
||||
import { useMinimisedComposerFlush } from "@/hooks/use-minimised-composer-flush";
|
||||
import { quickReplyEnvelope, saveQuickReplyDraft } from "@/lib/outgoing/quick-reply-envelope";
|
||||
import { MailConversationSkeleton, MailSkeleton } from "@/components/boot-skeleton/mail-skeleton";
|
||||
import { useSkeletonLabels } from "@/components/boot-skeleton/use-skeleton-labels";
|
||||
|
||||
export interface MailAppProps {
|
||||
/**
|
||||
@@ -231,6 +233,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// Static Lite build: the route params are empty, read the link from the URL.
|
||||
const linkSegments = useLiteLinkSegments('mail', routeSegments);
|
||||
const t = useTranslations();
|
||||
// The page skeleton the gates below draw: the same one the server sent.
|
||||
const skeletonLabels = useSkeletonLabels();
|
||||
const tNotifications = useTranslations('notifications');
|
||||
// The toasts' Undo after a delete to Trash, archive, move or spam report.
|
||||
const undo = undoLabels(tNotifications);
|
||||
@@ -367,6 +371,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// Mobile/tablet responsive hooks
|
||||
const { isMobile, isTablet } = useDeviceDetection();
|
||||
const isEmbedded = useIsEmbedded();
|
||||
// A message or conversation link shows the reader from the first frame, as
|
||||
// the page skeleton draws it, in its loading state until the link has been
|
||||
// applied. Otherwise the focused layout and phones showed the list alone
|
||||
// while the session came up and only then opened the reader.
|
||||
const [deepLinkPane, setDeepLinkPane] = useState<'message' | 'thread' | null>(() => {
|
||||
if (isEmbedded || typeof window === 'undefined') return null;
|
||||
const link = parseMailPath(linkSegments ?? [], new URLSearchParams(initialLinkSearch('mail')));
|
||||
return link?.kind === 'message' || link?.kind === 'thread' ? link.kind : null;
|
||||
});
|
||||
/**
|
||||
* Mountain View's list cursor. `j` / `k` there move a highlight without
|
||||
* opening the conversation, marking it read or changing the URL - only
|
||||
@@ -1948,7 +1961,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
useEffect(() => {
|
||||
if (deepLinkHandledRef.current) return;
|
||||
if (!isAuthenticated || !client) return;
|
||||
if (mailboxes.length === 0) return;
|
||||
if (mailboxes.length === 0) {
|
||||
// The account has no folders to open the link in: stop holding the reader open for it.
|
||||
if (initialMailLoadDone) setDeepLinkPane(null);
|
||||
return;
|
||||
}
|
||||
if (!initialMailLoadDone) return;
|
||||
|
||||
const params = new URLSearchParams(initialSearchRef.current ?? window.location.search);
|
||||
@@ -1960,6 +1977,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
|
||||
if (!link && !openLatestUnread) {
|
||||
deepLinkHandledRef.current = true;
|
||||
setDeepLinkPane(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1971,7 +1989,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
deepLinkHandledRef.current = true;
|
||||
|
||||
if (link) {
|
||||
void applyMailDeepLinkRef.current(link, { onLoad: true });
|
||||
void applyMailDeepLinkRef.current(link, { onLoad: true }).finally(() => setDeepLinkPane(null));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4472,14 +4490,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
], !isEmbedded);
|
||||
|
||||
if (!initialCheckDone || authLoading || (!isAuthenticated || !client)) {
|
||||
return (
|
||||
<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 +4510,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
&& (pendingDraft?.mode ?? composerMode) !== 'compose' && Boolean(selectedEmail);
|
||||
const floatingComposer = mvShell && showComposer && !isEmbedded && !mvInlineReply;
|
||||
const hasViewerContent = (showComposer && !floatingComposer) || Boolean(conversationThread)
|
||||
|| Boolean(selectedEmail && !(mvShell && cursorOnly));
|
||||
const shouldCollapseListPane = (isTablet && !tabletListVisible && !readingPaneEmpty) || (!isMobile && isFocusedMailLayout && hasViewerContent);
|
||||
|| Boolean(selectedEmail && !(mvShell && cursorOnly)) || deepLinkPane !== null;
|
||||
// A linked message on a phone opens over the list, as one picked there does.
|
||||
const mobileView = deepLinkPane !== null && isMobile ? 'viewer' : activeView;
|
||||
const shouldCollapseListPane = (isTablet && ((!tabletListVisible && !readingPaneEmpty) || deepLinkPane !== null)) || (!isMobile && isFocusedMailLayout && hasViewerContent);
|
||||
const shouldHideViewerPane = !isMobile && !hasViewerContent && isFocusedMailLayout;
|
||||
const shouldHideHorizontalViewerPane = isHorizontalMailLayout && !hasViewerContent;
|
||||
|
||||
@@ -4887,7 +4900,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
return <MailSkeleton label={skeletonLabels.label} labels={skeletonLabels.labels} embedded={isEmbedded} />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -5087,7 +5100,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
: "h-full border-e border-border",
|
||||
// Mobile: full width, hidden when viewing email
|
||||
"max-md:flex-1 max-md:border-e-0 max-md:border-b-0",
|
||||
isMobile && activeView !== "list" && "max-md:hidden",
|
||||
isMobile && mobileView !== "list" && "max-md:hidden",
|
||||
// Pane-mobile (narrow Pro pane on a wide viewport): the
|
||||
// `max-md:` viewport classes above never apply, so mirror
|
||||
// them with pane-scoped equivalents.
|
||||
@@ -5328,7 +5341,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
hideBatchToolbar={mvShell}
|
||||
emails={activeEmails}
|
||||
selectedEmailKey={selectedEmail ? emailKeyFor(selectedEmail) : undefined}
|
||||
isLoading={activeIsLoading}
|
||||
// Until the first page of the folder has arrived an empty list
|
||||
// is not an empty folder: it draws its skeleton rows, not "no
|
||||
// emails".
|
||||
isLoading={activeIsLoading || (!initialMailLoadDone && activeEmails.length === 0)}
|
||||
hasMore={activeHasMore}
|
||||
isLoadingMoreItems={isScheduledView ? isLoadingScheduled && activeEmails.length > 0 : undefined}
|
||||
isScheduledView={isScheduledView}
|
||||
@@ -5518,7 +5534,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// Mobile: full screen overlay when active
|
||||
"max-md:fixed max-md:inset-0 max-md:z-30",
|
||||
"max-md:h-full max-md:pt-[env(safe-area-inset-top)]",
|
||||
isMobile && activeView !== "viewer" && "max-md:hidden",
|
||||
isMobile && mobileView !== "viewer" && "max-md:hidden",
|
||||
// Tablet/Desktop: relative. Pane-mobile instead overlays the
|
||||
// pane (absolute within the relative content row above) - the
|
||||
// viewport `max-md:` classes never fire there, and `fixed`
|
||||
@@ -5568,7 +5584,14 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
</button>
|
||||
)}
|
||||
{/* 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.
|
||||
<div className="flex flex-col h-full bg-background" aria-busy="true">
|
||||
<span role="status" className="sr-only">{t('threads.loading')}</span>
|
||||
<MailConversationSkeleton />
|
||||
</div>
|
||||
) : isMobile && conversationThread ? (
|
||||
<ThreadConversationView
|
||||
thread={conversationThread}
|
||||
emails={conversationEmails}
|
||||
@@ -5623,7 +5646,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 +5772,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}
|
||||
|
||||
@@ -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(<MailtoProtocolClient openingText="Opening email…" />);
|
||||
const region = container.querySelector('[data-boot-skeleton="mail"]');
|
||||
expect(region).not.toBeNull();
|
||||
expect(region!.querySelector('[role="status"]')?.textContent).toBe('Opening email…');
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<main className="flex min-h-screen items-center justify-center">
|
||||
<p>{openingText}</p>
|
||||
</main>
|
||||
);
|
||||
// The mail page this hands over to, where the composer opens: its skeleton,
|
||||
// with the opening text as the status message.
|
||||
return <MailSkeleton label={openingText} labels={labels} />;
|
||||
}
|
||||
|
||||
@@ -129,6 +129,20 @@ describe('boot script: layout settings', () => {
|
||||
expect(attr('density')).toBe(density);
|
||||
});
|
||||
|
||||
it('names the density and whether previews are off, for the mail rows', () => {
|
||||
boot('/en/mail');
|
||||
expect(attr('density')).toBe('regular');
|
||||
expect(attr('preview')).toBeNull();
|
||||
settings({ density: 'extra-compact', showPreview: false });
|
||||
boot('/en/mail');
|
||||
expect(attr('density')).toBe('extra-compact');
|
||||
expect(attr('preview')).toBe('off');
|
||||
settings({ density: 'cosy', showPreview: true });
|
||||
boot('/en/mail');
|
||||
expect(attr('density')).toBe('regular');
|
||||
expect(attr('preview')).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to regular for an unknown density', () => {
|
||||
settings({ density: 'cosy' });
|
||||
boot('/en/mail');
|
||||
|
||||
@@ -167,8 +167,9 @@ export function applyBootLayout(root: HTMLElement, c: BootLayoutConfig): void {
|
||||
var density = typeof s.density === 'string' && c.density[s.density] ? s.density : 'regular';
|
||||
var vars = (layout === 'mountain-view' ? c.mvDensity : c.density)[density];
|
||||
for (var prop in vars) root.style.setProperty(prop, vars[prop]);
|
||||
// Rows that drop their avatar or second line at some densities (contact rows).
|
||||
// Which rows the mail list draws: avatars and previews depend on these.
|
||||
mark('density', density);
|
||||
mark('preview', s.showPreview === false ? 'off' : null);
|
||||
|
||||
var mailLayout = s.mailLayout === 'focus' || s.mailLayout === 'horizontal' ? s.mailLayout : 'split';
|
||||
if (layout === 'mountain-view' || s.simpleMode === true) mailLayout = 'focus';
|
||||
|
||||
Reference in New Issue
Block a user