Merge branch 'main' into feat/proofreading

# Conflicts:
#	components/admin/admin-shell.tsx
This commit is contained in:
Linus Rath
2026-11-02 21:27:17 +01:00
196 changed files with 11731 additions and 716 deletions
+9
View File
@@ -56,3 +56,12 @@ jobs:
env:
CI: "true"
run: npm run test:a11y-guard
# Same build: every route's server HTML shows its page skeleton with
# scripts blocked, and the hand-off to the loaded page shifts nothing
# (scripts/boot-skeleton-guard).
- name: Run boot skeleton guard
if: success() || failure()
env:
CI: "true"
run: npm run test:boot-skeleton-guard
@@ -0,0 +1,24 @@
import { renderToString } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
// While the sign-in is completed, the callback draws the mail page's
// skeleton: that is where it lands, and the mail page's own gate draws the
// same, so nothing changes when it gets there.
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
useParams: () => ({ locale: 'en' }),
useSearchParams: () => new URLSearchParams('code=abc&state=xyz'),
}));
const { default: OAuthCallbackPage } = await import('../page');
describe('OAuth callback while the sign-in completes', () => {
it('server-renders the mail skeleton with the completing message, not a spinner', () => {
const container = document.createElement('div');
container.innerHTML = renderToString(<OAuthCallbackPage />);
expect(container.querySelector('[data-boot-skeleton="mail"]')).not.toBeNull();
expect(container.querySelector('[data-boot-skeleton="mail"] [role="status"]')?.textContent).toBe('oauth_completing');
expect(container.querySelector('.animate-spin')).toBeNull();
});
});
+10 -18
View File
@@ -7,18 +7,21 @@ import { useAuthStore } from "@/stores/auth-store";
import { apiFetch, getPathPrefix, toRouterPath } from "@/lib/browser-navigation";
import { buildSettingsPath } from "@/lib/deep-links";
import { LITE_OAUTH_AVAILABLE, getLiteOAuthRedirectUri } from "@/lib/auth/lite-oauth";
import { Loader2, AlertCircle } from "@/components/icons";
import { AlertCircle } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { useParams } from "next/navigation";
import { buildMobileHandoffUrl, mobileHandoffTarget } from "@/lib/auth/mobile-handoff";
import { confirmMobileHandoffCodeUrl } from "@/lib/auth/mobile-handoff-client";
import { MobileHandoffConsent } from "@/components/auth/mobile-handoff-consent";
import { MailSkeleton } from "@/components/boot-skeleton/mail-skeleton";
import { useSkeletonLabels } from "@/components/boot-skeleton/use-skeleton-labels";
function OAuthCallbackInner() {
const router = useRouter();
const params = useParams();
const searchParams = useSearchParams();
const t = useTranslations("login");
const { labels } = useSkeletonLabels();
const { loginWithOAuth, loginWithServerSso } = useAuthStore();
const [error, setError] = useState<string | null>(null);
// A sign-in started for the mobile app, waiting for the user to confirm
@@ -300,27 +303,16 @@ function OAuthCallbackInner() {
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20">
<div className="w-full max-w-sm mx-auto px-4 text-center" role="status">
<Loader2 className="w-8 h-8 animate-spin text-primary mx-auto mb-4" />
<p className="text-muted-foreground text-sm">{t("oauth_completing")}</p>
</div>
</div>
);
// A finished sign-in lands in the mailbox (or the page saved before it),
// so the mail page's skeleton: it stays in place while the app loads there.
return <MailSkeleton label={t("oauth_completing")} labels={labels} />;
}
export default function OAuthCallbackPage() {
const t = useTranslations("login");
const { labels } = useSkeletonLabels();
return (
<Suspense
fallback={
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20">
<div className="w-full max-w-sm mx-auto px-4 text-center" role="status">
<Loader2 className="w-8 h-8 animate-spin text-primary mx-auto mb-4" />
</div>
</div>
}
>
<Suspense fallback={<MailSkeleton label={t("oauth_completing")} labels={labels} />}>
<OAuthCallbackInner />
</Suspense>
);
@@ -0,0 +1,78 @@
import React from 'react';
import { render } from '@testing-library/react';
import { renderToString } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
// Until /api/config answers (and in the server HTML, where it never has),
// the login page draws its card's skeleton instead of a spinner, the same
// one signed-out visitors of the app pages see first.
vi.mock('next/navigation', () => ({
useParams: () => ({ locale: 'en' }),
useSearchParams: () => new URLSearchParams(),
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }),
usePathname: () => '/login',
}));
vi.mock('@/i18n/navigation', () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }),
usePathname: () => '/login',
Link: ({ children }: { children: React.ReactNode }) => children,
}));
const config = vi.hoisted(() => ({ loading: true, oauthOnly: false }));
vi.mock('@/hooks/use-config', () => ({
useConfig: () => ({
appName: 'Webmail',
isLoading: config.loading,
error: null,
jmapServerUrl: 'https://mail.example.org',
jmapServers: [],
loginShowHeading: true,
loginShowSubtitle: true,
loginShowVersion: true,
loginShowTotp: true,
oauthEnabled: config.oauthOnly,
oauthOnly: config.oauthOnly,
}),
}));
// The provider's metadata never arrives in these tests.
vi.mock('@/lib/browser-navigation', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/browser-navigation')>()),
apiFetch: vi.fn(() => new Promise(() => {})),
}));
const { default: LoginPage } = await import('../page');
describe('login page while the config loads', () => {
it('server-renders the card skeleton, not a spinner', () => {
config.loading = true;
const html = renderToString(<LoginPage />);
expect(html).toContain('data-boot-skeleton="login"');
expect(html).not.toContain('animate-spin');
});
it('keeps the skeleton on the client until the config is there', () => {
config.loading = true;
const { container } = render(<LoginPage />);
const region = container.querySelector('[data-boot-skeleton="login"]');
expect(region).not.toBeNull();
expect(region!.getAttribute('aria-busy')).toBe('true');
expect(container.querySelector('[role="status"]')?.textContent).toBe('loading');
expect(container.querySelector('.animate-spin')).toBeNull();
});
it('draws the sign-in button\'s place while the provider is looked up, not a spinner', () => {
config.loading = false;
config.oauthOnly = true;
try {
const { container } = render(<LoginPage />);
expect(container.querySelector('[data-boot-skeleton]')).toBeNull();
expect(container.querySelector('.animate-spin')).toBeNull();
expect(container.querySelector('.sk-shape.h-11')).not.toBeNull();
} finally {
config.oauthOnly = false;
}
});
});
+9 -12
View File
@@ -39,6 +39,8 @@ import { buildMobileHandoffUrl, mobileCodeChallenge, mobileHandoffTarget, signed
import { requestMobileHandoffCodeUrl } from "@/lib/auth/mobile-handoff-client";
import { MobileHandoffConsent } from "@/components/auth/mobile-handoff-consent";
import { HelpLinks } from "@/components/layout/help-links";
import { LoginSkeleton } from "@/components/boot-skeleton/login-skeleton";
import { Block } from "@/components/boot-skeleton/kit";
import { consumeSessionEnd, type SessionEndReason } from "@/lib/session-policy/sign-out-reason";
/** The domain of a complete address (`user@example.com`), or '' while it is still being typed. */
@@ -698,15 +700,10 @@ function LoginPageContent() {
setShowThemeMenu(false);
}, [setTheme]);
// The server HTML, and every visit until /api/config answers: the card's
// shape, the same one signed-out visitors of the app pages get first.
if (configLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
<div className="w-full max-w-sm mx-auto px-4 text-center" role="status">
<Loader2 className="w-8 h-8 animate-spin text-primary mx-auto" />
<span className="sr-only">{t("loading")}</span>
</div>
</div>
);
return <LoginSkeleton label={t("loading")} />;
}
if (configError) {
@@ -1533,9 +1530,8 @@ function LoginPageContent() {
</div>
</div>
) : (
<div className="flex justify-center py-4">
<Loader2 className="w-6 h-6 animate-spin text-primary" />
</div>
// Where the sign-in button goes once the provider answers.
<Block className="w-full h-11 rounded-xl" />
)}
</div>
) : (
@@ -1928,8 +1924,9 @@ function LoginPageContent() {
// useSearchParams() above must sit under a Suspense boundary for the route to
// prerender (the static Lite build); at runtime the boundary never suspends.
export default function LoginPage() {
const t = useTranslations("login");
return (
<Suspense fallback={null}>
<Suspense fallback={<LoginSkeleton label={t("loading")} />}>
<LoginPageContent />
</Suspense>
);
@@ -0,0 +1,114 @@
import { act, render } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Until the session is up the Pro page draws the same skeleton the server
// sent (the Pro frame), not a spinner and not a blank page, so the first
// paint and the loaded page line up.
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => (key === 'common.loading' ? 'Loading…' : key),
}));
const device = vi.hoisted(() => ({ isMobile: false, isTablet: false, isDesktop: true }));
vi.mock('@/hooks/use-media-query', () => ({ useDeviceDetection: () => device }));
const auth = vi.hoisted(() => ({
state: {
isAuthenticated: false,
client: null as unknown,
isLoading: false,
checkAuth: () => new Promise<void>(() => {}),
logout: () => {},
},
}));
vi.mock('@/stores/auth-store', () => {
const useAuthStore = Object.assign(
(selector: (s: typeof auth.state) => unknown) => selector(auth.state),
{ getState: () => auth.state },
);
return { useAuthStore, redirectToLogin: vi.fn() };
});
// The apps the panes host and the shell's dialogs are not under test.
vi.mock('@/components/mail/mail-app', () => ({ MailApp: () => <div data-testid="mail-app" /> }));
vi.mock('@/components/calendar/calendar-app', () => ({ CalendarApp: () => null }));
vi.mock('@/components/contacts/contacts-app', () => ({ ContactsApp: () => null }));
vi.mock('@/components/files/files-app', () => ({ FilesApp: () => null }));
vi.mock('@/components/settings/settings-app', () => ({ SettingsApp: () => null }));
vi.mock('@/components/pro/pro-compose-tab-body', () => ({ ProComposeTabBody: () => null }));
vi.mock('@/components/pro/pro-email-tab-body', () => ({ ProEmailTabBody: () => null }));
vi.mock('@/components/pro/pro-folder-tab-body', () => ({ ProFolderTabBody: () => null }));
vi.mock('@/components/global-search/search-tab-body', () => ({ SearchTabBody: () => null }));
vi.mock('@/components/global-search/global-search-palette', () => ({ GlobalSearchPalette: () => null }));
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => <nav data-testid="rail" /> }));
vi.mock('@/components/keyboard-shortcuts-modal', () => ({ KeyboardShortcutsModal: () => null }));
vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null }));
vi.mock('@/components/layout/inline-app-view', () => ({ InlineAppView: () => null }));
vi.mock('@/components/org/org-announcement', () => ({ OrgAnnouncement: () => null }));
vi.mock('@/components/pro/pro-tab-bar', () => ({ ProTabBar: () => <div role="tablist" /> }));
vi.mock('@/hooks/use-sidebar-apps', () => ({
useSidebarApps: () => ({
showAppsModal: false,
inlineApp: null,
loadedApps: [],
handleManageApps: () => {},
handleInlineApp: () => {},
closeInlineApp: () => {},
closeAppsModal: () => {},
}),
}));
vi.mock('@/hooks/use-deep-link-url', () => ({ useDeepLinkUrl: () => {} }));
const { default: ProHome } = await import('../page');
const { useSettingsStore } = await import('@/stores/settings-store');
const replace = vi.fn();
beforeEach(() => {
auth.state.isAuthenticated = false;
auth.state.client = null;
auth.state.isLoading = false;
useSettingsStore.setState({ proInterface: true });
Object.assign(device, { isMobile: false, isTablet: false, isDesktop: true });
Object.defineProperty(window, 'location', {
configurable: true,
value: { ...window.location, replace, pathname: '/pro', search: '' },
});
});
afterEach(() => {
vi.clearAllMocks();
});
describe('Pro page gate', () => {
it('draws the Pro skeleton while the session is restored', async () => {
const { container } = render(<ProHome />);
await act(async () => {});
const region = container.querySelector('[data-boot-skeleton="pro"]');
expect(region).not.toBeNull();
expect(region?.querySelector('[data-pro-shell]')).not.toBeNull();
expect(container.querySelectorAll('[data-sk-pro-pane]')).toHaveLength(2);
expect(container.querySelector('.animate-spin')).toBeNull();
expect(container.querySelector('[data-testid="mail-app"]')).toBeNull();
});
it('keeps the skeleton up, not a blank page, below desktop width', async () => {
auth.state.isAuthenticated = true;
auth.state.client = {};
Object.assign(device, { isMobile: false, isTablet: true, isDesktop: false });
const { container } = render(<ProHome />);
await act(async () => {});
expect(container.querySelector('[data-boot-skeleton="pro"]')).not.toBeNull();
expect(container.querySelector('[data-testid="mail-app"]')).toBeNull();
});
it('marks the loaded shell so a skeleton inside a pane keeps its own frame', async () => {
auth.state.isAuthenticated = true;
auth.state.client = {};
const { container } = render(<ProHome />);
await act(async () => {});
expect(container.querySelector('[data-boot-skeleton]')).toBeNull();
const shell = container.querySelector('[data-pro-shell]');
expect(shell).not.toBeNull();
expect(shell?.querySelector('[data-testid="mail-app"]')).not.toBeNull();
});
});
+11 -13
View File
@@ -36,6 +36,8 @@ import { useProTabStore, type ProTab, type ProTabKind, type ProPaneId, type ProS
import { cn } from "@/lib/utils";
import { getPathPrefix } from "@/lib/browser-navigation";
import { onOpenEmail, proEmailTabOpener } from "@/lib/plugin-sandbox/open-email";
import { ProSkeleton } from "@/components/boot-skeleton/pro-skeleton";
import { useSkeletonLabels } from "@/components/boot-skeleton/use-skeleton-labels";
import { MailApp } from "@/components/mail/mail-app";
import { CalendarApp } from "@/components/calendar/calendar-app";
@@ -189,6 +191,7 @@ function Pane({ paneId, tabs, activeTabId, loadedTabIds, onPaneFocus, isFocused
export default function ProHome() {
const t = useTranslations();
const { label: skeletonLabel } = useSkeletonLabels();
const { isMobile, isTablet, isDesktop } = useDeviceDetection();
const [initialCheckDone, setInitialCheckDone] = useState(
@@ -448,20 +451,14 @@ export default function ProHome() {
}
};
// Loading state (matches standard page exactly)
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>
);
// Until the session is up: the same skeleton the server drew, so hydration
// matches and nothing moves. Below desktop width the effect above is about
// to send the visitor to the standard page; keep the skeleton up meanwhile
// rather than a blank page.
if (!initialCheckDone || authLoading || !isAuthenticated || !client || !isDesktop) {
return <ProSkeleton label={skeletonLabel} />;
}
if (!isDesktop) return null;
// Each pane renders as a column: its own tab strip directly above its
// body, so a tab visibly belongs to the pane beneath it and clicking a
// tab only ever changes that pane.
@@ -511,7 +508,8 @@ export default function ProHome() {
return (
<EmbeddedContext.Provider value={true}>
<div className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
{/* data-pro-shell: a page skeleton inside a pane draws its own frame, not the Pro one. */}
<div data-pro-shell="" className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
{/* Once for the whole shell: email and folder tabs have no app strip, and split panes would show it twice. */}
<OrgAnnouncement />
<div className="flex flex-1 overflow-hidden">
@@ -0,0 +1,28 @@
import { renderToString } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
// The admin sign-in page renders in full on the server, which can't know the
// theme: it sends both logos and CSS shows the one for the theme, instead of
// the light logo turning white after the first paint in dark mode.
vi.mock('@/hooks/use-config', () => ({
useConfig: () => ({
appName: 'Webmail',
loginLogoLightUrl: '/branding/light.svg',
loginLogoDarkUrl: '/branding/dark.svg',
}),
}));
const { default: AdminLoginPage } = await import('../login/page');
describe('admin sign-in logo', () => {
it('sends the light and the dark logo, each for its theme', () => {
const container = document.createElement('div');
container.innerHTML = renderToString(<AdminLoginPage />);
const logos = [...container.querySelectorAll('img')].map((img) => [img.getAttribute('src'), img.className]);
expect(logos).toEqual([
['/branding/light.svg', expect.stringContaining('dark:hidden')],
['/branding/dark.svg', expect.stringContaining('hidden dark:block')],
]);
});
});
@@ -0,0 +1,37 @@
import type { ReactNode } from 'react';
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
// While the admin session is checked, the content column shows the page's
// header and section placeholders where the boot skeleton drew them, not a
// line of text at a different height.
vi.mock('next/navigation', () => ({
useRouter: () => ({ replace: vi.fn(), push: vi.fn() }),
usePathname: () => '/admin/settings',
}));
vi.mock('@/lib/browser-navigation', () => ({
// The session check never answers.
apiFetch: vi.fn(() => new Promise(() => {})),
getPathPrefix: () => '',
withBasePath: (href: string) => href,
}));
vi.mock('@/components/admin/admin-shell', () => ({
AdminShell: ({ children }: { children: ReactNode }) => <main>{children}</main>,
}));
vi.mock('../_unsaved-changes-guard', () => ({ UnsavedChangesGuard: () => null }));
const { AdminLayoutClient } = await import('../_layout-client');
describe('admin layout while the session is checked', () => {
it('draws the page header and section placeholders in the content slot', () => {
render(<AdminLayoutClient><p>page</p></AdminLayoutClient>);
const main = screen.getByRole('main');
expect(main.querySelector('[data-testid="admin-body-skeleton"]')).not.toBeNull();
// The open page's own title and description, as AdminPageHeader shows them.
expect(main.textContent).toContain('How this Bulwark connects and what the login page offers');
expect(screen.getByRole('status').textContent).toBe('Loading admin panel…');
expect(screen.getByRole('status').className).toContain('sr-only');
expect(screen.queryByText('page')).toBeNull();
});
});
+6 -3
View File
@@ -6,6 +6,8 @@ import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { apiFetch } from '@/lib/browser-navigation';
import { useAdminUnsavedStore } from '@/stores/admin-unsaved-store';
import { AdminShell } from '@/components/admin/admin-shell';
import { AdminContentSkeleton } from '@/components/boot-skeleton/admin-skeleton';
import { useAdminLocation } from '@/hooks/use-admin-navigation';
import { UnsavedChangesGuard } from './_unsaved-changes-guard';
import { ToastContainer } from '@/components/ui/toast';
import { useToastStore } from '@/stores/toast-store';
@@ -25,6 +27,7 @@ function AdminToasts() {
export function AdminLayoutClient({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const location = useAdminLocation();
const requestLeave = useAdminUnsavedStore((s) => s.requestLeave);
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
const [authError, setAuthError] = useState<string | null>(null);
@@ -135,9 +138,9 @@ export function AdminLayoutClient({ children }: { children: React.ReactNode }) {
<p className="mt-1 text-destructive/80">{authError}</p>
</div>
) : authenticated === null ? (
<div className="py-12 text-center text-sm text-muted-foreground animate-pulse">
Loading admin panel…
</div>
// The page's header and section placeholders, where the boot
// skeleton drew them.
<AdminContentSkeleton page={location.page} status="Loading admin panel…" />
) : authenticated ? children : null}
</AdminShell>
</>
+2 -3
View File
@@ -10,6 +10,7 @@ import { SettingsSection, SettingItem, SectionNav } from '@/components/settings/
import { Button } from '@/components/ui/button';
import { Dialog } from '@/components/ui/dialog';
import { AdminPageHeader } from '@/components/admin/page-header';
import { AdminBodySkeleton } from '@/components/boot-skeleton/admin-skeleton';
import { AdminSaveBar } from '@/components/admin/save-bar';
import { withEdit } from '@/lib/admin/edits';
import { useAdminFeedback } from '@/components/admin/feedback';
@@ -172,9 +173,7 @@ export function AuthPage() {
return (
<div>
<AdminPageHeader title={PAGE.label} description={PAGE.description} />
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
</div>
<AdminBodySkeleton status="Loading" />
</div>
);
}
+2 -3
View File
@@ -14,6 +14,7 @@ import {
import { SettingsSection, SettingItem, SectionNav } from '@/components/settings/settings-section';
import { Button } from '@/components/ui/button';
import { AdminPageHeader } from '@/components/admin/page-header';
import { AdminBodySkeleton } from '@/components/boot-skeleton/admin-skeleton';
import { AdminSaveBar } from '@/components/admin/save-bar';
import { withEdit } from '@/lib/admin/edits';
import { Badge } from '@/components/admin/badge';
@@ -390,9 +391,7 @@ export function BrandingPage() {
return (
<div>
<AdminPageHeader title={PAGE.label} description={PAGE.description} />
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
</div>
<AdminBodySkeleton status="Loading" />
</div>
);
}
+2 -4
View File
@@ -1,12 +1,12 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { Loader2 } from '@/components/icons';
import { apiFetch } from '@/lib/browser-navigation';
import { adminPageDef } from '@/lib/admin/admin-nav';
import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
import { SettingsSection, SectionNav } from '@/components/settings/settings-section';
import { AdminPageHeader } from '@/components/admin/page-header';
import { AdminBodySkeleton } from '@/components/boot-skeleton/admin-skeleton';
import { AdminSaveBar } from '@/components/admin/save-bar';
import { withEdit } from '@/lib/admin/edits';
import { useAdminFeedback } from '@/components/admin/feedback';
@@ -109,9 +109,7 @@ export function MonitoringPage() {
return (
<div>
<AdminPageHeader title={PAGE.label} description={PAGE.description} />
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
</div>
<AdminBodySkeleton status="Loading" />
</div>
);
}
+3 -4
View File
@@ -1,7 +1,7 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { Loader2, Plus, Trash2, ArrowUp, ArrowDown } from '@/components/icons';
import { Plus, Trash2, ArrowUp, ArrowDown } from '@/components/icons';
import { iconForName } from '@/components/icons';
import type { SettingsPolicy, FeatureGates, PushRelayOption, AdminSidebarApp } from '@/lib/admin/types';
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
@@ -20,6 +20,7 @@ import { SettingsSection, SettingItem, ToggleSwitch, SectionNav } from '@/compon
import type { GlossaryId } from '@/components/ui/term';
import { Button } from '@/components/ui/button';
import { AdminPageHeader } from '@/components/admin/page-header';
import { AdminBodySkeleton } from '@/components/boot-skeleton/admin-skeleton';
import { AdminSaveBar } from '@/components/admin/save-bar';
import { Badge } from '@/components/admin/badge';
import { useAdminFeedback } from '@/components/admin/feedback';
@@ -261,9 +262,7 @@ export function PolicyPage() {
{loading ? (
<div className="flex items-center justify-center py-10 text-sm text-muted-foreground">
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
</div>
<AdminBodySkeleton status="Loading" />
) : (
<div className="space-y-10">
<SectionNav />
+2 -4
View File
@@ -1,12 +1,12 @@
'use client';
import { useEffect, useState } from 'react';
import { Loader2 } from '@/components/icons';
import { apiFetch } from '@/lib/browser-navigation';
import { adminPageDef } from '@/lib/admin/admin-nav';
import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
import { SettingsSection, SectionNav } from '@/components/settings/settings-section';
import { AdminPageHeader } from '@/components/admin/page-header';
import { AdminBodySkeleton } from '@/components/boot-skeleton/admin-skeleton';
import { AdminSaveBar } from '@/components/admin/save-bar';
import { withEdit } from '@/lib/admin/edits';
import { useAdminFeedback } from '@/components/admin/feedback';
@@ -100,9 +100,7 @@ export function SettingsPage() {
return (
<div>
<AdminPageHeader title={PAGE.label} description={PAGE.description} />
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
</div>
<AdminBodySkeleton status="Loading" />
</div>
);
}
+2 -3
View File
@@ -5,6 +5,7 @@ import { Loader2, Send, ExternalLink } from '@/components/icons';
import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
import { Button } from '@/components/ui/button';
import { AdminPageHeader } from '@/components/admin/page-header';
import { AdminBodySkeleton } from '@/components/boot-skeleton/admin-skeleton';
import { AdminSaveBar } from '@/components/admin/save-bar';
import { useAdminFeedback } from '@/components/admin/feedback';
import { ConfigNote, inputClass } from '@/components/admin/config-items';
@@ -149,9 +150,7 @@ export function TelemetryPage() {
return (
<div>
{header}
<div className="flex items-center justify-center py-10 text-sm text-muted-foreground">
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
</div>
<AdminBodySkeleton status="Loading" />
</div>
);
}
+2 -3
View File
@@ -12,6 +12,7 @@ import {
import { SettingsSection, SettingItem } from '@/components/settings/settings-section';
import { Button } from '@/components/ui/button';
import { AdminPageHeader } from '@/components/admin/page-header';
import { AdminBodySkeleton } from '@/components/boot-skeleton/admin-skeleton';
import { Badge, type BadgeTone } from '@/components/admin/badge';
import { useAdminFeedback } from '@/components/admin/feedback';
import { adminPageDef } from '@/lib/admin/admin-nav';
@@ -117,9 +118,7 @@ export function VersionPage() {
return (
<div>
{header}
<div className="flex items-center justify-center py-10 text-sm text-muted-foreground">
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
</div>
<AdminBodySkeleton status="Loading" />
</div>
);
}
+9 -5
View File
@@ -4,7 +4,6 @@ import { useEffect, useState, type FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import { AlertCircle, Shield } from '@/components/icons';
import { useConfig } from '@/hooks/use-config';
import { useThemeStore } from '@/stores/theme-store';
import { apiFetch, withBasePath } from '@/lib/browser-navigation';
import { safeAdminNext } from '@/lib/connector/admin-next';
@@ -14,8 +13,10 @@ export default function AdminLoginPage() {
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { loginLogoLightUrl, loginLogoDarkUrl, appName } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const logoUrl = withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl);
// Both logos, picked by CSS: the server can't know the theme, and a logo
// chosen from the theme store swaps after the first paint.
const logoLight = withBasePath(loginLogoLightUrl || loginLogoDarkUrl);
const logoDark = withBasePath(loginLogoDarkUrl || loginLogoLightUrl);
useEffect(() => {
document.title = `Sign in - Admin - ${appName}`;
@@ -57,8 +58,11 @@ export default function AdminLoginPage() {
<div className="min-h-screen flex items-center justify-center bg-background px-4">
<div className="w-full max-w-sm">
<div className="flex flex-col items-center mb-8">
{logoUrl ? (
<img src={logoUrl} alt="" className="h-12 object-contain mb-4" />
{logoLight ? (
<>
<img src={logoLight} alt="" className="h-12 object-contain mb-4 dark:hidden" />
<img src={logoDark} alt="" className="h-12 object-contain mb-4 hidden dark:block" />
</>
) : (
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center mb-4">
<Shield className="w-6 h-6 text-primary" />
+3
View File
@@ -19,6 +19,7 @@ import { withBasePath } from "@/lib/browser-navigation";
import { locales, defaultLocale } from "@/i18n/routing";
import { IS_LITE, LITE_MOUNT_GLOBAL } from "@/lib/lite";
import { accessibilityBootScript } from "@/lib/accessibility-settings";
import { bootSkeletonScript } from "@/lib/boot-skeleton/boot-script";
import "../globals.css";
import "../print.css";
@@ -314,10 +315,12 @@ export default async function RootLayout({
const resolved = theme === 'system' ? systemTheme : theme;
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(resolved);
document.documentElement.style.colorScheme = resolved;
} catch (e) {
document.documentElement.classList.add('light');
}
${accessibilityBootScript()}
${bootSkeletonScript()}
})();
`,
}}
+9 -1
View File
@@ -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} />;
}
+16 -1
View File
@@ -1,8 +1,23 @@
import { getTranslations } from "next-intl/server";
import { WebcalProtocolClient } from "@/components/protocol/webcal-protocol-client";
import { calendarSkeletonText } from "@/components/boot-skeleton/calendar-skeleton";
export default async function WebcalProtocolPage() {
const t = await getTranslations("protocol_handlers");
const tSidebar = await getTranslations("sidebar");
const tCalendar = await getTranslations("calendar");
return <WebcalProtocolClient openingText={t("opening_webcal")} />;
return (
<WebcalProtocolClient
openingText={t("opening_webcal")}
labels={{
mail: tSidebar("mail"),
calendar: tSidebar("calendar"),
contacts: tSidebar("contacts"),
files: tSidebar("files"),
settings: tSidebar("settings"),
}}
text={calendarSkeletonText(tCalendar)}
/>
);
}
@@ -0,0 +1,46 @@
import { render } from '@testing-library/react';
import { renderToString } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import { NextIntlClientProvider } from 'next-intl';
import en from '@/locales/en/common.json';
// Until /api/setup/status answers (and in the server HTML), the wizard
// draws its own frame with the welcome step, not a card saying "Loading".
vi.unmock('next-intl');
vi.mock('next/navigation', () => ({ useRouter: () => ({ replace: vi.fn(), push: vi.fn() }) }));
vi.mock('@/lib/browser-navigation', () => ({
apiFetch: vi.fn(() => new Promise(() => {})),
getPathPrefix: () => '',
withBasePath: (p: string) => p,
}));
vi.mock('@/lib/setup/url-token', () => ({ takeSetupTokenFromUrl: () => '' }));
const { default: SetupWizardPage } = await import('../page');
const page = (
<NextIntlClientProvider locale="en" messages={en}>
<SetupWizardPage />
</NextIntlClientProvider>
);
describe('setup wizard while the status loads', () => {
it('server-renders the wizard frame', () => {
const container = document.createElement('div');
container.innerHTML = renderToString(page);
const region = container.querySelector('[data-boot-skeleton="setup"]');
expect(region).not.toBeNull();
expect(region!.querySelector('[role="status"]')?.textContent).toBe(en.setup.loading);
// Header, all seven steps and the welcome card, in the page's words.
expect(region!.textContent).toContain(en.setup.title);
for (const step of Object.values(en.setup.steps)) expect(region!.textContent).toContain(step);
expect(region!.textContent).toContain(en.setup.welcome.token_label);
expect(container.querySelectorAll('a[href], button, input')).toHaveLength(0);
});
it('keeps it on the client until the status is there', () => {
const { container } = render(page);
expect(container.querySelector('[data-boot-skeleton="setup"]')).not.toBeNull();
expect(container.querySelector('.max-w-md')).toBeNull();
});
});
+20 -10
View File
@@ -6,6 +6,8 @@ import { useTranslations } from 'next-intl';
import { Check, CheckCircle2, AlertTriangle, AlertCircle, Server, ShieldCheck, KeyRound, FileText, Palette, Lock, ShieldAlert } from '@/components/icons';
import { apiFetch, getPathPrefix, withBasePath } from '@/lib/browser-navigation';
import { takeSetupTokenFromUrl } from '@/lib/setup/url-token';
import { SetupSkeleton } from '@/components/boot-skeleton/setup-skeleton';
import { SETUP_STEPS } from '@/lib/setup/steps';
type State = 'bootstrap' | 'configured' | 'env-managed';
@@ -85,15 +87,7 @@ const EMPTY_CONFIG: WizardConfig = {
// The wizard follows the browser's language, like the login page; the
// labels are setup.steps.<id>.
const STEPS = [
{ id: 'welcome' },
{ id: 'server' },
{ id: 'auth' },
{ id: 'security' },
{ id: 'logging' },
{ id: 'branding' },
{ id: 'review' },
] as const;
const STEPS = SETUP_STEPS;
type SetupT = ReturnType<typeof useTranslations<'setup'>>;
@@ -201,8 +195,24 @@ export default function SetupWizardPage() {
return <InsecureContextScreen onContinue={() => setInsecureAcknowledged(true)} />;
}
// The server HTML, and every visit until the status answers: the wizard's
// frame with the welcome step, where a fresh install starts.
if (bootstrapping) {
return <CenteredCard><p className="text-muted-foreground">{t('loading')}</p></CenteredCard>;
return (
<SetupSkeleton
label={t('loading')}
strings={{
title: t('title'),
subtitle: t('subtitle'),
steps: STEPS.map((step) => t(`steps.${step.id}`)),
welcomeTitle: t('welcome.title'),
welcomeBody: t('welcome.body'),
tokenLabel: t('welcome.token_label'),
tokenPlaceholder: t('welcome.token_placeholder'),
continueLabel: t('welcome.continue'),
}}
/>
);
}
if (completed) {
+421
View File
@@ -0,0 +1,421 @@
/*
* Page skeletons (components/boot-skeleton).
*
* The server draws every variant of a skeleton; these variants show the one
* that matches the attributes the boot script set on <html> before the first
* paint (lib/boot-skeleton/boot-script.ts). Part of the first stylesheet, so
* nothing here arrives late.
*/
/* Mountain View shell. Its geometry only applies on desktop, so pair it with
lg: in markup (lg:mv:hidden), except the phone bottom bar, which follows
the layout at every width. */
@custom-variant mv (&:where([data-interface-layout="mountain-view"] *));
/* Mail layout (effectiveMailLayout: Mountain View and simple mode are focus). */
@custom-variant boot-split (&:where([data-boot-mail-layout="split"] *));
@custom-variant boot-horizontal (&:where([data-boot-mail-layout="horizontal"] *));
@custom-variant boot-focus (&:where([data-boot-mail-layout="focus"] *));
/* A message, thread, contact, settings page or admin page is open. */
@custom-variant boot-open (&:where([data-boot-pane] *));
@custom-variant boot-thread (&:where([data-boot-pane="thread"] *));
/* The Pro interface takes this page over on a desktop. */
@custom-variant boot-pro (&:where([data-boot-pro="on"] *));
/* Nothing stored that checkAuth could restore: draw the login card. */
@custom-variant boot-out (&:where([data-boot-auth="out"] *));
/* Calendar view. */
@custom-variant boot-cal-month (&:where([data-boot-calendar-view="month"] *));
@custom-variant boot-cal-week (&:where([data-boot-calendar-view="week"] *));
@custom-variant boot-cal-day (&:where([data-boot-calendar-view="day"] *));
@custom-variant boot-cal-agenda (&:where([data-boot-calendar-view="agenda"] *));
@custom-variant boot-cal-tasks (&:where([data-boot-calendar-view="tasks"] *));
@custom-variant boot-cal-collapsed (&:where([data-boot-collapsed~="calendar"] *));
/* Files. */
@custom-variant boot-files-grid (&:where([data-boot-files-view="grid"] *));
@custom-variant boot-files-details (&:where([data-boot-files-details="on"] *));
/* What the rail showed last time (bulwark-boot-hints). Without hints every
app is drawn: the rail of a server with all of them. */
@custom-variant boot-no-calendar (&:where([data-boot-rail]:not([data-boot-rail~="calendar"]) *));
@custom-variant boot-no-contacts (&:where([data-boot-rail]:not([data-boot-rail~="contacts"]) *));
@custom-variant boot-no-files (&:where([data-boot-rail]:not([data-boot-rail~="files"]) *));
@custom-variant boot-rail-add-app (&:where([data-boot-rail~="add-app"] *));
@custom-variant boot-rail-admin (&:where([data-boot-rail~="admin"] *));
@custom-variant boot-rail-a11y (&:where([data-boot-rail~="a11y"] *));
@custom-variant boot-rail-quota (&:where([data-boot-rail~="quota"] *));
/* A placeholder shape. A slow opacity pulse rather than a moving shimmer:
opacity runs on the compositor, so the skeleton costs the main thread
nothing while the app's JS is still starting up. The colour is a tint of
the text colour rather than --color-muted, which equals the sidebar
colour in dark mode (and nearly so in light mode), so shapes show on every
panel and follow a theme's palette. */
.sk-shape {
background-color: color-mix(in oklab, var(--color-foreground) 9%, transparent);
animation: sk-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes sk-pulse {
50% {
opacity: 0.55;
}
}
/* Reduced motion: no pulse at all (the global rule only shortens it). */
@media (prefers-reduced-motion: reduce) {
:root:not([data-motion="full"]) .sk-shape {
animation: none;
}
}
:root[data-motion="reduce"] .sk-shape {
animation: none;
}
/* More contrast: shapes that stand out from the panels they sit on. */
@media (prefers-contrast: more) {
:root:not([data-contrast="normal"]) .sk-shape {
background-color: color-mix(in oklab, var(--color-foreground) 28%, transparent);
}
}
:root[data-contrast="more"] .sk-shape {
background-color: color-mix(in oklab, var(--color-foreground) 28%, transparent);
}
/* Forced colours drop backgrounds; keep the shapes as system grey. */
@media (forced-colors: active) {
.sk-shape {
forced-color-adjust: none;
background-color: GrayText;
animation: none;
}
}
/* pro */
/* The Pro shell takes a standard page over (components/boot-skeleton/shell.tsx):
on a desktop with the Pro interface on, unless the visitor is signed out,
and never again inside the Pro shell itself (an embedded page's gate). */
@custom-variant boot-pro-frame (&:where([data-boot-pro="on"]:not([data-boot-auth="out"]) *):not(:where([data-pro-shell] *)));
/* The Pro split pane, on its stored side. */
@custom-variant boot-pro-split (&:where([data-boot-pro-split] *));
@custom-variant boot-pro-split-left (&:where([data-boot-pro-split="left"] *));
/* Each pane's tab strip draws as many tabs as the store holds (up to eight),
and its body the active tab's page (lib/boot-skeleton/boot-script.ts,
data-boot-pro-<pane>-tabs / -active / -kind). One tab and mail without them. */
[data-sk-pro-tab]:nth-child(n+2),
[data-sk-pro-body] {
display: none;
}
:root[data-boot-pro-main-tabs="2"] [data-sk-pro-pane="main"] [data-sk-pro-tab]:nth-child(-n+2),
:root[data-boot-pro-main-tabs="3"] [data-sk-pro-pane="main"] [data-sk-pro-tab]:nth-child(-n+3),
:root[data-boot-pro-main-tabs="4"] [data-sk-pro-pane="main"] [data-sk-pro-tab]:nth-child(-n+4),
:root[data-boot-pro-main-tabs="5"] [data-sk-pro-pane="main"] [data-sk-pro-tab]:nth-child(-n+5),
:root[data-boot-pro-main-tabs="6"] [data-sk-pro-pane="main"] [data-sk-pro-tab]:nth-child(-n+6),
:root[data-boot-pro-main-tabs="7"] [data-sk-pro-pane="main"] [data-sk-pro-tab]:nth-child(-n+7),
:root[data-boot-pro-main-tabs="8"] [data-sk-pro-pane="main"] [data-sk-pro-tab]:nth-child(-n+8),
:root[data-boot-pro-split-tabs="2"] [data-sk-pro-pane="split"] [data-sk-pro-tab]:nth-child(-n+2),
:root[data-boot-pro-split-tabs="3"] [data-sk-pro-pane="split"] [data-sk-pro-tab]:nth-child(-n+3),
:root[data-boot-pro-split-tabs="4"] [data-sk-pro-pane="split"] [data-sk-pro-tab]:nth-child(-n+4),
:root[data-boot-pro-split-tabs="5"] [data-sk-pro-pane="split"] [data-sk-pro-tab]:nth-child(-n+5),
:root[data-boot-pro-split-tabs="6"] [data-sk-pro-pane="split"] [data-sk-pro-tab]:nth-child(-n+6),
:root[data-boot-pro-split-tabs="7"] [data-sk-pro-pane="split"] [data-sk-pro-tab]:nth-child(-n+7),
:root[data-boot-pro-split-tabs="8"] [data-sk-pro-pane="split"] [data-sk-pro-tab]:nth-child(-n+8) {
display: flex;
}
:root:not([data-boot-pro-main-active]) [data-sk-pro-pane="main"] [data-sk-pro-tab]:first-child,
:root:not([data-boot-pro-split-active]) [data-sk-pro-pane="split"] [data-sk-pro-tab]:first-child,
:root[data-boot-pro-main-active="0"] [data-sk-pro-pane="main"] [data-sk-pro-tab]:nth-child(1),
:root[data-boot-pro-main-active="1"] [data-sk-pro-pane="main"] [data-sk-pro-tab]:nth-child(2),
:root[data-boot-pro-main-active="2"] [data-sk-pro-pane="main"] [data-sk-pro-tab]:nth-child(3),
:root[data-boot-pro-main-active="3"] [data-sk-pro-pane="main"] [data-sk-pro-tab]:nth-child(4),
:root[data-boot-pro-main-active="4"] [data-sk-pro-pane="main"] [data-sk-pro-tab]:nth-child(5),
:root[data-boot-pro-main-active="5"] [data-sk-pro-pane="main"] [data-sk-pro-tab]:nth-child(6),
:root[data-boot-pro-main-active="6"] [data-sk-pro-pane="main"] [data-sk-pro-tab]:nth-child(7),
:root[data-boot-pro-main-active="7"] [data-sk-pro-pane="main"] [data-sk-pro-tab]:nth-child(8),
:root[data-boot-pro-split-active="0"] [data-sk-pro-pane="split"] [data-sk-pro-tab]:nth-child(1),
:root[data-boot-pro-split-active="1"] [data-sk-pro-pane="split"] [data-sk-pro-tab]:nth-child(2),
:root[data-boot-pro-split-active="2"] [data-sk-pro-pane="split"] [data-sk-pro-tab]:nth-child(3),
:root[data-boot-pro-split-active="3"] [data-sk-pro-pane="split"] [data-sk-pro-tab]:nth-child(4),
:root[data-boot-pro-split-active="4"] [data-sk-pro-pane="split"] [data-sk-pro-tab]:nth-child(5),
:root[data-boot-pro-split-active="5"] [data-sk-pro-pane="split"] [data-sk-pro-tab]:nth-child(6),
:root[data-boot-pro-split-active="6"] [data-sk-pro-pane="split"] [data-sk-pro-tab]:nth-child(7),
:root[data-boot-pro-split-active="7"] [data-sk-pro-pane="split"] [data-sk-pro-tab]:nth-child(8) {
/* ProTabBar's active tab; its bottom edge covers the strip's border. */
background-color: var(--color-background);
border-inline-color: rgba(128, 128, 128, 0.3);
--sk-pro-tab-accent: var(--sk-pro-pane-accent);
}
:root:not([data-boot-pro-focus="split"]) [data-sk-pro-pane="main"],
:root[data-boot-pro-focus="split"] [data-sk-pro-pane="split"] {
/* The focused pane's active tab carries the accent top edge. */
--sk-pro-pane-accent: var(--color-primary);
}
:root:not([data-boot-pro-main-kind]) [data-sk-pro-pane="main"] [data-sk-pro-body="mail"],
:root:not([data-boot-pro-split-kind]) [data-sk-pro-pane="split"] [data-sk-pro-body="mail"],
:root[data-boot-pro-main-kind="mail"] [data-sk-pro-pane="main"] [data-sk-pro-body="mail"],
:root[data-boot-pro-main-kind="calendar"] [data-sk-pro-pane="main"] [data-sk-pro-body="calendar"],
:root[data-boot-pro-main-kind="contacts"] [data-sk-pro-pane="main"] [data-sk-pro-body="contacts"],
:root[data-boot-pro-main-kind="files"] [data-sk-pro-pane="main"] [data-sk-pro-body="files"],
:root[data-boot-pro-main-kind="settings"] [data-sk-pro-pane="main"] [data-sk-pro-body="settings"],
:root[data-boot-pro-main-kind="other"] [data-sk-pro-pane="main"] [data-sk-pro-body="other"],
:root[data-boot-pro-split-kind="mail"] [data-sk-pro-pane="split"] [data-sk-pro-body="mail"],
:root[data-boot-pro-split-kind="calendar"] [data-sk-pro-pane="split"] [data-sk-pro-body="calendar"],
:root[data-boot-pro-split-kind="contacts"] [data-sk-pro-pane="split"] [data-sk-pro-body="contacts"],
:root[data-boot-pro-split-kind="files"] [data-sk-pro-pane="split"] [data-sk-pro-body="files"],
:root[data-boot-pro-split-kind="settings"] [data-sk-pro-pane="split"] [data-sk-pro-body="settings"],
:root[data-boot-pro-split-kind="other"] [data-sk-pro-pane="split"] [data-sk-pro-body="other"] {
display: block;
}
/* The rail marks the focused tab's app (pro/page.tsx, railActiveItemId). */
:root:not([data-boot-pro-kind]) [data-pro-shell] [data-sk-rail="mail"],
:root[data-boot-pro-kind="mail"] [data-pro-shell] [data-sk-rail="mail"],
:root[data-boot-pro-kind="calendar"] [data-pro-shell] [data-sk-rail="calendar"],
:root[data-boot-pro-kind="contacts"] [data-pro-shell] [data-sk-rail="contacts"],
:root[data-boot-pro-kind="files"] [data-pro-shell] [data-sk-rail="files"],
:root[data-boot-pro-kind="settings"] [data-pro-shell] [data-sk-rail="settings"] {
background-color: color-mix(in oklab, var(--color-primary) 10%, transparent);
color: var(--color-primary);
}
/* settings */
/* The open settings page in the skeleton nav: the row whose
data-sk-settings-tab matches data-boot-settings-tab. */
@custom-variant boot-settings-active (&:where(
[data-boot-settings-tab="account"] [data-sk-settings-tab="account"],
[data-boot-settings-tab="general"] [data-sk-settings-tab="general"],
[data-boot-settings-tab="notifications"] [data-sk-settings-tab="notifications"],
[data-boot-settings-tab="appearance"] [data-sk-settings-tab="appearance"],
[data-boot-settings-tab="accessibility"] [data-sk-settings-tab="accessibility"],
[data-boot-settings-tab="message_list"] [data-sk-settings-tab="message_list"],
[data-boot-settings-tab="reading"] [data-sk-settings-tab="reading"],
[data-boot-settings-tab="composing"] [data-sk-settings-tab="composing"],
[data-boot-settings-tab="vacation"] [data-sk-settings-tab="vacation"],
[data-boot-settings-tab="filters"] [data-sk-settings-tab="filters"],
[data-boot-settings-tab="organize"] [data-sk-settings-tab="organize"],
[data-boot-settings-tab="security"] [data-sk-settings-tab="security"],
[data-boot-settings-tab="sharing"] [data-sk-settings-tab="sharing"],
[data-boot-settings-tab="recovery"] [data-sk-settings-tab="recovery"],
[data-boot-settings-tab="calendar"] [data-sk-settings-tab="calendar"],
[data-boot-settings-tab="contacts"] [data-sk-settings-tab="contacts"],
[data-boot-settings-tab="files"] [data-sk-settings-tab="files"],
[data-boot-settings-tab="addons"] [data-sk-settings-tab="addons"],
[data-boot-settings-tab="backup"] [data-sk-settings-tab="backup"]
));
/* Settings pages the nav listed last time (bulwark-boot-hints). Without
hints every page is drawn except recovery (an Enterprise feature). */
@custom-variant boot-no-settings-security (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="security"]) *));
@custom-variant boot-no-settings-sharing (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="sharing"]) *));
@custom-variant boot-no-settings-filters (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="filters"]) *));
@custom-variant boot-no-settings-vacation (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="vacation"]) *));
@custom-variant boot-no-settings-calendar (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="calendar"]) *));
@custom-variant boot-no-settings-contacts (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="contacts"]) *));
@custom-variant boot-no-settings-files (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="files"]) *));
@custom-variant boot-no-settings-addons (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="addons"]) *));
@custom-variant boot-settings-recovery (&:where([data-boot-settings-tabs~="recovery"] *));
/* Settings pages with one section have no section jump row (SectionNav). */
@custom-variant boot-settings-one-section (&:where([data-boot-settings-tab="security"] *, [data-boot-settings-tab="filters"] *, [data-boot-settings-tab="vacation"] *, [data-boot-settings-tab="recovery"] *, [data-boot-settings-tab="addons"] *));
/* Accessibility has eleven sections: its jump row wraps to a second line. */
@custom-variant boot-settings-two-rows (&:where([data-boot-settings-tab="accessibility"] *));
/* contacts, files, apps */
/* List density (data-boot-density, the settings' density): contact rows drop
the avatar and the second line in extra compact. */
@custom-variant boot-density-xc (&:where([data-boot-density="extra-compact"] *));
/* /contacts/new: the new-contact form (data-boot-pane="create"). */
@custom-variant boot-create (&:where([data-boot-pane="create"] *));
/* calendar */
/* Week numbers beside the sidebar's mini month (showWeekNumbers). */
@custom-variant boot-cal-weeknums (&:where([data-boot-cal-weeknums="on"] *));
/* Calendar tasks are on (enableCalendarTasks): a view pill and a sidebar row. */
@custom-variant boot-cal-tasks-on (&:where([data-boot-cal-tasks="on"] *));
/* The views measure their pane to size their rows; the skeleton does the
same with the pane as a size container. --sk-head is the header's
offsetHeight (whole pixels, as the views read it). */
.sk-cal-view {
container-type: size;
}
.sk-cal-week,
.sk-cal-day {
/* pt-2 + weekday line + gap-0.5 + date circle + pb-1.5 + border */
--sk-head: round(nearest, 3.5rem + 1.03125rem + 1px, 1px);
--sk-hour-min: 60px;
/* fillHourHeight takes the fitted height from ceil(min * 0.9) up. */
--sk-hour-floor: 54px;
--sk-hour: var(--sk-hour-min);
--sk-shift: 0px;
}
.sk-cal-day {
--sk-hour-min: 64px;
--sk-hour-floor: 58px;
}
.sk-cal-month {
/* py-2 + text-xs line + border */
--sk-head: calc(2rem + 1px);
--sk-week-min: 146px;
--sk-week: var(--sk-week-min);
}
@media (width < 48rem) {
.sk-cal-week {
--sk-head: round(nearest, 3rem + 1.03125rem + 1px, 1px);
}
.sk-cal-month {
--sk-head: round(nearest, 0.75rem + 1.03125rem + 1px, 1px);
--sk-week-min: 52px;
}
}
@supports (height: round(down, 1.5px, 1px)) and (height: 1cqh) {
.sk-cal-week,
.sk-cal-day {
/* clientHeight, a whole number of pixels */
--sk-pane: round(nearest, 100cqh, 1px);
--sk-fit: round(down, (var(--sk-pane) - var(--sk-head)) / var(--boot-cal-hours, 12), 1px);
--sk-hour: max(var(--sk-fit), clamp(0px, (var(--sk-hour-floor) - var(--sk-fit)) * 1000, var(--sk-hour-min)));
/* The scroll the grid opens at: an hour before now, at most to the end. */
--sk-shift: clamp(
0px,
var(--boot-cal-scroll, -1) * var(--sk-hour-min) - 8px,
var(--boot-cal-hours, 12) * var(--sk-hour) - (var(--sk-pane) - var(--sk-head))
);
}
.sk-cal-month {
--sk-week: max(var(--sk-week-min), round(down, (round(nearest, 100cqh, 1px) - var(--sk-head)) / 6, 1px));
}
}
.sk-cal-hours {
height: calc(var(--boot-cal-hours, 12) * var(--sk-hour));
}
.sk-cal-hours > * {
top: calc(-1 * var(--sk-shift));
}
.sk-cal-hour {
height: var(--sk-hour);
}
/* The slots' border-b border-border/50, one per hour, as a background. */
.sk-cal-slots {
background-image: linear-gradient(
to bottom,
transparent calc(var(--sk-hour) - 1px),
color-mix(in oklab, var(--color-border) 50%, transparent) calc(var(--sk-hour) - 1px)
);
background-size: 100% var(--sk-hour);
}
/* A range that starts at midnight leaves its first hour unlabelled. */
.sk-cal-first-label {
opacity: min(1, var(--boot-cal-start, 8));
}
/* The all-hours toggle shows while the hours are limited. */
.sk-cal-hours-toggle {
opacity: min(1, 24 - var(--boot-cal-hours, 12));
}
.sk-cal-month-week {
height: var(--sk-week);
}
.sk-cal-mini-days {
height: calc(var(--boot-cal-mini-rows, 6) * 1.75rem);
}
@media (pointer: coarse) {
.sk-cal-mini-days {
height: calc(var(--boot-cal-mini-rows, 6) * 2.75rem);
}
}
/* Week columns: floor((pane - gutter) / columns), at least 80px; 120px on
phones (calendar-week-view.tsx, measureColWidth). Columns past the pane
are the next days of the strip, cut off. */
.sk-cal-cols {
--sk-col: calc((100cqw - 3.5rem) / var(--boot-cal-cols, 7));
grid-auto-flow: column;
grid-auto-columns: var(--sk-col);
}
@supports (width: round(down, 1.5px, 1px)) and (width: 1cqw) {
.sk-cal-cols {
--sk-col: max(80px, round(down, (100cqw - 3.5rem) / var(--boot-cal-cols, 7), 1px));
}
}
@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);
}
}
+1
View File
@@ -3,6 +3,7 @@
@import "./theme-inline.css";
@import "./accessibility.css";
@import "./boot-skeleton.css";
/* The app's font stack replaces Tailwind's default --font-sans, so the
font-sans utility and body use the same stack, and a theme's
@@ -37,10 +37,17 @@ describe('AdminShell', () => {
Element.prototype.scrollTo = () => {};
});
it('does not server-render the phone layout, which desktop would show until hydration', () => {
// The server can't know the width: it draws the panel's skeleton, whose
// phone and desktop layouts CSS picks, rather than either real layout.
it('server-renders the panel skeleton, not a real layout', () => {
const html = renderToString(shell({ authenticated: false, isStalwartAutoLogin: false }));
expect(html).not.toContain('back_to_mail');
expect(html).not.toContain('admin_search');
const container = document.createElement('div');
container.innerHTML = html;
const region = container.querySelector('[data-boot-skeleton="admin"]');
expect(region).not.toBeNull();
expect(region!.querySelector('[role="status"]')?.textContent).toBe('common.loading');
expect(container.querySelectorAll('a[href], button, input')).toHaveLength(0);
expect(html).not.toContain('<p>page</p>');
});
// The shell's own names come from the translations, and the tab says
@@ -59,7 +66,10 @@ describe('AdminShell', () => {
it('hides the session links until the check says which kind of admin this is', () => {
const { rerender } = render(shell({ authenticated: false, isStalwartAutoLogin: false }));
expect(screen.queryByText('Sign out of admin')).toBeNull();
// Only their shapes hold the place while the check runs.
expect(screen.queryByRole('button', { name: 'Sign out of admin' })).toBeNull();
expect(screen.queryByRole('link', { name: 'Change password' })).toBeNull();
expect(screen.getByText('Sign out of admin').closest('[aria-hidden="true"]')).not.toBeNull();
rerender(shell({ authenticated: true, isStalwartAutoLogin: true }));
expect(screen.queryByText('Sign out of admin')).toBeNull();
rerender(shell({ authenticated: true, isStalwartAutoLogin: false }));
+66
View File
@@ -0,0 +1,66 @@
/**
* The admin sidebar's icons and group headings. A plain module (no
* "use client"), so the admin shell and its page skeleton, which the server
* renders, draw the same list.
*/
import {
Activity,
ArrowUpCircle,
Bell,
Building2,
CalendarDays,
HeartRateMonitor,
LayoutDashboard,
LayoutTemplate,
Palette,
PenLine,
Puzzle,
Scale,
ScrollText,
Server,
Share2,
Shield,
ShieldCheck,
Smartphone,
Store,
SwatchBook,
Tag,
SpellCheck,
type AppIcon,
} from '@/components/icons';
import type { AdminPageDef, AdminPageId } from '@/lib/admin/admin-nav';
export const ADMIN_PAGE_ICONS: Record<AdminPageId, AppIcon> = {
dashboard: LayoutDashboard,
settings: Server,
auth: Shield,
branding: Palette,
policy: Scale,
organisation: Building2,
'org-signatures': PenLine,
'org-templates': LayoutTemplate,
'org-security': ShieldCheck,
'org-sharing': Share2,
'org-labels': Tag,
'org-proofreading': SpellCheck,
'org-calendars': CalendarDays,
'org-notices': Bell,
'org-devices': Smartphone,
plugins: Puzzle,
themes: SwatchBook,
marketplace: Store,
version: ArrowUpCircle,
telemetry: Activity,
monitoring: HeartRateMonitor,
logs: ScrollText,
};
/** In sidebar order; `top` has no heading. */
export const ADMIN_GROUPS = ['top', 'organisation', 'extensions', 'system'] as const;
export const ADMIN_GROUP_LABELS: Record<AdminPageDef['group'], string | null> = {
top: null,
organisation: 'Organisation',
extensions: 'Extensions',
system: 'System',
};
+31 -68
View File
@@ -3,37 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from 'react';
import { usePathname } from 'next/navigation';
import { useTranslations } from 'next-intl';
import {
ArrowLeft,
ArrowUpCircle,
Activity,
Bell,
HeartRateMonitor,
Building2,
CalendarDays,
ChevronRight,
KeyRound,
LayoutDashboard,
LayoutTemplate,
LogOut,
Palette,
PenLine,
Puzzle,
Scale,
ScrollText,
Search,
Server,
Share2,
Shield,
ShieldCheck,
Smartphone,
Store,
SwatchBook,
Tag,
SpellCheck,
X,
type AppIcon,
} from '@/components/icons';
import { ArrowLeft, ChevronRight, KeyRound, LogOut, Search, Shield, X } from '@/components/icons';
import { Input } from '@/components/ui/input';
import { NavigationRail } from '@/components/layout/navigation-rail';
import { AboutFooter } from '@/components/settings/about-data-settings';
@@ -45,42 +15,13 @@ import { useUpdateStore, selectHasUpdate } from '@/stores/update-store';
import { useAdminUnsavedStore } from '@/stores/admin-unsaved-store';
import { apiFetch, getPathPrefix } from '@/lib/browser-navigation';
import { ADMIN_PAGES, adminPath, type AdminPageDef, type AdminPageId, type AdminSectionDef } from '@/lib/admin/admin-nav';
import { AdminSessionLinksSkeleton, AdminSkeleton } from '@/components/boot-skeleton/admin-skeleton';
import { useSkeletonLabels } from '@/components/boot-skeleton/use-skeleton-labels';
import { ADMIN_GROUPS, ADMIN_GROUP_LABELS, ADMIN_PAGE_ICONS as PAGE_ICONS } from './admin-page-icons';
import { cn } from '@/lib/utils';
import { AdminLink } from './admin-link';
import { AdminShellContext } from './shell-context';
const PAGE_ICONS: Record<AdminPageId, AppIcon> = {
dashboard: LayoutDashboard,
settings: Server,
auth: Shield,
branding: Palette,
policy: Scale,
organisation: Building2,
'org-signatures': PenLine,
'org-templates': LayoutTemplate,
'org-security': ShieldCheck,
'org-sharing': Share2,
'org-labels': Tag,
'org-proofreading': SpellCheck,
'org-calendars': CalendarDays,
'org-notices': Bell,
'org-devices': Smartphone,
plugins: Puzzle,
themes: SwatchBook,
marketplace: Store,
version: ArrowUpCircle,
telemetry: Activity,
monitoring: HeartRateMonitor,
logs: ScrollText,
};
const GROUP_LABELS: Record<AdminPageDef['group'], string | null> = {
top: null,
organisation: 'Organisation',
extensions: 'Extensions',
system: 'System',
};
interface AdminShellProps {
/** Whether the admin session is in place; the sidebar counts load then. */
authenticated: boolean;
@@ -109,6 +50,7 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
const tSettings = useTranslations('settings');
const tSidebar = useTranslations('sidebar');
const location = useAdminLocation();
const { label: loadingLabel, labels: railLabels } = useSkeletonLabels();
const isDesktop = useIsDesktop();
// The server can't know the window width, so its HTML would be the phone
// layout, shown on desktop until hydration swaps it (the rail's Admin link
@@ -188,8 +130,8 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
}, [trimmed]);
const groups = useMemo(() => (
(['top', 'organisation', 'extensions', 'system'] as const)
.map((group) => ({ group, label: GROUP_LABELS[group], items: matches.filter((m) => m.page.group === group) }))
ADMIN_GROUPS
.map((group) => ({ group, label: ADMIN_GROUP_LABELS[group], items: matches.filter((m) => m.page.group === group) }))
.filter((g) => g.items.length > 0)
), [matches]);
@@ -301,7 +243,19 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
</div>
);
if (!hydrated) return <div className="h-dvh bg-background" />;
// The server HTML and the hydration pass: the panel's skeleton, both
// layouts drawn and picked by CSS until the width is known.
if (!hydrated) {
return (
<AdminSkeleton
page={location.page}
label={loadingLabel}
backLabel={tSettings('back_to_mail')}
searchLabel={tShell('admin_search')}
labels={railLabels}
/>
);
}
if (!isDesktop) {
const showIndex = location.page === null && !onChangePassword;
@@ -382,7 +336,11 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
</div>
))}
</div>
{showSessionLinks && <div className="border-t border-border px-2 py-2">{sessionLinks(true)}</div>}
{(showSessionLinks || !authenticated) && (
<div className="border-t border-border px-2 py-2">
{showSessionLinks ? sessionLinks(true) : <AdminSessionLinksSkeleton mobile />}
</div>
)}
<div className="border-t border-border px-2 py-2"><AboutFooter /></div>
</>
) : (
@@ -473,7 +431,12 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
</div>
<div className="border-t border-border px-1 py-1">
{showSessionLinks && <div className="space-y-0.5 px-1 py-1">{sessionLinks(false)}</div>}
{/* Held in place while the session is checked, as the boot skeleton draws them. */}
{(showSessionLinks || !authenticated) && (
<div className="space-y-0.5 px-1 py-1">
{showSessionLinks ? sessionLinks(false) : <AdminSessionLinksSkeleton mobile={false} />}
</div>
)}
<AboutFooter />
</div>
</aside>
@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest';
import { renderToString } from 'react-dom/server';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { RouteSkeleton } from '../route-skeleton';
import { BottomBarSkeleton, RailSkeleton } from '../shell';
import { AdminSkeleton } from '../admin-skeleton';
import type { BootSurface } from '@/lib/boot-skeleton/surfaces';
function dom(html: string) {
const container = document.createElement('div');
container.innerHTML = html;
return container;
}
const css = readFileSync(join(process.cwd(), 'app/boot-skeleton.css'), 'utf8');
// A signed-in page drawn for a visitor with nothing stored: the boot script
// sets data-boot-auth="out", and the CSS swaps the app's frame for the
// login card, the page the app is about to send them to.
describe('signed-in pages for a signed-out visitor', () => {
it('boot-out matches data-boot-auth="out"', () => {
expect(css).toMatch(/@custom-variant boot-out \(&:where\(\[data-boot-auth="out"\] \*\)\);/);
});
it.each<BootSurface>(['mail', 'calendar', 'contacts', 'files', 'apps', 'settings'])('%s carries the login card, shown only with boot-out', (surface) => {
const container = dom(renderToString(<RouteSkeleton surface={surface} label="Loading" />));
const login = container.querySelector('[data-boot-skeleton="login"]');
expect(login).not.toBeNull();
// Hidden unless the page is marked signed out.
expect(login!.parentElement!.className.split(' ')).toEqual(expect.arrayContaining(['hidden', 'boot-out:block']));
// The card itself: logo, fields, button.
expect(login!.querySelectorAll('.h-11').length).toBeGreaterThanOrEqual(3);
// And the accessibility button the login page will have in its corner.
expect(login!.querySelector('.fixed.bottom-4.end-4')).not.toBeNull();
// And the app's own frame goes away then.
const app = container.querySelector(`[data-boot-skeleton="${surface}"]`);
expect(app!.className.split(' ')).toContain('boot-out:hidden');
});
});
describe('login skeleton', () => {
// The login page renders the real accessibility button itself.
it('leaves the accessibility button to the login page', () => {
const container = dom(renderToString(<RouteSkeleton surface="login" label="Loading" />));
expect(container.querySelector('.fixed')).toBeNull();
});
});
describe('admin skeleton', () => {
it('draws the page list with its real names and the open page current', () => {
const container = dom(renderToString(<AdminSkeleton page="branding" label="Loading" />));
const text = container.textContent ?? '';
for (const name of ['Overview', 'Server', 'Branding', 'Organisation', 'Extensions', 'System', 'Audit log']) {
expect(text).toContain(name);
}
const current = [...container.querySelectorAll('.bg-accent')].map((el) => el.textContent);
expect(current).toEqual(['Branding']);
});
it('shows the phone list at /admin and the page below it', () => {
const index = dom(renderToString(<AdminSkeleton page={null} />));
const detail = dom(renderToString(<AdminSkeleton page="logs" />));
const phone = (c: HTMLElement) => c.querySelector('.lg\\:hidden')!;
// Index: the list (with its chevrons) is shown, the page column hidden.
expect(phone(index).querySelector('.p-4')!.className).toContain('hidden');
expect(phone(detail).querySelector('.p-4')!.className).not.toContain('hidden');
// Unknown page (a connector link): the boot script's pane attribute decides.
const unknown = dom(renderToString(<AdminSkeleton />));
expect(phone(unknown).querySelector('.p-4')!.className).toContain('boot-open:block');
});
});
describe('rail with the admin panel current', () => {
it('always draws the admin entry, as current', () => {
const rail = dom(renderToString(<RailSkeleton active="admin" />));
const shield = rail.querySelector('.bg-primary\\/10')!;
expect(shield).not.toBeNull();
expect(shield.className).not.toContain('hidden');
// Without it, only the last session's hint shows the entry.
const plain = dom(renderToString(<RailSkeleton active="mail" />));
expect(plain.innerHTML).toContain('boot-rail-admin:flex');
});
it('has no account or quota circle: the admin panel has no mailbox', () => {
const circles = (html: string) => dom(html).querySelectorAll('.rounded-full.w-8.h-8').length;
expect(circles(renderToString(<RailSkeleton active="admin" />))).toBe(0);
expect(circles(renderToString(<RailSkeleton active="mail" />))).toBe(2);
});
it('adds Admin to the bottom bar before Settings', () => {
const bar = dom(renderToString(<BottomBarSkeleton active="admin" labels={{ mail: 'Mail', admin: 'Admin', settings: 'Settings' }} />));
const labels = [...bar.querySelectorAll('span.truncate')].map((el) => el.textContent);
expect(labels.slice(-2)).toEqual(['Admin', 'Settings']);
const plain = dom(renderToString(<BottomBarSkeleton active="mail" labels={{ admin: 'Admin' }} />));
expect(plain.textContent).not.toContain('Admin');
});
});
@@ -0,0 +1,135 @@
import { act, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
// Contacts, Files and plugin pages until the session is restored: each draws
// the page skeleton the server drew (not a blank page or a spinner), and the
// first screens after it keep that shape.
const nav = vi.hoisted(() => ({ suspend: false }));
const never = new Promise<never>(() => {});
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn(), back: vi.fn(), replace: vi.fn() }),
useParams: () => ({ locale: 'en' }),
usePathname: () => '/en/contacts',
useSearchParams: () => {
// The static Lite export prerenders without search params: the hook
// suspends there and the Suspense fallback is what lands in the HTML.
if (nav.suspend) throw never;
return new URLSearchParams();
},
}));
vi.mock('@/i18n/navigation', () => ({
useRouter: () => ({ push: vi.fn(), back: vi.fn(), replace: vi.fn() }),
usePathname: () => '/',
Link: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a>,
}));
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
vi.mock('@/components/contacts/contact-activity', () => ({ ContactActivity: () => null }));
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => <nav data-testid="rail" /> }));
vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null }));
vi.mock('@/components/layout/inline-app-view', () => ({ InlineAppView: () => null }));
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null }));
vi.mock('@/hooks/use-sidebar-apps', () => ({
useSidebarApps: () => ({
showAppsModal: false, inlineApp: null, loadedApps: [], handleManageApps: vi.fn(),
handleInlineApp: vi.fn(), closeInlineApp: vi.fn(), closeAppsModal: vi.fn(),
}),
}));
const { ContactsApp } = await import('@/components/contacts/contacts-app');
const { FilesApp } = await import('@/components/files/files-app');
const { PluginPageApp } = await import('@/components/plugins/plugin-page-app');
const { useAuthStore } = await import('@/stores/auth-store');
const { useContactStore } = await import('@/stores/contact-store');
const { usePluginStore } = await import('@/stores/plugin-store');
/** Signed out as far as the page knows, with checkAuth still running. */
function restoring() {
act(() => {
useAuthStore.setState({ isAuthenticated: false, client: null, isLoading: true, checkAuth: () => never });
});
}
const skeleton = (container: HTMLElement, surface: string) => container.querySelector(`[data-boot-skeleton="${surface}"]`);
beforeEach(() => {
localStorage.clear();
nav.suspend = false;
});
afterEach(() => {
act(() => {
useAuthStore.setState({ isAuthenticated: false, client: null, isLoading: false });
useContactStore.setState({ contacts: [], supportsSync: false, contactsLoaded: false });
});
});
describe('gates draw the page skeleton while the session is restored', () => {
it('contacts', () => {
restoring();
const { container } = render(<ContactsApp />);
expect(skeleton(container, 'contacts')).not.toBeNull();
expect(container.querySelector('.animate-spin')).toBeNull();
});
it('files', () => {
restoring();
const { container } = render(<FilesApp />);
expect(skeleton(container, 'files')).not.toBeNull();
expect(container.querySelector('.animate-spin')).toBeNull();
});
it('plugin pages', () => {
restoring();
const { container } = render(<PluginPageApp linkSegments={['kanban', 'board']} />);
expect(skeleton(container, 'apps')).not.toBeNull();
expect(container.querySelector('.animate-spin')).toBeNull();
});
});
describe('contacts', () => {
it('puts the skeleton, not nothing, into a prerendered page whose search params suspend', () => {
nav.suspend = true;
restoring();
const { container } = render(<ContactsApp />);
expect(skeleton(container, 'contacts')).not.toBeNull();
});
it('shows the rows loading, not an empty address book, before the session is back', () => {
// A remembered session: signed in from storage, no client yet, and on a
// first visit nothing stored about the server's contacts support.
act(() => {
useAuthStore.setState({ isAuthenticated: true, client: null, isLoading: true, checkAuth: () => never });
useContactStore.setState({ contacts: [], supportsSync: false, contactsLoaded: false });
});
render(<ContactsApp />);
expect(screen.getByTestId('contact-list-skeleton')).toBeInTheDocument();
expect(screen.queryByText('empty_state_title')).toBeNull();
});
it('still says the book is empty once a server without contacts is known', () => {
const client = { supportsPrincipals: () => false } as unknown as IJMAPClient;
act(() => {
useAuthStore.setState({ isAuthenticated: true, client, isLoading: false });
useContactStore.setState({ contacts: [], supportsSync: false, contactsLoaded: false });
});
render(<ContactsApp />);
expect(screen.queryByTestId('contact-list-skeleton')).toBeNull();
expect(screen.getByText('empty_state_title')).toBeInTheDocument();
});
});
describe('plugin pages', () => {
it('keeps the empty content area, not a spinner, while the plugins start', () => {
act(() => {
useAuthStore.setState({ isAuthenticated: true, client: {} as never, isLoading: false });
usePluginStore.setState({ initialized: false, initializePlugins: vi.fn().mockResolvedValue(undefined) });
});
const { container } = render(<PluginPageApp linkSegments={['kanban', 'board']} />);
expect(container.querySelector('.animate-spin')).toBeNull();
expect(screen.getByRole('status')).toHaveTextContent('loading');
expect(screen.queryByText('not_available_title')).toBeNull();
});
});
@@ -0,0 +1,103 @@
import { act } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { renderToString } from 'react-dom/server';
import { hydrateRoot } from 'react-dom/client';
import { RouteSkeleton } from '../route-skeleton';
import { CalendarSkeleton, CalendarViewSkeleton } from '../calendar-skeleton';
import type { BootSurface } from '@/lib/boot-skeleton/surfaces';
/**
* Every page skeleton is rendered by the server and then hydrated by the same
* component on the client. They must produce identical markup, or React throws
* the server HTML away and the page flashes.
*/
const SURFACES: BootSurface[] = [
'mail', 'calendar', 'contacts', 'files', 'apps', 'settings', 'pro', 'admin', 'admin-login', 'login', 'setup',
];
const LABELS = { mail: 'Mail', calendar: 'Calendar', contacts: 'Contacts', files: 'Files', settings: 'Settings' };
afterEach(() => {
document.body.innerHTML = '';
vi.restoreAllMocks();
});
describe('page skeletons hydrate without a mismatch', () => {
it.each(SURFACES)('%s', async (surface) => {
const element = <RouteSkeleton surface={surface} label="Loading..." labels={LABELS} />;
const html = renderToString(element);
expect(html).toContain(`data-boot-skeleton="${surface}"`);
const container = document.createElement('div');
container.innerHTML = html;
document.body.appendChild(container);
const errors: unknown[] = [];
const consoleError = vi.spyOn(console, 'error').mockImplementation((...args) => { errors.push(args); });
await act(async () => {
hydrateRoot(container, element, { onRecoverableError: (e) => { errors.push(e); } });
});
consoleError.mockRestore();
expect(errors).toEqual([]);
expect(container.innerHTML).toBe(html);
});
it.each(SURFACES)('%s: one status message, busy, and nothing focusable', (surface) => {
const container = document.createElement('div');
container.innerHTML = renderToString(<RouteSkeleton surface={surface} label="Loading..." labels={LABELS} />);
// A signed-in page also carries the login card for signed-out visitors,
// hidden by CSS; count per region.
for (const region of container.querySelectorAll('[data-boot-skeleton]')) {
expect(region.getAttribute('aria-busy')).toBe('true');
expect(region.querySelectorAll('[role="status"]').length).toBeLessThanOrEqual(1);
}
expect(container.querySelectorAll('[role="status"]').length).toBeGreaterThan(0);
expect(container.querySelectorAll('a[href], button, input, select, textarea, [tabindex]')).toHaveLength(0);
for (const shape of container.querySelectorAll('.sk-shape')) {
expect(shape.closest('[aria-hidden="true"]'), shape.outerHTML).not.toBeNull();
}
});
});
describe('the calendar skeleton with its words', () => {
const text = {
title: 'Calendar', today: 'Today', search: 'Search events', tasks: 'Tasks',
views: { month: 'Month', week: 'Week', day: 'Day', agenda: 'Agenda', tasks: 'Tasks' },
taskFilters: ['All', 'Pending', 'Completed', 'Overdue'] as [string, string, string, string],
newTask: 'New task', quickAdd: 'Add a task',
};
it('hydrates without a mismatch and reads out only its status', async () => {
const element = <CalendarSkeleton label="Loading..." labels={LABELS} text={text} />;
const html = renderToString(element);
const container = document.createElement('div');
container.innerHTML = html;
document.body.appendChild(container);
const errors: unknown[] = [];
const consoleError = vi.spyOn(console, 'error').mockImplementation((...args) => { errors.push(args); });
await act(async () => {
hydrateRoot(container, element, { onRecoverableError: (e) => { errors.push(e); } });
});
consoleError.mockRestore();
expect(errors).toEqual([]);
expect(container.innerHTML).toBe(html);
const region = container.querySelector('[data-boot-skeleton="calendar"]')!;
expect(region.textContent).toContain('Search events');
const walker = document.createTreeWalker(region, NodeFilter.SHOW_TEXT);
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
if (!node.textContent?.trim()) continue;
const el = node.parentElement!;
expect(el.closest('[aria-hidden="true"]') || el.closest('[role="status"]'), el.outerHTML).not.toBeNull();
}
});
it('draws one view, with its status, for the loaded page', () => {
const container = document.createElement('div');
container.innerHTML = renderToString(<CalendarViewSkeleton view="week" label="Loading calendars" text={text} />);
expect(container.querySelectorAll('[role="status"]')).toHaveLength(1);
expect(container.querySelector('.sk-cal-week')).not.toBeNull();
expect(container.querySelector('.sk-cal-month')).toBeNull();
expect(container.querySelectorAll('button, [tabindex]')).toHaveLength(0);
});
});
@@ -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();
});
});
@@ -0,0 +1,23 @@
import { render } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { MvTopBarSkeleton } from '../shell';
// The Mountain View top bar as the pages draw it (components/layout/mv-top-bar.tsx).
describe('MvTopBarSkeleton', () => {
it('starts with the sidebar toggle unless the page leaves it out, as contacts, files and plugin pages do', () => {
const withMenu = render(<MvTopBarSkeleton />).container;
expect(withMenu.querySelector('.tabler-icon-menu-2, .tabler-icon-menu')).not.toBeNull();
const without = render(<MvTopBarSkeleton menu={false} />).container;
expect(without.querySelector('.tabler-icon-menu-2, .tabler-icon-menu')).toBeNull();
// MvTopBar showMenuButton={false} pads the brand instead.
expect(without.firstElementChild?.firstElementChild).toHaveClass('ps-3');
});
it('ends with the account avatar, as the real bar does', () => {
const bar = render(<MvTopBarSkeleton />).container.firstElementChild!;
const cluster = bar.lastElementChild!;
expect(cluster.lastElementChild?.querySelector('.rounded-full.w-8.h-8')).not.toBeNull();
});
});
@@ -0,0 +1,146 @@
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { renderToString } from 'react-dom/server';
import { AppShellSkeleton } from '../shell';
import { ProSkeleton } from '../pro-skeleton';
// The Pro interface takes a standard page over on a desktop (redirect to
// /pro after mount). The page's skeleton has to be the Pro frame from the
// first paint, or the hop moves everything. The swap is CSS on attributes the
// boot script sets; these tests pin the markup the CSS works on.
function html(element: React.ReactElement) {
const container = document.createElement('div');
container.innerHTML = renderToString(element);
return container;
}
const classes = (el: Element | null | undefined) => (el?.getAttribute('class') ?? '').split(/\s+/);
describe('AppShellSkeleton under the Pro interface', () => {
const page = () =>
html(
<AppShellSkeleton surface="calendar" label="Loading" railBelow="md" active="calendar">
<div data-testid="calendar-body" />
</AppShellSkeleton>,
);
it('draws the Pro frame next to the standard one, swapped by boot-pro-frame', () => {
const container = page();
const pro = container.querySelector('[data-boot-skeleton="pro"]');
const standard = container.querySelector('[data-boot-skeleton="calendar"]');
expect(pro).not.toBeNull();
expect(standard).not.toBeNull();
// Hidden unless Pro takes over; the standard frame hides when it does.
expect(classes(pro?.parentElement)).toEqual(expect.arrayContaining(['hidden', 'boot-pro-frame:block']));
expect(classes(standard)).toEqual(expect.arrayContaining(['boot-pro-frame:hidden', 'boot-out:hidden']));
// The page's own body stays in the standard frame only.
expect(pro?.querySelector('[data-testid="calendar-body"]')).toBeNull();
expect(standard?.querySelector('[data-testid="calendar-body"]')).not.toBeNull();
});
it('gives the Pro frame the shell root, the rail with search, and two panes', () => {
const pro = page().querySelector('[data-boot-skeleton="pro"]');
expect(classes(pro)).toEqual(expect.arrayContaining(['flex', 'flex-col', 'h-dvh']));
const shell = pro?.querySelector('[data-pro-shell]');
const rail = shell?.firstElementChild;
expect(classes(rail)).toEqual(expect.arrayContaining(['w-14', 'flex']));
expect(rail?.querySelectorAll('[data-sk-rail]')).toHaveLength(5);
const panes = pro?.querySelectorAll('[data-sk-pro-pane]') ?? [];
expect([...panes].map((p) => p.getAttribute('data-sk-pro-pane'))).toEqual(['main', 'split']);
for (const pane of panes) {
// The tab strip, with eight tab slots for the CSS to pick from, right
// above the pane body.
const [strip, body] = [...pane.children];
expect(strip.querySelectorAll('[data-sk-pro-tab]')).toHaveLength(8);
expect(classes(strip)).toEqual(expect.arrayContaining(['h-9', 'bg-secondary']));
// A page Pro takes over draws its panes empty, to keep its HTML small.
// (The /pro page draws every body, see ProSkeleton below.)
expect([...body.children].map((b) => b.getAttribute('data-sk-pro-body'))).toEqual(['other']);
}
// The split pane only shows with a split.
expect(classes(panes[1])).toEqual(expect.arrayContaining(['hidden', 'boot-pro-split:flex']));
});
it('lets the login card win for a signed-out visitor', () => {
const container = page();
const login = container.querySelector('[data-boot-skeleton="login"]');
expect(classes(login?.parentElement)).toEqual(expect.arrayContaining(['hidden', 'boot-out:block']));
});
});
describe('ProSkeleton', () => {
it('is the Pro frame, or the login card for a signed-out visitor', () => {
const container = html(<ProSkeleton label="Loading" />);
const pro = container.querySelector('[data-boot-skeleton="pro"]');
expect(classes(pro)).toContain('boot-out:hidden');
expect(pro?.querySelector('[data-pro-shell]')).not.toBeNull();
expect(container.querySelector('[data-boot-skeleton="login"]')).not.toBeNull();
});
it('draws every page body in both panes on /pro, where the stored tabs decide', () => {
const container = html(<ProSkeleton label="Loading" />);
for (const pane of container.querySelectorAll('[data-sk-pro-pane]')) {
const body = pane.children[1];
expect([...body.children].map((b) => b.getAttribute('data-sk-pro-body'))).toEqual([
'mail', 'calendar', 'contacts', 'files', 'settings', 'other',
]);
}
});
});
describe('the Pro CSS picks the stored tabs and pages', () => {
// The "pro" section of app/boot-skeleton.css, minus the Tailwind variants,
// applied to the real markup with the attributes the boot script sets.
const css = readFileSync(path.resolve(__dirname, '../../../app/boot-skeleton.css'), 'utf8')
.split('/* pro */')[1]
.replace(/@custom-variant[^;]*;/g, '');
let container: HTMLElement;
beforeEach(() => {
const style = document.createElement('style');
style.id = 'pro-css';
style.textContent = css;
document.head.appendChild(style);
container = document.createElement('div');
container.innerHTML = renderToString(<ProSkeleton label="Loading" />);
document.body.appendChild(container);
});
afterEach(() => {
document.getElementById('pro-css')?.remove();
container.remove();
for (const a of [...document.documentElement.attributes]) {
if (a.name.startsWith('data-boot-')) document.documentElement.removeAttribute(a.name);
}
});
const boot = (attrs: Record<string, string>) => {
for (const [k, v] of Object.entries(attrs)) document.documentElement.setAttribute(`data-boot-${k}`, v);
};
const pane = (which: string) => container.querySelector(`[data-sk-pro-pane="${which}"]`)!;
const shown = (els: Iterable<Element>) => [...els].filter((el) => getComputedStyle(el).display !== 'none');
const tabs = (which: string) => shown(pane(which).querySelectorAll('[data-sk-pro-tab]'));
const active = (which: string) =>
[...pane(which).querySelectorAll('[data-sk-pro-tab]')].findIndex(
(el) => !['', 'transparent', 'rgba(0, 0, 0, 0)'].includes(getComputedStyle(el).backgroundColor),
);
const body = (which: string) =>
shown(pane(which).querySelectorAll('[data-sk-pro-body]')).map((el) => el.getAttribute('data-sk-pro-body'));
it('draws one active mail tab without attributes', () => {
expect(tabs('main')).toHaveLength(1);
expect(active('main')).toBe(0);
expect(body('main')).toEqual(['mail']);
});
it('draws the stored count, the active tab and its page in each pane', () => {
boot({ pro: 'on', 'pro-main-tabs': '3', 'pro-main-active': '2', 'pro-main-kind': 'calendar', 'pro-split-tabs': '2', 'pro-split-active': '0', 'pro-split-kind': 'other', 'pro-split': 'right' });
expect(tabs('main')).toHaveLength(3);
expect(active('main')).toBe(2);
expect(body('main')).toEqual(['calendar']);
expect(tabs('split')).toHaveLength(2);
expect(active('split')).toBe(0);
expect(body('split')).toEqual(['other']);
});
});
@@ -0,0 +1,93 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { renderToString } from 'react-dom/server';
import { SettingsSkeleton, SettingsSkeletonBody } from '../settings-skeleton';
import { SETTINGS_TAB_IDS } from '@/lib/settings-tabs';
/**
* The settings skeleton marks the open page and hides the pages the last
* session's nav didn't have, both from attributes the boot script sets on
* <html>. The selectors live in app/boot-skeleton.css as Tailwind variants;
* read them from there and run them against the rendered rows.
*/
const css = readFileSync(join(process.cwd(), 'app/boot-skeleton.css'), 'utf8');
/** The selector inside `@custom-variant <name> (&:where(<selector>))`. */
function variantSelector(name: string): string {
const match = css.match(new RegExp(`@custom-variant ${name} \\(&:where\\(([\\s\\S]*?)\\)\\);`));
if (!match) throw new Error(`no variant ${name}`);
return match[1].trim();
}
/** The standard settings frame (the hidden Pro frame carries its own copy of the body). */
function mount(html: string) {
document.body.innerHTML = html;
return document.body.querySelector<HTMLElement>('[data-boot-skeleton="settings"]') ?? document.body;
}
afterEach(() => {
document.body.innerHTML = '';
document.documentElement.removeAttribute('data-boot-settings-tab');
document.documentElement.removeAttribute('data-boot-settings-tabs');
});
describe('settings skeleton nav', () => {
it('draws a row for every settings page', () => {
const root = mount(renderToString(<SettingsSkeleton label="Loading" />));
const ids = [...root.querySelectorAll('[data-sk-settings-tab]')].map((el) => el.getAttribute('data-sk-settings-tab'));
expect([...ids].sort()).toEqual([...SETTINGS_TAB_IDS].sort());
});
it.each(SETTINGS_TAB_IDS)('highlights only the %s row when that page is open', (tab) => {
const root = mount(renderToString(<SettingsSkeleton label="Loading" />));
const active = variantSelector('boot-settings-active');
document.documentElement.setAttribute('data-boot-settings-tab', tab);
const rows = [...root.querySelectorAll<HTMLElement>('[data-sk-settings-tab]')];
const matching = rows.filter((row) => row.matches(active)).map((row) => row.getAttribute('data-sk-settings-tab'));
expect(matching).toEqual([tab]);
const row = rows.find((r) => r.getAttribute('data-sk-settings-tab') === tab)!;
expect(row.className).toContain('boot-settings-active:bg-accent');
});
it('hides the optional pages the last nav did not list, and shows recovery only when it did', () => {
const root = mount(renderToString(<SettingsSkeletonBody />));
const row = (tab: string) => root.querySelector<HTMLElement>(`[data-sk-settings-tab="${tab}"]`)!;
const hidesFilters = variantSelector('boot-no-settings-filters');
const showsRecovery = variantSelector('boot-settings-recovery');
expect(row('filters').className).toContain('boot-no-settings-filters:hidden');
expect(row('recovery').className).toContain('boot-settings-recovery:flex');
// No hints: everything but recovery.
expect(row('filters').matches(hidesFilters)).toBe(false);
expect(row('recovery').matches(showsRecovery)).toBe(false);
document.documentElement.setAttribute('data-boot-settings-tabs', 'account appearance recovery');
expect(row('filters').matches(hidesFilters)).toBe(true);
expect(row('recovery').matches(showsRecovery)).toBe(true);
document.documentElement.setAttribute('data-boot-settings-tabs', 'account filters');
expect(row('filters').matches(hidesFilters)).toBe(false);
expect(row('recovery').matches(showsRecovery)).toBe(false);
});
it('drops the section jump row on pages with one section', () => {
const root = mount(renderToString(<SettingsSkeleton label="Loading" />));
const row = root.querySelector<HTMLElement>('.boot-settings-one-section\\:hidden')!;
expect(row).not.toBeNull();
const oneSection = variantSelector('boot-settings-one-section');
document.documentElement.setAttribute('data-boot-settings-tab', 'filters');
expect(row.matches(oneSection)).toBe(true);
document.documentElement.setAttribute('data-boot-settings-tab', 'appearance');
expect(row.matches(oneSection)).toBe(false);
});
it('has the back-to-mail box only outside a Pro pane', () => {
const page = mount(renderToString(<SettingsSkeleton label="Loading" />));
expect(page.querySelector('.boot-pro\\:hidden')).not.toBeNull();
const pane = mount(renderToString(<SettingsSkeletonBody embedded />));
expect(pane.querySelector('.boot-pro\\:hidden')).toBeNull();
// A Pro pane is desktop only: no phone list.
expect(pane.querySelector('.boot-open\\:hidden')).toBeNull();
});
});
+296
View File
@@ -0,0 +1,296 @@
/**
* The admin panel as it loads (components/admin/admin-shell.tsx): the rail
* with Admin current, the page list with its group headings, and the
* content column; on phones the top bar, the page list or a page, and the
* bottom bar. Page names, group headings and page descriptions are fixed
* English text in lib/admin/admin-nav.ts, so they are drawn for real; the
* rest is placeholders.
*/
import { ArrowLeft, ChevronRight, KeyRound, LogOut, Search, Shield } from "@/components/icons";
import { ADMIN_GROUPS, ADMIN_GROUP_LABELS, ADMIN_PAGE_ICONS } from "@/components/admin/admin-page-icons";
import { ADMIN_PAGES, type AdminPageDef, type AdminPageId } from "@/lib/admin/admin-nav";
import { cn } from "@/lib/cn";
import { Block, SkeletonRegion, repeat, textWidth } from "./kit";
import { TextLine } from "./login-skeleton";
import { BottomBarSkeleton, RailSkeleton, type RailLabels } from "./shell";
const GROUPS = ADMIN_GROUPS.map((group) => ({
group,
label: ADMIN_GROUP_LABELS[group],
pages: ADMIN_PAGES.filter((page) => page.group === group),
}));
function pageDef(id: AdminPageId | null | undefined): AdminPageDef | null {
return id ? ADMIN_PAGES.find((page) => page.id === id) ?? null : null;
}
/** The search field above the page list (an Input at h-9 with the icon inside). */
function SearchField({ placeholder }: { placeholder?: string }) {
return (
<span aria-hidden="true" className="relative block">
<Search className="pointer-events-none absolute start-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<span className="flex h-9 w-full items-center rounded-md border border-input bg-background ps-9 pe-3 text-sm text-muted-foreground">
{placeholder}
</span>
</span>
);
}
/**
* "Change password" and "Sign out of admin", which a password admin gets
* once the session check is done. Drawn while it runs (here and in the
* shell), so the foot of the list doesn't grow under the pointer then; a
* Stalwart admin signed in automatically loses them.
*/
export function AdminSessionLinksSkeleton({ mobile }: { mobile: boolean }) {
const item = cn("flex w-full items-center gap-2.5 rounded-md text-sm text-foreground", mobile ? "px-3 py-2.5" : "px-3 py-2");
return (
<>
<span aria-hidden="true" className={item}>
<KeyRound className="h-4 w-4 shrink-0 text-muted-foreground" />
Change password
</span>
<span aria-hidden="true" className={item}>
<LogOut className="h-4 w-4 shrink-0 text-muted-foreground" />
Sign out of admin
</span>
</>
);
}
/** The about card at the foot of the list (AboutFooter): logo, name and version, source link. */
function AboutFooterSkeleton() {
return (
<div aria-hidden="true" className="px-3 py-2">
<div className="flex items-center gap-3">
<div className="flex items-center gap-3 flex-1 min-w-0">
<Block className="w-7 h-7 shrink-0" />
<div className="min-w-0 flex-1">
<TextLine className="text-xs font-medium" w="5rem" />
<TextLine className="text-[0.6875rem]" w="7rem" />
</div>
</div>
<TextLine className="shrink-0 text-[0.6875rem] w-12" w="100%" />
</div>
</div>
);
}
/**
* The top of an admin page and placeholders for its sections: what the
* content column shows while the admin session is checked. `page` gives the
* real title and description (the title is desktop-only, as in
* AdminPageHeader); left out, bars. `status` is announced; the boot
* skeleton leaves it out, its region carries the one status message.
*/
export function AdminContentSkeleton({ page, status }: { page?: AdminPageId | null; status?: string }) {
const def = pageDef(page ?? "dashboard");
return (
<div>
<div aria-hidden="true" className="mb-6 space-y-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-3 min-w-0">
<div className="min-w-0">
{def && page !== undefined ? (
<>
<span className="hidden lg:block text-xl font-semibold text-foreground leading-tight">{def.label}</span>
<span className="block text-sm text-muted-foreground mt-1">{def.description}</span>
</>
) : (
<>
<TextLine className="text-xl font-semibold leading-tight hidden lg:block" w="10rem" />
<TextLine className="text-sm mt-1" w="22rem" />
</>
)}
</div>
</div>
</div>
</div>
<AdminBodySkeleton status={status} />
</div>
);
}
/**
* Placeholders for a page's sections (SettingsSection: a heading and rows of
* settings), below its header: the pages show it while they fetch, so the
* boot skeleton's shapes stay where they are. `status` is announced.
*/
export function AdminBodySkeleton({ status }: { status?: string }) {
return (
<div data-testid="admin-body-skeleton">
{status ? <span role="status" className="sr-only">{status}</span> : null}
<div aria-hidden="true" className="space-y-10">
{repeat(2, (s) => (
<div className="space-y-4">
<TextLine className="text-lg font-medium" w={s === 0 ? "8rem" : "11rem"} />
<div className="space-y-4">
{repeat(s === 0 ? 4 : 3, (i) => (
<div className="flex items-center justify-between gap-4 py-1">
<div className="min-w-0 flex-1 space-y-1">
<TextLine className="text-sm" w={textWidth(i + s * 4, 25, 25)} />
<TextLine className="text-xs" w={textWidth(i + s * 4 + 2, 40, 35)} />
</div>
<Block className={i % 2 === 0 ? "h-6 w-11 rounded-full shrink-0" : "h-9 w-48 shrink-0"} />
</div>
))}
</div>
</div>
))}
</div>
</div>
);
}
export interface AdminSkeletonProps {
/** common.loading */
label?: string;
/**
* The open page, as the shell reads it from the URL: null for /admin.
* Leave it out where the URL isn't the admin's own (a connector link):
* then the boot script's data-boot-pane picks the phone list or a page.
*/
page?: AdminPageId | null;
/** settings.back_to_mail, for the box above the page list. */
backLabel?: string;
/** shell.admin_search, the search field's placeholder. */
searchLabel?: string;
/** The phone bottom bar's labels. */
labels?: RailLabels;
className?: string;
}
export function AdminSkeleton({ label, page, backLabel, searchLabel, labels, className }: AdminSkeletonProps) {
const active: AdminPageId | null = page === undefined ? null : page ?? "dashboard";
const def = pageDef(page);
// Phone: the list at /admin, the page below it.
const showIndex = page === undefined ? "boot-open:hidden" : page === null ? "" : "hidden";
const showPage = page === undefined ? "hidden boot-open:block" : page === null ? "hidden" : "";
return (
<SkeletonRegion
surface="admin"
label={label}
className={cn("h-dvh bg-background pt-[env(safe-area-inset-top)]", className)}
>
{/* Desktop */}
<div className="hidden lg:flex h-full">
<RailSkeleton active="admin" className="flex" />
<div aria-hidden="true" className="flex w-64 shrink-0 flex-col border-e border-border bg-secondary">
<div className="border-b border-border">
<div className="flex h-14 items-center px-2">
<span className="flex h-9 w-full items-center rounded-md px-3 text-sm font-medium text-foreground">
<ArrowLeft className="me-2 h-4 w-4" />
{backLabel ?? <TextLine className="flex-1" w="6rem" />}
</span>
</div>
</div>
<div className="flex-1 overflow-hidden py-2">
<div className="px-3 pb-1 pt-1"><SearchField placeholder={searchLabel} /></div>
<div className="space-y-0.5 px-2">
{GROUPS.map(({ group, label: heading, pages }, groupIndex) => (
<div key={group}>
{groupIndex > 0 && <div className="mx-1 my-2 border-t border-border" />}
{heading && (
<div className="px-3 pb-1 pt-2.5">
<span className="text-[0.6875rem] font-semibold text-muted-foreground">{heading}</span>
</div>
)}
{pages.map((p) => {
const Icon = ADMIN_PAGE_ICONS[p.id];
const on = active === p.id || (page === undefined && p.id === "dashboard");
return (
<div key={p.id}>
<span
className={cn(
"flex w-full items-center gap-2.5 rounded-md py-2 pe-3 ps-3 text-start text-sm",
on ? "bg-accent font-medium text-accent-foreground" : "text-foreground",
)}
>
<Icon className={cn("h-4 w-4 shrink-0", on ? "text-accent-foreground" : "text-muted-foreground")} />
<span className="truncate">{p.label}</span>
</span>
</div>
);
})}
</div>
))}
</div>
</div>
<div className="border-t border-border px-1 py-1">
<div className="space-y-0.5 px-1 py-1"><AdminSessionLinksSkeleton mobile={false} /></div>
<AboutFooterSkeleton />
</div>
</div>
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex-1 overflow-hidden">
<div className="mx-auto max-w-3xl px-6 py-6">
<AdminContentSkeleton page={page} />
</div>
</div>
</div>
</div>
{/* Phone and tablet */}
<div className="flex lg:hidden h-full flex-col">
<div aria-hidden="true" className="flex h-14 shrink-0 items-center gap-1 border-b border-border bg-background px-2">
<span className="flex h-10 w-10 items-center justify-center rounded-md text-foreground">
<ArrowLeft className="h-5 w-5" />
</span>
<div className={cn("flex min-w-0 items-center gap-2", showIndex)}>
<Shield className="h-5 w-5 text-muted-foreground" />
<span className="truncate text-lg font-semibold">Admin</span>
</div>
<div className={cn("min-w-0 items-center gap-2", page === undefined ? "hidden boot-open:flex" : page === null ? "hidden" : "flex")}>
{def ? (
<span className="truncate text-lg font-semibold">{def.label}</span>
) : (
<TextLine className="text-lg font-semibold w-32" w="100%" />
)}
</div>
</div>
<div className="flex-1 overflow-hidden">
<div aria-hidden="true" className={showIndex}>
<div className="px-4 pb-1 pt-3"><SearchField placeholder={searchLabel} /></div>
<div className="py-2">
{GROUPS.map(({ group, label: heading, pages }, groupIndex) => (
<div key={group}>
{groupIndex > 0 && <div className="mx-5 my-2 border-t border-border" />}
{heading && (
<div className="px-5 pb-1.5 pt-3">
<span className="text-xs font-semibold text-muted-foreground">{heading}</span>
</div>
)}
{pages.map((p) => {
const Icon = ADMIN_PAGE_ICONS[p.id];
return (
<div key={p.id}>
<span className="flex w-full items-center justify-between py-3.5 pe-5 ps-5 text-sm text-foreground">
<span className="flex items-center gap-3">
<Icon className="h-4 w-4 text-muted-foreground" />
{p.label}
</span>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</span>
</div>
);
})}
</div>
))}
</div>
</div>
<div className={cn("p-4 pb-6", showPage)}>
<AdminContentSkeleton page={page} />
</div>
</div>
<BottomBarSkeleton active="admin" labels={labels} className="flex" />
</div>
</SkeletonRegion>
);
}
+25
View File
@@ -0,0 +1,25 @@
import type { BootSurface } from "@/lib/boot-skeleton/surfaces";
import { MailSkeletonBody } from "./mail-skeleton";
import { CalendarSkeletonBody } from "./calendar-skeleton";
import { ContactsSkeletonBody } from "./contacts-skeleton";
import { FilesSkeletonBody } from "./files-skeleton";
import { SettingsSkeletonBody } from "./settings-skeleton";
/**
* A surface's content without its frame, for the Pro shell's panes, which
* host the apps embedded (no rail, no Mountain View shell).
*/
export function SkeletonBody({ surface, embedded = true }: { surface: BootSurface; embedded?: boolean }) {
switch (surface) {
case "calendar":
return <CalendarSkeletonBody embedded={embedded} />;
case "contacts":
return <ContactsSkeletonBody embedded={embedded} />;
case "files":
return <FilesSkeletonBody embedded={embedded} />;
case "settings":
return <SettingsSkeletonBody embedded={embedded} />;
default:
return <MailSkeletonBody embedded={embedded} />;
}
}
@@ -0,0 +1,635 @@
/**
* The calendar page as a skeleton: sidebar with the mini month, toolbar and
* the grid of the view the URL or the last visit picked
* (`data-boot-calendar-view`). Class lists mirror the real components
* (components/calendar/calendar-app.tsx, calendar-toolbar.tsx,
* mini-calendar.tsx, calendar-sidebar-panel.tsx and the views), and the
* sizes the views measure at run time (hour rows, week rows, columns) come
* from the `.sk-cal-*` rules in app/boot-skeleton.css.
*
* Dates, weekday names, hour labels and the title depend on the time zone,
* the first day of the week, the time format and the language, so they are
* placeholders; the icons are drawn for real.
*/
import type { ReactNode } from "react";
import {
CalendarDays,
ChevronDown,
ChevronLeft,
ChevronRight,
ListTodo,
Menu,
MoreVertical,
Plus,
Search,
UnfoldVertical,
} from "@/components/icons";
import type { CalendarViewMode } from "@/stores/calendar-store";
import { cn } from "@/lib/cn";
import { Bar, Block, Circle, repeat, textWidth } from "./kit";
import { AppShellSkeleton, type RailLabels } from "./shell";
export interface CalendarSkeletonProps {
/** common.loading */
label?: string;
/** Rail labels for the phone bottom bar (sidebar.*). */
labels?: RailLabels;
/** The calendar's own words, translated by the caller. */
text?: CalendarSkeletonText;
}
/**
* Words of the calendar's chrome that are the same before and after the app
* loads (they depend on the language only). Placeholders where one is missing.
*/
export interface CalendarSkeletonText {
/** calendar.title: the sidebar's heading. */
title?: string;
/** calendar.views.today */
today?: string;
/** calendar.search.title: the search field's placeholder. */
search?: string;
/** calendar.views.<view>: the view menu button and the phone's pills. */
views?: Record<CalendarViewMode, string>;
/** calendar.tasks.label: the sidebar's tasks entry. */
tasks?: string;
/** calendar.tasks.filter_all, _pending, _completed, _overdue */
taskFilters?: [string, string, string, string];
/** calendar.tasks.create */
newTask?: string;
/** calendar.tasks.quick_add_placeholder */
quickAdd?: string;
}
const MODES: CalendarViewMode[] = ["month", "week", "day", "agenda", "tasks"];
/** The skeleton's words from a translator for the "calendar" namespace. */
export function calendarSkeletonText(t: (key: string) => string): CalendarSkeletonText {
return {
title: t("title"),
today: t("views.today"),
search: t("search.title"),
views: {
month: t("views.month"),
week: t("views.week"),
day: t("views.day"),
agenda: t("views.agenda"),
tasks: t("views.tasks"),
},
tasks: t("tasks.label"),
taskFilters: [t("tasks.filter_all"), t("tasks.filter_pending"), t("tasks.filter_completed"), t("tasks.filter_overdue")],
newTask: t("tasks.create"),
quickAdd: t("tasks.quick_add_placeholder"),
};
}
/** Shown for the view on <html>. */
const VIEW_SHOWN: Record<CalendarViewMode, string> = {
month: "hidden boot-cal-month:flex",
week: "hidden boot-cal-week:flex",
day: "hidden boot-cal-day:flex",
agenda: "hidden boot-cal-agenda:flex",
tasks: "hidden boot-cal-tasks:flex",
};
/** The view menu button's label for the view on <html>. */
const VIEW_LABEL: Record<CalendarViewMode, string> = {
month: "hidden boot-cal-month:inline",
week: "hidden boot-cal-week:inline",
day: "hidden boot-cal-day:inline",
agenda: "hidden boot-cal-agenda:inline",
tasks: "hidden boot-cal-tasks:inline",
};
/** The phone's view pill drawn as current. */
const PILL_ON: Record<CalendarViewMode, string> = {
month: "boot-cal-month:bg-primary boot-cal-month:text-primary-foreground",
week: "boot-cal-week:bg-primary boot-cal-week:text-primary-foreground",
day: "boot-cal-day:bg-primary boot-cal-day:text-primary-foreground",
agenda: "boot-cal-agenda:bg-primary boot-cal-agenda:text-primary-foreground",
// Only there when tasks are on.
tasks: "hidden boot-cal-tasks-on:flex boot-cal-tasks:bg-primary boot-cal-tasks:text-primary-foreground",
};
/** Tasks have no dates to step through and no events to search. */
const NOT_TASKS = "boot-cal-tasks:hidden";
/** ToolbarButton (components/ui/toolbar.tsx): a 32px muted icon button. */
function ToolbarIcon({ className, children }: { className?: string; children: ReactNode }) {
return (
<span className={cn("flex items-center justify-center h-8 w-8 shrink-0 rounded-md text-muted-foreground [&_svg]:size-4", className)}>
{children}
</span>
);
}
/** IconButton size icon-lg: the phone toolbar's 44px buttons. */
function PhoneIcon({ outline, className, children }: { outline?: boolean; className?: string; children: ReactNode }) {
return (
<span
className={cn(
"flex items-center justify-center h-11 w-11 shrink-0 rounded-md [&_svg]:size-5",
outline && "border border-input bg-background",
className,
)}
>
{children}
</span>
);
}
/** An outline xs button (Today, the view menu). */
function OutlineButton({ className, children }: { className?: string; children?: ReactNode }) {
return (
<span className={cn("flex items-center gap-1 h-8 px-2.5 shrink-0 rounded-md border border-input bg-background text-sm font-medium", className)}>
{children}
</span>
);
}
/** A word when the caller has it, else a placeholder as wide as a short one. */
function Word({ text, w }: { text?: string; w: string }) {
return text ? <span className="whitespace-nowrap">{text}</span> : <Bar className="h-2.5" w={w} />;
}
/** A placeholder bar in a line box of the given height class. */
function Line({ className, bar, w }: { className: string; bar: string; w: string }) {
return (
<span className={cn("flex items-center", className)}>
<Bar className={bar} w={w} />
</span>
);
}
/** The toolbar's title: a date range (a placeholder), or "Tasks" in the tasks view. */
function Title({ tasks, w }: { tasks?: string; w: string }) {
return (
<>
<Bar className={cn("h-3", tasks && "boot-cal-tasks:hidden")} w={w} />
{tasks ? <span className="hidden boot-cal-tasks:inline truncate">{tasks}</span> : null}
</>
);
}
/** CalendarToolbar: the 57px desktop and tablet row, the two-row phone header. */
function ToolbarSkeleton({ text }: { text?: CalendarSkeletonText }) {
const views = text?.views;
return (
<>
<div className="hidden md:flex items-center gap-1 px-3 h-14 box-content border-b border-border flex-shrink-0">
<ToolbarIcon className="me-1">
<Menu />
</ToolbarIcon>
{/* The sidebar's "+" stands in for this one while the sidebar shows. */}
<ToolbarIcon className="lg:hidden lg:boot-cal-collapsed:flex">
<Plus />
</ToolbarIcon>
<OutlineButton className={cn("me-1", NOT_TASKS)}>
<Word text={text?.today} w="2.5rem" />
</OutlineButton>
<ToolbarIcon className={NOT_TASKS}>
<ChevronLeft />
</ToolbarIcon>
<ToolbarIcon className={NOT_TASKS}>
<ChevronRight />
</ToolbarIcon>
<span className="flex items-center ms-2 h-6 min-w-0 text-base font-semibold">
<Title tasks={views?.tasks} w="9rem" />
</span>
<div className="flex-1" />
<span
className={cn(
"relative flex items-center w-40 lg:w-56 xl:w-64 me-1 h-8 ps-8 pe-8 shrink-0 rounded-md border border-input bg-background",
NOT_TASKS,
)}
>
<Search className="absolute start-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
{text?.search ? (
<span className="text-sm text-muted-foreground truncate">{text.search}</span>
) : (
<Bar className="h-2.5" w="6rem" />
)}
<span className="absolute end-1 top-1/2 -translate-y-1/2 mx-1 rounded border border-border px-1.5 font-sans text-[0.6875rem] leading-4 text-muted-foreground pointer-coarse:hidden">
/
</span>
</span>
<ToolbarIcon>
<MoreVertical />
</ToolbarIcon>
<OutlineButton className="ms-1">
{views ? MODES.map((v) => <span key={v} className={VIEW_LABEL[v]}>{views[v]}</span>) : <Bar className="h-2.5" w="2.25rem" />}
<ChevronDown className="w-3 h-3" />
</OutlineButton>
</div>
<div className="md:hidden border-b border-border">
<div className="flex flex-col gap-1 px-2 py-2">
<div className="flex flex-wrap items-center gap-1">
<PhoneIcon className="-ms-1">
<Menu />
</PhoneIcon>
<PhoneIcon className={NOT_TASKS}>
<ChevronLeft />
</PhoneIcon>
{/* Below 400px the title takes a line of its own. */}
<span className="flex flex-1 items-center justify-center h-5 min-w-0 text-sm font-semibold max-[25rem]:order-first max-[25rem]:basis-full max-[25rem]:ps-1 max-[25rem]:justify-start">
<Title tasks={views?.tasks} w="5rem" />
</span>
<PhoneIcon className={NOT_TASKS}>
<ChevronRight />
</PhoneIcon>
<span className={cn("flex items-center h-8 px-2.5 ms-0.5 shrink-0 text-sm font-medium", NOT_TASKS)}>
<Word text={text?.today} w="2.25rem" />
</span>
{/* PhoneHeaderActions: the shell's ⋮ and the account avatar. */}
<div className="flex items-center gap-1 flex-shrink-0 ms-auto">
<PhoneIcon>
<MoreVertical />
</PhoneIcon>
<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>
</div>
</div>
<div className="flex items-center gap-1.5">
<div className="flex flex-1 border border-border rounded-md overflow-hidden">
{MODES.map((v) => (
<span
key={v}
className={cn("flex flex-auto items-center justify-center min-h-11 px-1.5 text-[0.6875rem] font-medium text-muted-foreground", PILL_ON[v])}
>
{views ? views[v] : <Bar className="h-2" w="2rem" />}
</span>
))}
</div>
<PhoneIcon outline className={NOT_TASKS}>
<Search />
</PhoneIcon>
<PhoneIcon outline>
<CalendarDays />
</PhoneIcon>
</div>
</div>
</div>
</>
);
}
/** The inline sidebar of a desktop: header, mini month and calendar list. */
function SidebarSkeleton({ text }: { text?: CalendarSkeletonText }) {
const weekNumberCell = <span className="hidden boot-cal-weeknums:block" />;
const columns = "grid grid-cols-7 boot-cal-weeknums:grid-cols-[1.25rem_repeat(7,minmax(0,1fr))]";
const chevron = "flex items-center justify-center h-8 w-8 [&_svg]:size-4";
return (
<div
className="hidden lg:flex lg:boot-cal-collapsed:hidden flex-col border-e border-border bg-secondary overflow-hidden flex-shrink-0"
style={{ width: "var(--boot-cal-sidebar-w, 256px)" }}
>
{/* CalendarSidebarHeader */}
<div className="px-3 h-14 box-content border-b border-border flex items-center justify-between flex-shrink-0">
{text?.title ? (
<span className="text-sm font-semibold truncate">{text.title}</span>
) : (
<Bar className="h-3" w="4.5rem" />
)}
<span className="flex items-center justify-center h-8 w-8 shrink-0">
<Plus className="w-4 h-4" />
</span>
</div>
<div className="flex-1 overflow-hidden py-3">
{/* MiniCalendar */}
<div className="px-3">
<div className="flex items-center justify-between mb-2 text-muted-foreground">
<span className={chevron}>
<ChevronLeft />
</span>
<span className="inline-flex items-center gap-1 h-7 px-2">
<Bar className="h-3" w="6.5rem" />
<ChevronDown className="w-3.5 h-3.5" />
</span>
<span className={chevron}>
<ChevronRight />
</span>
</div>
<div className={columns}>
{weekNumberCell}
{repeat(7, () => (
<Line className="justify-center h-[0.9375rem] my-1" bar="h-1.5" w="0.875rem" />
))}
</div>
{/* 4 to 6 weeks: as many as the month has (--boot-cal-mini-rows). */}
<div className={cn(columns, "sk-cal-mini-days overflow-hidden auto-rows-[1.75rem] pointer-coarse:auto-rows-[2.75rem] items-center justify-items-center")}>
{repeat(6, () => (
<>
{weekNumberCell}
{repeat(7, () => <Bar className="h-2" w="0.75rem" />)}
</>
))}
</div>
</div>
{/* CalendarSidebarPanel: one section of calendars. */}
<div className="mt-4">
{/* The tasks entry, when tasks are on. */}
<div
className="hidden boot-cal-tasks-on:flex items-center gap-2 px-3 mb-2 min-h-8 pointer-coarse:min-h-11 text-sm"
style={{ paddingBlock: "var(--density-sidebar-py, 4px)" }}
>
<ListTodo className="w-4 h-4 text-muted-foreground" />
<Word text={text?.tasks} w="3rem" />
</div>
{/* A section header, its add button taking the row to 32px. */}
<div className="flex items-center gap-1 px-3 h-8 text-muted-foreground">
<ChevronDown className="w-3 h-3 flex-shrink-0" />
<Bar className="h-2" w="4rem" />
</div>
{repeat(4, (i) => (
<div
className="flex items-center gap-2 ps-5 pe-3 min-h-8 pointer-coarse:min-h-11"
style={{ paddingBlock: "var(--density-sidebar-py, 4px)" }}
>
<Block className="w-3.5 h-3.5 rounded-sm flex-shrink-0" />
<Bar className="h-2.5" w={textWidth(i, 30, 35)} />
</div>
))}
</div>
</div>
</div>
);
}
/**
* The hour labels down the gutter, one per hour row. Each sits centred on
* its row's top line; a range that starts later than midnight labels its
* first hour inside the row instead (calendar-week-view.tsx). The gutter
* stops short of the last line, which has no label.
*/
function HourGutter({ className, labelW }: { className: string; labelW: string }) {
return (
<div className={cn("relative flex-shrink-0 h-[calc(100%-5px)] overflow-hidden bg-background", className)}>
{repeat(24, (i) => (
<div className="sk-cal-hour relative">
<Bar
className={cn("absolute end-2 h-2", i === 0 ? "sk-cal-first-label top-1.5" : "top-0 -translate-y-1/2")}
w={labelW}
/>
</div>
))}
</div>
);
}
/** The all-hours toggle in the gutter's header cell, while the hours are limited. */
function HoursToggle({ className }: { className: string }) {
return (
<div className={cn("flex items-end justify-center pb-1 flex-shrink-0", className)}>
<span className="sk-cal-hours-toggle flex items-center justify-center rounded p-1 text-muted-foreground">
<UnfoldVertical className="h-3.5 w-3.5" />
</span>
</div>
);
}
/** The weekday and date of a column header. */
function DayHead({ className, date }: { className?: string; date: string }) {
return (
<>
<Line className={cn("h-[1.03125rem]", className)} bar="h-2" w="1.5rem" />
<Line className={cn("justify-center", date)} bar="h-3.5" w="1.125rem" />
</>
);
}
/** CalendarWeekView: gutter, one column per day, hour rows that fill the pane. */
function WeekSkeleton({ className }: { className: string }) {
return (
<div aria-hidden="true" className={cn("sk-cal-view sk-cal-week flex-col flex-1 min-h-0 min-w-0 overflow-hidden", className)}>
<div className="relative z-10 flex flex-shrink-0 border-b border-border bg-background">
<HoursToggle className="w-14 max-md:w-10" />
<div className="sk-cal-cols grid border-s border-border">
{repeat(8, () => (
<div className="flex flex-col items-center gap-0.5 pt-2 pb-1.5 border-e border-border">
<DayHead date="w-10 h-10 max-md:w-8 max-md:h-8" />
</div>
))}
</div>
</div>
<div className="sk-cal-hours flex flex-shrink-0">
<HourGutter className="w-14 max-md:w-10" labelW="1.5rem" />
<div className="sk-cal-cols relative grid h-full border-s border-border">
{repeat(8, () => <div className="sk-cal-slots border-e border-border" />)}
</div>
</div>
</div>
);
}
/** CalendarDayView: the same grid with one wide column. */
function DaySkeleton({ className }: { className: string }) {
return (
<div aria-hidden="true" className={cn("sk-cal-view sk-cal-day flex-col flex-1 min-h-0 min-w-0 overflow-hidden", className)}>
<div className="relative z-10 flex flex-shrink-0 border-b border-border bg-background">
<HoursToggle className="w-16 max-md:w-10" />
<div className="flex flex-col flex-1 min-w-0 items-start gap-0.5 px-3 max-md:px-2 pt-2 pb-1.5 border-e border-border">
<DayHead className="w-10 justify-center" date="w-10 h-10" />
</div>
</div>
<div className="sk-cal-hours flex flex-shrink-0">
<HourGutter className="w-16 max-md:w-10" labelW="1.75rem" />
<div className="sk-cal-slots relative flex-1 h-full border-e border-border" />
</div>
</div>
);
}
/** CalendarMonthView: weekday header and six week rows filling the pane. */
function MonthSkeleton({ className }: { className: string }) {
const cell = "border-e border-border last:border-e-0";
return (
<div aria-hidden="true" className={cn("sk-cal-view sk-cal-month flex-col flex-1 min-h-0 min-w-0 overflow-hidden", className)}>
<div className="grid grid-cols-7 flex-shrink-0 border-b border-border">
{repeat(7, () => (
<div className={cn(cell, "flex justify-center py-2 max-md:py-1.5")}>
<Line className="h-4 max-md:h-[1.03125rem]" bar="h-2" w="1.5rem" />
</div>
))}
</div>
{repeat(6, () => (
<div className="sk-cal-month-week grid grid-cols-7 flex-shrink-0 border-b border-border">
{repeat(7, () => (
<div className={cn(cell, "p-1")}>
<Line className="justify-center h-6 max-md:h-7" bar="h-2.5" w="0.875rem" />
</div>
))}
</div>
))}
</div>
);
}
/** CalendarAgendaView: the range line, then day rows with their events. */
function AgendaSkeleton({ className }: { className: string }) {
return (
<div aria-hidden="true" className={cn("flex-col flex-1 min-h-0 overflow-hidden", className)}>
<div className="flex justify-center px-4 py-2">
<Line className="h-6" bar="h-2.5" w="7rem" />
</div>
{repeat(6, (i) => (
<div className="flex items-start gap-2 px-2 sm:px-4 py-1.5 border-b border-border">
<div className="flex items-center gap-2 w-24 sm:w-32 flex-shrink-0 py-1.5">
<Line className="justify-center w-9 h-9 flex-shrink-0" bar="h-3.5" w="1.125rem" />
<Bar className="h-2" w="2.5rem" />
</div>
<div className="flex-1 min-w-0 py-0.5">
{repeat(1 + (i % 2), (j) => (
<div className="flex items-start gap-3 px-2" style={{ paddingBlock: "var(--density-item-py)" }}>
<Line className="w-28 sm:w-32 h-5 flex-shrink-0" bar="h-2.5" w="5.5rem" />
<Circle className="w-[9px] h-[9px] mt-[5.5px]" />
<Line className="flex-1 min-w-0 h-5" bar="h-3" w={textWidth(i * 2 + j, 30, 40)} />
</div>
))}
</div>
</div>
))}
</div>
);
}
/** The tasks view: TaskToolbar and TaskListView. */
function TasksSkeleton({ className, text }: ViewProps) {
const filters = text?.taskFilters;
return (
<div aria-hidden="true" className={cn("flex-col flex-1 min-h-0 overflow-hidden", className)}>
<div className="flex items-center gap-2 px-4 min-h-14 box-content border-b border-border flex-wrap">
{/* The filter starts at "all" on every load (stores/task-store.ts). */}
<div className="flex border border-border rounded-md overflow-hidden">
{repeat(4, (i) => (
<span
className={cn(
"flex items-center h-7 px-3 text-xs font-medium",
i === 0 ? "bg-primary text-primary-foreground" : "text-muted-foreground",
)}
>
{filters ? filters[i] : <Bar className="h-2" w="2.5rem" />}
</span>
))}
</div>
<div className="flex-1" />
{text?.newTask ? (
<span className="flex items-center h-9 px-3 rounded-md bg-primary text-primary-foreground text-sm font-medium shadow-sm">
<Plus className="w-4 h-4 me-1" />
{text.newTask}
</span>
) : (
<Block className="h-9" w="6.5rem" />
)}
</div>
<div className="px-4 py-2 border-b border-border">
<div className="flex items-center gap-2 h-5">
<Plus className="h-4 w-4 text-muted-foreground flex-shrink-0" />
{text?.quickAdd ? (
<span className="text-sm text-muted-foreground truncate">{text.quickAdd}</span>
) : (
<Bar className="h-2.5" w="10rem" />
)}
</div>
</div>
<div className="divide-y divide-border">
{repeat(8, (i) => (
<div className="flex items-start gap-3 px-4 py-3">
<span className="mt-0.5 flex-shrink-0 w-5 h-5 rounded-full border-2 border-muted-foreground/40" />
<div className="flex-1 min-w-0">
<Line className="h-5" bar="h-3" w={textWidth(i, 25, 35)} />
<Line className="h-4 mt-0.5" bar="h-2" w="6rem" />
</div>
</div>
))}
</div>
</div>
);
}
interface ViewProps {
className: string;
text?: CalendarSkeletonText;
}
const VIEWS: Record<CalendarViewMode, (props: ViewProps) => ReactNode> = {
month: MonthSkeleton,
week: WeekSkeleton,
day: DaySkeleton,
agenda: AgendaSkeleton,
tasks: TasksSkeleton,
};
/**
* The grid of a calendar view where calendar-app's renderView() puts it.
* With `view` it draws that one (the loaded page while its calendars load,
* with `label` as its status); without, the one `data-boot-calendar-view`
* names.
*/
export function CalendarViewSkeleton({
view,
label,
text,
}: {
view?: CalendarViewMode;
label?: string;
text?: CalendarSkeletonText;
}) {
return (
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden" data-calendar-view-skeleton={view ?? ""} aria-busy="true">
{label ? <span role="status" className="sr-only">{label}</span> : null}
{view
? VIEWS[view]({ className: "flex", text })
: (Object.keys(VIEWS) as CalendarViewMode[]).map((v) => {
const View = VIEWS[v];
return <View key={v} className={VIEW_SHOWN[v]} text={text} />;
})}
</div>
);
}
/**
* The calendar 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). Pro runs
* on desktops only, so the breakpoints pick the desktop layout there too.
*/
export function CalendarSkeletonBody({ embedded = false, text }: { embedded?: boolean; text?: CalendarSkeletonText }) {
return (
<div aria-hidden="true" className="flex flex-col flex-1 min-w-0 min-h-0" data-embedded={embedded ? "" : undefined}>
<div className="flex flex-1 min-w-0 min-h-0">
<SidebarSkeleton text={text} />
<div className="flex flex-col flex-1 min-w-0 min-h-0">
<ToolbarSkeleton text={text} />
<div className="relative flex flex-1 overflow-hidden isolate">
<div className="flex flex-1 min-w-0">
<CalendarViewSkeleton text={text} />
</div>
{/* The floating create button of a phone. */}
<span className="md:hidden absolute bottom-4 end-4 z-40 flex items-center justify-center h-14 w-14 rounded-full shadow-lg bg-primary text-primary-foreground">
<Plus className="h-6 w-6" />
</span>
</div>
</div>
</div>
</div>
);
}
/** Skeleton of the calendar page. */
export function CalendarSkeleton({ label, labels, text }: CalendarSkeletonProps) {
return (
<AppShellSkeleton
surface="calendar"
label={label}
labels={labels}
railBelow="md"
active="calendar"
mvMenu={false}
rowClassName="lg:mv:mb-4 lg:mv:mx-4 lg:mv:rounded-[var(--mv-card-radius)] lg:mv:bg-background"
>
<CalendarSkeletonBody text={text} />
</AppShellSkeleton>
);
}
@@ -0,0 +1,268 @@
import { ArrowLeft, Book, BookUser, ChevronDown, Filter, Menu, Plus, Printer, Square, X } from "@/components/icons";
import type { Density } from "@/lib/density";
import { cn } from "@/lib/cn";
import { Bar, Block, Circle, repeat, textWidth } from "./kit";
import { AppShellSkeleton, type RailLabels } from "./shell";
import { PhoneActionsSkeleton, SearchFieldSkeleton, ToolIcon } from "./toolbar-skeleton";
export interface ContactsSkeletonProps {
/** common.loading */
label?: string;
/** Rail labels for the phone bottom bar (sidebar.*). */
labels?: RailLabels;
}
/**
* Mountain View's content card on a desktop: the data-mv-card row of the
* contacts, files and plugin pages, inset 16px from the canvas.
*/
export const MV_CARD_ROW = "lg:mv:mb-4 lg:mv:mx-4 lg:mv:rounded-[var(--mv-card-radius)] lg:mv:bg-background";
const SIDEBAR_ROW_PY = { paddingBlock: "var(--density-sidebar-py, 4px)" } as const;
const CONTACT_ROW_STYLE = {
gap: "var(--density-item-gap)",
paddingInline: "16px",
paddingBlock: "var(--density-item-py)",
} as const;
/**
* Contact rows as ContactListItem draws them (components/contacts/
* contact-list-item.tsx): the density's padding and gap, a 32px avatar, a
* name line and an email line; extra compact drops the avatar and the email.
* Shared by the page skeleton and the list's own loading state, so the one
* hands over to the other without a jump.
*
* `density` comes from the settings store once the app runs; without it (the
* server HTML) the boot script's data-boot-density picks the variant. With
* `letters`, a letter heading leads, as when the list groups by letter.
*/
export function ContactRowsSkeleton({
density,
letters = true,
count = 18,
}: {
density?: Density;
letters?: boolean;
count?: number;
}) {
const compactHide = density === undefined ? "boot-density-xc:hidden" : density === "extra-compact" ? "hidden" : undefined;
return (
<div aria-hidden="true" data-testid="contact-list-skeleton">
{letters ? (
// A block, not a flex box: the shape sits on a line of the heading's
// own font size, so the heading is as tall as a real letter.
<div className="px-4 py-0.5 text-[0.6875rem] font-medium">
<span className="sk-shape inline-block align-middle w-2 h-2 rounded-sm" />
</div>
) : null}
{repeat(count, (i) => (
<div className="w-full flex items-center border-b border-border" style={CONTACT_ROW_STYLE}>
<Circle className={cn("w-8 h-8", compactHide)} />
<div className="flex-1 min-w-0">
<div className="flex items-center h-5">
<Bar className="h-3.5" w={textWidth(i, 35, 30)} />
</div>
<div className={cn("flex items-center h-4", compactHide)}>
<Bar className="h-3" w={textWidth(i + 3, 45, 30)} />
</div>
</div>
</div>
))}
</div>
);
}
/** The categories sidebar (components/contacts/contacts-sidebar.tsx). */
function ContactsSidebarSkeleton() {
return (
<div className="flex flex-col h-full bg-secondary">
<div className="px-3 h-14 box-content border-b border-border flex items-center justify-between flex-shrink-0">
<Bar className="h-3.5" w="4.5rem" />
<span className="grid place-items-center w-8 h-8 shrink-0">
<Plus className="w-4 h-4" />
</span>
</div>
<div className="flex-1 overflow-hidden py-1">
{/* All contacts: always first, and selected on arrival. */}
<div
className="w-full flex items-center gap-2 px-3 text-sm min-h-8 pointer-coarse:min-h-11 bg-accent text-accent-foreground"
style={SIDEBAR_ROW_PY}
>
<BookUser className="w-4 h-4 flex-shrink-0" />
<span className="flex items-center h-5 flex-1 min-w-0">
<Bar className="h-3" w="2.5rem" />
</span>
<Bar className="h-2.5" w="1rem" />
</div>
<div className="mt-2">
<div className="flex items-center gap-1 px-3 py-1 min-h-10 text-muted-foreground">
<ChevronDown className="w-3 h-3" />
<Bar className="h-2.5" w="6rem" />
</div>
{repeat(3, (i) => (
<div className="flex items-center gap-2 ps-5 pe-3 min-h-10 text-muted-foreground">
<Book className="w-4 h-4 flex-shrink-0" />
<Bar className="h-3" w={textWidth(i, 30, 30)} />
</div>
))}
</div>
</div>
</div>
);
}
/** The list's toolbar (contact-list.tsx): menu on narrow panes, select, search, print, phone actions. */
function ContactListToolbarSkeleton({ embedded }: { embedded: boolean }) {
return (
<div className="border-b border-border bg-background">
<div className="px-3 h-14 flex items-center">
<div className="flex items-center gap-1 w-full">
<ToolIcon icon={Menu} className={embedded ? "hidden" : "lg:hidden"} />
<ToolIcon icon={Square} />
<SearchFieldSkeleton trailing={Filter} className="flex-1 min-w-0" />
<ToolIcon icon={Printer} className={embedded ? undefined : "hidden md:grid"} />
{embedded ? null : <PhoneActionsSkeleton className="flex md:hidden" />}
</div>
</div>
</div>
);
}
/** ContactDetail with nothing open (contact-detail.tsx). */
function EmptyDetailSkeleton() {
return (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<BookUser className="w-16 h-16 mb-4 opacity-20" />
<span className="flex items-center h-5">
<Bar className="h-3.5" w="12rem" />
</span>
</div>
);
}
/**
* ContactForm for a new contact (contact-form.tsx): its title bar, the photo
* row, the first fields and the Cancel/Save footer. /contacts/new opens it at
* once, without waiting for any data.
*/
function NewContactFormSkeleton() {
return (
<div className="flex flex-col h-full bg-background">
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<span className="flex items-center h-7">
<Bar className="h-4" w="7rem" />
</span>
<span className="grid place-items-center w-8 h-8 text-muted-foreground">
<X className="w-4 h-4" />
</span>
</div>
<div className="flex-1 overflow-hidden">
<div className="px-6 py-4 max-w-3xl">
<div className="flex items-center gap-4 pb-4">
<Circle className="w-20 h-20" />
<span className="flex items-center h-4 flex-1 min-w-0">
<Bar className="h-3" w="60%" />
</span>
</div>
<div className="py-5 space-y-3">
<span className="flex items-center gap-2 h-5">
<Bar className="h-3.5" w="8rem" />
</span>
{repeat(3, () => (
<div className="space-y-1.5">
<Bar className="h-3" w="5rem" />
<Block className="h-10 w-full" />
</div>
))}
</div>
</div>
</div>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border flex-shrink-0">
<Block className="h-10" w="5.5rem" />
<Block className="h-10" w="5rem" />
</div>
</div>
);
}
/**
* The contacts 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).
*
* Desktop: sidebar, list and the empty detail. Tablet: the sidebar is a
* closed drawer and the list toolbar gets its Menu button. Phone: the list
* with its create button, or, with a contact (data-boot-pane="detail") or the
* new-contact form ("create") in the URL, the detail with its back bar.
*/
export function ContactsSkeletonBody({ embedded = false }: { embedded?: boolean }) {
return (
<div aria-hidden="true" className={cn("flex flex-col flex-1 min-w-0 min-h-0", embedded && "h-full")} data-embedded={embedded ? "" : undefined}>
<div className="relative flex flex-1 min-h-0 overflow-hidden">
<div
className={cn(
"flex-col flex-shrink-0 border-e border-border bg-background w-[var(--boot-contacts-sidebar-w,256px)]",
embedded ? "flex" : "hidden lg:flex",
)}
>
<ContactsSidebarSkeleton />
</div>
<div className="relative flex flex-1 min-w-0 min-h-0">
<div
className={cn(
"relative border-e border-border bg-background flex flex-col flex-shrink-0",
embedded
? "w-[var(--boot-contacts-list-w,384px)]"
: "max-md:w-full max-md:boot-open:hidden md:w-[var(--boot-contacts-list-w,384px)]",
)}
>
<ContactListToolbarSkeleton embedded={embedded} />
<div className="flex-1 overflow-hidden">
<ContactRowsSkeleton />
</div>
{embedded ? null : (
<span className="md:hidden absolute bottom-4 end-4 z-20 grid place-items-center w-11 h-11 rounded-full shadow-lg bg-primary text-primary-foreground">
<Plus className="w-5 h-5" />
</span>
)}
</div>
<div className={cn("flex-1 min-w-0 flex flex-col", !embedded && "max-md:[:root:not([data-boot-pane])_&]:hidden")}>
{embedded ? null : (
<div className="hidden max-md:block px-3 py-2 border-b border-border">
<span className="flex items-center h-9 px-3">
<ArrowLeft className="w-4 h-4 me-2" />
<Bar className="h-3.5" w="7rem" />
</span>
</div>
)}
<div className={cn("flex-1 min-h-0", !embedded && "boot-create:hidden")}>
<EmptyDetailSkeleton />
</div>
{embedded ? null : (
<div className="hidden flex-1 min-h-0 boot-create:block">
<NewContactFormSkeleton />
</div>
)}
</div>
</div>
</div>
</div>
);
}
/** Skeleton of the contacts page. */
export function ContactsSkeleton({ label, labels }: ContactsSkeletonProps) {
return (
<AppShellSkeleton
surface="contacts"
label={label}
labels={labels}
railBelow="md"
active="contacts"
mvMenu={false}
rowClassName={MV_CARD_ROW}
>
<ContactsSkeletonBody />
</AppShellSkeleton>
);
}
+360
View File
@@ -0,0 +1,360 @@
import {
ArrowUpDown,
FolderOpen,
HardDrive,
LayoutGrid,
LayoutList,
Menu,
MoreVertical,
PanelRight,
Plus,
RefreshCw,
Search,
Trash2,
X,
} from "@/components/icons";
import { FILE_LIST_COLUMNS } from "@/components/files/file-list-layout";
import { cn } from "@/lib/cn";
import { Bar, Block, Circle, repeat, textWidth } from "./kit";
import { MV_CARD_ROW } from "./contacts-skeleton";
import { AppShellSkeleton, type RailLabels } from "./shell";
import { PhoneActionsSkeleton, SearchFieldSkeleton, ToolIcon } from "./toolbar-skeleton";
export interface FilesSkeletonProps {
/** common.loading */
label?: string;
/** Rail labels for the phone bottom bar (sidebar.*). */
labels?: RailLabels;
}
const SIDEBAR_ROW = { paddingBlock: "var(--density-sidebar-py, 4px)", paddingInlineStart: "12px" } as const;
/** An unticked SelectBox (file-views.tsx). */
const SELECT_BOX = "block w-4 h-4 shrink-0 rounded border border-muted-foreground/50 bg-background";
/** A row's or tile's ⋮ button (IconButton icon-sm). */
function MoreButton({ className }: { className?: string }) {
return (
<span className={cn("grid place-items-center w-8 h-8 shrink-0 text-muted-foreground", className)}>
<MoreVertical className="w-4 h-4" />
</span>
);
}
/**
* The list view (FileListView): the column header and h-10 rows on the
* shared column grid; a phone with a touch screen gets the taller two-line
* rows without a select column.
*/
function FileListSkeleton({ className }: { className?: string }) {
return (
<div aria-hidden="true" data-testid="files-list-skeleton" className={cn("text-sm", className)}>
<div className={cn(FILE_LIST_COLUMNS, "border-b border-border px-4 h-9 text-xs")}>
<div className="flex items-center">
<span className={SELECT_BOX} />
</div>
<Bar className="h-2.5" w="2.5rem" />
<div className="hidden md:block">
<Bar className="h-2.5" w="3.5rem" />
</div>
<div className="hidden sm:flex justify-end">
<Bar className="h-2.5" w="2rem" />
</div>
<div className="w-[4.75rem]" />
</div>
{repeat(18, (i) => (
<div className={cn(FILE_LIST_COLUMNS, "px-4 border-b border-border/60 h-10 max-md:pointer-coarse:h-auto max-md:pointer-coarse:min-h-[52px] max-md:pointer-coarse:py-1.5")}>
<div className={"flex items-center max-md:pointer-coarse:hidden"}>
<span className={cn(SELECT_BOX, "opacity-0 pointer-coarse:opacity-100")} />
</div>
<div className={"flex items-center gap-3 min-w-0 max-md:pointer-coarse:col-span-2"}>
<Block className="w-5 h-5 rounded" />
<div className="min-w-0 flex-1">
<div className="flex items-center h-5">
<Bar className="h-3.5" w={textWidth(i, 25, 35)} />
</div>
<div className={"hidden items-center h-4 max-md:pointer-coarse:flex"}>
<Bar className="h-3" w="6rem" />
</div>
</div>
</div>
<div className="hidden md:block">
<Bar className="h-3" w="3.5rem" />
</div>
<div className="hidden sm:flex justify-end">
<Bar className="h-3" w="2.5rem" />
</div>
<div className="flex items-center justify-end gap-0.5 w-[4.75rem]">
<MoreButton />
</div>
</div>
))}
</div>
);
}
/** The grid view (FileGridView): folder tiles, then file tiles with their previews. */
function FileGridSkeleton({ className }: { className?: string }) {
return (
<div aria-hidden="true" data-testid="files-grid-skeleton" className={cn("p-4 space-y-5", className)}>
<div>
<div className="flex items-center h-4 mb-2 px-0.5">
<Bar className="h-2.5" w="3.5rem" />
</div>
<div className="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-2">
{repeat(4, (i) => (
<div className="flex items-center gap-2.5 h-11 ps-3 pe-1 rounded-lg border border-border bg-background">
<Block className="w-5 h-5 rounded" />
<span className="flex-1 min-w-0">
<Bar className="h-3.5" w={textWidth(i, 40, 40)} />
</span>
<MoreButton className="opacity-0" />
</div>
))}
</div>
</div>
<div>
<div className="flex items-center h-4 mb-2 px-0.5">
<Bar className="h-2.5" w="2.5rem" />
</div>
<div className="grid grid-cols-[repeat(auto-fill,minmax(9.5rem,1fr))] gap-3">
{repeat(8, (i) => (
<div className="flex flex-col rounded-lg border border-border bg-background overflow-hidden">
<div className="aspect-[4/3] bg-muted/50 flex items-center justify-center border-b border-border/60">
<Block className="w-14 h-14 rounded-lg" />
</div>
<div className="flex items-center gap-1 ps-2.5 pe-1 py-1.5 min-w-0">
<div className="min-w-0 flex-1">
<div className="flex items-center h-5">
<Bar className="h-3.5" w={textWidth(i, 45, 40)} />
</div>
<div className="flex items-center h-4">
<Bar className="h-3" w="60%" />
</div>
</div>
<MoreButton className="opacity-0 max-md:pointer-coarse:opacity-100" />
</div>
</div>
))}
</div>
</div>
</div>
);
}
/**
* The listing while the first folder listing loads, in the view the user
* picked. Shared by the page skeleton and FileBrowser's own loading state.
* Without `view` (the server HTML) the boot script's data-boot-files-view
* picks it.
*/
export function FilesListingSkeleton({ view }: { view?: "list" | "grid" }) {
if (view === "grid") return <FileGridSkeleton />;
if (view === "list") return <FileListSkeleton />;
return (
<>
<FileListSkeleton className="boot-files-grid:hidden" />
<FileGridSkeleton className="hidden boot-files-grid:block" />
</>
);
}
/** The folder sidebar (files-sidebar.tsx) before its folders are listed: My files and Trash. */
function FilesSidebarSkeleton() {
const row = "w-full flex items-center gap-2 pe-3 text-sm min-h-8 pointer-coarse:min-h-11";
return (
<div className="flex flex-col h-full bg-secondary">
<div className="px-3 h-14 box-content border-b border-border flex items-center justify-between flex-shrink-0">
<Bar className="h-3.5" w="3rem" />
<span className="grid place-items-center w-8 h-8 shrink-0">
<Plus className="w-4 h-4" />
</span>
</div>
<div className="flex-1 overflow-hidden py-1">
<div className={cn(row, "bg-accent text-accent-foreground")} style={SIDEBAR_ROW}>
<span className="w-3.5 -ms-1 shrink-0" />
<HardDrive className="w-4 h-4 shrink-0" />
<span className="flex items-center h-5">
<Bar className="h-3" w="4rem" />
</span>
</div>
<div className="mt-3">
<div className={cn(row, "text-muted-foreground")} style={SIDEBAR_ROW}>
<span className="w-3.5 -ms-1 shrink-0" />
<Trash2 className="w-4 h-4 shrink-0" />
<span className="flex items-center h-5">
<Bar className="h-3" w="2.5rem" />
</span>
</div>
</div>
</div>
</div>
);
}
/**
* FilesHeader (files-header.tsx): the drawer button on narrow panes, the
* breadcrumb, then search (an icon on a phone), sort, the list/grid switch,
* refresh (not on a phone), the details toggle and the phone actions. The
* Pro account picker shows the breadcrumb alone.
*/
function FilesHeaderSkeleton({ picker }: { picker: boolean }) {
const on = "bg-primary/10 text-primary";
return (
<div className="flex items-center gap-1 px-3 h-14 box-content border-b border-border min-w-0 flex-shrink-0">
{picker ? null : <ToolIcon icon={Menu} className="lg:hidden" />}
<div className="flex-1 min-w-0 flex items-center text-sm">
<span className="flex items-center px-1.5 py-1 h-7">
<Bar className="h-3.5" w="4rem" />
</span>
</div>
{picker ? null : (
<div className="flex items-center gap-1 shrink-0">
<ToolIcon icon={Search} className="md:hidden" />
<SearchFieldSkeleton className="hidden md:flex w-56" />
<ToolIcon icon={ArrowUpDown} />
<span className="flex items-center h-8 rounded-md border border-input p-0.5 shrink-0">
<span className={cn("h-full w-7 grid place-items-center rounded", on, "boot-files-grid:bg-transparent boot-files-grid:text-muted-foreground")}>
<LayoutList className="w-4 h-4" />
</span>
<span className="h-full w-7 grid place-items-center rounded text-muted-foreground boot-files-grid:bg-primary/10 boot-files-grid:text-primary">
<LayoutGrid className="w-4 h-4" />
</span>
</span>
<ToolIcon icon={RefreshCw} className="hidden md:grid" />
<ToolIcon icon={PanelRight} className="boot-files-details:bg-primary/10 boot-files-details:text-primary" />
<PhoneActionsSkeleton className="flex md:hidden" />
</div>
)}
</div>
);
}
/** FileDetailsPanel with nothing selected: the open folder's summary. */
function FileDetailsSkeleton() {
return (
<div
aria-hidden="true"
className={cn(
"hidden boot-files-details:flex flex-col flex-shrink-0 bg-background border-s border-border",
"lg:w-72 max-lg:absolute max-lg:inset-y-0 max-lg:end-0 max-lg:z-30 max-lg:w-80 max-lg:max-w-[85%] max-lg:shadow-xl",
)}
>
<div className="flex items-center justify-between px-4 h-11 border-b border-border flex-shrink-0">
<Bar className="h-3.5" w="3.5rem" />
<span className="grid place-items-center h-7 w-7 -me-1.5 text-muted-foreground">
<X className="w-4 h-4" />
</span>
</div>
<div className="flex-1 overflow-hidden">
<div className="p-4 space-y-5">
<div className="aspect-[4/3] rounded-lg bg-muted/50 flex items-center justify-center">
<FolderOpen className="w-14 h-14 text-primary" />
</div>
<div>
<div className="flex items-center h-6">
<Bar className="h-4" w="6rem" />
</div>
<div className="flex items-center h-5">
<Bar className="h-3" w="8rem" />
</div>
</div>
<div className="space-y-3">
{repeat(2, (i) => (
<div>
<div className="flex items-center h-4">
<Bar className="h-2.5" w="2.5rem" />
</div>
<div className="flex items-center h-5">
<Bar className="h-3" w={i === 0 ? "3rem" : "1rem"} />
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}
/** The Pro account picker (FileBrowser accountPickerMode): a heading and account cards. */
function AccountPickerSkeleton() {
return (
<div aria-hidden="true" className="p-4">
<div className="flex items-center h-4 mb-2 px-0.5">
<Bar className="h-2.5" w="7rem" />
</div>
<div className="grid gap-3 grid-cols-[repeat(auto-fill,minmax(14rem,1fr))]">
{repeat(2, (i) => (
<div className="flex items-center gap-3 p-3 rounded-lg border border-border min-w-0">
<Circle className="w-10 h-10" />
<div className="min-w-0 flex-1">
<div className="flex items-center h-5">
<Bar className="h-3.5" w={textWidth(i, 50, 30)} />
</div>
<div className="flex items-center h-4">
<Bar className="h-3" w="75%" />
</div>
</div>
</div>
))}
</div>
</div>
);
}
/**
* The files 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); there the
* page opens on the account picker, without a sidebar or tools.
*
* Desktop: sidebar, header, listing and, when left open, the details panel.
* Below 64rem the sidebar is a closed drawer, the header gets its Menu button,
* the details panel lies over the listing and a create button floats.
*/
export function FilesSkeletonBody({ embedded = false }: { embedded?: boolean }) {
return (
<div aria-hidden="true" className={cn("flex flex-col flex-1 min-w-0", embedded && "h-full")} data-embedded={embedded ? "" : undefined}>
<div className="relative flex flex-1 min-h-0 overflow-hidden">
{embedded ? null : (
<div className="hidden lg:block border-e border-border flex-shrink-0 w-[var(--boot-files-sidebar-w,256px)]">
<FilesSidebarSkeleton />
</div>
)}
<div className="flex flex-col flex-1 min-w-0">
<FilesHeaderSkeleton picker={embedded} />
<div className="relative flex flex-1 min-h-0">
<div className="relative flex-1 min-w-0 overflow-hidden">
{embedded ? <AccountPickerSkeleton /> : <FilesListingSkeleton />}
</div>
{embedded ? null : (
<>
<div className="hidden max-lg:boot-files-details:block absolute inset-0 z-20 bg-black/30" />
<FileDetailsSkeleton />
<span className="lg:hidden absolute bottom-4 end-4 z-20 grid place-items-center w-11 h-11 rounded-full shadow-lg bg-primary text-primary-foreground">
<Plus className="w-5 h-5" />
</span>
</>
)}
</div>
</div>
</div>
</div>
);
}
/** Skeleton of the files page. */
export function FilesSkeleton({ label, labels }: FilesSkeletonProps) {
return (
<AppShellSkeleton
surface="files"
label={label}
labels={labels}
railBelow="md"
active="files"
mvMenu={false}
rowClassName={MV_CARD_ROW}
>
<FilesSkeletonBody />
</AppShellSkeleton>
);
}
+100
View File
@@ -0,0 +1,100 @@
/**
* The page skeleton kit.
*
* Plain markup with no hooks, no store reads and no next-intl, so the same
* component renders on the server (before any JS runs), during hydration and
* inside a loaded page's own loading state, and Lite pages without a message
* catalog can draw it too. Variants are picked by CSS from the `data-boot-*`
* attributes the boot script sets on <html> (lib/boot-skeleton/boot-script.ts);
* the classes behind them live in app/boot-skeleton.css.
*/
import { Fragment, type CSSProperties, type ReactNode } from "react";
import { cn } from "@/lib/cn";
export type SkeletonSurface =
| "mail"
| "calendar"
| "contacts"
| "files"
| "apps"
| "settings"
| "pro"
| "admin"
| "admin-login"
| "login"
| "setup";
interface RegionProps {
/** Which page this is the skeleton of; tests and the probes look for it. */
surface: SkeletonSurface;
/**
* The one polite "Loading" announcement for the whole page. Leave it out
* only where no translation is at hand (Lite before its catalog loads).
*/
label?: string;
className?: string;
style?: CSSProperties;
children?: ReactNode;
}
/**
* The outermost element of a page skeleton: marks the page busy and carries
* the single status message. Everything visual inside is aria-hidden.
*/
export function SkeletonRegion({ surface, label, className, style, children }: RegionProps) {
return (
<div data-boot-skeleton={surface} aria-busy="true" className={className} style={style}>
{label ? <span role="status" className="sr-only">{label}</span> : null}
{children}
</div>
);
}
interface ShapeProps {
className?: string;
/** Width as any CSS length, e.g. "60%" or "8rem". */
w?: string;
style?: CSSProperties;
}
/** A placeholder for a line of text: give it a height (h-3, h-4) and a width. */
export function Bar({ className, w, style }: ShapeProps) {
return (
<span
aria-hidden="true"
className={cn("sk-shape block h-3 rounded", className)}
style={w ? { ...style, width: w } : style}
/>
);
}
/** A placeholder for an avatar or a round button: give it a size (w-8 h-8). */
export function Circle({ className, style }: Omit<ShapeProps, "w">) {
return <span aria-hidden="true" className={cn("sk-shape block shrink-0 rounded-full", className)} style={style} />;
}
/** A placeholder for a box: a button, an input, a card. */
export function Block({ className, w, style }: ShapeProps) {
return (
<span
aria-hidden="true"
className={cn("sk-shape block rounded-md", className)}
style={w ? { ...style, width: w } : style}
/>
);
}
/**
* Widths for placeholder text that look like text: varied, but the same on
* every render (no randomness, so server and client markup agree).
*/
export function textWidth(i: number, min = 40, spread = 45): string {
return `${min + ((i * 37 + 11) % spread)}%`;
}
/** `count` items from `render`, keyed by index. */
export function repeat(count: number, render: (i: number) => ReactNode): ReactNode[] {
const out: ReactNode[] = [];
for (let i = 0; i < count; i++) out.push(<Fragment key={i}>{render(i)}</Fragment>);
return out;
}
+150
View File
@@ -0,0 +1,150 @@
/**
* The sign-in page as it loads: the card with logo, heading, the user name
* and password fields, the 2FA link and the button, and the version line
* under it (app/(main)/[locale]/login/page.tsx, the default form). Class
* lists mirror the page's, so the form takes the card's place without a
* jump.
*
* What the card holds depends on /api/config, which arrives after the first
* paint: SSO-only, demo and dev mode, a server list, the remember-me and
* token switches, a hidden heading or subtitle and a custom logo size give a
* card of a different height. This draws the default form.
*
* Signed-in pages draw it too, for a visitor with nothing stored that could
* sign them back in (AppShellSkeleton, data-boot-auth="out").
*/
import { cn } from "@/lib/cn";
import { Bar, Block, SkeletonRegion } from "./kit";
/**
* A placeholder for one line of text in `className`'s font: exactly as tall
* as that line (a zero-width space sets it), with a bar across `w`
* of it. Give it a width class where it sits in a flex row.
*/
export function TextLine({ className, w, bar }: { className?: string; w: string; bar?: string }) {
return (
<span aria-hidden="true" className={cn("relative block", className)}>
{"\u200b"}
<Bar className={cn("absolute top-1/2 -translate-y-1/2", bar)} w={w} />
</span>
);
}
const CENTERED = "inset-x-0 mx-auto";
/** An empty text field with its label, as the login form draws them. */
function Field({ label }: { label: string }) {
return (
<div className="space-y-1.5">
<TextLine className="text-sm font-medium" w={label} />
<span aria-hidden="true" className="block h-11 rounded-xl border border-border/60 bg-muted/40" />
</div>
);
}
export interface LoginSkeletonProps {
/** common.loading */
label?: string;
/**
* Draw the accessibility button the login page has in its corner. The
* login page itself renders the real one (AccessibilityQuickPanelHost);
* a signed-in page drawing the card for a signed-out visitor doesn't.
*/
accessibilityButton?: boolean;
className?: string;
}
export function LoginSkeleton({ label, accessibilityButton = false, className }: LoginSkeletonProps) {
return (
<SkeletonRegion
surface="login"
label={label}
className={cn(
"min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-background via-muted/10 to-muted/30 relative px-4",
className,
)}
>
{/* The theme menu button: its icon, and the theme's name from sm up. */}
<div aria-hidden="true" className="absolute top-5 end-5">
<span className="flex items-center gap-2 px-3 py-2 rounded-xl border text-sm bg-background/60 border-border/50">
<Block className="w-4 h-4 rounded" />
<TextLine className="hidden sm:block w-11" w="100%" />
</span>
</div>
{accessibilityButton ? (
<span
aria-hidden="true"
className="fixed bottom-4 end-4 z-40 inline-flex items-center gap-2 rounded-md border border-border bg-background px-3 py-2 text-sm shadow-sm"
style={{ marginBottom: "env(safe-area-inset-bottom, 0px)" }}
>
<Block className="w-4 h-4 rounded" />
<TextLine className="w-[4.625rem]" w="100%" />
</span>
) : null}
<div className="w-full max-w-[400px] mx-auto">
<div className="rounded-2xl border border-border/60 bg-background/80 backdrop-blur-sm shadow-xl shadow-black/5 dark:shadow-black/20 overflow-hidden">
<div className="px-8 pt-10 pb-6 text-center">
<div aria-hidden="true" className="inline-flex items-center justify-center mb-5 w-16 h-16">
<Block className="w-16 h-16 rounded-2xl" />
</div>
<TextLine className="text-2xl font-semibold tracking-tight" w="9rem" bar={cn("h-6", CENTERED)} />
<TextLine className="text-sm mt-1.5" w="11rem" bar={CENTERED} />
</div>
<div className="px-8 pb-8">
<div className="space-y-5">
<div className="space-y-4">
<Field label="3rem" />
<Field label="4.5rem" />
{/* "I have a 2FA code": its icon and a line of small text. */}
<span aria-hidden="true" className="flex items-center gap-1.5 text-xs">
<Block className="w-3.5 h-3.5 rounded" />
<TextLine className="w-24" w="100%" />
</span>
</div>
<Block className="w-full h-11 rounded-xl" />
</div>
</div>
</div>
{/* The version line under the card. */}
<div className="mt-6 flex flex-col items-center gap-2">
<TextLine className="text-xs w-12" w="100%" />
</div>
</div>
</SkeletonRegion>
);
}
/**
* The admin sign-in page (app/(main)/admin/login/page.tsx): no card, the
* logo, the fixed English heading and line, one password field and the
* button. The page itself renders in full on the server; this is for
* the places that draw a page by its surface.
*/
export function AdminLoginSkeleton({ label, className }: LoginSkeletonProps) {
return (
<SkeletonRegion
surface="admin-login"
label={label}
className={cn("min-h-screen flex items-center justify-center bg-background px-4", className)}
>
<div aria-hidden="true" className="w-full max-w-sm">
<div className="flex flex-col items-center mb-8">
<Block className="w-12 h-12 rounded-xl mb-4" />
<span className="text-xl font-semibold text-foreground">Admin Dashboard</span>
<span className="text-sm text-muted-foreground mt-1">Enter your admin password to continue</span>
</div>
<div className="space-y-4">
<div>
<span className="block text-sm font-medium text-foreground mb-1.5">Password</span>
<span className="block h-10 w-full rounded-md border border-input bg-background" />
</div>
<Block className="w-full h-10" />
</div>
</div>
</SkeletonRegion>
);
}
+700
View File
@@ -0,0 +1,700 @@
/**
* 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 {
/** common.loading */
label?: string;
/** Rail labels for the phone bottom bar (sidebar.*). */
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.
*/
/**
* The reader's top toolbar while a message loads. The loaded reader lifts its
* toolbar above the message row on a tablet or desktop (EmailViewer's
* `liftToolbar`); its loading state draws this there too, so the row below
* doesn't move when the message arrives.
*/
export function MailMessageToolbarSkeleton() {
const button = "h-8 w-20 rounded-md";
return (
<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>
);
}
export function MailMessageSkeleton({ className, toolbar = true }: { className?: string; toolbar?: boolean }) {
return (
<div aria-hidden="true" className={cn("flex-1 min-w-0 flex flex-col h-full bg-background overflow-hidden", className)}>
{toolbar ? <MailMessageToolbarSkeleton /> : null}
<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 (
<>
{/* 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. `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
bottomBarClassName="max-md:boot-open:hidden"
>
<MailSkeletonBody />
</AppShellSkeleton>
);
}
@@ -0,0 +1,44 @@
import { cn } from "@/lib/cn";
import { Bar } from "./kit";
import { MV_CARD_ROW } from "./contacts-skeleton";
import { AppShellSkeleton, type RailLabels } from "./shell";
import { PhoneActionsSkeleton } from "./toolbar-skeleton";
export interface PluginPageSkeletonProps {
/** common.loading */
label?: string;
/** Rail labels for the phone bottom bar (sidebar.*). */
labels?: RailLabels;
}
/**
* A plugin page's own content (components/plugins/plugin-page-app.tsx).
* Nothing about the page is known before its plugin starts, not even its
* title, so this is the frame's column: the phone header (PhoneAppHeader,
* below 48rem) and an empty content area, which is also what the page shows
* while the plugins start. `embedded` is the Pro pane variant: no header.
*/
export function PluginPageSkeletonBody({ embedded = false }: { embedded?: boolean }) {
return (
<div aria-hidden="true" className={cn("flex flex-col flex-1 min-w-0", embedded && "h-full")} data-embedded={embedded ? "" : undefined}>
{embedded ? null : (
<div className="flex md:hidden items-center gap-2 h-14 shrink-0 border-b border-border bg-background px-4">
<span className="flex items-center h-7 min-w-0">
<Bar className="h-4" w="7rem" />
</span>
<PhoneActionsSkeleton className="flex" />
</div>
)}
<div className="flex flex-col flex-1 min-h-0" />
</div>
);
}
/** Skeleton of a plugin page (/apps/<plugin>/<page>). */
export function PluginPageSkeleton({ label, labels }: PluginPageSkeletonProps) {
return (
<AppShellSkeleton surface="apps" label={label} labels={labels} railBelow="md" mvMenu={false} rowClassName={MV_CARD_ROW}>
<PluginPageSkeletonBody />
</AppShellSkeleton>
);
}
+144
View File
@@ -0,0 +1,144 @@
/**
* The Pro shell as a skeleton (app/(main)/[locale]/pro/page.tsx): the rail,
* then one or two panes, each a tab strip (components/pro/pro-tab-bar.tsx)
* over the active tab's page drawn embedded.
*
* The server can't know the stored tabs, so it draws every variant and CSS
* picks one from what the boot script read out of `pro-tabs`
* (data-boot-pro-*, see the "pro" section of app/boot-skeleton.css): how many
* tabs each pane has, which is active, which pane has focus, whether the
* split pane shows and on which side, and which page each pane shows.
*/
import type { CSSProperties } from "react";
import { cn } from "@/lib/cn";
import type { BootSurface } from "@/lib/boot-skeleton/surfaces";
import { Bar, Block, SkeletonRegion, repeat, textWidth } from "./kit";
import { SkeletonBody } from "./bodies";
import { LoginSkeleton } from "./login-skeleton";
import { RailSkeleton } from "./shell";
export interface ProSkeletonProps {
/** common.loading */
label?: string;
}
const LINE = "1px solid rgba(128, 128, 128, 0.3)";
const STRIP_BORDER: CSSProperties = { borderBottom: LINE };
const DIVIDER: CSSProperties = { borderInlineStart: LINE };
/** Most tabs a strip draws; the boot script caps its count to match. */
const MAX_TABS = 8;
const BODIES: BootSurface[] = ["mail", "calendar", "contacts", "files", "settings"];
/** ProTabBar: up to eight tabs, shown by CSS from the stored count. */
function TabStripSkeleton() {
return (
<div
aria-hidden="true"
className="relative flex items-stretch h-9 bg-secondary px-1 overflow-hidden flex-shrink-0"
style={STRIP_BORDER}
>
{repeat(MAX_TABS, (i) => (
<span
data-sk-pro-tab=""
className={cn(
"relative flex items-center gap-1.5 px-3 h-9 text-sm",
"min-w-0 flex-1 basis-0 max-w-[200px] [min-width:80px]",
"border-e border-border first:border-s",
)}
>
<Block className="w-4 h-4 flex-shrink-0 rounded-sm" />
<span className="flex-1 min-w-0">
<Bar className="h-3 max-w-full" w={textWidth(i, 50, 30)} />
</span>
{/* The active tab's bottom edge hides the strip's border; the
focused pane's active tab gets the accent top edge. */}
<span className="absolute inset-x-0 -bottom-px h-px" style={{ backgroundColor: "inherit" }} />
<span
className="absolute inset-x-0 top-0 h-0.5"
style={{ backgroundColor: "var(--sk-pro-tab-accent, transparent)" }}
/>
</span>
))}
<span className="flex-1 min-w-[8px]" />
</div>
);
}
/** One pane: its tab strip over the active tab's page. */
function PaneSkeleton({ pane, bodies, className }: { pane: "main" | "split"; bodies: BootSurface[]; className?: string }) {
return (
<div data-sk-pro-pane={pane} className={cn("flex flex-1 flex-col overflow-hidden min-w-0 min-h-0", className)}>
<TabStripSkeleton />
<div className="@container relative flex flex-1 flex-col overflow-hidden min-w-0 min-h-0">
{bodies.map((surface) => (
<div key={surface} data-sk-pro-body={surface} className="absolute inset-0 overflow-hidden">
<div className="flex h-full min-h-0 overflow-hidden">
<SkeletonBody surface={surface} embedded />
</div>
</div>
))}
{/* A message, folder, search or compose tab: no page skeleton of its own. */}
<div data-sk-pro-body="other" className="absolute inset-0 overflow-hidden bg-background" />
</div>
</div>
);
}
/**
* The Pro frame alone. AppShellSkeleton draws it in place of a standard
* page's frame when Pro is about to take that page over; `data-pro-shell`
* keeps a skeleton inside the real Pro shell from doing that again.
*/
export function ProFrameSkeleton({
label,
className,
surface,
}: ProSkeletonProps & {
className?: string;
/**
* Set when this frame stands in for a standard page Pro is about to take
* over. Its panes are then drawn empty (tab strips only): the frame doesn't
* move on the hop to /pro, and the page's body, which would otherwise be in
* every standard page's HTML a second and third time for the few who use
* Pro, fills in once /pro has loaded. /pro's own HTML draws the bodies.
*/
surface?: BootSurface;
}) {
const bodies = surface ? [] : BODIES;
return (
<SkeletonRegion
surface="pro"
label={label}
className={cn("flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]", className)}
>
<div data-pro-shell="" className="flex flex-1 overflow-hidden">
<RailSkeleton search className="flex" />
<div className="relative flex flex-row flex-1 overflow-hidden min-w-0">
<PaneSkeleton pane="main" bodies={bodies} />
<div
aria-hidden="true"
className="hidden boot-pro-split:block boot-pro-split-left:-order-1 flex-shrink-0 w-px"
style={DIVIDER}
/>
<PaneSkeleton pane="split" bodies={bodies} className="hidden boot-pro-split:flex boot-pro-split-left:order-first" />
</div>
</div>
</SkeletonRegion>
);
}
/**
* Skeleton of the Pro page, for its gate and the server HTML of /pro. A
* visitor with nothing stored that could sign them back in gets the login
* card's shape, as on every signed-in page.
*/
export function ProSkeleton({ label }: ProSkeletonProps) {
return (
<>
<div className="hidden boot-out:block">
<LoginSkeleton label={label} />
</div>
<ProFrameSkeleton label={label} className="boot-out:hidden" />
</>
);
}
@@ -0,0 +1,50 @@
import type { BootSurface } from "@/lib/boot-skeleton/surfaces";
import type { RailLabels } from "./shell";
import { MailSkeleton } from "./mail-skeleton";
import { CalendarSkeleton } from "./calendar-skeleton";
import { ContactsSkeleton } from "./contacts-skeleton";
import { FilesSkeleton } from "./files-skeleton";
import { PluginPageSkeleton } from "./plugin-page-skeleton";
import { SettingsSkeleton } from "./settings-skeleton";
import { AdminSkeleton } from "./admin-skeleton";
import { ProSkeleton } from "./pro-skeleton";
import { AdminLoginSkeleton, LoginSkeleton } from "./login-skeleton";
import { SetupSkeleton } from "./setup-skeleton";
interface RouteSkeletonProps {
surface: BootSurface;
label?: string;
labels?: RailLabels;
}
/**
* The skeleton of a whole page, picked by surface. Used where the page is not
* the one drawing it: a connector link (its destination), the protocol
* handlers, a Lite page waiting for its message catalog, loading.tsx.
*/
export function RouteSkeleton({ surface, label, labels }: RouteSkeletonProps) {
switch (surface) {
case "mail":
return <MailSkeleton label={label} labels={labels} />;
case "calendar":
return <CalendarSkeleton label={label} labels={labels} />;
case "contacts":
return <ContactsSkeleton label={label} labels={labels} />;
case "files":
return <FilesSkeleton label={label} labels={labels} />;
case "apps":
return <PluginPageSkeleton label={label} labels={labels} />;
case "settings":
return <SettingsSkeleton label={label} labels={labels} />;
case "pro":
return <ProSkeleton label={label} />;
case "admin":
return <AdminSkeleton label={label} labels={labels} />;
case "admin-login":
return <AdminLoginSkeleton label={label} />;
case "login":
return <LoginSkeleton label={label} />;
case "setup":
return <SetupSkeleton label={label} />;
}
}
@@ -0,0 +1,303 @@
import { ArrowLeft, ChevronRight, LogOut, MoreVertical, Search, Settings } from "@/components/icons";
import { SETTINGS_NAV_GROUPS, SETTINGS_TAB_ICONS } from "@/components/settings/settings-tab-icons";
import type { SettingsSearchTab } from "@/lib/settings-search";
import { cn } from "@/lib/cn";
import { Bar, Block, Circle, repeat, textWidth } from "./kit";
import { AppShellSkeleton, type RailLabels } from "./shell";
export interface SettingsSkeletonProps {
/** common.loading */
label?: string;
/** Rail labels for the phone bottom bar (sidebar.*). */
labels?: RailLabels;
}
/**
* Rows the nav may leave out, hidden when the last session's nav didn't have
* them (`data-boot-settings-tabs`, app/boot-skeleton.css). Calendar and files
* also follow the rail's hints. Recovery is an Enterprise feature, so it is
* only drawn when the hints say it was there.
*/
const OPTIONAL: Partial<Record<SettingsSearchTab, string>> = {
security: "boot-no-settings-security:hidden",
sharing: "boot-no-settings-sharing:hidden",
filters: "boot-no-settings-filters:hidden",
vacation: "boot-no-settings-vacation:hidden",
recovery: "hidden boot-settings-recovery:flex",
calendar: "boot-no-calendar:hidden boot-no-settings-calendar:hidden",
contacts: "boot-no-settings-contacts:hidden",
files: "boot-no-files:hidden boot-no-settings-files:hidden",
addons: "boot-no-settings-addons:hidden",
};
/** A line of text as tall as its line box, so rows keep the real height. */
function Line({ className, bar, w }: { className?: string; bar: string; w: string }) {
return (
<span className={cn("flex items-center h-[1lh]", className)}>
<Bar className={bar} w={w} />
</span>
);
}
/** The search field (ToolbarSearch): an h-8 input with the magnifier at its start. */
function SearchField() {
return (
<span className="relative block">
<Search className="absolute start-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<span className="block h-8 w-full rounded-md border border-input bg-background" />
</span>
);
}
/** The Advanced switch row at the foot of the nav (AdvancedSettingsSwitch). */
function AdvancedRow() {
return (
<div className="flex items-center justify-between gap-2 px-3 py-2 text-sm">
<Line bar="h-3" w="7rem" />
<Block className="h-6 w-11 rounded-full" />
</div>
);
}
/** The desktop nav (settings-app.tsx, "Desktop layout"). */
function SettingsNav({ embedded }: { embedded: boolean }) {
let row = 0;
return (
<div
aria-hidden="true"
className="border-e border-border bg-secondary flex flex-col shrink-0"
style={{ width: "var(--boot-settings-nav-w, 256px)" }}
>
{embedded ? null : (
// Back to mail; the Pro interface has none.
<div className="border-b border-border boot-pro:hidden">
<div className="flex items-center h-14 px-2">
<span className="flex items-center w-full h-9 px-3 text-sm">
<ArrowLeft className="w-4 h-4 me-2" />
<Bar className="h-3" w="6rem" />
</span>
</div>
</div>
)}
<div className="flex-1 min-h-0 overflow-hidden py-2">
<div className="px-3 pt-1 pb-1">
<SearchField />
</div>
<div className="px-3 space-y-0.5">
{SETTINGS_NAV_GROUPS.map(({ group, tabs }, groupIndex) => (
<div key={group}>
{groupIndex > 0 ? <div className="mx-1 my-2 border-t border-border" /> : null}
{group === "mail" || group === "apps" ? (
<div className="px-3 pt-2.5 pb-1">
{/* An inline span in the real nav: the line box is the parent's. */}
<Line bar="h-2" w="3rem" />
</div>
) : null}
{tabs.map((tab) => {
const Icon = SETTINGS_TAB_ICONS[tab];
const i = row++;
return (
<div
key={tab}
data-sk-settings-tab={tab}
className={cn(
"flex w-full py-2 pe-3 ps-3 rounded-md text-sm items-center gap-2.5 text-muted-foreground",
// The open page, from data-boot-settings-tab (app/boot-skeleton.css).
"boot-settings-active:bg-accent boot-settings-active:text-accent-foreground",
OPTIONAL[tab],
)}
>
<Icon className="w-4 h-4 shrink-0" />
<Line className="flex-1 min-w-0" bar="h-3" w={textWidth(i, 35, 35)} />
</div>
);
})}
</div>
))}
</div>
</div>
<div className="border-t border-border px-1 py-1">
<AdvancedRow />
</div>
</div>
);
}
/** A settings page: the section jump row, then sections of rows (settings-section.tsx). */
function SettingsPage({ sections }: { sections: number }) {
return (
<>
{/* The section jump row; none on a page with one section, two lines on
Accessibility (app/boot-skeleton.css). */}
<div className="flex flex-wrap gap-1.5 mb-6 boot-settings-one-section:hidden">
{repeat(4, (i) => (
<Block className="h-8" w={`${4.5 + ((i * 3) % 4)}rem`} />
))}
<Block className="h-8 basis-full hidden boot-settings-two-rows:block" />
</div>
<div className="space-y-8">
{repeat(sections, (s) => (
<div className="space-y-4">
<div>
<Line className="text-lg" bar="h-4" w={textWidth(s, 20, 20)} />
<Line className="text-sm mt-1" bar="h-3" w={textWidth(s + 3, 45, 35)} />
</div>
<div className="space-y-4">
{repeat(3, (r) => (
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 py-3 border-b border-border last:border-0">
<div className="flex-1 min-w-0 sm:pe-4">
<Line className="text-sm" bar="h-3" w={textWidth(s * 3 + r, 25, 25)} />
<Line className="text-xs mt-1" bar="h-2.5" w={textWidth(s * 3 + r + 5, 45, 40)} />
</div>
<Block className={r % 2 ? "h-6 w-11 rounded-full" : "h-8 w-28"} />
</div>
))}
</div>
</div>
))}
</div>
</>
);
}
/** A phone header row: back arrow, title, then the ⋮ menu and the avatar (PhoneHeaderActions). */
function PhoneHeader({ list }: { list: boolean }) {
return (
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
<span className="grid place-items-center h-11 w-11 shrink-0">
<ArrowLeft className="w-5 h-5" />
</span>
{list ? (
<span className="flex items-center gap-2">
<Settings className="w-5 h-5 text-muted-foreground" />
<Line className="text-lg" bar="h-4" w="5rem" />
</span>
) : (
<Line className="text-lg" bar="h-4" w="7rem" />
)}
<span className="flex items-center gap-1 flex-shrink-0 ms-auto">
<span className="grid place-items-center h-11 w-11">
<MoreVertical className="w-5 h-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>
);
}
/** The phone and tablet list of pages (settings-app.tsx, "Mobile layout"). */
function PhoneList() {
let row = 0;
return (
<div aria-hidden="true" className="flex flex-col flex-1 min-h-0 boot-open:hidden">
<PhoneHeader list />
<div className="flex-1 min-h-0 overflow-hidden">
<div className="px-4 pt-3 pb-1">
<SearchField />
</div>
<div className="py-2">
{SETTINGS_NAV_GROUPS.map(({ group, tabs }, groupIndex) => (
<div key={group}>
{groupIndex > 0 ? <div className="mx-5 my-2 border-t border-border" /> : null}
{group === "mail" || group === "apps" ? (
<div className="px-5 pt-3 pb-1.5">
{/* An inline span in the real nav: the line box is the parent's. */}
<Line bar="h-2" w="3rem" />
</div>
) : null}
{tabs.map((tab) => {
const Icon = SETTINGS_TAB_ICONS[tab];
const i = row++;
return (
<div
key={tab}
className={cn(
"flex w-full items-center justify-between py-3.5 pe-5 ps-5 text-sm text-muted-foreground",
OPTIONAL[tab],
)}
>
<span className="flex items-center gap-3 flex-1 min-w-0">
<Icon className="w-4 h-4 shrink-0" />
<Line className="flex-1" bar="h-3" w={textWidth(i, 30, 30)} />
</span>
<ChevronRight className="w-4 h-4 shrink-0" />
</div>
);
})}
</div>
))}
</div>
<div className="border-t border-border px-2 py-2">
<AdvancedRow />
</div>
<div className="border-t border-border px-5 py-3">
<span className="w-full flex items-center gap-3 py-2.5 text-sm text-destructive px-2">
<LogOut className="w-4 h-4" />
<Line bar="h-3" w="4rem" />
</span>
</div>
</div>
</div>
);
}
/** One settings page on the phone or tablet, when the URL names one. */
function PhonePage() {
return (
<div aria-hidden="true" className="hidden boot-open:flex flex-col flex-1 min-h-0">
<PhoneHeader list={false} />
<div className="flex-1 min-h-0 overflow-hidden p-4">
<SettingsPage sections={2} />
</div>
</div>
);
}
/**
* The settings 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); it has no
* back-to-mail box either, and the Pro shell is desktop only.
*/
export function SettingsSkeletonBody({ embedded = false }: { embedded?: boolean }) {
return (
<div
className={cn("flex-1 min-w-0 min-h-0 flex", embedded ? "h-full" : "flex-col lg:flex-row")}
data-embedded={embedded ? "" : undefined}
>
<div className={cn("flex-1 min-w-0 min-h-0", embedded ? "flex" : "hidden lg:flex")}>
<SettingsNav embedded={embedded} />
<div aria-hidden="true" className="flex-1 min-w-0 overflow-hidden">
<div className="max-w-3xl mx-auto px-6 py-6">
<SettingsPage sections={3} />
</div>
</div>
</div>
{embedded ? null : (
<div className="flex flex-col flex-1 min-h-0 lg:hidden">
<PhoneList />
<PhonePage />
</div>
)}
</div>
);
}
/** Skeleton of the settings page. */
export function SettingsSkeleton({ label, labels }: SettingsSkeletonProps) {
return (
<AppShellSkeleton
surface="settings"
label={label}
labels={labels}
railBelow="lg"
active="settings"
// Mountain View: the page sits on a card inset from the canvas.
rowClassName="lg:mv:mb-4 lg:mv:mx-4 lg:mv:rounded-[var(--mv-card-radius)] lg:mv:bg-background"
>
<SettingsSkeletonBody />
</AppShellSkeleton>
);
}
+109
View File
@@ -0,0 +1,109 @@
/**
* The setup wizard as it loads (app/(main)/setup/page.tsx): the header, the
* seven-step progress bar and the card with the welcome step, which a fresh
* install opens on. The wizard asks /api/setup/status first; a wizard
* session that already passed the token goes on to a later step, whose card
* is taller.
*
* The page passes its translated strings, so the frame is drawn for real;
* without them (a connector link to /setup) it is drawn in bars.
*/
import { cn } from "@/lib/cn";
import { SETUP_STEPS } from "@/lib/setup/steps";
import { SkeletonRegion, repeat } from "./kit";
import { TextLine } from "./login-skeleton";
export interface SetupSkeletonStrings {
title: string;
subtitle: string;
/** One label per step, in order. */
steps: readonly string[];
welcomeTitle: string;
welcomeBody: string;
tokenLabel: string;
tokenPlaceholder: string;
continueLabel: string;
}
export interface SetupSkeletonProps {
/** common.loading */
label?: string;
strings?: SetupSkeletonStrings;
className?: string;
}
const CENTERED = "inset-x-0 mx-auto";
export function SetupSkeleton({ label, strings, className }: SetupSkeletonProps) {
return (
<SkeletonRegion
surface="setup"
label={label}
className={cn("min-h-screen flex items-center justify-center px-4 py-8", className)}
>
<div aria-hidden="true" className="w-full max-w-2xl">
<div className="text-center mb-6">
{strings ? (
<>
<span className="block text-2xl font-semibold">{strings.title}</span>
<span className="block text-sm text-muted-foreground mt-1">{strings.subtitle}</span>
</>
) : (
<>
<TextLine className="text-2xl font-semibold" w="16rem" bar={cn("h-6", CENTERED)} />
<TextLine className="text-sm mt-1" w="20rem" bar={CENTERED} />
</>
)}
</div>
<div className="mb-6">
<div className="flex items-center gap-1.5 text-xs">
{repeat(SETUP_STEPS.length, (i) => (
<div className="flex-1">
<div className={cn("h-1 rounded-full", i === 0 ? "bg-primary/60" : "bg-muted")} />
{strings?.steps[i] ? (
<div className={cn("mt-1.5 text-center", i === 0 ? "text-foreground font-medium" : "text-muted-foreground")}>
{strings.steps[i]}
</div>
) : (
<TextLine className="mt-1.5" w="60%" bar={CENTERED} />
)}
</div>
))}
</div>
</div>
<div className="bg-card border border-border rounded-lg p-6 shadow-sm">
<div className="space-y-4">
{strings ? (
<>
<span className="block text-lg font-semibold">{strings.welcomeTitle}</span>
<span className="block text-sm text-muted-foreground">{strings.welcomeBody}</span>
<div>
<span className="block text-sm font-medium mb-1">{strings.tokenLabel}</span>
<span className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-muted-foreground">
{strings.tokenPlaceholder}
</span>
</div>
{/* Disabled until a token is typed in, as on the page. */}
<span className="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium opacity-50">
{strings.continueLabel}
</span>
</>
) : (
<>
<TextLine className="text-lg font-semibold" w="6rem" />
<TextLine className="text-sm" w="85%" />
<div>
<TextLine className="text-sm font-medium mb-1" w="6rem" />
<span className="block h-10 w-full rounded-md border border-input bg-background" />
</div>
<TextLine className="text-sm w-24" w="100%" bar="h-9 rounded-md" />
</>
)}
</div>
</div>
</div>
</SkeletonRegion>
);
}
+315
View File
@@ -0,0 +1,315 @@
/**
* The app frame every signed-in page shares, as a skeleton: the page root, the
* Mountain View top bar, the vertical rail and the phone bottom bar. Class
* lists mirror the real ones (components/layout/navigation-rail.tsx,
* components/layout/mv-top-bar.tsx and the roots of mail-app, calendar-app,
* contacts-app, files-app, settings-app), so the frame doesn't move when the
* page takes over.
*/
import type { ReactNode } from "react";
import {
Accessibility,
Bell,
CalendarEvent,
Folder,
Grid3x3,
Keyboard,
Mail,
Menu,
Plus,
Search,
Settings,
Shield,
Users,
} from "@/components/icons";
import { cn } from "@/lib/cn";
import { Bar, Circle, SkeletonRegion, type SkeletonSurface } from "./kit";
import { LoginSkeleton } from "./login-skeleton";
import { ProFrameSkeleton } from "./pro-skeleton";
import { PRO_SURFACES, type BootSurface } from "@/lib/boot-skeleton/surfaces";
export type RailApp = "mail" | "calendar" | "contacts" | "files" | "settings";
/** A rail item drawn as current: an app, or the admin panel (NavigationRail adminActive). */
export type RailActive = RailApp | "admin";
/** Translated rail labels for the phone bottom bar; bars are drawn where one is missing. */
export type RailLabels = Partial<Record<RailApp | "admin", string>>;
const RAIL_BORDER = { borderRight: "1px solid rgba(128, 128, 128, 0.3)" } as const;
const DIVIDER = { borderColor: "rgba(128, 128, 128, 0.3)" } as const;
/** Hidden when the last session's rail didn't show the app (bulwark-boot-hints). */
const APP_HIDE: Record<"calendar" | "contacts" | "files", string> = {
calendar: "boot-no-calendar:hidden",
contacts: "boot-no-contacts:hidden",
files: "boot-no-files:hidden",
};
const APPS = [
{ id: "mail", icon: Mail },
{ id: "calendar", icon: CalendarEvent },
{ id: "contacts", icon: Users },
{ id: "files", icon: Folder },
] as const;
/**
* The 56px vertical rail (NavigationRail collapsed). `className` decides at
* which widths it shows. `search` draws the global search button the Pro
* shell's rail starts with. Items carry `data-sk-rail` so the Pro frame can
* mark the focused tab's app from CSS.
*/
export function RailSkeleton({
active,
search = false,
className,
}: {
active?: RailActive;
search?: boolean;
className?: string;
}) {
const item = (on: boolean) =>
cn(
"relative flex items-center justify-center w-10 h-10 rounded-md max-lg:min-h-[44px]",
on ? "bg-primary/10 text-primary" : "text-muted-foreground",
);
const footerItem = "flex items-center justify-center w-10 h-10 rounded-md";
return (
<div aria-hidden="true" className={cn("w-14 bg-secondary flex-col flex-shrink-0", className)} style={RAIL_BORDER}>
<div className="flex flex-col h-full items-center">
<div className="flex flex-col flex-1 min-h-0 items-center gap-1 py-3 px-1">
{search ? (
<span className={item(false)}>
<Search className="w-[18px] h-[18px] flex-shrink-0" />
</span>
) : null}
{APPS.map(({ id, icon: Icon }) => (
<span key={id} data-sk-rail={id} className={cn(item(active === id), id !== "mail" && APP_HIDE[id])}>
<Icon className={cn("w-[18px] h-[18px] flex-shrink-0", active === id && "text-primary")} />
</span>
))}
<span className={cn(item(false), "hidden boot-rail-add-app:flex")}>
<Plus className="w-[18px] h-[18px] flex-shrink-0" />
</span>
</div>
<div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1 text-muted-foreground">
<span className={cn(footerItem, active === "admin" ? "bg-primary/10 text-primary" : "hidden boot-rail-admin:flex")}>
<Shield className="w-[18px] h-[18px]" />
</span>
<span className={footerItem}>
<Bell className="w-[18px] h-[18px]" />
</span>
<span className={cn(footerItem, "hidden boot-rail-a11y:flex")}>
<Accessibility className="w-[18px] h-[18px]" />
</span>
<span data-sk-rail="settings" className={cn(footerItem, active === "settings" && "bg-primary/10 text-primary")}>
<Settings className="w-[18px] h-[18px]" />
</span>
<div className="w-8 border-t" style={DIVIDER} />
<span className={footerItem}>
<Keyboard className="w-[18px] h-[18px]" />
</span>
{/* The admin panel's rail has no mailbox: no quota, no account. */}
{active === "admin" ? null : (
<>
<Circle className="hidden boot-rail-quota:block w-8 h-8" />
<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>
</>
)}
</div>
</div>
</div>
);
}
/**
* The phone/tablet bottom bar (NavigationRail horizontal). `className` decides
* at which widths it shows.
*/
export function BottomBarSkeleton({
active,
labels,
className,
}: {
active?: RailActive;
labels?: RailLabels;
className?: string;
}) {
// The admin panel's own bar has its entry before Settings.
const items: { id: RailActive; icon: typeof Mail }[] = [
...APPS,
...(active === "admin" ? [{ id: "admin" as const, icon: Shield }] : []),
{ id: "settings", icon: Settings },
];
return (
<div
aria-hidden="true"
className={cn(
"items-center bg-background border-t border-border shrink-0 overflow-hidden pb-[calc(env(safe-area-inset-bottom)/2)]",
"mv:min-h-16 mv:border-t-0 mv:bg-secondary mv:shadow-[0_-1px_3px_rgb(0_0_0/0.08)]",
className,
)}
>
{items.map(({ id, icon: Icon }) => (
<span
key={id}
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
active === id ? "text-primary" : "text-muted-foreground",
(id === "calendar" || id === "contacts" || id === "files") && APP_HIDE[id],
)}
>
<span className={cn("relative", active === id && "mv:min-w-14 mv:rounded-full mv:bg-primary/15 mv:px-4 mv:py-[3px]")}>
<Icon className="w-5 h-5" />
{active === id ? (
<span className="mv:hidden absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
) : null}
</span>
{labels?.[id] ? (
<span className="text-[0.625rem] font-medium leading-tight truncate max-w-full">{labels[id]}</span>
) : (
<Bar className="h-2 my-px" w="2rem" />
)}
</span>
))}
</div>
);
}
/** Mountain View's 64px top bar (components/layout/mv-top-bar.tsx), desktop only. */
export function MvTopBarSkeleton({
search = true,
menu = true,
className,
}: {
search?: boolean;
/** The sidebar toggle before the brand (MvTopBar showMenuButton). */
menu?: boolean;
className?: string;
}) {
const round = "grid place-items-center w-12 h-12 rounded-full text-muted-foreground";
return (
<div aria-hidden="true" className={cn("items-center h-16 gap-2 ps-1 pe-4 shrink-0 bg-[var(--mv-canvas)]", className)}>
<div className={cn("flex items-center gap-1 shrink-0", !menu && "ps-3")}>
{menu ? (
<span className={round}>
<Menu className="w-5 h-5" />
</span>
) : null}
<span className="flex items-center gap-2 pe-4 min-w-0">
<Bar className="h-5" w="7rem" />
</span>
</div>
{search ? (
<div className="relative flex items-center flex-1 min-w-0 max-w-[720px]">
<span className="block w-full h-[var(--mv-search-h,48px)] rounded-[var(--mv-search-radius,8px)] bg-[var(--mv-search-bg)]" />
</div>
) : null}
<div className="flex-1 min-w-0" />
<div className="flex items-center gap-1 shrink-0">
<span className={round}>
<Bell className="w-5 h-5" />
</span>
<span className={round}>
<Keyboard className="w-5 h-5" />
</span>
<span className={cn(round, "hidden boot-rail-admin:grid")}>
<Shield className="w-5 h-5" />
</span>
<span className={round}>
<Settings className="w-5 h-5" />
</span>
<span className={round}>
<Grid3x3 className="w-5 h-5" />
</span>
{/* The account switcher (AccountSwitcher variant="header"). */}
<span className="flex items-center justify-center w-10 h-10 pointer-coarse:w-11 pointer-coarse:h-11 ms-1">
<Circle className="w-8 h-8" />
</span>
</div>
</div>
);
}
interface AppShellSkeletonProps {
surface: SkeletonSurface;
/** The page's own "Loading" string (common.loading). */
label?: string;
/** Rail item drawn as current. */
active?: RailApp;
/**
* Below which width the vertical rail gives way to the bottom bar: mail and
* settings switch at 64rem, calendar, contacts, files and plugin pages at 48rem.
*/
railBelow: "md" | "lg";
labels?: RailLabels;
/** Whether the Mountain View top bar holds the search field (mail does). */
mvSearch?: boolean;
/** Whether the Mountain View top bar starts with the sidebar toggle (MvTopBar showMenuButton). */
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;
}
/**
* Page root plus frame. `children` go in the row next to the rail: the page's
* own sidebar, list and panes.
*
* When nothing is stored that could sign the visitor back in, the boot script
* marks the page `data-boot-auth="out"` and the login card is drawn instead:
* that is where the app is about to send them.
*
* When the Pro interface is about to take the page over (`data-boot-pro="on"`:
* the setting is on and the screen is a desktop), the Pro frame is drawn
* instead, so the hop to /pro moves nothing. The login card wins over it: a
* signed-out visitor ends up on the login page either way.
*/
export function AppShellSkeleton({
surface,
label,
active,
railBelow,
labels,
mvSearch = false,
mvMenu = true,
rowClassName,
bottomBarClassName,
children,
}: AppShellSkeletonProps) {
return (
<>
<div className="hidden boot-out:block">
<LoginSkeleton label={label} accessibilityButton />
</div>
{(PRO_SURFACES as readonly string[]).includes(surface) ? (
<div className="hidden boot-pro-frame:block">
<ProFrameSkeleton label={label} surface={surface as BootSurface} />
</div>
) : null}
<SkeletonRegion
surface={surface}
label={label}
className="flex flex-col overflow-hidden pt-[env(safe-area-inset-top)] h-dvh bg-background lg:mv:bg-[var(--mv-canvas)] boot-out:hidden boot-pro-frame:hidden"
>
<MvTopBarSkeleton search={mvSearch} menu={mvMenu} className="hidden lg:mv:flex" />
<div className={cn("flex flex-1 min-h-0 overflow-hidden", rowClassName)}>
<RailSkeleton
active={active}
className={railBelow === "lg" ? "hidden lg:flex lg:mv:hidden" : "hidden md:flex lg:mv:hidden"}
/>
{children}
</div>
<BottomBarSkeleton
active={active}
labels={labels}
className={cn(railBelow === "lg" ? "flex lg:hidden" : "flex md:hidden", bottomBarClassName)}
/>
</SkeletonRegion>
</>
);
}
@@ -0,0 +1,62 @@
/**
* Header-row pieces shared by the contacts, files and plugin page skeletons,
* drawn at the sizes of the real controls in components/ui/toolbar.tsx and
* components/layout/phone-header-actions.tsx. Plain spans: nothing here is
* focusable, and the caller's region is aria-hidden.
*/
import type { ComponentType } from "react";
import { MoreVertical, Search } from "@/components/icons";
import { cn } from "@/lib/cn";
import { Bar, Circle } from "./kit";
type Icon = ComponentType<{ className?: string }>;
/** A 32px toolbar icon button (ToolbarButton, IconButton icon-sm). */
export function ToolIcon({ icon: Icon, className }: { icon: Icon; className?: string }) {
return (
<span className={cn("grid place-items-center w-8 h-8 shrink-0 rounded-md text-muted-foreground", className)}>
<Icon className="w-4 h-4" />
</span>
);
}
/**
* The 32px search field (ToolbarSearch): the magnifier, a placeholder bar,
* the "/" key cap where there is a keyboard, and an optional trailing icon.
*/
export function SearchFieldSkeleton({ trailing, className }: { trailing?: Icon; className?: string }) {
const Trailing = trailing;
return (
<span className={cn("relative flex items-center h-8 rounded-md border border-input bg-background ps-8", className)}>
<Search className="absolute start-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Bar className="h-3" w="6rem" />
<span className="absolute end-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5">
<span className="mx-1 rounded border border-border px-1.5 font-sans text-[0.6875rem] leading-4 text-muted-foreground pointer-coarse:hidden">
/
</span>
{Trailing ? (
<span className="p-1 rounded-md text-muted-foreground">
<Trailing className="w-4 h-4" />
</span>
) : null}
</span>
</span>
);
}
/**
* The end of a phone header (PhoneHeaderActions): the overflow menu and the
* account avatar. `className` decides at which widths it shows.
*/
export function PhoneActionsSkeleton({ className }: { className?: string }) {
return (
<span className={cn("items-center gap-1 flex-shrink-0 ms-auto", className)}>
<span className="grid place-items-center w-11 h-11 rounded-md text-muted-foreground">
<MoreVertical className="w-5 h-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>
);
}
@@ -0,0 +1,24 @@
"use client";
import { useTranslations } from "next-intl";
import type { RailLabels } from "./shell";
/**
* The few strings a page skeleton shows: the status message and the phone
* bottom bar's labels. For the gates of client pages, which have the message
* catalog; the skeleton components themselves stay free of next-intl.
*/
export function useSkeletonLabels(): { label: string; labels: RailLabels } {
const t = useTranslations();
return {
label: t("common.loading"),
labels: {
mail: t("sidebar.mail"),
calendar: t("sidebar.calendar"),
contacts: t("sidebar.contacts"),
files: t("sidebar.files"),
settings: t("sidebar.settings"),
admin: t("sidebar.admin"),
},
};
}
@@ -0,0 +1,109 @@
import { render } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Calendar } from '@/lib/jmap/types';
import { useCalendarStore } from '@/stores/calendar-store';
import { useAuthStore } from '@/stores/auth-store';
import { useSettingsStore } from '@/stores/settings-store';
// The calendar page before its data is there: the boot skeleton while the
// session is restored, then the view's grid in the same shapes while the
// calendars load (components/boot-skeleton/calendar-skeleton.tsx).
vi.mock('@/hooks/use-media-query', () => ({
useIsMobile: () => false,
useIsDesktop: () => true,
useIsTablet: () => false,
useMediaQuery: () => false,
}));
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null }));
vi.mock('@/components/layout/inline-app-view', () => ({ InlineAppView: () => null }));
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null }));
vi.mock('@/hooks/use-refresh-gesture', () => ({ useRefreshGesture: () => ({ indicator: null }) }));
vi.mock('@/hooks/use-calendar-push-refresh', () => ({ useCalendarPushRefresh: () => {} }));
vi.mock('@/hooks/use-account-principal-emails', () => ({ useAccountPrincipalEmails: () => [] }));
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ demoMode: false }) }));
import { CalendarApp } from '../calendar-app';
vi.setConfig({ testTimeout: 30000 });
const RIGHTS = {
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true,
};
const calendars = [{
id: 'cal', name: 'Personal', color: '#123456', isDefault: true, sortOrder: 0, description: null,
isSubscribed: true, isVisible: true, includeInAvailability: 'all', defaultAlertsWithTime: null,
defaultAlertsWithoutTime: null, timeZone: null, shareWith: null, myRights: RIGHTS,
}] as unknown as Calendar[];
const client = {
getAccountId: () => 'acc',
getMaxParticipantsPerEvent: () => null,
supportsCalendars: () => true,
queryCalendarEvents: vi.fn().mockResolvedValue([]),
getCalendarEvent: vi.fn().mockResolvedValue(null),
getCalendarTasks: vi.fn().mockResolvedValue([]),
};
const noop = () => vi.fn().mockResolvedValue(undefined);
beforeEach(() => {
useSettingsStore.setState({ enableCalendarTasks: true, proInterface: false } as never);
useCalendarStore.setState({
calendars: [], events: [], supportsCalendar: false, selectedCalendarIds: [], viewMode: 'week',
selectedDate: new Date(2026, 10, 4), isLoading: false, isLoadingEvents: false, error: null,
fetchCalendars: noop(), fetchEvents: noop(), refreshAllSubscriptions: noop(),
} as never);
});
const bootSkeleton = (root: HTMLElement) => root.querySelector('[data-boot-skeleton="calendar"]');
const viewSkeleton = (root: HTMLElement) => root.querySelector('[data-calendar-view-skeleton]');
describe('CalendarApp before its data is there', () => {
it('draws the calendar skeleton while the sign-in is restored', () => {
// Still checking: no redirect to the login page yet.
useAuthStore.setState({ isAuthenticated: false, client: null, isLoading: true, checkAuth: noop() } as never);
const { container } = render(<CalendarApp />);
const skeleton = bootSkeleton(container);
expect(skeleton).not.toBeNull();
expect(skeleton?.getAttribute('aria-busy')).toBe('true');
expect(skeleton?.querySelector('[role="status"]')?.textContent).toBe('common.loading');
// The toolbar's words, from the calendar's own messages.
expect(skeleton?.textContent).toContain('views.today');
});
it('draws the skeleton while the session has not said the account has calendars', () => {
useAuthStore.setState({ isAuthenticated: true, client: client as never, isLoading: false, activeAccountId: 'acc' } as never);
const { container } = render(<CalendarApp />);
expect(bootSkeleton(container)).not.toBeNull();
expect(container.querySelector('main')).toBeNull();
});
it('draws the grid of the view in the skeleton shapes while the calendars load', () => {
useAuthStore.setState({ isAuthenticated: true, client: client as never, isLoading: false, activeAccountId: 'acc' } as never);
useCalendarStore.setState({ supportsCalendar: true, isLoading: true, viewMode: 'month' } as never);
const { container } = render(<CalendarApp />);
// The real chrome is up; only the grid waits.
expect(bootSkeleton(container)).toBeNull();
expect(container.querySelector('main')).not.toBeNull();
const grid = viewSkeleton(container);
expect(grid?.getAttribute('data-calendar-view-skeleton')).toBe('month');
expect(grid?.querySelector('[role="status"]')?.textContent).toBe('status.loading_calendars');
expect(container.querySelector('.animate-spin')).toBeNull();
});
it('draws the real grid, without a spinner over it, while the first events load', () => {
useAuthStore.setState({ isAuthenticated: true, client: client as never, isLoading: false, activeAccountId: 'acc' } as never);
useCalendarStore.setState({
supportsCalendar: true, calendars, selectedCalendarIds: ['cal'], isLoadingEvents: true,
} as never);
const { container } = render(<CalendarApp />);
expect(viewSkeleton(container)).toBeNull();
expect(container.querySelector('[role="grid"]')).not.toBeNull();
expect(container.querySelector('.animate-spin')).toBeNull();
expect(Array.from(container.querySelectorAll('[role="status"]')).some((s) => s.textContent === 'status.loading_events')).toBe(true);
});
});
+75 -29
View File
@@ -21,7 +21,9 @@ import { toast } from "@/stores/toast-store";
import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
import { useAccountPrincipalEmails } from "@/hooks/use-account-principal-emails";
import { Button } from "@/components/ui/button";
import { PaneSpinner, Spinner } from "@/components/ui/spinner";
import { CalendarSkeleton, CalendarSkeletonBody, CalendarViewSkeleton, calendarSkeletonText } from "@/components/boot-skeleton/calendar-skeleton";
import { SkeletonRegion } from "@/components/boot-skeleton/kit";
import { useSkeletonLabels } from "@/components/boot-skeleton/use-skeleton-labels";
import { CalendarToolbar } from "@/components/calendar/calendar-toolbar";
import { CalendarMonthView } from "@/components/calendar/calendar-month-view";
import { CalendarWeekView } from "@/components/calendar/calendar-week-view";
@@ -30,6 +32,8 @@ import { CalendarAgendaView } from "@/components/calendar/calendar-agenda-view";
import { TaskListView, filterTasksForList } from "@/components/calendar/task-list-view";
import { CalendarLoadErrorNotice } from "@/components/calendar/calendar-load-error-notice";
import { TaskToolbar } from "@/components/calendar/task-toolbar";
import { PluginTaskView, usePluginTaskView } from "@/components/plugins/plugin-task-view";
import { onOpenTask } from "@/lib/plugin-sandbox/open-task";
import { TaskModal } from "@/components/calendar/task-modal";
import { MiniCalendar } from "@/components/calendar/mini-calendar";
import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel";
@@ -150,6 +154,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
const describeError = useCalendarErrorMessage();
const tSidebar = useTranslations("sidebar");
useDocumentTitle(tSidebar("calendar"));
const skeletonLabels = useSkeletonLabels();
const tWebcalAction = useTranslations("calendar.webcal_action");
const { demoMode } = useConfig();
// Subscriptions fetch the feed through /api/fetch-ical, which the demo has no
@@ -758,6 +763,15 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
setShowTaskModal(true);
}), [closeEventPanelForTask, whenEditorsDiscarded]);
// A plugin drawing the task view opens a task in the editor
// (api.ui.openTask), as a click in Bulwark's list does.
useEffect(() => onOpenTask((taskId) => {
const task = useTaskStore.getState().tasks.find((t) => t.id === taskId);
if (!task) return false;
openEditTaskModal(task);
return true;
}), [openEditTaskModal]);
const handleToggleTaskComplete = useCallback((task: CalendarTask) => {
if (!client) return;
// The checkbox shows the result; only a failure needs saying.
@@ -1764,6 +1778,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
return withCompany;
}, [events, selectedCalendarIds, showBirthdayCalendar, birthdayEvents, companyEvents]);
// The plugin drawing the task view's list, if one offers it.
const pluginTaskView = usePluginTaskView();
// The tasks the task list shows; plugin buttons in its toolbar act on them.
const listedTaskIds = useMemo(
() => filterTasksForList(taskStore.tasks, selectedCalendarIds, taskStore.filter).map((task) => task.id),
@@ -1845,13 +1861,27 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
</Dialog>
) : null;
if (!isAuthenticated) return null;
// Until the session says what the account has, the page is drawn as the
// skeleton the server sent (components/boot-skeleton): the same markup, so
// nothing moves while the sign-in is restored or the redirects decide.
const skeletonText = calendarSkeletonText(t);
const skeleton = isEmbedded ? (
<SkeletonRegion surface="calendar" label={skeletonLabels.label} className="flex flex-col h-full">
<CalendarSkeletonBody embedded text={skeletonText} />
</SkeletonRegion>
) : (
<CalendarSkeleton label={skeletonLabels.label} labels={skeletonLabels.labels} text={skeletonText} />
);
if (!isAuthenticated) return skeleton;
// Turned off by policy: nothing, while the effect above leaves for mail.
if (!calendarEnabled) return null;
if (!supportsCalendar) return renderWebcalAccountPicker();
if (!supportsCalendar) return renderWebcalAccountPicker() ?? skeleton;
const renderView = () => {
// The calendars are loading: the view's grid in the skeleton's shapes,
// where the real one is about to be drawn.
if (isLoading && calendars.length === 0) {
return <PaneSpinner label={t("status.loading_calendars")} className="flex-1" />;
return <CalendarViewSkeleton view={normalizedViewMode} label={t("status.loading_calendars")} text={skeletonText} />;
}
const viewContent = (() => {
@@ -1968,7 +1998,30 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
onCreateEvent={() => openCreateModal()}
/>
);
case "tasks":
case "tasks": {
const taskList = (
<TaskListView
tasks={taskStore.tasks}
calendars={displayCalendars}
selectedCalendarIds={selectedCalendarIds}
filter={taskStore.filter}
onSelectTask={openEditTaskModal}
onToggleComplete={handleToggleTaskComplete}
selectedTaskId={taskStore.selectedTaskId}
onQuickCreate={(title) => {
// Into a calendar that takes it: the default one, else the
// first the user may write to.
const target = writableTaskCalendars.find((c) => c.isDefault) ?? writableTaskCalendars[0];
if (!client || !target) {
toast.error(t("notifications.task_save_error"));
return;
}
taskStore.createTask(client, { "@type": "Task", title, progress: "needs-action", calendarIds: { [target.id]: true } })
.then((created) => { if (created) toast.success(t("notifications.task_created")); })
.catch((error: unknown) => toast.error(describeError(error, t("notifications.task_save_error"))));
}}
/>
);
return (
<div className="flex flex-col h-full">
{taskStore.error === TASKS_LOAD_ERROR && (
@@ -1981,29 +2034,21 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
taskIds={listedTaskIds}
compact={isMobile}
/>
<TaskListView
tasks={taskStore.tasks}
calendars={displayCalendars}
selectedCalendarIds={selectedCalendarIds}
filter={taskStore.filter}
onSelectTask={openEditTaskModal}
onToggleComplete={handleToggleTaskComplete}
selectedTaskId={taskStore.selectedTaskId}
onQuickCreate={(title) => {
// Into a calendar that takes it: the default one, else the
// first the user may write to.
const target = writableTaskCalendars.find((c) => c.isDefault) ?? writableTaskCalendars[0];
if (!client || !target) {
toast.error(t("notifications.task_save_error"));
return;
}
taskStore.createTask(client, { "@type": "Task", title, progress: "needs-action", calendarIds: { [target.id]: true } })
.then((created) => { if (created) toast.success(t("notifications.task_created")); })
.catch((error: unknown) => toast.error(describeError(error, t("notifications.task_save_error"))));
}}
/>
{/* A plugin may draw the list itself (the calendar-task-view
slot), with Bulwark's list back while its frame is not
drawn; the toolbar above stays Bulwark's. */}
{pluginTaskView ? (
<PluginTaskView
pluginId={pluginTaskView.pluginId}
fill={pluginTaskView.fill}
filter={taskStore.filter}
calendarIds={selectedCalendarIds}
fallback={taskList}
/>
) : taskList}
</div>
);
}
}
})();
@@ -2013,10 +2058,11 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
<CalendarLoadErrorNotice onRetry={retryEventsLoad} />
)}
{viewContent}
{/* The first events: the grid is drawn already and they fill in on
it, so this is said rather than shown (a dimmed grid with a
spinner flashed between the skeleton and the events). */}
{isLoadingEvents && calendars.length > 0 && events.length === 0 && (
<div className="absolute inset-0 bg-background/50 flex items-center justify-center pointer-events-none">
<Spinner label={t("status.loading_events")} />
</div>
<span role="status" className="sr-only">{t("status.loading_events")}</span>
)}
</div>
);
@@ -1,5 +1,8 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { renderToString } from 'react-dom/server';
import { TARGET_NAMES } from '@/lib/connector/registry';
import { CONNECTOR_SURFACES } from '@/lib/boot-skeleton/surfaces';
// A connector link that can't be opened explains why in the reader's
// language (keys in tests), not in English only.
@@ -27,3 +30,21 @@ describe('connector link page', () => {
window.history.replaceState(null, '', '/');
});
});
describe('connector link page while it resolves', () => {
// The server HTML is what shows until the redirect: the skeleton of the
// page the link opens, with the opening text as its status message.
it.each(TARGET_NAMES)('%s draws its destination skeleton', (target) => {
const html = renderToString(<ConnectorClient target={target} />);
const surface = CONNECTOR_SURFACES[target];
expect(html).toContain(`data-boot-skeleton="${surface}"`);
expect(html).toContain('role="status"');
expect(html).toContain('opening');
});
it('keeps the plain text for a target it does not know', () => {
const html = renderToString(<ConnectorClient target="constructor" />);
expect(html).not.toContain('data-boot-skeleton');
expect(html).toContain('opening');
});
});
@@ -7,6 +7,8 @@ import { resolveTarget, type ResolveResult } from '@/lib/connector/registry';
import { appPath } from '@/lib/deep-links';
import { replaceWindowLocation } from '@/lib/browser-navigation';
import { productName } from '@/lib/product-name';
import { CONNECTOR_SURFACES } from '@/lib/boot-skeleton/surfaces';
import { RouteSkeleton } from '@/components/boot-skeleton/route-skeleton';
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '';
@@ -49,6 +51,12 @@ export function ConnectorClient({ target }: { target: string }) {
}, [destination]);
if (result === null || result.kind === 'ok') {
// The page the link opens, as its skeleton: the boot script has already
// set that page's variant attributes from the link's parameters, so the
// hard redirect lands on the same shapes. No bottom bar labels: this page
// is English only and the destination may not be.
const surface = Object.hasOwn(CONNECTOR_SURFACES, target) ? CONNECTOR_SURFACES[target] : null;
if (surface) return <RouteSkeleton surface={surface} label={t('opening')} />;
return <Shell>{t('opening')}</Shell>;
}
@@ -77,6 +77,19 @@ describe('ContactList', () => {
expect(screen.getByTestId('contact-list-skeleton')).toBeInTheDocument();
});
it('draws the loading rows at the list density, as the page skeleton does', () => {
const avatars = () => [...screen.getByTestId('contact-list-skeleton').querySelectorAll('.rounded-full')];
act(() => { useSettingsStore.setState({ density: 'extra-compact', groupContactsByLetter: false }); });
const { unmount } = render(<ContactList {...defaultProps} contacts={[]} isLoading />);
expect(avatars().length).toBeGreaterThan(0);
expect(avatars().every((el) => el.classList.contains('hidden'))).toBe(true);
unmount();
act(() => { useSettingsStore.setState({ density: 'regular', groupContactsByLetter: true }); });
render(<ContactList {...defaultProps} contacts={[]} isLoading />);
expect(avatars().some((el) => el.classList.contains('hidden'))).toBe(false);
});
it('shows the contacts as soon as they are there, even while loading', () => {
render(<ContactList {...defaultProps} isLoading />);
expect(screen.queryByTestId('contact-list-skeleton')).toBeNull();
+3 -11
View File
@@ -21,6 +21,7 @@ import { getContactDisplayName, getContactSortName, hasContactPhoto } from "@/st
import { useSettingsStore } from "@/stores/settings-store";
import { isAppShortcutBlocked } from "@/lib/keyboard";
import { isSingleKeyShortcutOff } from "@/lib/single-key-shortcuts";
import { ContactRowsSkeleton } from "@/components/boot-skeleton/contacts-skeleton";
type TriState = boolean | null;
@@ -651,17 +652,8 @@ export function ContactList({
className="flex-1 overflow-y-auto"
>
{sorted.length === 0 && isLoading && !searchQuery && activeFilters === 0 ? (
<div aria-hidden className="py-1" data-testid="contact-list-skeleton">
{Array.from({ length: 8 }, (_, i) => (
<div key={i} className="flex items-center gap-3 px-4 py-2.5">
<div className="w-9 h-9 rounded-full bg-muted animate-pulse shrink-0" />
<div className="flex-1 min-w-0 space-y-1.5">
<div className="h-3.5 rounded bg-muted animate-pulse" style={{ width: `${45 + ((i * 29) % 35)}%` }} />
<div className="h-3 rounded bg-muted/70 animate-pulse" style={{ width: `${30 + ((i * 17) % 30)}%` }} />
</div>
</div>
))}
</div>
// The rows the page skeleton drew, at the same density.
<ContactRowsSkeleton density={density} letters={groupByLetter} />
) : sorted.length === 0 ? (
searchQuery || activeFilters > 0 ? (
<EmptyState
+19 -3
View File
@@ -53,6 +53,8 @@ import { useDeepLinkUrl } from "@/hooks/use-deep-link-url";
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
import { useDocumentTitle } from "@/hooks/use-document-title";
import { ContactsSkeleton, ContactsSkeletonBody } from "@/components/boot-skeleton/contacts-skeleton";
import { useSkeletonLabels } from "@/components/boot-skeleton/use-skeleton-labels";
import { useRegisterCommands } from "@/lib/command-registry";
import { useCloseOnBack } from "@/hooks/use-close-on-back";
import { useLeaveGuard } from "@/lib/leave-guard";
@@ -90,6 +92,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
// Static Lite build: the route params are empty, read the link from the URL.
const linkSegments = useLiteLinkSegments('contacts', routeSegments);
const t = useTranslations("contacts");
const skeleton = useSkeletonLabels();
// Organisation > Devices > No printing: no Print buttons (Ctrl+P prints a note).
const printOff = useStrictestDevices().noPrint;
const tSidebar = useTranslations("sidebar");
@@ -956,7 +959,12 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
}
};
if (!isAuthenticated) return null;
// Until the session is restored: the page skeleton the server drew.
if (!isAuthenticated) {
return isEmbedded
? <ContactsSkeletonBody embedded />
: <ContactsSkeleton label={skeleton.label} labels={skeleton.labels} />;
}
const renderRightPanel = () => {
// The directory is read-only: its panel shows the open entry whatever the view.
@@ -1392,7 +1400,9 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
{!printOff && <ContextMenuItem icon={Printer} label={tCommon("print")} onClick={() => { close(); void requestPrint({ kind: "contact-list" }); }} />}
</>
)}
isLoading={supportsSync && !contactsLoaded}
// Until the session is back, nobody knows yet whether the server
// keeps contacts: show the rows loading, not an empty book.
isLoading={!contactsLoaded && (supportsSync || !client)}
onPrint={printOff ? undefined : () => { void requestPrint({ kind: "contact-list" }); }}
printTitle={printContent?.kind === "list" ? listTitle : null}
/>
@@ -1566,8 +1576,14 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
// prerender (static Lite build); at runtime it never suspends.
export function ContactsApp(props: ContactsAppProps = {}) {
return (
<Suspense fallback={null}>
// The static Lite export writes this fallback into the page's HTML.
<Suspense fallback={<ContactsFallback />}>
<ContactsAppContent {...props} />
</Suspense>
);
}
function ContactsFallback() {
const { label, labels } = useSkeletonLabels();
return <ContactsSkeleton label={label} labels={labels} />;
}
@@ -49,6 +49,8 @@ const settings = vi.hoisted(() => ({
emptySubjectWarningEnabled: false,
sendDelaySeconds: 0,
signaturePosition: 'below_quote',
signatureNewMailEnabled: true,
signatureReplyForwardEnabled: true,
signatureSeparatorEnabled: false,
requestReadReceiptDefault: false,
sendConfirmation: false,
@@ -95,6 +95,8 @@ vi.mock('@/stores/settings-store', () => {
// The default position: replies/forwards get the signature appended at
// send time rather than embedded above the quote.
signaturePosition: 'below_quote',
signatureNewMailEnabled: true,
signatureReplyForwardEnabled: true,
signatureSeparatorEnabled: true,
requestReadReceiptDefault: false,
addTrustedSender: () => {},
@@ -95,6 +95,8 @@ vi.mock('@/stores/settings-store', () => {
// The default position: replies/forwards get the signature appended at
// send time rather than embedded above the quote.
signaturePosition: 'below_quote',
signatureNewMailEnabled: true,
signatureReplyForwardEnabled: true,
signatureSeparatorEnabled: true,
requestReadReceiptDefault: false,
addTrustedSender: () => {},
@@ -95,6 +95,8 @@ vi.mock('@/stores/settings-store', () => {
// The default position: replies/forwards get the signature appended at
// send time rather than embedded above the quote.
signaturePosition: 'below_quote',
signatureNewMailEnabled: true,
signatureReplyForwardEnabled: true,
signatureSeparatorEnabled: true,
requestReadReceiptDefault: false,
addTrustedSender: () => {},
@@ -257,3 +259,40 @@ describe('composer signature follows the picked identity', () => {
expect(sent.body).toBe('Plain draft');
});
});
describe('signature auto-insert toggles', () => {
afterEach(() => {
useSettingsStore.setState({
plainTextMode: false,
signatureNewMailEnabled: true,
signatureReplyForwardEnabled: true,
});
vi.clearAllMocks();
});
it('keeps a new message free of the signature when the new-mail toggle is off', async () => {
useSettingsStore.setState({ plainTextMode: true, signatureNewMailEnabled: false });
const onSend = vi.fn();
render(<EmailComposer initialData={{ ...DRAFT, body: 'Draft text' }} onSend={onSend} />);
// Not embedded into the editable body…
await waitFor(() => expect(textarea().value).toBe('Draft text'));
// …and not appended at send time either.
const sent = await send(onSend);
expect(sent.body).toBe('Draft text');
});
it('sends a reply without appending the signature when the reply toggle is off', async () => {
useSettingsStore.setState({ signatureReplyForwardEnabled: false });
const onSend = vi.fn();
render(<EmailComposer mode="reply" initialData={{ ...DRAFT, mode: 'reply' }} onSend={onSend} />);
// No read-only signature preview under the editor: it would promise a
// signature that never goes out.
expect(document.body.textContent).not.toContain('Alice');
const sent = await send(onSend);
expect(sent.htmlBody).not.toContain('Alice');
expect(sent.body).not.toContain('Alice');
});
});
@@ -136,6 +136,8 @@ vi.mock('@/stores/settings-store', () => {
// The default position: replies/forwards get the signature appended at
// send time rather than embedded above the quote.
signaturePosition: 'below_quote',
signatureNewMailEnabled: true,
signatureReplyForwardEnabled: true,
signatureSeparatorEnabled: true,
requestReadReceiptDefault: false,
addTrustedSender: () => {},
@@ -96,6 +96,8 @@ vi.mock('@/stores/settings-store', () => {
// The default position: replies/forwards get the signature appended at
// send time rather than embedded above the quote.
signaturePosition: 'below_quote',
signatureNewMailEnabled: true,
signatureReplyForwardEnabled: true,
signatureSeparatorEnabled: true,
requestReadReceiptDefault: false,
addTrustedSender: () => {},
@@ -123,6 +123,8 @@ vi.mock('@/stores/settings-store', () => {
// The default position: replies/forwards get the signature appended at
// send time rather than embedded above the quote.
signaturePosition: 'below_quote',
signatureNewMailEnabled: true,
signatureReplyForwardEnabled: true,
signatureSeparatorEnabled: true,
requestReadReceiptDefault: false,
addTrustedSender: () => {},
@@ -304,6 +306,33 @@ describe('reply signature above the quote (#842)', () => {
expect(html).toContain('<i>Sales team</i>');
expect(html.indexOf('<i>Sales team</i>')).toBeLessThan(html.indexOf('reply_line'));
});
// #1503: with the delimiter on, a reply started with the "-- " line, so
// there was no line above the signature to type the reply into.
it('leaves an empty line above the delimiter', () => {
useSettingsStore.setState({ signaturePosition: 'above_quote' });
setIdentities([ME]);
render(<EmailComposer mode="reply" replyTo={TO_ME} />);
const html = screen.getByTestId('rich-text-editor').getAttribute('data-html')!;
expect(html.startsWith('<p></p><p data-signature-block="separator">-- </p>')).toBe(true);
});
it('leaves an empty line above a delimiter that arrives with late identities', async () => {
useSettingsStore.setState({ signaturePosition: 'above_quote' });
setIdentities([]);
const props = { mode: 'reply' as const, replyTo: TO_ME };
const { rerender } = render(<EmailComposer {...props} />);
setIdentities([ME]);
rerender(<EmailComposer {...props} />);
await waitFor(() => expect(editorState.html).toContain('<b>Alice</b>'));
const lines = new DOMParser().parseFromString(editorState.html, 'text/html').body.children;
expect(lines[0].tagName).toBe('P');
expect(lines[0].textContent).toBe('');
expect(lines[0].hasAttribute('data-signature-block')).toBe(false);
expect(lines[1].getAttribute('data-signature-block')).toBe('separator');
});
});
it('still signs a reply below the quote at send time by default', async () => {
@@ -0,0 +1,247 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import React from 'react';
import type { Editor } from '@tiptap/core';
import { EmailComposer } from '../email-composer';
import { useAuthStore } from '@/stores/auth-store';
// The real composer around the real editor: a template's data: image goes
// out as an inline cid: part, not as a data: URI (#1490).
// A 1×1 PNG.
const PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
const TEMPLATE = vi.hoisted(() => ({
id: 'tpl-1',
name: 'Offer',
category: '',
subject: 'Your offer',
body: '<p>Your apartment</p><p><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" alt="Floor plan"></p>',
isHTML: true,
isFavorite: false,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
}));
vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null }));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
// The picker hands the composer a template, as choosing one does. It takes
// the focus away from the editor first, as the real dialog does.
vi.mock('@/components/templates/template-picker', () => ({
TemplatePicker: ({ onSelect }: { onSelect: (t: unknown, v: Record<string, string>) => void }) =>
React.createElement('button', { 'data-testid': 'pick-template', onClick: () => onSelect(TEMPLATE, {}) }, 'pick'),
}));
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
vi.mock('@/hooks/use-focus-trap', () => ({
useFocusTrap: () => ({ current: null }),
}));
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
}));
// ─── Store mocks ──────────────────────────────────────────────────────────────
vi.mock('@/stores/auth-store', () => {
const state = {
client: null,
identities: [],
primaryIdentity: null,
isAuthenticated: false,
isDemoMode: false,
activeAccountId: null,
connectionLost: false,
getClientForAccount: () => undefined,
getAllConnectedClients: () => new Map(),
syncIdentities: () => {},
refreshIdentities: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAuthStore: hook };
});
vi.mock('@/stores/identity-store', () => {
const state = {
identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me', htmlSignature: '<p>Cheers, Me</p>' }],
defaultIdentityId: 'id-me',
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useIdentityStore: hook };
});
vi.mock('@/stores/account-store', () => {
const state = { accounts: [], getAccountById: () => undefined };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAccountStore: hook };
});
vi.mock('@/stores/email-store', () => {
const state = {
draftSaveEnabled: false,
sendRawEmail: async () => ({ sent: true }),
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useEmailStore: hook };
});
vi.mock('@/stores/settings-store', () => {
const state = {
timeFormat: '24h',
plainTextMode: false,
subAddressDelimiter: '+',
autoSelectReplyIdentity: true,
attachmentReminderEnabled: false,
attachmentReminderKeywords: [],
emptySubjectWarningEnabled: true,
recipientMentionsEnabled: false,
sendDelaySeconds: 0,
signaturePosition: 'above_quote',
signatureSeparatorEnabled: false,
requestReadReceiptDefault: false,
externalContentPolicy: 'block',
isSenderTrusted: () => false,
addTrustedSender: () => {},
trustedSendersAddressBook: null,
updateSetting: () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useSettingsStore: hook, registerTemplateSyncBridge: () => {} };
});
vi.mock('@/stores/contact-store', () => {
const state = {
contacts: [],
getAutocomplete: async () => [],
addToTrustedSendersBook: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useContactStore: hook };
});
// ─── Misc dependency mocks ────────────────────────────────────────────────────
vi.mock('@/stores/toast-store', () => ({
toast: { info: () => {}, error: () => {}, success: () => {} },
}));
vi.mock('@/lib/plugin-hooks', () => ({
identityHooks: {
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
onIdentitySelect: { size: 0, emit: async () => {} },
},
templateHooks: {
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
onTemplateApply: { size: 0, emit: async () => {} },
},
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
getRecipientSuggestions: { call: async () => [] },
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
onComposeSend: { takeOver: async () => 'default' },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
},
contactHooks: {
search: { call: async () => [] },
onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
},
}));
vi.mock('@/lib/email-threading', () => ({
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
}));
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
// ─── Tests ────────────────────────────────────────────────────────────────────
const DRAFT = {
to: 'guest@example.com',
cc: '',
bcc: '',
subject: '',
body: '<p></p>',
showCc: false,
showBcc: false,
selectedIdentityId: 'id-me',
subAddressTag: '',
mode: 'compose' as const,
draftId: null,
};
const nextFrame = () => act(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
describe('applying a template with a data: image (#1490)', () => {
const range = Range.prototype as unknown as Record<string, unknown>;
const doc = document as unknown as Record<string, unknown>;
const client = {
uploadBlob: vi.fn(async (_file: File) => ({ blobId: 'blob-plan' })),
hasDelayedSend: () => false,
getMaxDelayedSend: () => 0,
};
beforeAll(() => {
range.getClientRects = () => [];
range.getBoundingClientRect = () => ({ x: 0, y: 0, width: 0, height: 0, top: 0, right: 0, bottom: 0, left: 0 });
doc.elementFromPoint = () => null;
useAuthStore.setState({ client: client as never });
});
afterAll(() => {
delete range.getClientRects;
delete range.getBoundingClientRect;
delete doc.elementFromPoint;
useAuthStore.setState({ client: null });
});
afterEach(() => {
vi.clearAllMocks();
});
it('uploads the image and sends it as an inline part shown by cid', async () => {
const onSend = vi.fn();
render(<EmailComposer initialData={DRAFT} onSend={onSend} onClose={() => {}} />);
await waitFor(() => {
const el = document.querySelector('.ProseMirror') as (HTMLElement & { editor?: Editor }) | null;
expect(el?.editor).toBeTruthy();
});
await nextFrame();
act(() => { fireEvent.keyDown(window, { key: 't' }); });
fireEvent.click(await screen.findByTestId('pick-template'));
await waitFor(() => expect(screen.queryByTestId('pick-template')).toBeNull());
await waitFor(() => expect(document.querySelector('.ProseMirror img')).not.toBeNull());
expect(client.uploadBlob).toHaveBeenCalledTimes(1);
const file = client.uploadBlob.mock.calls[0][0];
expect(file.type).toBe('image/png');
expect(new Uint8Array(await file.arrayBuffer())).toEqual(Uint8Array.from(atob(PNG), (c) => c.charCodeAt(0)));
fireEvent.click(screen.getAllByTestId('composer-send')[0]);
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
const { htmlBody, attachments } = onSend.mock.calls[0][0];
const inline = attachments?.find((a: { disposition?: string }) => a.disposition === 'inline');
expect(inline).toMatchObject({ blobId: 'blob-plan', type: 'image/png' });
expect(htmlBody).toContain(`src="cid:${inline.cid}"`);
expect(htmlBody).not.toContain('data:image');
});
});
@@ -107,6 +107,8 @@ vi.mock('@/stores/settings-store', () => {
recipientMentionsEnabled: false,
sendDelaySeconds: 0,
signaturePosition: 'above_quote',
signatureNewMailEnabled: true,
signatureReplyForwardEnabled: true,
signatureSeparatorEnabled: false,
requestReadReceiptDefault: false,
externalContentPolicy: 'block',
@@ -131,6 +131,33 @@ describe('the reader toolbar and the plugin side panel', () => {
expect(document.querySelector('[data-plugin-detail-panel]')).toBeTruthy();
});
it('loads a message with its toolbar where the loaded reader puts it', () => {
// The loading state lifts the toolbar above the message row too, so the
// row stays put when the message arrives: the same element at the same
// place (a moved row counts as a layout shift).
const view = (props: { email: Email | null; isLoading: boolean }) => (
<EmailViewer mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onDelete={() => {}} {...props} />
);
const { container, rerender } = render(view({ email: null, isLoading: true }));
const root = container.firstElementChild as HTMLElement;
expect(root.children).toHaveLength(2);
const row = root.children[1];
expect(row.className).toContain('flex-row');
expect(root.children[0].querySelector('.sk-shape')).toBeTruthy();
rerender(view({ email, isLoading: false }));
expect(root.querySelector('[data-viewer-top-toolbar="full-width"]')).toBe(root.children[0]);
expect(root.children[1]).toBe(row);
});
it('loads a message on a phone with the toolbar in the message column', () => {
device.isMobile = true;
const { container } = render(
<EmailViewer email={null} isLoading mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onDelete={() => {}} />,
);
const root = container.firstElementChild as HTMLElement;
expect(root.children).toHaveLength(1);
});
it('keeps the toolbar in the message column on a phone', () => {
device.isMobile = true;
show();
+13 -1
View File
@@ -139,7 +139,19 @@ export function replaceEmbeddedSignature(
? children.find((node) => node.textContent?.includes(options.quoteHeader!))
: undefined;
const quote = header ?? children.find(isQuoteBoundary) ?? null;
removed = { parent: doc.body, insertBefore: quote };
// With no line above it, the signature would open the body and leave no
// place to type (#1503). Put an empty one first, as getInitialBody does,
// and keep the bare line breaks for the gap down to the quote.
const above = quote ? children.slice(0, children.indexOf(quote)) : children;
const hasLineAbove = above.some((node) => node.nodeType === 1
? (node as Element).tagName !== 'BR'
: !!node.textContent?.trim());
if (!hasLineAbove) {
removed = { parent: doc.body, insertBefore: above[0] ?? quote };
signatureHtml = `<p></p>${signatureHtml}`;
} else {
removed = { parent: doc.body, insertBefore: quote };
}
}
const host = doc.createElement('div');
host.innerHTML = signatureHtml;
+73 -50
View File
@@ -103,7 +103,7 @@ import { OrgDisclaimerPreview } from "@/components/email/org-disclaimer-preview"
import "@/lib/org/outgoing";
import { SubAddressHelper } from "@/components/identity/sub-address-helper";
import { generateSubAddress } from "@/lib/sub-addressing";
import { substitutePlaceholders, spliceTemplateAboveSignature, composeBodyHasUserContent } from "@/lib/template-utils";
import { substitutePlaceholders, spliceTemplateAboveSignature, composeBodyHasUserContent, embedTemplateImages } from "@/lib/template-utils";
import { findUnfilledPlaceholders, stripEmbeddedSignatureHtml } from "@/lib/placeholder-check";
import enMessages from "@/locales/en/common.json";
import { TemplatePicker } from "@/components/templates/template-picker";
@@ -553,6 +553,13 @@ export function EmailComposer({
const updateSetting = useSettingsStore((state) => state.updateSetting);
const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds);
const signaturePosition = useSettingsStore((state) => state.signaturePosition);
const signatureNewMailEnabled = useSettingsStore((state) => state.signatureNewMailEnabled);
const signatureReplyForwardEnabled = useSettingsStore((state) => state.signatureReplyForwardEnabled);
// Whether automatic signature insertion is on for this composer mode: new
// mail (and re-opened drafts, which open in compose mode) follow the
// new-mail toggle, replies and forwards the reply/forward one. What the
// body already carries is never stripped - only the insert/append is gated.
const autoSignatureEnabled = mode === 'compose' ? signatureNewMailEnabled : signatureReplyForwardEnabled;
const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled);
const requestReadReceiptDefault = useSettingsStore((state) => state.requestReadReceiptDefault);
// A draft belongs to the account the composer was opened in. Switching
@@ -628,12 +635,13 @@ export function EmailComposer({
const shouldEmbedSignatureAboveQuote =
(mode === 'reply' || mode === 'replyAll' || mode === 'forward') &&
signaturePosition === 'above_quote' &&
signatureReplyForwardEnabled &&
hasInitialSignature;
// New-mail composes always embed the signature into the editor body so it's
// editable/removable (the previous read-only preview below the editor was
// never spec-correct - see #329). Compose mode also ignores any leftover
// `replyTo` from a still-selected email; getInitialBody short-circuits below.
const shouldEmbedSignatureInNewMail = mode === 'compose' && hasInitialSignature;
const shouldEmbedSignatureInNewMail = mode === 'compose' && signatureNewMailEnabled && hasInitialSignature;
// Format a single EmailAddress for display in the composer input
const toRecipient = (r: { name?: string; email?: string }): Recipient =>
@@ -777,10 +785,13 @@ export function EmailComposer({
: (from.email || from.name || tCommon('unknown')))
: tCommon('unknown');
// Like a new mail, an empty line above the signature gives the cursor a
// place for the reply. Without it the reply started on the "-- " line (#1503).
const signatureBlock = buildEmbeddedSignatureHtml(initialSignatureIdentity, {
embed: shouldEmbedSignatureAboveQuote,
separator: signatureSeparatorEnabled,
});
const head = `${prefix || (signatureBlock ? '<p></p>' : '')}${signatureBlock}`;
// Whether the quoted original may show its remote images in the editor:
// the viewer's rule, minus the per-message "load" click it doesn't know.
@@ -804,7 +815,7 @@ export function EmailComposer({
rewriteCidImagesForEditor(sanitizeEmailHtml(replyTo.htmlBody)),
{ remoteAllowed: quoteRemoteAllowed, showQuoteBorder: replyTo.quoteWrapInBlockquote !== false },
);
return `${prefix}${signatureBlock}<br>${replyHeader(replyTo.quoteHeaderHtml)}${island}`;
return `${head}<br>${replyHeader(replyTo.quoteHeaderHtml)}${island}`;
}
const escaped = replyTo.body
? replyTo.body.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')
@@ -813,7 +824,7 @@ export function EmailComposer({
const bodyHtml = wrap
? `<blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escaped}</blockquote>`
: escaped;
return `${prefix}${signatureBlock}<br>${replyHeader(replyTo.quoteHeaderHtml)}${bodyHtml}`;
return `${head}<br>${replyHeader(replyTo.quoteHeaderHtml)}${bodyHtml}`;
}
// Build quoted content as HTML
@@ -831,15 +842,15 @@ export function EmailComposer({
rewriteCidImagesForEditor(sanitizeEmailHtml(replyTo.htmlBody)),
{ remoteAllowed: quoteRemoteAllowed },
);
return `${prefix}${signatureBlock}<br>${replyHeader(`<div>${quoteHeader}</div>`)}${island}`;
return `${head}<br>${replyHeader(`<div>${quoteHeader}</div>`)}${island}`;
}
if (replyTo.body) {
const escapedOriginal = replyTo.body.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>');
if (mode === 'forward') {
return `${prefix}${signatureBlock}<br><br>${tQuote('forwarded_separator')}<br>${tQuote('from_label')}: ${escapeHtml(fromStrFull)}<br>${tQuote('date_label')}: ${escapeHtml(date)}<br>${tQuote('subject_label')}: ${escapeHtml(replyTo.subject || '')}<br><br>${escapedOriginal}`;
return `${head}<br><br>${tQuote('forwarded_separator')}<br>${tQuote('from_label')}: ${escapeHtml(fromStrFull)}<br>${tQuote('date_label')}: ${escapeHtml(date)}<br>${tQuote('subject_label')}: ${escapeHtml(replyTo.subject || '')}<br><br>${escapedOriginal}`;
} else if (mode === 'reply' || mode === 'replyAll') {
return `${prefix}${signatureBlock}<br><br>${replyHeader(`<p>${tQuote('reply_line', { date: escapeHtml(date), from: escapeHtml(fromStrFull) })}<br></p>`)}<blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escapedOriginal}</blockquote>`;
return `${head}<br><br>${replyHeader(`<p>${tQuote('reply_line', { date: escapeHtml(date), from: escapeHtml(fromStrFull) })}<br></p>`)}<blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escapedOriginal}</blockquote>`;
}
}
return prefix;
@@ -1326,6 +1337,9 @@ export function EmailComposer({
const isReplyLike = mode === 'reply' || mode === 'replyAll' || mode === 'forward';
if (!isReplyLike && mode !== 'compose') return;
if (isReplyLike && signaturePosition !== 'above_quote') return;
// With automatic insertion off the body never got a signature spliced
// in, so there is nothing to swap.
if (!autoSignatureEnabled) return;
if (plainTextMode) {
// A new plain-text message carries the signature at the end of the
@@ -1367,7 +1381,7 @@ export function EmailComposer({
// would re-run on unrelated identity-field changes and re-splice the
// signature into the live editor.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [signatureIdentity?.id, signatureContent, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode, pluginSignatureRevision]);
}, [signatureIdentity?.id, signatureContent, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode, pluginSignatureRevision, autoSignatureEnabled]);
const openScheduleDialog = useCallback(() => {
setShowScheduleDialog(true);
@@ -2112,6 +2126,40 @@ export function EmailComposer({
}
};
const handleImageUpload = useCallback(async (
file: File,
): Promise<{ src: string; cid: string } | null> => {
if (!client) return null;
try {
const readAsDataUrl = new Promise<string | null>((resolve) => {
const reader = new FileReader();
reader.onload = (e) => resolve((e.target?.result as string) ?? null);
reader.onerror = () => resolve(null);
reader.readAsDataURL(file);
});
const [{ blobId }, dataUrl] = await Promise.all([
(composerClientRef.current ?? client).uploadBlob(file),
readAsDataUrl,
]);
if (!dataUrl) throw new Error('Failed to read image as data URL');
const cid = `${generateUUID()}@webmail`;
inlineImagesRef.current.push({
cid,
blobId,
type: file.type || 'application/octet-stream',
name: file.name,
size: file.size,
dataUrl,
uploadedAt: Date.now(),
});
return { src: dataUrl, cid };
} catch (error) {
debug.error(`Failed to upload inline image ${file.name}:`, error);
toast.error(t('upload_failed', { filename: file.name }));
return null;
}
}, [client, t]);
const handleTemplateSelect = useCallback(async (template: EmailTemplate, filledValues: Record<string, string>) => {
let filledSubject = Object.keys(filledValues).length > 0
? substitutePlaceholders(template.subject, filledValues)
@@ -2134,6 +2182,8 @@ export function EmailComposer({
filledSubject = rendered.subject;
bodyContent = plainTextMode ? rendered.text : sanitizePluginBodyHtml(rendered.html);
}
// Its data: images go out as cid: parts, like pasted ones (#1490).
if (!plainTextMode) bodyContent = await embedTemplateImages(bodyContent, handleImageUpload);
if (mode === 'compose') {
// An empty template subject must not wipe one the user typed (#540).
@@ -2219,7 +2269,7 @@ export function EmailComposer({
setShowTemplatePicker(false);
notifyTemplateApplied(template);
}, [mode, plainTextMode, signatureIdentity, signatureSeparatorEnabled]);
}, [mode, plainTextMode, signatureIdentity, signatureSeparatorEnabled, handleImageUpload]);
useEffect(() => {
const handleTemplateKey = (e: KeyboardEvent) => {
@@ -2402,40 +2452,6 @@ export function EmailComposer({
}
}, [client, t, plainTextMode]);
const handleImageUpload = useCallback(async (
file: File,
): Promise<{ src: string; cid: string } | null> => {
if (!client) return null;
try {
const readAsDataUrl = new Promise<string | null>((resolve) => {
const reader = new FileReader();
reader.onload = (e) => resolve((e.target?.result as string) ?? null);
reader.onerror = () => resolve(null);
reader.readAsDataURL(file);
});
const [{ blobId }, dataUrl] = await Promise.all([
(composerClientRef.current ?? client).uploadBlob(file),
readAsDataUrl,
]);
if (!dataUrl) throw new Error('Failed to read image as data URL');
const cid = `${generateUUID()}@webmail`;
inlineImagesRef.current.push({
cid,
blobId,
type: file.type || 'application/octet-stream',
name: file.name,
size: file.size,
dataUrl,
uploadedAt: Date.now(),
});
return { src: dataUrl, cid };
} catch (error) {
debug.error(`Failed to upload inline image ${file.name}:`, error);
toast.error(t('upload_failed', { filename: file.name }));
return null;
}
}, [client, t]);
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (!event.target.files) return;
await addFiles(Array.from(event.target.files));
@@ -2667,7 +2683,7 @@ export function EmailComposer({
// below the editor - was saved without it (#823). Embed the *marked-up*
// form so re-opening the draft round-trips the signature as one block and
// signatureAlreadyInBody sees it instead of appending a second copy.
const draftSignatureHtml = signatureAlreadyInBody
const draftSignatureHtml = signatureAlreadyInBody || !autoSignatureEnabled
? ''
: buildEmbeddedSignatureHtml(signatureIdentity, {
embed: true,
@@ -2675,7 +2691,7 @@ export function EmailComposer({
});
const draftHtmlBody = plainTextMode ? undefined : `${body}${draftSignatureHtml}`;
const draftTextBody = plainTextMode
? (signatureAlreadyInBody
? (signatureAlreadyInBody || !autoSignatureEnabled
? body
: appendPlainTextSignature(body, signatureIdentity, { separator: signatureSeparatorEnabled }))
: htmlToPlainText(draftHtmlBody!, imageAltText);
@@ -3637,9 +3653,14 @@ export function EmailComposer({
// above-quote replies, a re-opened draft) `signatureAlreadyInBody` is set
// and the trailing append below is skipped so we don't duplicate it.
// Whether automatic signature insertion is on for this composer mode
// (component-level `autoSignatureEnabled` - see signatureAlreadyInBody).
// What the body already carries is never stripped - only the append is
// gated.
// Build HTML signature block (used only in rich text mode)
const buildSignatureHtml = (): string => {
if (signatureAlreadyInBody) return '';
if (signatureAlreadyInBody || !autoSignatureEnabled) return '';
const sep = wantsSignatureSeparator(signatureSeparatorEnabled, signatureIdentity) ? `<br><br>-- <br>` : `<br><br>`;
if (signatureIdentity?.htmlSignature) {
return `${sep}${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}`;
@@ -3656,8 +3677,8 @@ export function EmailComposer({
// In plain text mode, send text/plain only (no HTML body)
const signatureOpts = { separator: signatureSeparatorEnabled };
const builtTextBody = plainTextMode
? (signatureAlreadyInBody ? body : appendPlainTextSignature(body, signatureIdentity, signatureOpts))
: (signatureAlreadyInBody ? htmlToPlainText(body, imageAltText) : appendPlainTextSignature(htmlToPlainText(body, imageAltText), signatureIdentity, signatureOpts));
? (signatureAlreadyInBody || !autoSignatureEnabled ? body : appendPlainTextSignature(body, signatureIdentity, signatureOpts))
: (signatureAlreadyInBody || !autoSignatureEnabled ? htmlToPlainText(body, imageAltText) : appendPlainTextSignature(htmlToPlainText(body, imageAltText), signatureIdentity, signatureOpts));
// Before any blobId is read: forwarded parts may still sit on the account
// that holds the original message.
@@ -4834,8 +4855,10 @@ export function EmailComposer({
{/* Hide the visual signature preview when the signature has already been
embedded into the body (compose, above-quote replies, re-opened
drafts) - it would otherwise read as a second signature. */}
{signatureAlreadyInBody ? null
drafts) - it would otherwise read as a second signature. Also when
automatic insertion is off: nothing will be appended at send time,
so a preview would promise a signature that never goes out. */}
{signatureAlreadyInBody || !autoSignatureEnabled ? null
: plainTextMode ? (
getPlainTextSignature(signatureIdentity) ? (
<div className="px-4 pb-3 text-sm leading-6 text-muted-foreground break-words whitespace-pre-wrap font-mono">
+14 -22
View File
@@ -29,6 +29,8 @@ import { useShallow } from "zustand/react/shallow";
import { useAuthStore } from "@/stores/auth-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useListRowShape } from "@/hooks/use-list-row-shape";
import { MailListRowsSkeleton, type MailRowShape } from "@/components/boot-skeleton/mail-skeleton";
import type { Density } from "@/lib/density";
import { useUIStore } from "@/stores/ui-store";
import { useMessageListTabsStore } from "@/stores/message-list-tabs-store";
import { emailKeyFor, groupEmailsByThread, sortThreadGroups, threadKeyFor, getEmailTagIds } from "@/lib/thread-utils";
@@ -124,27 +126,11 @@ const BATCH_TOOLBAR_WIDE = 480;
// Declared out here so re-renders of the list keep the same element. Inside
// EmailList every render made a new component, which remounted the skeleton
// and replayed its fade-in each time the store changed while a list loaded.
function LoadingSkeleton() {
return (
<div className="animate-in fade-in duration-200" data-testid="email-list-skeleton">
{[...Array(8)].map((_, i) => (
<div key={i} className="border-b border-border px-4 py-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-muted/50 rounded-full" />
<div className="flex-1">
<div className="flex items-center justify-between mb-2">
<div className="h-4 bg-muted/50 rounded w-32" />
<div className="h-3 bg-muted/50 rounded w-16" />
</div>
<div className="h-4 bg-muted/50 rounded w-3/4 mb-2" />
<div className="h-3 bg-muted/50 rounded w-full" />
</div>
</div>
</div>
))}
</div>
);
// each time the store changed while a list loaded.
function LoadingSkeleton({ shape, density, preview }: { shape: MailRowShape; density: Density; preview: boolean }) {
// The page skeleton's rows (components/boot-skeleton), so the list the app
// draws while it loads is the one the server painted before it started.
return <MailListRowsSkeleton shape={shape} density={density} preview={preview} className="contents" />;
}
/** Rows per page in simple mode's paged list. */
@@ -1222,7 +1208,13 @@ export function EmailList({
)}
{isLoading && emails.length === 0 ? (
<LoadingSkeleton />
<div data-testid="email-list-skeleton">
<LoadingSkeleton
shape={isFocusedMailLayout ? (mountainViewList ? 'mv' : 'one-line') : 'stacked'}
density={density}
preview={showPreview}
/>
</div>
) : emails.length === 0 && !isLoading ? (
<div className="flex flex-col items-center justify-center h-full">
{/* R9: an empty search offers Clear search; Drafts, which the
+10 -49
View File
@@ -117,6 +117,7 @@ import {
Pencil,
Paperclip,
} from "@/components/icons";
import { MailMessageSkeleton, MailMessageToolbarSkeleton } from "@/components/boot-skeleton/mail-skeleton";
import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/navigation";
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
@@ -3929,12 +3930,12 @@ ${printWatermark ? printWatermark.html : ''}
)}
</>
);
const shell = (content: React.ReactNode) => (
const shell = (content: React.ReactNode, top: React.ReactNode = null) => (
<div
ref={setViewerRootEl}
className={cn("flex-1 flex flex-col h-full bg-background overflow-hidden relative", className)}
>
{null}
{top}
<div className="flex-1 min-h-0 flex flex-row overflow-hidden relative">
{content}
{sidePanel}
@@ -3943,54 +3944,14 @@ ${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.
// Its toolbar sits where the loaded reader puts its own: above the message
// row when lifted, so the row doesn't move when the message arrives.
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>
);
const lifted = toolbarPosition === 'top' && liftToolbar;
return shell(<MailMessageSkeleton toolbar={!lifted} />, lifted ? <MailMessageToolbarSkeleton /> : null);
}
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 (
@@ -143,6 +143,19 @@ describe('FileBrowser', () => {
act(() => { useFileStore.setState({ client: null, sharedRoots: [], allFolders: [] }); });
});
it('draws the loading listing in the list view, as the page skeleton did', () => {
renderBrowser({ resources: [], hasListed: false });
expect(screen.getByTestId('files-list-skeleton')).toBeInTheDocument();
expect(screen.queryByTestId('files-grid-skeleton')).toBeNull();
});
it('draws the loading listing as tiles when the grid view is picked', () => {
act(() => { useSettingsStore.setState({ filesSettings: { ...DEFAULT_FILES_SETTINGS, defaultViewMode: 'grid' } }); });
renderBrowser({ resources: [], hasListed: false });
expect(screen.getByTestId('files-grid-skeleton')).toBeInTheDocument();
expect(screen.queryByTestId('files-list-skeleton')).toBeNull();
});
it('lists folders shared with the user at the root and in the sidebar (#1181)', async () => {
const { onNavigate } = renderBrowser();
await screen.findAllByText('Team');
+2 -16
View File
@@ -42,6 +42,7 @@ import type { FileNodeRights } from "@/lib/jmap/types";
import type { FileResourceView } from "@/lib/plugin-types";
import { isAppShortcutBlocked, isArrowKeyWidgetTarget } from "@/lib/keyboard";
import { isSingleKeyShortcutOff } from "@/lib/single-key-shortcuts";
import { FilesListingSkeleton } from "@/components/boot-skeleton/files-skeleton";
const NO_FORMATS: string[] = [];
@@ -1079,7 +1080,7 @@ export function FileBrowser({
);
}
// Until the first listing is in, an empty list means "not loaded yet".
if ((isLoading || !hasListed) && resources.length === 0) return <ListSkeleton />;
if ((isLoading || !hasListed) && resources.length === 0) return <FilesListingSkeleton view={viewMode} />;
if (showEmpty && searchQuery) return <NoSearchResults query={searchQuery} onClear={() => setSearchQuery("")} />;
if (showEmpty && inTrash) {
return <EmptyState icon={Trash2} title={t("trash_empty_title")} description={t("trash_empty_hint")} />;
@@ -1522,21 +1523,6 @@ function SharedWithMe({ folders, pages, onOpen, iconOptions }: {
);
}
function ListSkeleton() {
return (
<div className="px-4" aria-hidden>
<div className="h-9 border-b border-border" />
{Array.from({ length: 7 }, (_, i) => (
<div key={i} className="h-10 flex items-center gap-3 border-b border-border/60">
<div className="w-4 h-4 rounded bg-muted animate-pulse" />
<div className="w-5 h-5 rounded bg-muted animate-pulse" />
<div className="h-3.5 rounded bg-muted animate-pulse" style={{ width: `${30 + ((i * 37) % 35)}%` }} />
</div>
))}
</div>
);
}
/** Plugin entries at the end of a file's menu, after a separator when there are any. */
function FilePluginMenuEntries({ files, mimeType, onRan }: { files: FileResourceView[]; mimeType?: string; onRan: () => void }) {
const hasActions = usePluginActions("file-actions", { mimeType }).length > 0;
+8
View File
@@ -0,0 +1,8 @@
/**
* The file list's columns: select, name, then modified (from md) and size
* (from sm), then the row actions. A plain module, so the page skeleton
* (components/boot-skeleton/files-skeleton.tsx) lays its rows out on the
* same grid as FileListView without importing a client component.
*/
export const FILE_LIST_COLUMNS =
"grid grid-cols-[auto_minmax(0,1fr)_auto] sm:grid-cols-[auto_minmax(0,1fr)_5.5rem_auto] md:grid-cols-[auto_minmax(0,1fr)_9rem_5.5rem_auto] items-center gap-x-3";
+2 -1
View File
@@ -12,6 +12,7 @@ import {
} from "@/components/files/file-item-parts";
import type { SortDir, SortKey } from "@/components/files/files-header";
import { TOUCH_HIT_AREA } from "@/components/ui/button";
import { FILE_LIST_COLUMNS } from "./file-list-layout";
export interface FileItemHandlers {
/** Selection click (plain, Ctrl/Cmd, Shift) on the item body. */
@@ -147,7 +148,7 @@ function SortHeader({ label, column, sortKey, sortDir, onSort, className }: {
);
}
const LIST_COLUMNS = "grid grid-cols-[auto_minmax(0,1fr)_auto] sm:grid-cols-[auto_minmax(0,1fr)_5.5rem_auto] md:grid-cols-[auto_minmax(0,1fr)_9rem_5.5rem_auto] items-center gap-x-3";
const LIST_COLUMNS = FILE_LIST_COLUMNS;
export function FileListView({
resources, selected, focusedName, tabStopName, dropTarget, cutNames, starredPaths, pathOf, touch, iconOptions, getImageUrl, handlers,
+9 -1
View File
@@ -43,6 +43,8 @@ import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
import { useDocumentTitle } from "@/hooks/use-document-title";
import { MvTopBar } from "@/components/layout/mv-top-bar";
import { useMountainViewShell } from "@/hooks/use-mountain-view-shell";
import { FilesSkeleton, FilesSkeletonBody } from "@/components/boot-skeleton/files-skeleton";
import { useSkeletonLabels } from "@/components/boot-skeleton/use-skeleton-labels";
export interface FilesAppProps {
/** Path segments after `/files` - the folder path, one segment per level. */
@@ -53,6 +55,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
// Static Lite build: the route params are empty, read the link from the URL.
const linkSegments = useLiteLinkSegments('files', routeSegments);
const t = useTranslations("files");
const skeleton = useSkeletonLabels();
const tSidebar = useTranslations("sidebar");
const tShell = useTranslations("shell");
useDocumentTitle(tSidebar("files"));
@@ -802,7 +805,12 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
useFileStore.setState({ supportsFiles: null });
}, [getClientForAccount]);
if (!isAuthenticated) return null;
// Until the session is restored: the page skeleton the server drew.
if (!isAuthenticated) {
return isEmbedded
? <FilesSkeletonBody embedded />
: <FilesSkeleton label={skeleton.label} labels={skeleton.labels} />;
}
return (
<div className={cn("flex flex-col overflow-hidden pt-[env(safe-area-inset-top)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}>
@@ -0,0 +1,85 @@
import { render, fireEvent } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import type { Mailbox } from '@/lib/jmap/types';
// A folder row opens its folder wherever it is clicked, not only on the
// button inside it. Mountain View draws a 32px pill around a ~20px button,
// and clicks on the pill's padding, the chevron slot or the count did
// nothing (#1501). The row's own controls keep their click.
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/components/tour/tour-provider', () => ({
useTour: () => ({ startTour: vi.fn(), resetTourCompletion: vi.fn(), activeTour: null, endTour: vi.fn(), registerStep: vi.fn() }),
}));
vi.mock('@/contexts/drag-drop-context', () => ({
useDragDropContext: () => ({ isDragging: false }),
}));
vi.mock('@/hooks/use-mailbox-drop', () => ({
useMailboxDrop: () => ({ dropHandlers: {}, isValidDropTarget: false, isInvalidDropTarget: false }),
}));
vi.mock('@/hooks/use-tag-drop', () => ({
useTagDrop: () => ({ dropHandlers: {}, isValidDropTarget: false }),
}));
vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null }));
const { Sidebar } = await import('../sidebar');
const { useSettingsStore } = await import('@/stores/settings-store');
const { useUIStore } = await import('@/stores/ui-store');
const mailboxes = [
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, totalEmails: 9, unreadEmails: 4, sortOrder: 0, myRights: { mayAddItems: true } },
{ id: 'receipts', name: 'Receipts', role: null, parentId: null, totalEmails: 5, unreadEmails: 3, sortOrder: 1, myRights: { mayAddItems: true } },
{ id: 'shops', name: 'Shops', role: null, parentId: 'receipts', totalEmails: 0, unreadEmails: 0, sortOrder: 0, myRights: { mayAddItems: true } },
] as unknown as Mailbox[];
const row = (id: string) => document.querySelector<HTMLElement>(`[data-testid="folder-row"][data-mailbox-id="${id}"]`)!;
describe.each([
['Mountain View', 'mountain-view', true],
['the default layout', 'default', false],
] as const)('folder row click area in %s', (_name, interfaceLayout, mvShell) => {
beforeEach(() => {
useUIStore.setState({ sidebarCollapsed: false } as never);
useSettingsStore.setState({ interfaceLayout, hideAccountSwitcher: true, enableUnifiedMailbox: false } as never);
});
it('opens the folder from a click on the row outside its button', () => {
const onMailboxSelect = vi.fn();
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" onMailboxSelect={onMailboxSelect} mvShell={mvShell} />);
fireEvent.click(row('receipts'));
expect(onMailboxSelect).toHaveBeenCalledWith('receipts');
});
it('opens the folder from a click on its count', () => {
const onMailboxSelect = vi.fn();
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" onMailboxSelect={onMailboxSelect} mvShell={mvShell} />);
fireEvent.click(row('receipts').querySelector('[data-testid="folder-counts"]')!);
expect(onMailboxSelect).toHaveBeenCalledWith('receipts');
});
it('shows the pointer over the whole row', () => {
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" onMailboxSelect={() => {}} mvShell={mvShell} />);
expect(row('receipts').className).toContain('cursor-pointer');
});
it('only expands or collapses the folder from its chevron', () => {
const onMailboxSelect = vi.fn();
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" onMailboxSelect={onMailboxSelect} mvShell={mvShell} />);
const shownBefore = row('shops') !== null;
fireEvent.click(row('receipts').querySelector('[data-testid="folder-expand-toggle"]')!);
expect(row('shops') !== null).toBe(!shownBefore);
expect(onMailboxSelect).not.toHaveBeenCalled();
});
});
describe('folder row click area with the unread filter', () => {
it('filters to unread from the unread count without also opening the folder', () => {
useSettingsStore.setState({ interfaceLayout: 'default', hideAccountSwitcher: true, enableUnifiedMailbox: false } as never);
const onMailboxSelect = vi.fn();
const onUnreadFilterClick = vi.fn();
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" onMailboxSelect={onMailboxSelect} onUnreadFilterClick={onUnreadFilterClick} />);
fireEvent.click(row('receipts').querySelector('[data-testid="folder-counts"] button')!);
expect(onUnreadFilterClick).toHaveBeenCalledWith('receipts');
expect(onMailboxSelect).not.toHaveBeenCalled();
});
});
+40 -5
View File
@@ -28,6 +28,7 @@ import { readableTextOn } from "@/lib/color-transform";
import { Avatar } from "@/components/ui/avatar";
import type { CalendarEvent } from "@/lib/jmap/types";
import { useCvdColor } from "@/hooks/use-cvd-color";
import { PluginSidePanelBody, SidePanelButton, usePluginSidePanel } from "@/components/plugins/plugin-side-panels";
export type MvSidePanelId = "calendar" | "tasks" | "contacts";
@@ -46,6 +47,18 @@ export function MvSidePanel() {
const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled("contactsEnabled"));
const tasksEnabled = useSettingsStore((s) => s.enableCalendarTasks);
const [open, setOpen] = useState<MvSidePanelId | null>(null);
// Plugins' panels (the side-panel slot), their buttons below the apps'.
// A plugin's panel opening (a click, or the plugin's openPanel) shuts the app's, and the other way round.
const plugins = usePluginSidePanel(() => setOpen(null));
const pluginOpen = plugins.entries.find((e) => e.pluginId === plugins.open) ?? null;
const openApp = (id: MvSidePanelId | null) => {
if (id) plugins.choose(null);
setOpen(id);
};
const openPlugin = (pluginId: string | null) => {
if (pluginId) setOpen(null);
plugins.choose(pluginId);
};
const showCalendar = supportsCalendar && calendarEnabled;
const showContacts = (client?.supportsContacts() ?? false) && contactsEnabled;
@@ -57,9 +70,11 @@ export function MvSidePanel() {
...(showContacts ? [{ id: "contacts" as const, label: t("contacts"), icon: Users, color: "text-[#1a73e8]" }] : []),
];
if (buttons.length === 0) return null;
if (buttons.length === 0 && plugins.entries.length === 0) return null;
// A plugin's panel without a rail button (setSidePanel's rail: false) opens on its request only.
const pluginButtons = plugins.entries.filter((e) => e.inRail);
// A panel whose app went away (policy change, account switch) stays shut.
const active = open && buttons.some((b) => b.id === open) ? open : null;
const active = open && !pluginOpen && buttons.some((b) => b.id === open) ? open : null;
return (
<>
@@ -75,7 +90,17 @@ export function MvSidePanel() {
{active === "contacts" && <ContactsPanel onClose={() => setOpen(null)} />}
</aside>
)}
<nav
{pluginOpen && (
<aside
data-simple-hide=""
data-plugin-side-panel={pluginOpen.pluginId}
className="mb-4 me-2 flex w-[300px] shrink-0 flex-col overflow-hidden rounded-2xl bg-background"
aria-label={pluginOpen.title}
>
<PluginSidePanelBody entry={pluginOpen} focusRequest={plugins.focusRequest} onClose={() => plugins.choose(null)} />
</aside>
)}
{(buttons.length > 0 || pluginButtons.length > 0) && <nav
data-mv-side-rail=""
className="flex w-14 shrink-0 flex-col items-center gap-2 pt-3"
aria-label={t("mv_side_panel")}
@@ -84,7 +109,7 @@ export function MvSidePanel() {
<button
key={id}
type="button"
onClick={() => setOpen((cur) => (cur === id ? null : id))}
onClick={() => openApp(active === id ? null : id)}
className={cn(
"relative grid h-10 w-10 place-items-center rounded-full transition-colors hover:bg-[var(--mv-nav-hover)]",
active === id && "bg-[var(--mv-nav-selected)]",
@@ -96,8 +121,18 @@ export function MvSidePanel() {
<Icon className={cn("h-5 w-5", color)} />
</button>
))}
{pluginButtons.map((entry) => (
<SidePanelButton
key={entry.pluginId}
entry={entry}
pressed={pluginOpen?.pluginId === entry.pluginId}
onClick={() => openPlugin(pluginOpen?.pluginId === entry.pluginId ? null : entry.pluginId)}
className="h-10 w-10 rounded-full hover:bg-[var(--mv-nav-hover)]"
pressedClassName="bg-[var(--mv-nav-selected)] text-foreground"
/>
))}
<div className="my-1 h-px w-5 bg-border" aria-hidden />
</nav>
</nav>}
</>
);
}
+18
View File
@@ -34,6 +34,7 @@ import { toUnicodeEmail } from "@/lib/idn";
import { usePluginPages, pluginPageHref, isPluginPageActive } from "@/hooks/use-plugin-pages";
import { NotificationBell } from "@/components/notifications/notification-center";
import { useBottomControls } from "@/hooks/use-bottom-controls";
import { writeRailHints, type RailHint } from "@/lib/boot-skeleton/hints";
interface NavItem {
id: string;
@@ -348,6 +349,23 @@ export function NavigationRail({
const visibleItems = navItems.filter((item) => !item.hidden);
// Leave the next page load's skeleton a note of what this rail shows
// (lib/boot-skeleton/hints.ts). Only once a session is up: before that the
// rail is still missing the apps the server offers.
const quotaShown = !!quota && quota.total > 0;
useEffect(() => {
if (!client) return;
const items: RailHint[] = [];
for (const item of navItems) if (item.id !== 'mail' && !item.hidden) items.push(item.id as RailHint);
if (sidebarAppsEnabled && onManageApps) items.push('add-app');
if (isStalwartAdmin) items.push('admin');
if (showAccessibilityButton) items.push('a11y');
if (quotaShown) items.push('quota');
writeRailHints(items);
// navItems is rebuilt every render; its inputs are listed instead.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [client, supportsCalendar, calendarEnabled, supportsContacts, contactsEnabled, supportsFiles, filesEnabled, sidebarAppsEnabled, !!onManageApps, isStalwartAdmin, showAccessibilityButton, quotaShown]);
const getIsActive = (href: string, itemId: string) => {
if (activeAppId) return false;
if (onNavigate) {
+23 -6
View File
@@ -44,6 +44,7 @@ import {
MoreVertical,
} from "@/components/icons";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { FOLDER_CHEVRON_SLOT, FOLDER_ROW_PX_BASE, FolderRowsSkeleton } from "@/components/boot-skeleton/mail-skeleton";
import { localizeMailboxName } from "@/lib/mailbox-label";
import {
buildKeywordTree,
@@ -168,8 +169,8 @@ interface SidebarProps {
onLoginSharedMailboxSelect?: (loginId: string, mailboxId: string) => void;
}
const ROW_PX_BASE = 8;
const CHEVRON_SLOT = 20;
const ROW_PX_BASE = FOLDER_ROW_PX_BASE;
const CHEVRON_SLOT = FOLDER_CHEVRON_SLOT;
const INDENT_STEP = 12;
// Icons and colours follow the JMAP role only.
@@ -430,11 +431,20 @@ function SidebarRow({
const longPress = useLongPress((point) => {
if (rowRef.current && onContextMenu) onContextMenu(menuEventAt(rowRef.current, point));
}, hasMenu);
// The whole row opens the folder, not just its button: the row's padding,
// the chevron slot and the space around the count were dead (#1501). The
// button stays the keyboard target; controls of their own keep their click.
const openFromRow = !isVirtual && onClick
? (e: React.MouseEvent) => {
if (!(e.target as Element).closest('button, a, input, [role="button"]')) onClick();
}
: undefined;
return (
<div
ref={rowRef}
{...(dropHandlers || {})}
onClick={openFromRow}
onContextMenu={onContextMenu}
onTouchStart={hasMenu ? longPress.onTouchStart : undefined}
onTouchMove={hasMenu ? longPress.onTouchMove : undefined}
@@ -452,6 +462,7 @@ function SidebarRow({
className={cn(
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-colors duration-150",
isCollapsed ? "justify-center px-1" : "pe-2",
openFromRow && "cursor-pointer select-none",
isVirtual
? "text-muted-foreground"
: isSelected
@@ -2054,8 +2065,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 +2134,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();
});
});
@@ -256,3 +256,41 @@ describe('archiving a group message by month', () => {
expect(client.createMailbox).toHaveBeenCalledWith('2026', 'k', 'ce', undefined);
});
});
describe('shared message deep link from the personal Inbox (#1491)', () => {
it('selects the shared folder for a freshly fetched message without source stamps', async () => {
window.history.replaceState(null, '', '/mail/message/eaaaaab?owner=ce');
const email = { ...groupMessage, mailboxIds: { 'ce:a': true }, sourceClientAccountId: undefined, sourceAccountId: undefined } as Email;
const fetchContent = vi.fn().mockImplementation(async () => {
useEmailStore.setState({ selectedEmail: email });
return email;
});
const fetchEmails = vi.fn().mockResolvedValue(undefined);
useEmailStore.setState({
emails: [], selectedEmail: null, selectedMailbox: 'a', selectedKeyword: null,
fetchEmailContent: fetchContent, fetchEmails,
});
const mounted = render(<MailApp linkSegments={['message', 'eaaaaab']} />);
try {
await waitFor(() => expect(fetchContent).toHaveBeenCalledWith(client, 'eaaaaab', 'ce'));
await waitFor(() => expect(useEmailStore.getState().selectedMailbox).toBe('ce:a'));
expect(fetchEmails).toHaveBeenCalledWith(client, 'ce:a', { background: true });
expect(useEmailStore.getState().selectedEmail?.subject).toBe('GRP one');
} finally {
mounted.unmount();
window.history.replaceState(null, '', '/');
}
});
it('opens the shared Inbox for a grouped notification of the shared account', async () => {
window.history.replaceState(null, '', '/mail/folder/inbox?owner=ce');
useEmailStore.setState({ emails: [], selectedEmail: null, selectedMailbox: 'a', selectedKeyword: null } as never);
const mounted = render(<MailApp linkSegments={['folder', 'inbox']} />);
try {
await waitFor(() => expect(useEmailStore.getState().selectedMailbox).toBe('ce:a'));
} finally {
mounted.unmount();
window.history.replaceState(null, '', '/');
}
});
});
+54 -20
View File
@@ -107,6 +107,7 @@ import { PluginActions } from "@/components/plugins/plugin-actions";
import { useAppCommands } from "@/lib/app-commands";
import { MvSearchForm } from "@/components/search/mv-search-form";
import { MvSidePanel } from "@/components/layout/mv-side-panel";
import { PluginSidePanel } from "@/components/plugins/plugin-side-panels";
import { MvListToolbar } from "@/components/email/mv-list-toolbar";
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view";
@@ -176,6 +177,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 +234,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 +372,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
@@ -1796,7 +1810,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
? await freshMailboxes(switchedFrom)
: useEmailStore.getState().mailboxes;
const state = useEmailStore.getState();
const mailboxId = resolveFolderRef(link.ref, mailboxes);
const mailboxId = resolveFolderRef(
link.ref,
mailboxes,
link.ownerAccountId === activeClient.getAccountId() ? undefined : link.ownerAccountId,
);
if (!mailboxId) {
toast.error(t('deep_link.folder_not_found'));
return;
@@ -1948,7 +1966,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 +1982,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
if (!link && !openLatestUnread) {
deepLinkHandledRef.current = true;
setDeepLinkPane(null);
return;
}
@@ -1971,7 +1994,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;
}
@@ -4118,6 +4141,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
signatureIdentity,
sendingIdentity,
separator: settings.signatureSeparatorEnabled,
// The user's reply/forward signature toggle: off sends the bare text.
insertSignature: settings.signatureReplyForwardEnabled,
to: recipients,
fromEmail: headerFromEmail,
identityId: sendingIdentity?.id,
@@ -4472,14 +4497,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 +4517,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 +4907,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
);
if (!isAuthenticated) {
return null;
return <MailSkeleton label={skeletonLabels.label} labels={skeletonLabels.labels} embedded={isEmbedded} />;
}
return (
@@ -5087,7 +5107,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 +5348,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 +5541,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 +5591,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 +5653,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 +5779,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}
@@ -5762,6 +5792,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
{/* Mountain View: Gmail's side panel - calendar, tasks and contacts
next to the mail, with their icons down the right edge. */}
{mvShell && !inlineApp && !fullscreenReading && <MvSidePanel />}
{/* The default layout: plugins' panels beside the mail (the
side-panel slot), with their buttons in a rail at the right edge.
Desktop only; Mountain View has them in its side panel. */}
{!mvShell && !isMobile && !isTablet && !isEmbedded && !inlineApp && !fullscreenReading && <PluginSidePanel />}
</div>
{/* Keyboard Shortcuts Modal */}
@@ -0,0 +1,62 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act, fireEvent, render, screen } from '@testing-library/react';
vi.mock('next-intl', () => ({ useTranslations: () => (key: string, values?: Record<string, string>) => (values?.name ? `${key}:${values.name}` : key) }));
vi.mock('../plugin-iframe-slot', () => ({ PluginIframeSlot: () => <div data-testid="custom-frame" /> }));
import { PluginDialogHost } from '../plugin-dialog-host';
import { awaitCustomDialog, head, resetDialogLimits, resolveHead } from '@/lib/plugin-sandbox/host-dialog';
import { POPOVER_GAP } from '@/lib/plugin-sandbox/dialog-anchor';
// A custom dialog with an anchor: a popover beside the control the user
// clicked, naming the plugin in one line, closed by Escape or a click outside.
beforeEach(() => {
while (head()) resolveHead(null);
resetDialogLimits();
});
const anchor = { left: 100, top: 50, right: 124, bottom: 74 };
describe('a plugin dialog beside its trigger', () => {
it('stands below the trigger, names the plugin, and has no centred backdrop', () => {
render(<PluginDialogHost />);
act(() => { void awaitCustomDialog({ pluginId: 'follow-ups', title: 'Follow up', message: '', slot: 'plugin-dialog', width: 320, anchor }); });
const dialog = screen.getByRole('dialog', { name: 'Follow up' });
expect(dialog).toHaveAttribute('data-plugin-dialog-popover');
expect(dialog.className).not.toMatch(/bg-black/);
expect(screen.getByTestId('custom-frame')).toBeInTheDocument();
expect(dialog.querySelector('[data-plugin-dialog-source]')).toHaveTextContent('from_plugin:follow-ups');
expect(screen.getByRole('button', { name: 'plugin_menu:follow-ups' })).toBeInTheDocument();
const panel = dialog.querySelector<HTMLElement>('[data-plugin-dialog="follow-ups"]')!;
expect(panel.style.left).toBe('100px');
expect(panel.style.top).toBe(`${74 + POPOVER_GAP}px`);
expect(panel.style.width).toContain('320px');
});
it('closes with null on a click outside', async () => {
render(<PluginDialogHost />);
let result: Promise<unknown>;
act(() => { result = awaitCustomDialog({ pluginId: 'follow-ups', title: 'Follow up', message: '', slot: 'plugin-dialog', anchor }); });
await act(async () => { fireEvent.mouseDown(screen.getByRole('dialog')); });
await expect(result!).resolves.toBeNull();
expect(screen.queryByRole('dialog')).toBeNull();
});
it('closes with null on Escape', async () => {
render(<PluginDialogHost />);
let result: Promise<unknown>;
act(() => { result = awaitCustomDialog({ pluginId: 'follow-ups', title: 'Follow up', message: '', slot: 'plugin-dialog', anchor }); });
await act(async () => { fireEvent.keyDown(document, { key: 'Escape' }); });
await expect(result!).resolves.toBeNull();
});
it('is the centred dialog without an anchor', () => {
render(<PluginDialogHost />);
act(() => { void awaitCustomDialog({ pluginId: 'follow-ups', title: 'Follow up', message: '', slot: 'plugin-dialog' }); });
const dialog = screen.getByRole('dialog', { name: 'Follow up' });
expect(dialog).not.toHaveAttribute('data-plugin-dialog-popover');
expect(screen.getByRole('button', { name: 'close' })).toBeInTheDocument();
});
});
@@ -107,7 +107,7 @@ describe('plugin pages', () => {
render(<PluginPageApp linkSegments={['kanban', 'board']} />);
expect(screen.queryByText('not_available_title')).toBeNull();
expect(screen.getByLabelText('loading')).toBeInTheDocument();
expect(screen.getByRole('status')).toHaveTextContent('loading');
});
it('does not mount another plugin page under a wrong page id', () => {
@@ -0,0 +1,169 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type { InstalledPlugin } from '@/lib/plugin-types';
const createSlotInstance = vi.fn((..._args: unknown[]) => ({ destroy: vi.fn(), updateProps: vi.fn(), setTheme: vi.fn() }));
vi.mock('@/lib/plugin-sandbox/host-bridge', () => ({
createSlotInstance: (...args: unknown[]) => createSlotInstance(...args),
}));
vi.mock('@/lib/plugin-sandbox/host-theme', () => ({ snapshotHostTheme: () => ({}) }));
const { PluginSidePanel } = await import('../plugin-side-panels');
const { MvSidePanel } = await import('@/components/layout/mv-side-panel');
const registry = await import('@/lib/plugin-sandbox/registry');
const { dispatchApiCall } = await import('@/lib/plugin-sandbox/host-api');
const { clearSidePanel } = await import('@/lib/plugin-sandbox/side-panels');
const { useCalendarStore } = await import('@/stores/calendar-store');
const { useSettingsStore } = await import('@/stores/settings-store');
function offer(id: string, name: string, opts: { slot?: string; fill?: boolean } = {}) {
const plugin: InstalledPlugin = {
id, name, version: '1.0.0', author: '', description: '', type: 'ui-extension',
permissions: ['ui:sidebar-widget'], grantedPermissions: ['ui:sidebar-widget'], entrypoint: 'index.js',
enabled: true, status: 'running', settings: {},
};
registry.register({
plugin,
code: '',
tier: 'untrusted',
background: { evaluateShouldShow: vi.fn(async () => true) } as never,
slotOffers: [{ name: (opts.slot ?? 'side-panel') as never, order: 100, hasShouldShow: false, ...(opts.fill ? { fill: true } : {}) }],
hookDisposables: [],
});
return plugin;
}
beforeEach(() => {
createSlotInstance.mockClear();
localStorage.clear();
});
afterEach(() => {
for (const p of registry.all()) {
clearSidePanel(p.plugin.id);
registry.deregister(p.plugin.id);
}
});
describe('PluginSidePanel (default layout)', () => {
it('draws nothing when no plugin offers a side panel', () => {
offer('w', 'Widget', { slot: 'sidebar-widget' });
const { container } = render(<PluginSidePanel />);
expect(container.firstChild).toBeNull();
});
it('gives each plugin a button in the rail that opens and closes its panel, filled by the frame', async () => {
offer('fu', 'Follow-ups', { fill: true });
const { container } = render(<PluginSidePanel />);
const button = screen.getByRole('button', { name: 'Follow-ups' });
expect(button).toHaveAttribute('aria-pressed', 'false');
expect(container.querySelector('[data-plugin-side-panel]')).toBeNull();
fireEvent.click(button);
expect(button).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('complementary', { name: 'Follow-ups' })).toBeInTheDocument();
await waitFor(() => expect(createSlotInstance).toHaveBeenCalled());
const opts = createSlotInstance.mock.calls[0][0] as { slot: string; extraProps: Record<string, unknown>; fill?: boolean };
expect(opts.slot).toBe('side-panel');
expect(opts.extraProps).toEqual({ focusRequest: 0 });
expect(container.querySelector('[data-plugin-iframe-fill]')).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'close' }));
expect(container.querySelector('[data-plugin-side-panel]')).toBeNull();
});
it('remembers the open panel in this browser', () => {
offer('fu', 'Follow-ups');
const first = render(<PluginSidePanel />);
fireEvent.click(screen.getByRole('button', { name: 'Follow-ups' }));
first.unmount();
const { container } = render(<PluginSidePanel />);
expect(container.querySelector('[data-plugin-side-panel="fu"]')).not.toBeNull();
});
it('names the button and panel as the plugin set them, and by its name again after null', async () => {
const plugin = offer('fu', 'Follow-ups');
render(<PluginSidePanel />);
await act(async () => { await dispatchApiCall(plugin, 'ui.setSidePanel', [{ title: 'Upcoming for me', icon: 'flag' }]); });
expect(screen.getByRole('button', { name: 'Upcoming for me' })).toBeInTheDocument();
await expect(dispatchApiCall(plugin, 'ui.setSidePanel', [{ title: 'x'.repeat(41) }])).rejects.toThrow(/at most 40/);
await expect(dispatchApiCall(plugin, 'ui.setSidePanel', [{ icon: 'no icon' }])).rejects.toThrow(/icon/);
await act(async () => { await dispatchApiCall(plugin, 'ui.setSidePanel', [null]); });
expect(screen.getByRole('button', { name: 'Follow-ups' })).toBeInTheDocument();
const noPerm = { ...plugin, id: 'other', permissions: [], grantedPermissions: [] };
await expect(dispatchApiCall(noPerm, 'ui.setSidePanel', [{ title: 'Mine' }])).rejects.toThrow(/lacks permission "ui:sidebar-widget"/);
});
it('leaves the button out with rail: false, the panel opened on the plugin\'s request only', async () => {
const plugin = offer('fu', 'Follow-ups');
const { container } = render(<PluginSidePanel />);
await act(async () => { await dispatchApiCall(plugin, 'ui.setSidePanel', [{ title: 'Upcoming for me', icon: 'flag', rail: false }]); });
expect(screen.queryByRole('button', { name: 'Upcoming for me' })).toBeNull();
expect(container.querySelector('[data-plugin-side-rail]')).toBeNull();
await act(async () => { await dispatchApiCall(plugin, 'ui.setPanel', ['side-panel', 'toggle']); });
expect(screen.getByRole('complementary', { name: 'Upcoming for me' })).toBeInTheDocument();
await expect(dispatchApiCall(plugin, 'ui.setSidePanel', [{ rail: 'no' }])).rejects.toThrow(/rail/);
});
it('opens on the plugin\'s request with a new focusRequest, and answers null without a panel on screen', async () => {
const plugin = offer('fu', 'Follow-ups');
const { container, unmount } = render(<PluginSidePanel />);
let answer: unknown;
await act(async () => { answer = await dispatchApiCall(plugin, 'ui.setPanel', ['side-panel', 'open']); });
expect(answer).toBe(true);
expect(container.querySelector('[data-plugin-side-panel="fu"]')).not.toBeNull();
await waitFor(() => expect(createSlotInstance).toHaveBeenCalled());
expect((createSlotInstance.mock.calls[0][0] as { extraProps: unknown }).extraProps).toEqual({ focusRequest: 1 });
await act(async () => { answer = await dispatchApiCall(plugin, 'ui.setPanel', ['side-panel', 'toggle']); });
expect(answer).toBe(false);
expect(container.querySelector('[data-plugin-side-panel]')).toBeNull();
unmount();
await expect(dispatchApiCall(plugin, 'ui.setPanel', ['side-panel', 'open'])).resolves.toBeNull();
});
});
describe('MvSidePanel with plugin panels', () => {
beforeEach(() => {
useCalendarStore.setState({ supportsCalendar: true, calendars: [], events: [] } as never);
useSettingsStore.setState({ enableCalendarTasks: false } as never);
});
it('adds the plugins\' buttons to its rail, one panel open at a time', () => {
offer('fu', 'Follow-ups');
const { container } = render(<MvSidePanel />);
fireEvent.click(screen.getByRole('button', { name: 'calendar' }));
expect(container.querySelector('[data-mv-side-panel="calendar"]')).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Follow-ups' }));
expect(container.querySelector('[data-mv-side-panel]')).toBeNull();
expect(container.querySelector('[data-plugin-side-panel="fu"]')).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'calendar' }));
expect(container.querySelector('[data-plugin-side-panel]')).toBeNull();
expect(container.querySelector('[data-mv-side-panel="calendar"]')).not.toBeNull();
});
it('draws no rail for a plugin panel without a button alone, and opens it on request', async () => {
useCalendarStore.setState({ supportsCalendar: false } as never);
const plugin = offer('fu', 'Follow-ups');
const { container } = render(<MvSidePanel />);
await act(async () => { await dispatchApiCall(plugin, 'ui.setSidePanel', [{ rail: false }]); });
expect(container.querySelector('[data-mv-side-rail]')).toBeNull();
await act(async () => { await dispatchApiCall(plugin, 'ui.setPanel', ['side-panel', 'open']); });
expect(container.querySelector('[data-plugin-side-panel="fu"]')).not.toBeNull();
});
it('shows the rail for a plugin panel alone', () => {
useCalendarStore.setState({ supportsCalendar: false } as never);
offer('fu', 'Follow-ups');
render(<MvSidePanel />);
expect(screen.getByRole('button', { name: 'Follow-ups' })).toBeInTheDocument();
});
});
@@ -0,0 +1,104 @@
import { cleanup, render, waitFor } from '@testing-library/react';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type { InstalledPlugin } from '@/lib/plugin-types';
const slotInstance = (initPromise: Promise<unknown> = Promise.resolve({})) => ({ destroy: vi.fn(), updateProps: vi.fn(), setTheme: vi.fn(), initPromise });
const createSlotInstance = vi.fn((..._args: unknown[]) => slotInstance());
vi.mock('@/lib/plugin-sandbox/host-bridge', () => ({
createSlotInstance: (...args: unknown[]) => createSlotInstance(...args),
}));
vi.mock('@/lib/plugin-sandbox/host-theme', () => ({ snapshotHostTheme: () => ({}) }));
const { PluginTaskView, usePluginTaskView } = await import('../plugin-task-view');
const registry = await import('@/lib/plugin-sandbox/registry');
function offer(id: string, opts: { slot?: string; fill?: boolean; permissions?: InstalledPlugin['permissions']; shouldShow?: boolean } = {}) {
const permissions = opts.permissions ?? ['ui:tasks', 'tasks:read'];
const plugin: InstalledPlugin = {
id, name: id, version: '1.0.0', author: '', description: '', type: 'ui-extension',
permissions, grantedPermissions: permissions, entrypoint: 'index.js',
enabled: true, status: 'running', settings: {},
};
registry.register({
plugin,
code: '',
tier: 'untrusted',
background: { evaluateShouldShow: vi.fn(async () => opts.shouldShow ?? true) } as never,
slotOffers: [{ name: (opts.slot ?? 'calendar-task-view') as never, order: 100, hasShouldShow: opts.shouldShow !== undefined, ...(opts.fill ? { fill: true } : {}) }],
hookDisposables: [],
});
}
/** The task view's list area as calendar-app draws it. */
function TaskArea({ calendarIds = ['c1', 'c2'] }: { calendarIds?: string[] }) {
const view = usePluginTaskView();
const list = <div data-testid="bulwark-task-list" />;
if (!view) return list;
return <PluginTaskView pluginId={view.pluginId} fill={view.fill} filter="pending" calendarIds={calendarIds} fallback={list} />;
}
beforeEach(() => { createSlotInstance.mockClear(); });
afterEach(() => {
cleanup();
for (const p of registry.all()) registry.deregister(p.plugin.id);
});
describe('calendar-task-view', () => {
it('leaves Bulwark\'s list while no plugin offers the slot', () => {
offer('other', { slot: 'task-detail-section' });
const { getByTestId, container } = render(<TaskArea />);
expect(createSlotInstance).not.toHaveBeenCalled();
expect(getByTestId('bulwark-task-list')).toBeInTheDocument();
expect(container.querySelector('[data-plugin-task-view]')).toBeNull();
});
it('draws the first plugin offering it, filled, with the toolbar\'s filter and the shown calendars', async () => {
offer('follow-ups', { fill: true });
offer('second');
const { container, queryByTestId } = render(<TaskArea />);
expect(queryByTestId('bulwark-task-list')).toBeNull();
expect(container.querySelectorAll('[data-plugin-task-view]')).toHaveLength(1);
expect(container.querySelector('[data-plugin-task-view]')).toHaveAttribute('data-plugin-task-view', 'follow-ups');
await waitFor(() => expect(createSlotInstance).toHaveBeenCalledTimes(1));
const opts = createSlotInstance.mock.calls[0][0] as { slot: string; extraProps: Record<string, unknown>; fill?: boolean };
expect(opts.slot).toBe('calendar-task-view');
expect(opts.fill).toBe(true);
expect(opts.extraProps).toEqual({ filter: 'pending', calendarIds: ['c1', 'c2'] });
expect(container.querySelector('[data-plugin-iframe-fill]')).not.toBeNull();
await Promise.resolve();
expect(queryByTestId('bulwark-task-list')).toBeNull();
});
it('hands the calendars only to a plugin that reads tasks', async () => {
offer('looks', { permissions: ['ui:tasks'] });
render(<TaskArea />);
await waitFor(() => expect(createSlotInstance).toHaveBeenCalled());
const opts = createSlotInstance.mock.calls[0][0] as { extraProps: Record<string, unknown> };
expect(opts.extraProps).toEqual({ filter: 'pending' });
});
it("gives Bulwark's list back when the plugin's shouldShow says no", async () => {
offer('picky', { shouldShow: false });
const { findByTestId, container } = render(<TaskArea />);
expect(await findByTestId('bulwark-task-list')).toBeInTheDocument();
expect(createSlotInstance).not.toHaveBeenCalled();
expect(container.querySelector('[data-plugin-task-view]')).toHaveClass('hidden');
});
it("gives Bulwark's list back when the plugin's frame fails to start", async () => {
createSlotInstance.mockImplementationOnce(() => slotInstance(Promise.reject(new Error('boom'))));
offer('broken');
const { findByTestId, container } = render(<TaskArea />);
expect(await findByTestId('bulwark-task-list')).toBeInTheDocument();
expect(createSlotInstance).toHaveBeenCalledTimes(1);
expect(container.querySelector('[data-plugin-task-view]')).toHaveClass('hidden');
});
it('lets the area scroll for a plugin that does not fill it', async () => {
offer('plain');
const { container } = render(<TaskArea />);
await waitFor(() => expect(createSlotInstance).toHaveBeenCalled());
expect(container.querySelector('[data-plugin-task-view]')?.className).toContain('overflow-y-auto');
});
});
+117 -28
View File
@@ -9,11 +9,14 @@
// They look like the app's own dialogs: a custom dialog (api.ui.openDialog)
// is the filter editor's layout, with the plugin's buttons (kit
// DialogButtons) drawn by the host in its footer; confirm, alert and prompt
// are the app's ConfirmDialog and PromptDialog.
// are the app's ConfirmDialog and PromptDialog. A custom dialog opened with
// `anchor: 'trigger'` is a popover beside the control the user clicked
// (dialog-anchor.ts), with a one-line header naming the plugin.
import React, { useCallback, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { useTranslations } from 'next-intl';
import { cancelForPlugin, head, overlay, resolveDialog, subscribe, type DialogRequest } from '@/lib/plugin-sandbox/host-dialog';
import { placePopover, POPOVER_MARGIN, trackTriggers, type AnchorRect } from '@/lib/plugin-sandbox/dialog-anchor';
import type { DialogButtonSpec } from '@/lib/plugin-sandbox/ui-kit/dialog-buttons';
import { usePluginStore } from '@/stores/plugin-store';
import { useFocusTrap } from '@/hooks/use-focus-trap';
@@ -43,6 +46,9 @@ export function PluginDialogHost(): React.JSX.Element | null {
const top = useSyncExternalStore(subscribe, overlay, () => null);
const t = useTranslations('plugins.dialog');
// What the user clicked last, for a dialog that opens beside it.
useEffect(() => trackTriggers(), []);
// Say so when a plugin was turned off for failing; otherwise it just
// stops working with no hint why.
useEffect(() => {
@@ -183,6 +189,9 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: '
},
});
const anchor = current?.kind === 'custom' ? current.anchor : undefined;
const place = usePopoverPlace(anchor, panelRef);
if (!current) return null;
const name = pluginName ?? current.pluginId;
@@ -204,6 +213,76 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: '
</p>
) : null;
const footer = buttons.length > 0 && (
<div className={cn('flex flex-wrap items-center justify-end gap-2 border-t border-border', anchor ? 'px-3 py-2' : 'px-6 py-4')} data-plugin-dialog-buttons="">
{buttons.map((b) => (
<Button
key={b.id}
type="button"
variant={DIALOG_BUTTON_VARIANT[b.variant ?? 'primary']}
size={anchor ? 'sm' : undefined}
disabled={b.disabled || b.busy}
aria-busy={b.busy || undefined}
className="gap-1.5"
onClick={() => pressButton(b.id)}
>
{b.busy ? <Loader2 className="w-4 h-4 animate-spin" aria-hidden="true" /> : <FooterIcon name={b.icon} />}
{b.label}
</Button>
))}
</div>
);
const frame = (
<PluginIframeSlot
pluginId={current.pluginId}
slot={(current.slot ?? 'plugin-dialog') as SlotName}
extraProps={customProps}
/>
);
// Beside the control the user clicked: no backdrop, no big header. The
// plugin is still named in the host's own line, with the way to turn it
// off; a click outside or Escape closes it as the dialog's X does.
if (anchor) {
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-hidden={layer === 'below' ? true : undefined}
className="fixed inset-0 z-[100000]"
onMouseDown={(e) => {
if (e.target === e.currentTarget) cancel();
}}
data-plugin-dialog-popover=""
>
<div
ref={panelRef}
className="absolute flex flex-col overflow-hidden rounded-lg border border-border bg-background text-foreground shadow-xl animate-in fade-in zoom-in-95 duration-150"
style={{
left: place?.left ?? 0,
top: place?.top ?? 0,
visibility: place ? undefined : 'hidden',
width: `min(${current.width ?? 560}px, calc(100vw - ${2 * POPOVER_MARGIN}px))`,
maxHeight: `calc(100vh - ${2 * POPOVER_MARGIN}px)`,
}}
data-plugin-dialog={current.pluginId}
>
<div className="flex items-center gap-1 border-b border-border ps-3 pe-1 py-0.5">
<h2 id={titleId} className="sr-only">{current.title || name}</h2>
<p className="flex min-w-0 flex-1 items-center gap-1 text-xs text-muted-foreground" data-plugin-dialog-source="">
<Puzzle className="h-3 w-3 flex-shrink-0" aria-hidden="true" />
<span className="min-w-0 truncate">{t('from_plugin', { name })}</span>
</p>
<PluginDialogMenu name={name} onTurnOff={turnOff} compact />
</div>
<div className="min-h-0 flex-1 overflow-y-auto">{frame}</div>
{footer}
</div>
</div>
);
}
return (
<div
role="dialog"
@@ -242,30 +321,9 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: '
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-4">
{message}
<PluginIframeSlot
pluginId={current.pluginId}
slot={(current.slot ?? 'plugin-dialog') as SlotName}
extraProps={customProps}
/>
{frame}
</div>
{buttons.length > 0 && (
<div className="flex flex-wrap items-center justify-end gap-2 px-6 py-4 border-t border-border" data-plugin-dialog-buttons="">
{buttons.map((b) => (
<Button
key={b.id}
type="button"
variant={DIALOG_BUTTON_VARIANT[b.variant ?? 'primary']}
disabled={b.disabled || b.busy}
aria-busy={b.busy || undefined}
className="gap-1.5"
onClick={() => pressButton(b.id)}
>
{b.busy ? <Loader2 className="w-4 h-4 animate-spin" aria-hidden="true" /> : <FooterIcon name={b.icon} />}
{b.label}
</Button>
))}
</div>
)}
{footer}
</>
) : (
<>
@@ -328,6 +386,37 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: '
);
}
/**
* Where a popover dialog's panel stands: measured before it paints and again
* when its content (the plugin's frame) or the window changes size.
*/
function usePopoverPlace(anchor: AnchorRect | undefined, panelRef: React.RefObject<HTMLElement | null>): { left: number; top: number } | null {
const [place, setPlace] = useState<{ left: number; top: number } | null>(null);
useLayoutEffect(() => {
const panel = panelRef.current;
if (!anchor || !panel) return;
const update = () => {
const r = panel.getBoundingClientRect();
const next = placePopover(
anchor,
{ width: r.width, height: r.height },
{ width: window.innerWidth, height: window.innerHeight },
document.documentElement.dir === 'rtl',
);
setPlace((p) => (p && p.left === next.left && p.top === next.top ? p : next));
};
update();
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(update);
observer?.observe(panel);
window.addEventListener('resize', update);
return () => {
observer?.disconnect();
window.removeEventListener('resize', update);
};
}, [anchor, panelRef]);
return place;
}
/** A footer button's icon by name; unknown names show none. */
function FooterIcon({ name }: { name?: string }) {
const Glyph = name ? iconForName(name) : null;
@@ -335,7 +424,7 @@ function FooterIcon({ name }: { name?: string }) {
}
/** The "⋯" menu in a plugin dialog's header: the way out of a plugin that keeps asking. */
function PluginDialogMenu({ name, onTurnOff }: { name: string; onTurnOff: () => void }) {
function PluginDialogMenu({ name, onTurnOff, compact }: { name: string; onTurnOff: () => void; compact?: boolean }) {
const t = useTranslations('plugins.dialog');
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
@@ -356,9 +445,9 @@ function PluginDialogMenu({ name, onTurnOff }: { name: string; onTurnOff: () =>
aria-expanded={open}
aria-label={t('plugin_menu', { name })}
title={t('plugin_menu', { name })}
className={cn('p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground', open && 'bg-muted text-foreground')}
className={cn(compact ? 'p-1' : 'p-1.5', 'rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground', open && 'bg-muted text-foreground')}
>
<MoreVertical className="w-5 h-5" />
<MoreVertical className={compact ? 'w-4 h-4' : 'w-5 h-5'} />
</button>
{open && (
<div role="menu" className="absolute end-0 top-full z-10 mt-1 min-w-[200px] rounded-md border border-border bg-background py-1 shadow-lg">
+24 -1
View File
@@ -25,6 +25,13 @@ interface Props {
extraProps?: Record<string, unknown>;
/** The frame fills its panel and scrolls inside, instead of growing with its content. */
fill?: boolean;
/**
* Told whether the frame is up: false when the plugin's shouldShow says no
* or its frame fails to start, true once it has started. For a slot that
* stands in for a part of Bulwark (calendar-task-view), so the caller can
* draw its own part again.
*/
onShownChange?: (shown: boolean) => void;
}
interface FloatingDropdownState {
@@ -38,9 +45,11 @@ interface FloatingDropdownState {
ariaLabel?: string;
}
export function PluginIframeSlot({ pluginId, slot, extraProps, fill = false }: Props) {
export function PluginIframeSlot({ pluginId, slot, extraProps, fill = false, onShownChange }: Props) {
const wrapperRef = useRef<HTMLDivElement>(null);
const instanceRef = useRef<SandboxInstance | null>(null);
const onShownChangeRef = useRef(onShownChange);
useEffect(() => { onShownChangeRef.current = onShownChange; }, [onShownChange]);
const [height, setHeight] = useState<number>(0);
const [width, setWidth] = useState<number | undefined>(undefined);
const [floatingDropdown, setFloatingDropdown] = useState<FloatingDropdownState | null>(null);
@@ -80,6 +89,10 @@ export function PluginIframeSlot({ pluginId, slot, extraProps, fill = false }: P
return () => { cancelled = true; };
}, [pluginId, slot, props]);
useEffect(() => {
if (show === false) onShownChangeRef.current?.(false);
}, [show]);
// Spawn / tear down the slot iframe.
useEffect(() => {
if (show !== true) return;
@@ -130,7 +143,17 @@ export function PluginIframeSlot({ pluginId, slot, extraProps, fill = false }: P
},
});
instanceRef.current = inst;
// A frame that fails to start stays blank; say so to a caller that can
// draw something in its place.
let live = true;
if (onShownChangeRef.current) {
inst.initPromise.then(
() => { if (live) onShownChangeRef.current?.(true); },
() => { if (live) onShownChangeRef.current?.(false); },
);
}
return () => {
live = false;
try { inst.destroy(); } catch { /* ignore */ }
instanceRef.current = null;
setFloatingDropdown(null);
+14 -4
View File
@@ -3,7 +3,7 @@
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { useTranslations } from "next-intl";
import { Link } from "@/i18n/navigation";
import { Loader2, Puzzle } from "@/components/icons";
import { Puzzle } from "@/components/icons";
import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { usePluginStore } from "@/stores/plugin-store";
@@ -22,6 +22,8 @@ import { useDocumentTitle } from "@/hooks/use-document-title";
import { findPage, subscribePages } from "@/lib/plugin-sandbox/pages";
import { offersForSlot, subscribe as subscribeSlots } from "@/lib/plugin-sandbox/registry";
import { cn } from "@/lib/utils";
import { PluginPageSkeleton, PluginPageSkeletonBody } from "@/components/boot-skeleton/plugin-page-skeleton";
import { useSkeletonLabels } from "@/components/boot-skeleton/use-skeleton-labels";
export interface PluginPageAppProps {
/** Path segments after `/apps`: `[pluginId, pageId, ...subPath]`. */
@@ -36,6 +38,7 @@ export interface PluginPageAppProps {
*/
export function PluginPageApp({ linkSegments = [] }: PluginPageAppProps) {
const t = useTranslations("plugin_pages");
const skeleton = useSkeletonLabels();
const [pluginId = "", pageId = "", ...rest] = linkSegments;
const subPath = rest.join("/");
@@ -91,7 +94,12 @@ export function PluginPageApp({ linkSegments = [] }: PluginPageAppProps) {
const extraProps = useMemo(() => ({ pageId, path: subPath }), [pageId, subPath]);
if (!isAuthenticated) return null;
// Until the session is restored: the page skeleton the server drew.
if (!isAuthenticated) {
return isEmbedded
? <PluginPageSkeletonBody embedded />
: <PluginPageSkeleton label={skeleton.label} labels={skeleton.labels} />;
}
const available = !!page && offersPage;
let content: React.ReactNode;
@@ -102,9 +110,11 @@ export function PluginPageApp({ linkSegments = [] }: PluginPageAppProps) {
</div>
);
} else if (!pluginsReady) {
// The plugins are starting: the same empty area the page skeleton drew,
// so nothing moves before the page (or the "not available" note) arrives.
content = (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="w-6 h-6 text-muted-foreground animate-spin" aria-label={t("loading")} />
<div className="flex flex-1 min-h-0" role="status" data-plugin-page-loading="">
<span className="sr-only">{t("loading")}</span>
</div>
);
} else {
+193
View File
@@ -0,0 +1,193 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { useTranslations } from 'next-intl';
import { iconForName, Puzzle, X, type AppIcon } from '@/components/icons';
import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers';
import { get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
import { onPanelRequest } from '@/lib/plugin-sandbox/panel-requests';
import { sidePanelLooks, subscribeSidePanels } from '@/lib/plugin-sandbox/side-panels';
import { cn } from '@/lib/utils';
import { PluginIframeSlot } from './plugin-iframe-slot';
// The `side-panel` slot: one panel per plugin beside the mail, opened from
// its button in the rail at the right edge. Mountain View puts the buttons
// in its own side panel's rail (mv-side-panel.tsx); the default layout
// shows PluginSidePanel. At most one panel is open; which one is
// remembered in this browser. A plugin that set `rail: false` has no
// button: its panel opens only on its request; a rail without buttons is
// not drawn.
const STORAGE_KEY = 'plugin-side-panel-open';
export interface SidePanelEntry {
pluginId: string;
title: string;
Icon: AppIcon;
fill: boolean;
/** Whether the rail shows its button (api.ui.setSidePanel's `rail`). */
inRail: boolean;
}
function readStored(): string | null {
try { return localStorage.getItem(STORAGE_KEY); } catch { return null; }
}
function writeStored(pluginId: string | null): void {
try {
if (pluginId) localStorage.setItem(STORAGE_KEY, pluginId);
else localStorage.removeItem(STORAGE_KEY);
} catch {
// Storage may be blocked; the panel is just shut next time.
}
}
/** The plugins offering a side panel, each with the title and icon it set (api.ui.setSidePanel). */
export function usePluginSidePanelEntries(): SidePanelEntry[] {
const offers = usePluginSlotOffers('side-panel');
const looks = useSyncExternalStore(subscribeSidePanels, sidePanelLooks, sidePanelLooks);
return useMemo(() => {
return offers.map((offer) => {
const look = looks.get(offer.pluginId);
return {
pluginId: offer.pluginId,
title: look?.title || getActivePlugin(offer.pluginId)?.plugin.name || offer.pluginId,
Icon: iconForName(look?.icon) ?? Puzzle,
fill: !!offer.fill,
inRail: look?.rail !== false,
};
});
}, [offers, looks]);
}
/**
* Which plugin's side panel is open (null: none), remembered in this
* browser, and the plugins' api.ui.openPanel('side-panel') requests;
* `onOpen` runs when a plugin's request opens its panel.
*/
export function usePluginSidePanel(onOpen?: () => void) {
const entries = usePluginSidePanelEntries();
const [chosen, setChosen] = useState<string | null>(readStored);
const [focusRequest, setFocusRequest] = useState(0);
// A plugin that went away (disabled, removed) leaves its panel shut.
const open = chosen && entries.some((e) => e.pluginId === chosen) ? chosen : null;
const choose = useCallback((pluginId: string | null) => {
setChosen(pluginId);
writeStored(pluginId);
}, []);
const state = useRef({ entries, open, onOpen });
state.current = { entries, open, onOpen };
useEffect(() => onPanelRequest((request) => {
if (request.slot !== 'side-panel') return null;
if (!state.current.entries.some((e) => e.pluginId === request.pluginId)) return null;
const isOpen = state.current.open === request.pluginId;
const wantOpen = request.mode === 'open' ? true : request.mode === 'close' ? false : !isOpen;
if (wantOpen) {
choose(request.pluginId);
setFocusRequest((n) => n + 1);
state.current.onOpen?.();
} else if (isOpen) {
choose(null);
}
return wantOpen;
}), [choose]);
return { entries, open, choose, focusRequest };
}
/** A plugin's panel: the host's header (title, close) over the plugin's frame, which fills the rest. */
export function PluginSidePanelBody({ entry, focusRequest, onClose }: {
entry: SidePanelEntry;
focusRequest: number;
onClose: () => void;
}) {
const tCommon = useTranslations('common');
const extraProps = useMemo(() => ({ focusRequest }), [focusRequest]);
return (
<>
<div className="flex shrink-0 items-center gap-1 ps-4 pe-2 pt-3 pb-1">
<div className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">{entry.title}</div>
<button
type="button"
onClick={onClose}
className="grid h-8 w-8 place-items-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
title={tCommon('close')}
aria-label={tCommon('close')}
>
<X className="h-4 w-4" />
</button>
</div>
<div
data-plugin-slot="side-panel"
className={cn('min-h-0 flex-1', entry.fill ? 'flex flex-col' : 'overflow-y-auto')}
>
<PluginIframeSlot key={entry.pluginId} pluginId={entry.pluginId} slot="side-panel" extraProps={extraProps} fill={entry.fill} />
</div>
</>
);
}
/**
* The default layout's side panel: a slim rail at the right edge with a
* button per plugin, and the open plugin's panel left of it. Nothing
* without a plugin offering one.
*/
export function PluginSidePanel() {
const t = useTranslations('sidebar');
const { entries, open, choose, focusRequest } = usePluginSidePanel();
if (entries.length === 0) return null;
const active = entries.find((e) => e.pluginId === open) ?? null;
const inRail = entries.filter((e) => e.inRail);
return (
<>
{active && (
<aside
data-plugin-side-panel={active.pluginId}
className="flex w-[300px] shrink-0 flex-col overflow-hidden border-s border-border bg-background"
aria-label={active.title}
>
<PluginSidePanelBody entry={active} focusRequest={focusRequest} onClose={() => choose(null)} />
</aside>
)}
{inRail.length > 0 && <nav
data-plugin-side-rail=""
className="flex w-12 shrink-0 flex-col items-center gap-1 border-s border-border bg-background pt-2"
aria-label={t('mv_side_panel')}
>
{inRail.map((entry) => (
<SidePanelButton
key={entry.pluginId}
entry={entry}
pressed={open === entry.pluginId}
onClick={() => choose(open === entry.pluginId ? null : entry.pluginId)}
className="h-9 w-9 rounded-md hover:bg-muted"
pressedClassName="bg-muted text-foreground"
/>
))}
</nav>}
</>
);
}
/** A plugin's button in a side panel's rail. */
export function SidePanelButton({ entry, pressed, onClick, className, pressedClassName }: {
entry: SidePanelEntry;
pressed: boolean;
onClick: () => void;
className?: string;
pressedClassName?: string;
}) {
const { Icon } = entry;
return (
<button
type="button"
onClick={onClick}
data-plugin-side-panel-button={entry.pluginId}
className={cn('grid place-items-center text-muted-foreground transition-colors', className, pressed && pressedClassName)}
title={entry.title}
aria-label={entry.title}
aria-pressed={pressed}
>
<Icon className="h-5 w-5" />
</button>
);
}
+56
View File
@@ -0,0 +1,56 @@
'use client';
import { useCallback, useMemo, useState, type ReactNode } from 'react';
import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers';
import type { TaskViewFilter } from '@/stores/task-store';
import { cn } from '@/lib/utils';
import { PluginIframeSlot } from './plugin-iframe-slot';
// The `calendar-task-view` slot: a plugin drawing the calendar's task view
// below its toolbar, in place of Bulwark's task list. One plugin at a time,
// the first offering it (by its offer's order, then the plugins' order);
// none, and the calendar shows its own list. So does a plugin whose
// shouldShow says no or whose frame fails to start.
/** The plugin drawing the task view, or null when none offers it. */
export function usePluginTaskView(): { pluginId: string; fill: boolean } | null {
const offers = usePluginSlotOffers('calendar-task-view');
const first = offers[0];
return useMemo(() => (first ? { pluginId: first.pluginId, fill: !!first.fill } : null), [first]);
}
/**
* The plugin's frame in the area below the task toolbar: filled when the
* plugin asks for it (it scrolls inside), else the area scrolls. Its props:
* the toolbar's filter and the calendars ticked in the sidebar. `fallback`
* (Bulwark's list) stands in while the frame is not drawn.
*/
export function PluginTaskView({ pluginId, fill, filter, calendarIds, fallback }: {
pluginId: string;
fill: boolean;
filter: TaskViewFilter;
calendarIds: readonly string[];
fallback: ReactNode;
}) {
// One array per set of ids, so the frame gets no update while they stay.
const idsKey = calendarIds.join('\u0000');
const ids = useMemo(() => (idsKey ? idsKey.split('\u0000') : []), [idsKey]);
const extraProps = useMemo(() => ({ filter, calendarIds: ids }), [filter, ids]);
// The plugin whose frame is not drawn. The frame stays mounted (hidden),
// so a shouldShow that says yes for another filter takes the view back.
const [notShownBy, setNotShownBy] = useState<string | null>(null);
const notShown = notShownBy === pluginId;
const onShownChange = useCallback((shown: boolean) => setNotShownBy(shown ? null : pluginId), [pluginId]);
return (
<>
{notShown && fallback}
<div
data-plugin-slot="calendar-task-view"
data-plugin-task-view={pluginId}
className={cn('min-h-0 flex-1', fill ? 'flex flex-col' : 'overflow-y-auto', notShown && 'hidden')}
>
<PluginIframeSlot key={pluginId} pluginId={pluginId} slot="calendar-task-view" extraProps={extraProps} fill={fill} onShownChange={onShownChange} />
</div>
</>
);
}
@@ -1,4 +1,4 @@
import { render } from '@testing-library/react';
import { cleanup, render } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
// The Pro shell takes over the standard routes and hands their deep link to
@@ -11,7 +11,9 @@ vi.mock('@/i18n/navigation', () => ({
usePathname: () => nav.pathname,
useRouter: () => ({ replace: nav.replace }),
}));
vi.mock('@/hooks/use-media-query', () => ({ useIsDesktop: () => true }));
const device = vi.hoisted(() => ({ desktop: true }));
vi.mock('@/hooks/use-media-query', () => ({ useIsDesktop: () => device.desktop }));
vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
vi.mock('@/stores/settings-store', () => ({
useSettingsStore: (selector: (s: { proInterface: boolean }) => unknown) => selector({ proInterface: true }),
}));
@@ -25,6 +27,7 @@ const { ProInterfaceRedirect } = await import('../pro-interface-redirect');
describe('ProInterfaceRedirect', () => {
beforeEach(() => {
vi.clearAllMocks();
device.desktop = true;
});
function visit(path: string, search: string) {
@@ -56,4 +59,24 @@ describe('ProInterfaceRedirect', () => {
expect(setPendingDeepLink).not.toHaveBeenCalled();
expect(openTab).toHaveBeenLastCalledWith('files');
});
it('covers the standard page with the Pro skeleton until /pro renders', () => {
visit('/calendar/week/2026-11-04', '');
const cover = document.querySelector('[data-pro-takeover]');
expect(cover?.querySelector('[data-boot-skeleton="pro"]')).not.toBeNull();
expect(cover?.className).toContain('fixed');
});
it('draws nothing on /pro itself, below desktop width, or on other pages', () => {
visit('/pro', '');
expect(document.querySelector('[data-pro-takeover]')).toBeNull();
cleanup();
visit('/login', '');
expect(document.querySelector('[data-pro-takeover]')).toBeNull();
cleanup();
device.desktop = false;
visit('/mail', '');
expect(document.querySelector('[data-pro-takeover]')).toBeNull();
expect(nav.replace).not.toHaveBeenCalled();
});
});
+17 -1
View File
@@ -7,6 +7,8 @@ import { useIsDesktop } from "@/hooks/use-media-query";
import { useProTabStore, type ProAppTabKind } from "@/stores/pro-tab-store";
import { matchSurface, type AppSurface } from "@/lib/deep-links";
import { setPendingDeepLink } from "@/lib/deep-link-handoff";
import { ProSkeleton } from "@/components/boot-skeleton/pro-skeleton";
import { useSkeletonLabels } from "@/components/boot-skeleton/use-skeleton-labels";
const SURFACE_TO_TAB: Record<AppSurface, ProAppTabKind> = {
mail: 'mail',
@@ -37,12 +39,21 @@ export function useProInterfaceActive(): boolean {
* segments are handed to the tab that opens (#733), so `/mail/message/<id>`
* opens that message in a Pro mail tab instead of silently landing on the
* inbox.
*
* Until /pro has rendered, the Pro skeleton covers the standard page, which
* may finish loading first (a fast session restore, a slow /pro chunk): the
* visitor sees the Pro frame from the first paint to the loaded Pro shell,
* never the standard page in between. The server HTML and the hydration pass
* draw nothing here (isDesktop is false there); the page's own skeleton is
* already the Pro frame then (components/boot-skeleton/shell.tsx).
*/
export function ProInterfaceRedirect() {
const router = useRouter();
const pathname = usePathname();
const proInterface = useSettingsStore((s) => s.proInterface);
const isDesktop = useIsDesktop();
const { label } = useSkeletonLabels();
const takingOver = proInterface && isDesktop && matchSurface(pathname) !== null;
useEffect(() => {
if (!proInterface || !isDesktop) return;
@@ -63,5 +74,10 @@ export function ProInterfaceRedirect() {
router.replace('/pro');
}, [proInterface, isDesktop, pathname, router]);
return null;
if (!takingOver) return null;
return (
<div className="fixed inset-0 z-50" data-pro-takeover="">
<ProSkeleton label={label} />
</div>
);
}
@@ -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} />;
}
+10 -6
View File
@@ -4,6 +4,8 @@ import { useEffect } from "react";
import { parseWebcal } from "@/lib/protocol-handlers/webcal";
import { savePendingWebcal } from "@/lib/protocol-handlers/session";
import { useSettingsStore } from "@/stores/settings-store";
import { CalendarSkeleton, type CalendarSkeletonText } from "@/components/boot-skeleton/calendar-skeleton";
import type { RailLabels } from "@/components/boot-skeleton/shell";
type StandaloneNavigator = Navigator & { standalone?: boolean };
@@ -39,9 +41,13 @@ function shouldOpenFallbackAppTab(): boolean {
interface WebcalProtocolClientProps {
openingText: string;
/** Rail labels for the phone bottom bar (sidebar.*). */
labels?: RailLabels;
/** The calendar skeleton's words (calendar.*). */
text?: CalendarSkeletonText;
}
export function WebcalProtocolClient({ openingText }: WebcalProtocolClientProps) {
export function WebcalProtocolClient({ openingText, labels, text }: WebcalProtocolClientProps) {
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const raw = params.get("url");
@@ -65,9 +71,7 @@ export function WebcalProtocolClient({ openingText }: WebcalProtocolClientProps)
window.location.replace(`${getProtocolPathPrefix()}/calendar`);
}, []);
return (
<main className="flex min-h-screen items-center justify-center">
<p>{openingText}</p>
</main>
);
// The calendar is where this goes next: its skeleton, with "Opening…" as
// the page's status.
return <CalendarSkeleton label={openingText} labels={labels} text={text} />;
}
@@ -5,10 +5,16 @@ import { useTranslations } from 'next-intl';
import { IntlProvider } from '../intl-provider';
import { useLocaleStore } from '@/stores/locale-store';
const nav = vi.hoisted(() => ({ pathname: null as string | null }));
vi.mock('next/navigation', async (importOriginal) => ({
...(await importOriginal<typeof import('next/navigation')>()),
usePathname: () => nav.pathname,
}));
// The Lite export leaves the catalog out of the [locale] layout, because the
// prop was baked into every exported page and pushed the Stalwart zip past its
// size limit. The provider then loads the catalog itself and must render
// nothing until it has it: English text would flash on a German page, and the
// no text until it has it (only the page's skeleton, which has none): English text would flash on a German page, and the
// prerendered HTML has to match the client's first render.
vi.unmock('next-intl');
@@ -20,12 +26,22 @@ function Cancel() {
describe('IntlProvider without server messages (Lite)', () => {
afterEach(() => {
useLocaleStore.setState({ locale: '' });
nav.pathname = null;
});
it('prerenders nothing for a non-English locale', () => {
expect(renderToString(<IntlProvider locale="de"><Cancel /></IntlProvider>)).toBe('');
});
it('prerenders the page skeleton, without any text, while the catalog is missing', () => {
nav.pathname = '/de/calendar';
const html = renderToString(<IntlProvider locale="de"><Cancel /></IntlProvider>);
expect(html).toContain('data-boot-skeleton="calendar"');
expect(html).toContain('aria-busy="true"');
expect(html).not.toContain('Cancel');
expect(html).not.toContain('role="status"');
});
it('prerenders English straight away from the bundled catalog', () => {
expect(renderToString(<IntlProvider locale="en"><Cancel /></IntlProvider>)).toContain('Cancel');
});

Some files were not shown because too many files have changed in this diff Show More