mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 08:15:38 +00:00
feat: keep the mail skeleton's shapes until the folders, list and linked message arrive
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MailListRowsSkeleton, MailSkeleton } from '../mail-skeleton';
|
||||
|
||||
function html(element: React.ReactElement): HTMLElement {
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = renderToString(element);
|
||||
return container;
|
||||
}
|
||||
|
||||
describe('mail skeleton', () => {
|
||||
// The app hides its bottom bar while a message is open on a phone; the
|
||||
// skeleton of /mail/message/<id> must not draw one either.
|
||||
it('hides the phone bottom bar when a message or conversation is open', () => {
|
||||
const page = html(<MailSkeleton label="Loading" />);
|
||||
// The bar's items are its 64px-basis tabs.
|
||||
const bar = page.querySelector('.basis-\\[64px\\]')?.parentElement as HTMLElement;
|
||||
expect(bar).toBeTruthy();
|
||||
expect(bar.className.split(' ')).toContain('max-md:boot-open:hidden');
|
||||
});
|
||||
|
||||
it('draws only the body inside a Pro pane: no frame, no sidebar header', () => {
|
||||
const pane = html(<MailSkeleton label="Loading" embedded />);
|
||||
const region = pane.querySelector('[data-boot-skeleton="mail"]')!;
|
||||
expect(region.className).toContain('h-full');
|
||||
expect(region.className).not.toContain('h-dvh');
|
||||
expect(pane.querySelector('.boot-out\\:block')).toBeNull();
|
||||
expect(pane.querySelector('.lg\\:mv\\:hidden.border-b')).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['extra-compact', 0, 0],
|
||||
['compact', 9, 0],
|
||||
['regular', 9, 9],
|
||||
] as const)('draws %s stacked rows with %i avatars and %i previews', (density, avatars, previews) => {
|
||||
const rows = html(<MailListRowsSkeleton shape="stacked" density={density} preview />);
|
||||
expect(rows.querySelectorAll('.w-10.h-10').length).toBe(avatars);
|
||||
expect(rows.querySelectorAll('.leading-relaxed').length).toBe(previews * 2);
|
||||
});
|
||||
|
||||
it('leaves the preview out when previews are turned off', () => {
|
||||
const rows = html(<MailListRowsSkeleton shape="stacked" density="comfortable" preview={false} />);
|
||||
expect(rows.querySelectorAll('.leading-relaxed').length).toBe(0);
|
||||
});
|
||||
|
||||
it('draws every shape for the boot page and lets the boot attributes pick', () => {
|
||||
const rows = html(<MailListRowsSkeleton />);
|
||||
expect(rows.querySelector('.boot-xc\\:hidden')).not.toBeNull();
|
||||
expect(rows.querySelector('.boot-dense\\:hidden')).not.toBeNull();
|
||||
expect(rows.querySelector('.md\\:mv\\:block')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
MoreVertical,
|
||||
} from "@/components/icons";
|
||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||
import { FOLDER_CHEVRON_SLOT, FOLDER_ROW_PX_BASE, FolderRowsSkeleton } from "@/components/boot-skeleton/mail-skeleton";
|
||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||
import {
|
||||
buildKeywordTree,
|
||||
@@ -168,8 +169,8 @@ interface SidebarProps {
|
||||
onLoginSharedMailboxSelect?: (loginId: string, mailboxId: string) => void;
|
||||
}
|
||||
|
||||
const ROW_PX_BASE = 8;
|
||||
const CHEVRON_SLOT = 20;
|
||||
const ROW_PX_BASE = FOLDER_ROW_PX_BASE;
|
||||
const CHEVRON_SLOT = FOLDER_CHEVRON_SLOT;
|
||||
const INDENT_STEP = 12;
|
||||
|
||||
// Icons and colours follow the JMAP role only.
|
||||
@@ -2054,8 +2055,11 @@ function SidebarImpl({
|
||||
{((expanded && !isCollapsed) || isCollapsed) && (
|
||||
<>
|
||||
{tree.length === 0 ? (
|
||||
<div className="px-4 py-2 text-sm text-muted-foreground">
|
||||
{!isCollapsed && t("loading_mailboxes")}
|
||||
// The page skeleton's folder rows, so nothing moves when the
|
||||
// folders arrive (components/boot-skeleton).
|
||||
<div data-testid="folders-loading">
|
||||
<span className="sr-only">{t("loading_mailboxes")}</span>
|
||||
{!isCollapsed && <FolderRowsSkeleton />}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -2120,8 +2124,11 @@ function SidebarImpl({
|
||||
{(mvFoldersHeadless || (foldersExpanded && !isCollapsed) || isCollapsed) && (
|
||||
<>
|
||||
{mailboxes.length === 0 ? (
|
||||
<div className="px-4 py-2 text-sm text-muted-foreground">
|
||||
{!isCollapsed && t("loading_mailboxes")}
|
||||
// The page skeleton's folder rows, so nothing moves when the
|
||||
// folders arrive (components/boot-skeleton).
|
||||
<div data-testid="folders-loading">
|
||||
<span className="sr-only">{t("loading_mailboxes")}</span>
|
||||
{!isCollapsed && <FolderRowsSkeleton />}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import React, { Suspense, lazy } from 'react';
|
||||
import { act, render, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Mailbox } from '@/lib/jmap/types';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { usePluginStore } from '@/stores/plugin-store';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { useUIStore } from '@/stores/ui-store';
|
||||
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
|
||||
|
||||
// The mail page hands over from the page skeleton the server drew without
|
||||
// anything jumping: the auth gate draws that same skeleton, the list keeps
|
||||
// its skeleton rows until the folder's first page is in, and a linked message
|
||||
// keeps the reader open while it loads.
|
||||
|
||||
const nav = vi.hoisted(() => ({ replace: vi.fn(), push: vi.fn(), back: vi.fn(), prefetch: vi.fn() }));
|
||||
vi.mock('@/i18n/navigation', () => ({
|
||||
useRouter: () => nav,
|
||||
usePathname: () => '/en',
|
||||
Link: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
vi.mock('next/dynamic', () => ({ default: (load: () => Promise<React.ComponentType<Record<string, unknown>>>) => {
|
||||
const Component = lazy(async () => ({ default: await load() }));
|
||||
return function Dynamic(props: Record<string, unknown>) {
|
||||
return <Suspense fallback={null}><Component {...props} /></Suspense>;
|
||||
};
|
||||
} }));
|
||||
const viewer = vi.hoisted(() => ({ isLoading: [] as boolean[] }));
|
||||
vi.mock('@/components/email/email-viewer', () => ({
|
||||
EmailViewer: ({ isLoading }: { isLoading?: boolean }) => {
|
||||
viewer.isLoading.push(!!isLoading);
|
||||
return <div data-testid="viewer" data-loading={isLoading ? '' : undefined} />;
|
||||
},
|
||||
}));
|
||||
const list = vi.hoisted(() => ({ isLoading: [] as boolean[] }));
|
||||
vi.mock('@/components/email/email-list', () => ({
|
||||
EmailList: ({ isLoading }: { isLoading?: boolean }) => {
|
||||
list.isLoading.push(!!isLoading);
|
||||
return <div data-testid="list" data-loading={isLoading ? '' : undefined} />;
|
||||
},
|
||||
}));
|
||||
vi.mock('@/components/email/email-composer', () => ({ EmailComposer: () => null }));
|
||||
vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null }));
|
||||
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => <div data-testid="bottom-nav" /> }));
|
||||
vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null }));
|
||||
vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null }));
|
||||
vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null }));
|
||||
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
|
||||
vi.mock('@/components/layout/mv-side-panel', () => ({ MvSidePanel: () => null }));
|
||||
vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), MessageListTabs: () => null }));
|
||||
vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) }));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
|
||||
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
|
||||
vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} }));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] }));
|
||||
vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} }));
|
||||
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
|
||||
vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
|
||||
const device = vi.hoisted(() => ({ isMobile: false }));
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useDeviceDetection: () => ({ isMobile: device.isMobile, isTablet: false, isDesktop: !device.isMobile }),
|
||||
useMediaQuery: () => false,
|
||||
useIsDesktop: () => !device.isMobile,
|
||||
}));
|
||||
vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
|
||||
|
||||
const { MailApp } = await import('../mail-app');
|
||||
|
||||
const inbox = {
|
||||
id: 'inbox', name: 'Inbox', role: 'inbox', sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true,
|
||||
} as Mailbox;
|
||||
|
||||
/** A promise the test settles by hand. */
|
||||
function deferred<T = void>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((r) => { resolve = r; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
let fetchEmails: ReturnType<typeof vi.fn>;
|
||||
let fetchEmailContent: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
list.isLoading = [];
|
||||
viewer.isLoading = [];
|
||||
device.isMobile = false;
|
||||
nav.replace.mockReset();
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
|
||||
usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
|
||||
useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) });
|
||||
useUIStore.setState({ activeView: 'list' });
|
||||
const client = {
|
||||
getAccountId: () => 'acc', getUsername: () => 'a@example.org',
|
||||
getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null,
|
||||
getMailboxes: vi.fn().mockResolvedValue([inbox]),
|
||||
};
|
||||
useAuthStore.setState({
|
||||
isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [],
|
||||
getClientForAccount: () => client as never,
|
||||
getAllConnectedClients: () => new Map([['login-a', client]]) as never,
|
||||
});
|
||||
useSettingsStore.setState({ enableUnifiedMailbox: false, interfaceLayout: 'default', mailLayout: 'focus' } as never);
|
||||
fetchEmails = vi.fn().mockResolvedValue(undefined);
|
||||
fetchEmailContent = vi.fn().mockResolvedValue(null);
|
||||
useEmailStore.setState({
|
||||
emails: [], selectedEmail: null, selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: [inbox],
|
||||
viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
|
||||
isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '',
|
||||
searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '',
|
||||
selectedEmailKeys: new Set(), unreadSection: null,
|
||||
fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails,
|
||||
fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined),
|
||||
refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined),
|
||||
fetchEmailContent,
|
||||
} as never);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('mail page hand-off from the boot skeleton', () => {
|
||||
it('draws the mail skeleton, not a spinner, while the session is restored', async () => {
|
||||
useAuthStore.setState({ isAuthenticated: false, client: null, checkAuth: vi.fn(() => new Promise(() => {})) } as never);
|
||||
const { container } = render(<MailApp />);
|
||||
await act(async () => {});
|
||||
expect(container.querySelector('[data-boot-skeleton="mail"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-boot-skeleton="mail"] [role="status"]')?.textContent).toBeTruthy();
|
||||
expect(container.querySelector('.animate-spin')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the list loading until the folder\'s first page is in, then shows an empty folder as empty', async () => {
|
||||
const page = deferred();
|
||||
fetchEmails.mockReturnValue(page.promise);
|
||||
const { getByTestId } = render(<MailApp />);
|
||||
await act(async () => {});
|
||||
// Nothing fetched yet: the store is not loading, but the list must not say "no emails".
|
||||
expect(useEmailStore.getState().isLoading).toBe(false);
|
||||
expect(getByTestId('list').hasAttribute('data-loading')).toBe(true);
|
||||
await act(async () => { page.resolve(); });
|
||||
await waitFor(() => expect(getByTestId('list').hasAttribute('data-loading')).toBe(false));
|
||||
});
|
||||
|
||||
it('keeps the reader open in its loading state while a linked message loads (focus layout)', async () => {
|
||||
const content = deferred<null>();
|
||||
fetchEmailContent.mockReturnValue(content.promise);
|
||||
const { getByTestId, container } = render(<MailApp linkSegments={['message', 'm9']} />);
|
||||
await act(async () => {});
|
||||
const reader = container.querySelector('[data-pane="reader"]')!;
|
||||
expect(reader.className).not.toMatch(/(^|\s)md:hidden(\s|$)/);
|
||||
expect(getByTestId('viewer').hasAttribute('data-loading')).toBe(true);
|
||||
// Not found: the reader goes away again and the list takes the page.
|
||||
await act(async () => { content.resolve(null); });
|
||||
await waitFor(() => expect(container.querySelector('[data-pane="reader"]')!.className).toMatch(/(^|\s)md:hidden(\s|$)/));
|
||||
});
|
||||
|
||||
it('opens a linked message over the list on a phone and hides the bottom bar meanwhile', async () => {
|
||||
device.isMobile = true;
|
||||
fetchEmailContent.mockReturnValue(new Promise(() => {}));
|
||||
const { container, queryByTestId } = render(<MailApp linkSegments={['message', 'm9']} />);
|
||||
await act(async () => {});
|
||||
const reader = container.querySelector('[data-pane="reader"]')!;
|
||||
expect(reader.className).not.toMatch(/(^|\s)max-md:hidden(\s|$)/);
|
||||
expect(queryByTestId('bottom-nav')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -176,6 +176,8 @@ import type { QuoteHeader } from "@/lib/plugin-types";
|
||||
import { initialLinkSearch, useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
|
||||
import { useMinimisedComposerFlush } from "@/hooks/use-minimised-composer-flush";
|
||||
import { quickReplyEnvelope, saveQuickReplyDraft } from "@/lib/outgoing/quick-reply-envelope";
|
||||
import { MailSkeleton } from "@/components/boot-skeleton/mail-skeleton";
|
||||
import { useSkeletonLabels } from "@/components/boot-skeleton/use-skeleton-labels";
|
||||
|
||||
export interface MailAppProps {
|
||||
/**
|
||||
@@ -231,6 +233,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// Static Lite build: the route params are empty, read the link from the URL.
|
||||
const linkSegments = useLiteLinkSegments('mail', routeSegments);
|
||||
const t = useTranslations();
|
||||
// The page skeleton the gates below draw: the same one the server sent.
|
||||
const skeletonLabels = useSkeletonLabels();
|
||||
const tNotifications = useTranslations('notifications');
|
||||
// The toasts' Undo after a delete to Trash, archive, move or spam report.
|
||||
const undo = undoLabels(tNotifications);
|
||||
@@ -367,6 +371,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// Mobile/tablet responsive hooks
|
||||
const { isMobile, isTablet } = useDeviceDetection();
|
||||
const isEmbedded = useIsEmbedded();
|
||||
// A message or conversation link shows the reader from the first frame, as
|
||||
// the page skeleton draws it, in its loading state until the link has been
|
||||
// applied. Otherwise the focused layout and phones showed the list alone
|
||||
// while the session came up and only then opened the reader.
|
||||
const [deepLinkPane, setDeepLinkPane] = useState<'message' | 'thread' | null>(() => {
|
||||
if (isEmbedded || typeof window === 'undefined') return null;
|
||||
const link = parseMailPath(linkSegments ?? [], new URLSearchParams(initialLinkSearch('mail')));
|
||||
return link?.kind === 'message' || link?.kind === 'thread' ? link.kind : null;
|
||||
});
|
||||
/**
|
||||
* Mountain View's list cursor. `j` / `k` there move a highlight without
|
||||
* opening the conversation, marking it read or changing the URL - only
|
||||
@@ -1960,6 +1973,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
|
||||
if (!link && !openLatestUnread) {
|
||||
deepLinkHandledRef.current = true;
|
||||
setDeepLinkPane(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1971,7 +1985,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
deepLinkHandledRef.current = true;
|
||||
|
||||
if (link) {
|
||||
void applyMailDeepLinkRef.current(link, { onLoad: true });
|
||||
void applyMailDeepLinkRef.current(link, { onLoad: true }).finally(() => setDeepLinkPane(null));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4472,14 +4486,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
], !isEmbedded);
|
||||
|
||||
if (!initialCheckDone || authLoading || (!isAuthenticated || !client)) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-foreground mx-auto"></div>
|
||||
<p className="mt-4 text-sm text-muted-foreground">{t("common.loading")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <MailSkeleton label={skeletonLabels.label} labels={skeletonLabels.labels} embedded={isEmbedded} />;
|
||||
}
|
||||
|
||||
// Get current mailbox name for mobile header
|
||||
@@ -4499,8 +4506,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
&& (pendingDraft?.mode ?? composerMode) !== 'compose' && Boolean(selectedEmail);
|
||||
const floatingComposer = mvShell && showComposer && !isEmbedded && !mvInlineReply;
|
||||
const hasViewerContent = (showComposer && !floatingComposer) || Boolean(conversationThread)
|
||||
|| Boolean(selectedEmail && !(mvShell && cursorOnly));
|
||||
const shouldCollapseListPane = (isTablet && !tabletListVisible && !readingPaneEmpty) || (!isMobile && isFocusedMailLayout && hasViewerContent);
|
||||
|| Boolean(selectedEmail && !(mvShell && cursorOnly)) || deepLinkPane !== null;
|
||||
// A linked message on a phone opens over the list, as one picked there does.
|
||||
const mobileView = deepLinkPane !== null && isMobile ? 'viewer' : activeView;
|
||||
const shouldCollapseListPane = (isTablet && ((!tabletListVisible && !readingPaneEmpty) || deepLinkPane !== null)) || (!isMobile && isFocusedMailLayout && hasViewerContent);
|
||||
const shouldHideViewerPane = !isMobile && !hasViewerContent && isFocusedMailLayout;
|
||||
const shouldHideHorizontalViewerPane = isHorizontalMailLayout && !hasViewerContent;
|
||||
|
||||
@@ -4887,7 +4896,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
return <MailSkeleton label={skeletonLabels.label} labels={skeletonLabels.labels} embedded={isEmbedded} />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -5087,7 +5096,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
: "h-full border-e border-border",
|
||||
// Mobile: full width, hidden when viewing email
|
||||
"max-md:flex-1 max-md:border-e-0 max-md:border-b-0",
|
||||
isMobile && activeView !== "list" && "max-md:hidden",
|
||||
isMobile && mobileView !== "list" && "max-md:hidden",
|
||||
// Pane-mobile (narrow Pro pane on a wide viewport): the
|
||||
// `max-md:` viewport classes above never apply, so mirror
|
||||
// them with pane-scoped equivalents.
|
||||
@@ -5328,7 +5337,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
hideBatchToolbar={mvShell}
|
||||
emails={activeEmails}
|
||||
selectedEmailKey={selectedEmail ? emailKeyFor(selectedEmail) : undefined}
|
||||
isLoading={activeIsLoading}
|
||||
// Until the first page of the folder has arrived an empty list
|
||||
// is not an empty folder: it draws its skeleton rows, not "no
|
||||
// emails".
|
||||
isLoading={activeIsLoading || (!initialMailLoadDone && activeEmails.length === 0)}
|
||||
hasMore={activeHasMore}
|
||||
isLoadingMoreItems={isScheduledView ? isLoadingScheduled && activeEmails.length > 0 : undefined}
|
||||
isScheduledView={isScheduledView}
|
||||
@@ -5518,7 +5530,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// Mobile: full screen overlay when active
|
||||
"max-md:fixed max-md:inset-0 max-md:z-30",
|
||||
"max-md:h-full max-md:pt-[env(safe-area-inset-top)]",
|
||||
isMobile && activeView !== "viewer" && "max-md:hidden",
|
||||
isMobile && mobileView !== "viewer" && "max-md:hidden",
|
||||
// Tablet/Desktop: relative. Pane-mobile instead overlays the
|
||||
// pane (absolute within the relative content row above) - the
|
||||
// viewport `max-md:` classes never fire there, and `fixed`
|
||||
@@ -5623,7 +5635,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
<EmailViewer
|
||||
email={selectedEmail}
|
||||
blobSource={resolveEmailBlobContext(selectedEmail, client)}
|
||||
isLoading={isLoadingEmail}
|
||||
isLoading={isLoadingEmail || deepLinkPane !== null}
|
||||
onReply={handleReply}
|
||||
onReplyAll={handleReplyAll}
|
||||
onForward={handleForward}
|
||||
@@ -5749,7 +5761,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
</div>
|
||||
|
||||
{/* Bottom Navigation - mobile and tablet (hidden when embedded) */}
|
||||
{(isMobile || isTablet) && activeView !== "viewer" && !isEmbedded && (
|
||||
{(isMobile || isTablet) && mobileView !== "viewer" && !isEmbedded && (
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
|
||||
Reference in New Issue
Block a user