diff --git a/.github/workflows/perf-guard.yml b/.github/workflows/perf-guard.yml
index 1ff1264e4..0c1aba7af 100644
--- a/.github/workflows/perf-guard.yml
+++ b/.github/workflows/perf-guard.yml
@@ -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
diff --git a/app/(main)/[locale]/auth/callback/__tests__/callback-skeleton.test.tsx b/app/(main)/[locale]/auth/callback/__tests__/callback-skeleton.test.tsx
new file mode 100644
index 000000000..b4a1cd2f7
--- /dev/null
+++ b/app/(main)/[locale]/auth/callback/__tests__/callback-skeleton.test.tsx
@@ -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();
+ 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();
+ });
+});
diff --git a/app/(main)/[locale]/auth/callback/page.tsx b/app/(main)/[locale]/auth/callback/page.tsx
index 8a6c3cdbf..97bace353 100644
--- a/app/(main)/[locale]/auth/callback/page.tsx
+++ b/app/(main)/[locale]/auth/callback/page.tsx
@@ -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(null);
// A sign-in started for the mobile app, waiting for the user to confirm
@@ -300,27 +303,16 @@ function OAuthCallbackInner() {
);
}
- return (
-
-
-
-
{t("oauth_completing")}
-
-
- );
+ // 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 ;
}
export default function OAuthCallbackPage() {
+ const t = useTranslations("login");
+ const { labels } = useSkeletonLabels();
return (
-
-
-
-
-
- }
- >
+ }>
);
diff --git a/app/(main)/[locale]/login/__tests__/login-page-skeleton.test.tsx b/app/(main)/[locale]/login/__tests__/login-page-skeleton.test.tsx
new file mode 100644
index 000000000..e0c3d6a4b
--- /dev/null
+++ b/app/(main)/[locale]/login/__tests__/login-page-skeleton.test.tsx
@@ -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()),
+ 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();
+ 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();
+ 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();
+ 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;
+ }
+ });
+});
diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx
index efdf33a1d..1ef9932e0 100644
--- a/app/(main)/[locale]/login/page.tsx
+++ b/app/(main)/[locale]/login/page.tsx
@@ -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 (
-
-
-
- {t("loading")}
-
-
- );
+ return ;
}
if (configError) {
@@ -1533,9 +1530,8 @@ function LoginPageContent() {
) : (
-
-
-
+ // Where the sign-in button goes once the provider answers.
+
)}
) : (
@@ -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 (
-
+ }>
);
diff --git a/app/(main)/[locale]/pro/__tests__/pro-gate.test.tsx b/app/(main)/[locale]/pro/__tests__/pro-gate.test.tsx
new file mode 100644
index 000000000..840c7f39e
--- /dev/null
+++ b/app/(main)/[locale]/pro/__tests__/pro-gate.test.tsx
@@ -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(() => {}),
+ 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: () => }));
+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: () => }));
+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: () => }));
+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();
+ 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();
+ 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();
+ 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();
+ });
+});
diff --git a/app/(main)/[locale]/pro/page.tsx b/app/(main)/[locale]/pro/page.tsx
index 6c6cc861c..a1015bd64 100644
--- a/app/(main)/[locale]/pro/page.tsx
+++ b/app/(main)/[locale]/pro/page.tsx
@@ -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 (
-
-
-
-
{t("common.loading")}
-
-
- );
+ // 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 ;
}
- 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 (
-
+ {/* data-pro-shell: a page skeleton inside a pane draws its own frame, not the Pro one. */}
+
{/* Once for the whole shell: email and folder tabs have no app strip, and split panes would show it twice. */}
diff --git a/app/(main)/admin/__tests__/admin-login-logo.test.tsx b/app/(main)/admin/__tests__/admin-login-logo.test.tsx
new file mode 100644
index 000000000..742242a8f
--- /dev/null
+++ b/app/(main)/admin/__tests__/admin-login-logo.test.tsx
@@ -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();
+ 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')],
+ ]);
+ });
+});
diff --git a/app/(main)/admin/__tests__/layout-client-loading.test.tsx b/app/(main)/admin/__tests__/layout-client-loading.test.tsx
new file mode 100644
index 000000000..a33668d5a
--- /dev/null
+++ b/app/(main)/admin/__tests__/layout-client-loading.test.tsx
@@ -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 }) => {children},
+}));
+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(
page
);
+ 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();
+ });
+});
diff --git a/app/(main)/admin/_layout-client.tsx b/app/(main)/admin/_layout-client.tsx
index c486d4927..ef6e2955c 100644
--- a/app/(main)/admin/_layout-client.tsx
+++ b/app/(main)/admin/_layout-client.tsx
@@ -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(null);
const [authError, setAuthError] = useState(null);
@@ -135,9 +138,9 @@ export function AdminLayoutClient({ children }: { children: React.ReactNode }) {
{authError}
) : authenticated === null ? (
-
- Loading admin panel…
-
+ // The page's header and section placeholders, where the boot
+ // skeleton drew them.
+
) : authenticated ? children : null}
>
diff --git a/app/(main)/admin/_pages/auth.tsx b/app/(main)/admin/_pages/auth.tsx
index e19fd9ea1..2ef463a45 100644
--- a/app/(main)/admin/_pages/auth.tsx
+++ b/app/(main)/admin/_pages/auth.tsx
@@ -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 (
-
- Loading
-
+
);
}
diff --git a/app/(main)/admin/_pages/branding.tsx b/app/(main)/admin/_pages/branding.tsx
index 634edb936..506b6ca77 100644
--- a/app/(main)/admin/_pages/branding.tsx
+++ b/app/(main)/admin/_pages/branding.tsx
@@ -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 (
-
- Loading
-
+
);
}
diff --git a/app/(main)/admin/_pages/monitoring.tsx b/app/(main)/admin/_pages/monitoring.tsx
index c574cb790..37862912c 100644
--- a/app/(main)/admin/_pages/monitoring.tsx
+++ b/app/(main)/admin/_pages/monitoring.tsx
@@ -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 (
-
- Loading
-
+
);
}
diff --git a/app/(main)/admin/_pages/policy.tsx b/app/(main)/admin/_pages/policy.tsx
index 2b7ff5798..e09b862bc 100644
--- a/app/(main)/admin/_pages/policy.tsx
+++ b/app/(main)/admin/_pages/policy.tsx
@@ -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 ? (
-
- Loading
-
+
) : (
diff --git a/app/(main)/admin/_pages/settings.tsx b/app/(main)/admin/_pages/settings.tsx
index 901056059..3dfca74e8 100644
--- a/app/(main)/admin/_pages/settings.tsx
+++ b/app/(main)/admin/_pages/settings.tsx
@@ -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 (
-
- Loading
-
+
);
}
diff --git a/app/(main)/admin/_pages/telemetry.tsx b/app/(main)/admin/_pages/telemetry.tsx
index 74a84ed06..322e1e0f1 100644
--- a/app/(main)/admin/_pages/telemetry.tsx
+++ b/app/(main)/admin/_pages/telemetry.tsx
@@ -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 (
{header}
-
- Loading
-
+
);
}
diff --git a/app/(main)/admin/_pages/version.tsx b/app/(main)/admin/_pages/version.tsx
index 6093aa948..5b0cd2b12 100644
--- a/app/(main)/admin/_pages/version.tsx
+++ b/app/(main)/admin/_pages/version.tsx
@@ -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 (
{header}
-
- Loading
-
+
);
}
diff --git a/app/(main)/admin/login/page.tsx b/app/(main)/admin/login/page.tsx
index 9a95d9312..df5c8ee86 100644
--- a/app/(main)/admin/login/page.tsx
+++ b/app/(main)/admin/login/page.tsx
@@ -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() {
diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx
index 208656678..d186b72da 100644
--- a/app/(main)/layout.tsx
+++ b/app/(main)/layout.tsx
@@ -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()}
})();
`,
}}
diff --git a/app/(main)/protocol/mailto/page.tsx b/app/(main)/protocol/mailto/page.tsx
index f2797d699..bef4cbd4c 100644
--- a/app/(main)/protocol/mailto/page.tsx
+++ b/app/(main)/protocol/mailto/page.tsx
@@ -3,6 +3,14 @@ import { MailtoProtocolClient } from "@/components/protocol/mailto-protocol-clie
export default async function MailtoProtocolPage() {
const t = await getTranslations("protocol_handlers");
+ const tSidebar = await getTranslations("sidebar");
+ const labels = {
+ mail: tSidebar("mail"),
+ calendar: tSidebar("calendar"),
+ contacts: tSidebar("contacts"),
+ files: tSidebar("files"),
+ settings: tSidebar("settings"),
+ };
- return ;
+ return ;
}
diff --git a/app/(main)/protocol/webcal/page.tsx b/app/(main)/protocol/webcal/page.tsx
index 47cdbfa8c..d4641c490 100644
--- a/app/(main)/protocol/webcal/page.tsx
+++ b/app/(main)/protocol/webcal/page.tsx
@@ -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 ;
+ return (
+
+ );
}
diff --git a/app/(main)/setup/__tests__/setup-skeleton.test.tsx b/app/(main)/setup/__tests__/setup-skeleton.test.tsx
new file mode 100644
index 000000000..5af673dd2
--- /dev/null
+++ b/app/(main)/setup/__tests__/setup-skeleton.test.tsx
@@ -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 = (
+
+
+
+);
+
+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();
+ });
+});
diff --git a/app/(main)/setup/page.tsx b/app/(main)/setup/page.tsx
index e913b3f77..c8c34e241 100644
--- a/app/(main)/setup/page.tsx
+++ b/app/(main)/setup/page.tsx
@@ -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..
-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>;
@@ -201,8 +195,24 @@ export default function SetupWizardPage() {
return 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
{t('loading')}
;
+ return (
+ 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) {
diff --git a/app/boot-skeleton.css b/app/boot-skeleton.css
new file mode 100644
index 000000000..b33ec0f2e
--- /dev/null
+++ b/app/boot-skeleton.css
@@ -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 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--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);
+ }
+}
diff --git a/app/globals.css b/app/globals.css
index 15f61878d..7cbfd50c8 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -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
diff --git a/components/admin/__tests__/admin-shell.test.tsx b/components/admin/__tests__/admin-shell.test.tsx
index d1d081e7e..6696a5445 100644
--- a/components/admin/__tests__/admin-shell.test.tsx
+++ b/components/admin/__tests__/admin-shell.test.tsx
@@ -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('
page
');
});
// 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 }));
diff --git a/components/admin/admin-page-icons.ts b/components/admin/admin-page-icons.ts
new file mode 100644
index 000000000..e8d6903e9
--- /dev/null
+++ b/components/admin/admin-page-icons.ts
@@ -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 = {
+ 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 = {
+ top: null,
+ organisation: 'Organisation',
+ extensions: 'Extensions',
+ system: 'System',
+};
diff --git a/components/admin/admin-shell.tsx b/components/admin/admin-shell.tsx
index 6a7dcb513..5343afe10 100644
--- a/components/admin/admin-shell.tsx
+++ b/components/admin/admin-shell.tsx
@@ -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 = {
- 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 = {
- 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,
);
- if (!hydrated) return ;
+ // 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 (
+
+ );
+ }
if (!isDesktop) {
const showIndex = location.page === null && !onChangePassword;
@@ -382,7 +336,11 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
);
diff --git a/components/connector/__tests__/connector-client.test.tsx b/components/connector/__tests__/connector-client.test.tsx
index 804f6a2fb..65bc7075b 100644
--- a/components/connector/__tests__/connector-client.test.tsx
+++ b/components/connector/__tests__/connector-client.test.tsx
@@ -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();
+ 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();
+ expect(html).not.toContain('data-boot-skeleton');
+ expect(html).toContain('opening');
+ });
+});
diff --git a/components/connector/connector-client.tsx b/components/connector/connector-client.tsx
index 53ef5ab91..6654568c3 100644
--- a/components/connector/connector-client.tsx
+++ b/components/connector/connector-client.tsx
@@ -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 ;
return {t('opening')};
}
diff --git a/components/contacts/__tests__/contact-list.test.tsx b/components/contacts/__tests__/contact-list.test.tsx
index 16fdd839f..a0864a598 100644
--- a/components/contacts/__tests__/contact-list.test.tsx
+++ b/components/contacts/__tests__/contact-list.test.tsx
@@ -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();
+ expect(avatars().length).toBeGreaterThan(0);
+ expect(avatars().every((el) => el.classList.contains('hidden'))).toBe(true);
+ unmount();
+
+ act(() => { useSettingsStore.setState({ density: 'regular', groupContactsByLetter: true }); });
+ render();
+ expect(avatars().some((el) => el.classList.contains('hidden'))).toBe(false);
+ });
+
it('shows the contacts as soon as they are there, even while loading', () => {
render();
expect(screen.queryByTestId('contact-list-skeleton')).toBeNull();
diff --git a/components/contacts/contact-list.tsx b/components/contacts/contact-list.tsx
index 8b0a1f4c0..ba73b53e9 100644
--- a/components/contacts/contact-list.tsx
+++ b/components/contacts/contact-list.tsx
@@ -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 ? (
-
- {Array.from({ length: 8 }, (_, i) => (
-
-
-
-
-
-
-
- ))}
-
+ // The rows the page skeleton drew, at the same density.
+
) : sorted.length === 0 ? (
searchQuery || activeFilters > 0 ? (
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
+ ?
+ : ;
+ }
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 && { 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 (
-
+ // The static Lite export writes this fallback into the page's HTML.
+ }>
);
}
+
+function ContactsFallback() {
+ const { label, labels } = useSkeletonLabels();
+ return ;
+}
diff --git a/components/email/__tests__/composer-a11y.test.tsx b/components/email/__tests__/composer-a11y.test.tsx
index 3a9d3f39f..e69492e89 100644
--- a/components/email/__tests__/composer-a11y.test.tsx
+++ b/components/email/__tests__/composer-a11y.test.tsx
@@ -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,
diff --git a/components/email/__tests__/composer-format-toggle.test.tsx b/components/email/__tests__/composer-format-toggle.test.tsx
index 28f3f029c..5df61c004 100644
--- a/components/email/__tests__/composer-format-toggle.test.tsx
+++ b/components/email/__tests__/composer-format-toggle.test.tsx
@@ -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: () => {},
diff --git a/components/email/__tests__/composer-from-override-envelope.test.tsx b/components/email/__tests__/composer-from-override-envelope.test.tsx
index e8cd2f9f6..c6d39338e 100644
--- a/components/email/__tests__/composer-from-override-envelope.test.tsx
+++ b/components/email/__tests__/composer-from-override-envelope.test.tsx
@@ -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: () => {},
diff --git a/components/email/__tests__/composer-identity-signature.test.tsx b/components/email/__tests__/composer-identity-signature.test.tsx
index 84a6c3a98..b0d9a0093 100644
--- a/components/email/__tests__/composer-identity-signature.test.tsx
+++ b/components/email/__tests__/composer-identity-signature.test.tsx
@@ -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();
+
+ // 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();
+
+ // 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');
+ });
+});
diff --git a/components/email/__tests__/composer-org-signature.test.tsx b/components/email/__tests__/composer-org-signature.test.tsx
index e6e76afa0..ee494c68f 100644
--- a/components/email/__tests__/composer-org-signature.test.tsx
+++ b/components/email/__tests__/composer-org-signature.test.tsx
@@ -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: () => {},
diff --git a/components/email/__tests__/composer-reopened-draft-signature.test.tsx b/components/email/__tests__/composer-reopened-draft-signature.test.tsx
index 3ddd45a2f..9e8aae0f9 100644
--- a/components/email/__tests__/composer-reopened-draft-signature.test.tsx
+++ b/components/email/__tests__/composer-reopened-draft-signature.test.tsx
@@ -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: () => {},
diff --git a/components/email/__tests__/composer-reply-signature.test.tsx b/components/email/__tests__/composer-reply-signature.test.tsx
index 9205d4c35..2198cec74 100644
--- a/components/email/__tests__/composer-reply-signature.test.tsx
+++ b/components/email/__tests__/composer-reply-signature.test.tsx
@@ -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('Sales team');
expect(html.indexOf('Sales team')).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();
+
+ const html = screen.getByTestId('rich-text-editor').getAttribute('data-html')!;
+ expect(html.startsWith('
--
')).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();
+ setIdentities([ME]);
+ rerender();
+
+ await waitFor(() => expect(editorState.html).toContain('Alice'));
+ 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 () => {
diff --git a/components/email/__tests__/composer-template-images.test.tsx b/components/email/__tests__/composer-template-images.test.tsx
new file mode 100644
index 000000000..ccd146dc5
--- /dev/null
+++ b/components/email/__tests__/composer-template-images.test.tsx
@@ -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: '
`;
}
}
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((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) => {
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((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) => {
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) ? `
-- ` : `
`;
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) ? (
diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx
index 05def799e..bebd4f42c 100644
--- a/components/email/email-list.tsx
+++ b/components/email/email-list.tsx
@@ -29,6 +29,8 @@ import { useShallow } from "zustand/react/shallow";
import { useAuthStore } from "@/stores/auth-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useListRowShape } from "@/hooks/use-list-row-shape";
+import { MailListRowsSkeleton, type MailRowShape } from "@/components/boot-skeleton/mail-skeleton";
+import type { Density } from "@/lib/density";
import { useUIStore } from "@/stores/ui-store";
import { useMessageListTabsStore } from "@/stores/message-list-tabs-store";
import { emailKeyFor, groupEmailsByThread, sortThreadGroups, threadKeyFor, getEmailTagIds } from "@/lib/thread-utils";
@@ -124,27 +126,11 @@ const BATCH_TOOLBAR_WIDE = 480;
// Declared out here so re-renders of the list keep the same element. Inside
// EmailList every render made a new component, which remounted the skeleton
-// and replayed its fade-in each time the store changed while a list loaded.
-function LoadingSkeleton() {
- return (
-
- {[...Array(8)].map((_, i) => (
-
-
-
-
-
-
-
-
-
-
-
-
-
- ))}
-
- );
+// each time the store changed while a list loaded.
+function LoadingSkeleton({ shape, density, preview }: { shape: MailRowShape; density: Density; preview: boolean }) {
+ // The page skeleton's rows (components/boot-skeleton), so the list the app
+ // draws while it loads is the one the server painted before it started.
+ return ;
}
/** Rows per page in simple mode's paged list. */
@@ -1222,7 +1208,13 @@ export function EmailList({
)}
{isLoading && emails.length === 0 ? (
-
+
+
+
) : emails.length === 0 && !isLoading ? (
{/* R9: an empty search offers Clear search; Drafts, which the
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index c5b85d991..8c3167aa9 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -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) => (
);
- // 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(
-
- {!isCollapsed && t("loading_mailboxes")}
+ // The page skeleton's folder rows, so nothing moves when the
+ // folders arrive (components/boot-skeleton).
+
+ {t("loading_mailboxes")}
+ {!isCollapsed && }
) : (
<>
diff --git a/components/mail/__tests__/mail-app-boot-skeleton.test.tsx b/components/mail/__tests__/mail-app-boot-skeleton.test.tsx
new file mode 100644
index 000000000..920c50b20
--- /dev/null
+++ b/components/mail/__tests__/mail-app-boot-skeleton.test.tsx
@@ -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>>) => {
+ const Component = lazy(async () => ({ default: await load() }));
+ return function Dynamic(props: Record) {
+ return ;
+ };
+} }));
+const viewer = vi.hoisted(() => ({ isLoading: [] as boolean[] }));
+vi.mock('@/components/email/email-viewer', () => ({
+ EmailViewer: ({ isLoading }: { isLoading?: boolean }) => {
+ viewer.isLoading.push(!!isLoading);
+ return ;
+ },
+}));
+const list = vi.hoisted(() => ({ isLoading: [] as boolean[] }));
+vi.mock('@/components/email/email-list', () => ({
+ EmailList: ({ isLoading }: { isLoading?: boolean }) => {
+ list.isLoading.push(!!isLoading);
+ return ;
+ },
+}));
+vi.mock('@/components/email/email-composer', () => ({ EmailComposer: () => null }));
+vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null }));
+vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => }));
+vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null }));
+vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null }));
+vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null }));
+vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
+vi.mock('@/components/layout/mv-side-panel', () => ({ MvSidePanel: () => null }));
+vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), MessageListTabs: () => null }));
+vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
+vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) }));
+vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
+vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
+vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
+vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} }));
+vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] }));
+vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} }));
+vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
+vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
+const device = vi.hoisted(() => ({ isMobile: false }));
+vi.mock('@/hooks/use-media-query', () => ({
+ useDeviceDetection: () => ({ isMobile: device.isMobile, isTablet: false, isDesktop: !device.isMobile }),
+ useMediaQuery: () => false,
+ useIsDesktop: () => !device.isMobile,
+}));
+vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
+
+const { MailApp } = await import('../mail-app');
+
+const inbox = {
+ id: 'inbox', name: 'Inbox', role: 'inbox', sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true,
+} as Mailbox;
+
+/** A promise the test settles by hand. */
+function deferred() {
+ let resolve!: (value: T) => void;
+ const promise = new Promise((r) => { resolve = r; });
+ return { promise, resolve };
+}
+
+let fetchEmails: ReturnType;
+let fetchEmailContent: ReturnType;
+
+beforeEach(() => {
+ list.isLoading = [];
+ viewer.isLoading = [];
+ device.isMobile = false;
+ nav.replace.mockReset();
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
+ vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
+ usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
+ useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) });
+ useUIStore.setState({ activeView: 'list' });
+ const client = {
+ getAccountId: () => 'acc', getUsername: () => 'a@example.org',
+ getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null,
+ getMailboxes: vi.fn().mockResolvedValue([inbox]),
+ };
+ useAuthStore.setState({
+ isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [],
+ getClientForAccount: () => client as never,
+ getAllConnectedClients: () => new Map([['login-a', client]]) as never,
+ });
+ useSettingsStore.setState({ enableUnifiedMailbox: false, interfaceLayout: 'default', mailLayout: 'focus' } as never);
+ fetchEmails = vi.fn().mockResolvedValue(undefined);
+ fetchEmailContent = vi.fn().mockResolvedValue(null);
+ useEmailStore.setState({
+ emails: [], selectedEmail: null, selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: [inbox],
+ viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
+ isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '',
+ searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '',
+ selectedEmailKeys: new Set(), unreadSection: null,
+ fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails,
+ fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined),
+ refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined),
+ fetchEmailContent,
+ } as never);
+});
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+});
+
+describe('mail page hand-off from the boot skeleton', () => {
+ it('draws the mail skeleton, not a spinner, while the session is restored', async () => {
+ useAuthStore.setState({ isAuthenticated: false, client: null, checkAuth: vi.fn(() => new Promise(() => {})) } as never);
+ const { container } = render();
+ await act(async () => {});
+ expect(container.querySelector('[data-boot-skeleton="mail"]')).not.toBeNull();
+ expect(container.querySelector('[data-boot-skeleton="mail"] [role="status"]')?.textContent).toBeTruthy();
+ expect(container.querySelector('.animate-spin')).toBeNull();
+ });
+
+ it('keeps the list loading until the folder\'s first page is in, then shows an empty folder as empty', async () => {
+ const page = deferred();
+ fetchEmails.mockReturnValue(page.promise);
+ const { getByTestId } = render();
+ await act(async () => {});
+ // Nothing fetched yet: the store is not loading, but the list must not say "no emails".
+ expect(useEmailStore.getState().isLoading).toBe(false);
+ expect(getByTestId('list').hasAttribute('data-loading')).toBe(true);
+ await act(async () => { page.resolve(); });
+ await waitFor(() => expect(getByTestId('list').hasAttribute('data-loading')).toBe(false));
+ });
+
+ it('keeps the reader open in its loading state while a linked message loads (focus layout)', async () => {
+ const content = deferred();
+ fetchEmailContent.mockReturnValue(content.promise);
+ const { getByTestId, container } = render();
+ await act(async () => {});
+ const reader = container.querySelector('[data-pane="reader"]')!;
+ expect(reader.className).not.toMatch(/(^|\s)md:hidden(\s|$)/);
+ expect(getByTestId('viewer').hasAttribute('data-loading')).toBe(true);
+ // Not found: the reader goes away again and the list takes the page.
+ await act(async () => { content.resolve(null); });
+ await waitFor(() => expect(container.querySelector('[data-pane="reader"]')!.className).toMatch(/(^|\s)md:hidden(\s|$)/));
+ });
+
+ it('opens a linked message over the list on a phone and hides the bottom bar meanwhile', async () => {
+ device.isMobile = true;
+ fetchEmailContent.mockReturnValue(new Promise(() => {}));
+ const { container, queryByTestId } = render();
+ await act(async () => {});
+ const reader = container.querySelector('[data-pane="reader"]')!;
+ expect(reader.className).not.toMatch(/(^|\s)max-md:hidden(\s|$)/);
+ expect(queryByTestId('bottom-nav')).toBeNull();
+ });
+
+ it('draws a linked conversation on a phone as the conversation view, not the message viewer, while it loads', async () => {
+ device.isMobile = true;
+ const client = useAuthStore.getState().client as unknown as Record;
+ client.getThreadEmails = vi.fn(() => new Promise(() => {}));
+ const { container, queryByTestId } = render();
+ await act(async () => {});
+ const reader = container.querySelector('[data-pane="reader"]')!;
+ expect(reader.querySelector('[aria-busy="true"] [role="status"]')).not.toBeNull();
+ expect(queryByTestId('viewer')).toBeNull();
+ });
+});
diff --git a/components/mail/__tests__/mail-app-source-routing.test.tsx b/components/mail/__tests__/mail-app-source-routing.test.tsx
index b0dd78261..add5d3d12 100644
--- a/components/mail/__tests__/mail-app-source-routing.test.tsx
+++ b/components/mail/__tests__/mail-app-source-routing.test.tsx
@@ -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();
+ 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();
+ try {
+ await waitFor(() => expect(useEmailStore.getState().selectedMailbox).toBe('ce:a'));
+ } finally {
+ mounted.unmount();
+ window.history.replaceState(null, '', '/');
+ }
+ });
+});
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index 33ae06070..4efe79fa7 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -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 (
-
-
-
-
{t("common.loading")}
-
-
- );
+ return ;
}
// 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 ;
}
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 = {}) {
)}
{/* Mobile Conversation View - shown when thread is selected on mobile */}
- {isMobile && conversationThread ? (
+ {isMobile && !conversationThread && deepLinkPane === 'thread' ? (
+ // A linked conversation on a phone, still loading: the shape the
+ // page skeleton gave it.
+
+ {t('threads.loading')}
+
+
+ ) : isMobile && conversationThread ? (
{/* Bottom Navigation - mobile and tablet (hidden when embedded) */}
- {(isMobile || isTablet) && activeView !== "viewer" && !isEmbedded && (
+ {(isMobile || isTablet) && mobileView !== "viewer" && !isEmbedded && (
}
+ {/* 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 && }
{/* Keyboard Shortcuts Modal */}
diff --git a/components/plugins/__tests__/plugin-dialog-popover.test.tsx b/components/plugins/__tests__/plugin-dialog-popover.test.tsx
new file mode 100644
index 000000000..699b86019
--- /dev/null
+++ b/components/plugins/__tests__/plugin-dialog-popover.test.tsx
@@ -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) => (values?.name ? `${key}:${values.name}` : key) }));
+vi.mock('../plugin-iframe-slot', () => ({ PluginIframeSlot: () => }));
+
+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();
+ 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('[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();
+ let result: Promise;
+ 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();
+ let result: Promise;
+ 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();
+ 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();
+ });
+});
diff --git a/components/plugins/__tests__/plugin-page-app.test.tsx b/components/plugins/__tests__/plugin-page-app.test.tsx
index cf983810c..f83fe1ab5 100644
--- a/components/plugins/__tests__/plugin-page-app.test.tsx
+++ b/components/plugins/__tests__/plugin-page-app.test.tsx
@@ -107,7 +107,7 @@ describe('plugin pages', () => {
render();
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', () => {
diff --git a/components/plugins/__tests__/plugin-side-panels.test.tsx b/components/plugins/__tests__/plugin-side-panels.test.tsx
new file mode 100644
index 000000000..d250299c0
--- /dev/null
+++ b/components/plugins/__tests__/plugin-side-panels.test.tsx
@@ -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();
+ 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();
+ 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; 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();
+ fireEvent.click(screen.getByRole('button', { name: 'Follow-ups' }));
+ first.unmount();
+
+ const { container } = render();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ expect(screen.getByRole('button', { name: 'Follow-ups' })).toBeInTheDocument();
+ });
+});
diff --git a/components/plugins/__tests__/plugin-task-view.test.tsx b/components/plugins/__tests__/plugin-task-view.test.tsx
new file mode 100644
index 000000000..fc5988e2f
--- /dev/null
+++ b/components/plugins/__tests__/plugin-task-view.test.tsx
@@ -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 = 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 = ;
+ if (!view) return list;
+ return ;
+}
+
+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();
+ 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();
+ 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; 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();
+ await waitFor(() => expect(createSlotInstance).toHaveBeenCalled());
+ const opts = createSlotInstance.mock.calls[0][0] as { extraProps: Record };
+ 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();
+ 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();
+ 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();
+ await waitFor(() => expect(createSlotInstance).toHaveBeenCalled());
+ expect(container.querySelector('[data-plugin-task-view]')?.className).toContain('overflow-y-auto');
+ });
+});
diff --git a/components/plugins/plugin-dialog-host.tsx b/components/plugins/plugin-dialog-host.tsx
index 7032cd9ad..13f4cee80 100644
--- a/components/plugins/plugin-dialog-host.tsx
+++ b/components/plugins/plugin-dialog-host.tsx
@@ -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: '
) : null;
+ const footer = buttons.length > 0 && (
+
+ {buttons.map((b) => (
+
+ ))}
+
+ );
+ const frame = (
+
+ );
+
+ // 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 (
+
- )}
+ {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): { 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(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')}
>
-
+
{open && (
diff --git a/components/plugins/plugin-iframe-slot.tsx b/components/plugins/plugin-iframe-slot.tsx
index e02b9bcc9..1865ce624 100644
--- a/components/plugins/plugin-iframe-slot.tsx
+++ b/components/plugins/plugin-iframe-slot.tsx
@@ -25,6 +25,13 @@ interface Props {
extraProps?: Record;
/** 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(null);
const instanceRef = useRef(null);
+ const onShownChangeRef = useRef(onShownChange);
+ useEffect(() => { onShownChangeRef.current = onShownChange; }, [onShownChange]);
const [height, setHeight] = useState(0);
const [width, setWidth] = useState(undefined);
const [floatingDropdown, setFloatingDropdown] = useState(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);
diff --git a/components/plugins/plugin-page-app.tsx b/components/plugins/plugin-page-app.tsx
index e81005e7b..3d2be1b96 100644
--- a/components/plugins/plugin-page-app.tsx
+++ b/components/plugins/plugin-page-app.tsx
@@ -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
+ ?
+ : ;
+ }
const available = !!page && offersPage;
let content: React.ReactNode;
@@ -102,9 +110,11 @@ export function PluginPageApp({ linkSegments = [] }: PluginPageAppProps) {
);
} 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 = (
-
-
+
+ {t("loading")}
);
} else {
diff --git a/components/plugins/plugin-side-panels.tsx b/components/plugins/plugin-side-panels.tsx
new file mode 100644
index 000000000..b81d389e2
--- /dev/null
+++ b/components/plugins/plugin-side-panels.tsx
@@ -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(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 (
+ <>
+
+
{entry.title}
+
+
+
+
+
+ >
+ );
+}
+
+/**
+ * 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 && (
+
+ )}
+ {inRail.length > 0 &&
+ {inRail.map((entry) => (
+ choose(open === entry.pluginId ? null : entry.pluginId)}
+ className="h-9 w-9 rounded-md hover:bg-muted"
+ pressedClassName="bg-muted text-foreground"
+ />
+ ))}
+ }
+ >
+ );
+}
+
+/** 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 (
+
+ );
+}
diff --git a/components/plugins/plugin-task-view.tsx b/components/plugins/plugin-task-view.tsx
new file mode 100644
index 000000000..eb5fdd6bd
--- /dev/null
+++ b/components/plugins/plugin-task-view.tsx
@@ -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(null);
+ const notShown = notShownBy === pluginId;
+ const onShownChange = useCallback((shown: boolean) => setNotShownBy(shown ? null : pluginId), [pluginId]);
+ return (
+ <>
+ {notShown && fallback}
+
+
+
+ >
+ );
+}
diff --git a/components/pro/__tests__/pro-interface-redirect.test.tsx b/components/pro/__tests__/pro-interface-redirect.test.tsx
index 7b91ccdbf..e16c7b7da 100644
--- a/components/pro/__tests__/pro-interface-redirect.test.tsx
+++ b/components/pro/__tests__/pro-interface-redirect.test.tsx
@@ -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();
+ });
});
diff --git a/components/pro/pro-interface-redirect.tsx b/components/pro/pro-interface-redirect.tsx
index 5f30c8348..fe7ea4fc2 100644
--- a/components/pro/pro-interface-redirect.tsx
+++ b/components/pro/pro-interface-redirect.tsx
@@ -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 = {
mail: 'mail',
@@ -37,12 +39,21 @@ export function useProInterfaceActive(): boolean {
* segments are handed to the tab that opens (#733), so `/mail/message/`
* 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 (
+
+
+
+ );
}
diff --git a/components/protocol/__tests__/mailto-protocol-client.test.tsx b/components/protocol/__tests__/mailto-protocol-client.test.tsx
new file mode 100644
index 000000000..ef594d63d
--- /dev/null
+++ b/components/protocol/__tests__/mailto-protocol-client.test.tsx
@@ -0,0 +1,26 @@
+import { render } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('@/lib/protocol-handlers/session', () => ({
+ // Still asking an open tab whether it takes the message.
+ requestOpenMailtoInExistingClient: vi.fn(() => new Promise(() => {})),
+ savePendingMailto: vi.fn(),
+}));
+
+const { MailtoProtocolClient } = await import('../mailto-protocol-client');
+
+afterEach(() => {
+ window.history.replaceState(null, '', '/');
+});
+
+describe('mailto handler page', () => {
+ // It hands over to the mail page, where the composer opens: it draws that
+ // page's skeleton instead of a line of text on a blank page.
+ it('draws the mail skeleton with the opening text as its status', () => {
+ window.history.replaceState(null, '', '/protocol/mailto?url=mailto%3Aa%40example.com');
+ const { container } = render();
+ const region = container.querySelector('[data-boot-skeleton="mail"]');
+ expect(region).not.toBeNull();
+ expect(region!.querySelector('[role="status"]')?.textContent).toBe('Opening email…');
+ });
+});
diff --git a/components/protocol/mailto-protocol-client.tsx b/components/protocol/mailto-protocol-client.tsx
index e6b8e2b1d..70bd5ed54 100644
--- a/components/protocol/mailto-protocol-client.tsx
+++ b/components/protocol/mailto-protocol-client.tsx
@@ -1,6 +1,8 @@
"use client";
import { useEffect } from "react";
+import { MailSkeleton } from "@/components/boot-skeleton/mail-skeleton";
+import type { RailLabels } from "@/components/boot-skeleton/shell";
import { parseMailto } from "@/lib/protocol-handlers/mailto";
import { requestOpenMailtoInExistingClient, savePendingMailto } from "@/lib/protocol-handlers/session";
import { useSettingsStore } from "@/stores/settings-store";
@@ -51,9 +53,11 @@ async function focusExistingClient() {
interface MailtoProtocolClientProps {
openingText: string;
+ /** The phone bottom bar's labels for the skeleton. */
+ labels?: RailLabels;
}
-export function MailtoProtocolClient({ openingText }: MailtoProtocolClientProps) {
+export function MailtoProtocolClient({ openingText, labels }: MailtoProtocolClientProps) {
useEffect(() => {
let cancelled = false;
@@ -99,9 +103,7 @@ export function MailtoProtocolClient({ openingText }: MailtoProtocolClientProps)
};
}, []);
- return (
-
-
{openingText}
-
- );
+ // The mail page this hands over to, where the composer opens: its skeleton,
+ // with the opening text as the status message.
+ return ;
}
diff --git a/components/protocol/webcal-protocol-client.tsx b/components/protocol/webcal-protocol-client.tsx
index 7bd697ffe..5a571d61a 100644
--- a/components/protocol/webcal-protocol-client.tsx
+++ b/components/protocol/webcal-protocol-client.tsx
@@ -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 (
-
-
{openingText}
-
- );
+ // The calendar is where this goes next: its skeleton, with "Opening…" as
+ // the page's status.
+ return ;
}
diff --git a/components/providers/__tests__/intl-provider-lite.test.tsx b/components/providers/__tests__/intl-provider-lite.test.tsx
index 25b06cc7d..56a6efeb2 100644
--- a/components/providers/__tests__/intl-provider-lite.test.tsx
+++ b/components/providers/__tests__/intl-provider-lite.test.tsx
@@ -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()),
+ 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()).toBe('');
});
+ it('prerenders the page skeleton, without any text, while the catalog is missing', () => {
+ nav.pathname = '/de/calendar';
+ const html = renderToString();
+ 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()).toContain('Cancel');
});
diff --git a/components/providers/intl-provider.tsx b/components/providers/intl-provider.tsx
index 9b3b564fb..ace1f1018 100644
--- a/components/providers/intl-provider.tsx
+++ b/components/providers/intl-provider.tsx
@@ -2,6 +2,10 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { NextIntlClientProvider } from 'next-intl';
+import { usePathname } from 'next/navigation';
+import { RouteSkeleton } from '@/components/boot-skeleton/route-skeleton';
+import { surfaceFromPath } from '@/lib/boot-skeleton/surfaces';
+import { locales } from '@/i18n/routing';
import { useLocaleStore } from '@/stores/locale-store';
import { useSettingsStore } from '@/stores/settings-store';
import { getBrowserTimeZone, resolveTimeZone } from '@/lib/timezone';
@@ -67,6 +71,7 @@ export function IntlProvider({ locale: initialLocale, messages: initialMessages,
const timeZoneSetting = useSettingsStore((state) => state.timeZone);
const timeZone = resolveTimeZone(timeZoneSetting, browserTimeZone);
const plainLanguage = useSettingsStore((state) => state.plainLanguage);
+ const pathname = usePathname();
// The catalog currently in use. Seeded with the server-provided messages for
// the SSR-resolved locale, so the first render needs no async work and
@@ -159,7 +164,13 @@ export function IntlProvider({ locale: initialLocale, messages: initialMessages,
[baseMessages, plainMessages]
);
- if (!catalog) return null;
+ if (!catalog) {
+ // Lite without this locale's catalog yet: no text can be drawn, but the
+ // page's skeleton can (it takes no translations), so the prerendered HTML
+ // still shows the page's shape instead of a blank screen.
+ const surface = pathname ? surfaceFromPath(pathname, locales) : null;
+ return surface ? : null;
+ }
return (
({
+ useTranslations: (ns?: string) => (key: string) => (ns ? `${ns}.${key}` : key),
+ useMessages: () => ({}),
+ useLocale: () => 'en',
+ useFormatter: () => ({ dateTime: String, relativeTime: String, number: String }),
+}));
+vi.mock('@/hooks/use-media-query', async (importOriginal) => ({
+ atLeast: (await importOriginal()).atLeast,
+ useIsDesktop: () => true,
+ useIsMobile: () => false,
+ useMediaQuery: () => false,
+ useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
+}));
+vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail', stalwartFeaturesEnabled: true }) }));
+vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
+vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null }));
+vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null }));
+vi.mock('@/components/settings/appearance-settings', () => ({
+ AppearanceSettings: () =>
appearance pane
,
+}));
+
+const { SettingsApp } = await import('../settings-app');
+const { useAuthStore } = await import('@/stores/auth-store');
+const { usePluginStore } = await import('@/stores/plugin-store');
+const { useThemeStore } = await import('@/stores/theme-store');
+const { useSettingsStore } = await import('@/stores/settings-store');
+const { SETTINGS_NAV_GROUPS } = await import('../settings-tab-icons');
+const { BOOT_HINTS_KEY } = await import('@/lib/boot-skeleton/keys');
+const { resetRailHintsMemo } = await import('@/lib/boot-skeleton/hints');
+
+/** A client that supports everything it is asked about. */
+const fullClient = new Proxy({}, {
+ get: (_t, prop) => {
+ if (prop === 'then') return undefined;
+ if (prop === 'getAccountId') return () => 'acc';
+ if (typeof prop === 'string' && prop.startsWith('supports')) return () => true;
+ if (prop === 'getShareTargets') return () => Promise.resolve([]);
+ return () => null;
+ },
+});
+/** A client with none of the optional capabilities. */
+const bareClient = new Proxy({}, {
+ get: (_t, prop) => (prop === 'then' ? undefined : prop === 'getAccountId' ? () => 'acc' : () => null),
+});
+
+const skeleton = () => document.querySelector('[data-boot-skeleton="settings"]');
+
+beforeEach(() => {
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
+ localStorage.clear();
+ resetRailHintsMemo();
+ localStorage.setItem('settings-active-tab', 'appearance');
+ usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
+ useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) } as never);
+ // The restore stays pending: the page is still waiting for the session.
+ useAuthStore.setState({ checkAuth: vi.fn(() => new Promise(() => {})) } as never);
+});
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+});
+
+describe('Settings while the session restores', () => {
+ it('draws the settings skeleton when signed out', () => {
+ useAuthStore.setState({ isAuthenticated: false, isLoading: true, client: null });
+ render();
+ expect(skeleton()).not.toBeNull();
+ expect(skeleton()!.getAttribute('aria-busy')).toBe('true');
+ expect(skeleton()!.querySelector('[role="status"]')!.textContent).toBe('common.loading');
+ });
+
+ it('keeps the skeleton and no nav while the persisted sign-in waits for its client', () => {
+ useAuthStore.setState({ isAuthenticated: true, isLoading: true, client: null });
+ render();
+ expect(skeleton()).not.toBeNull();
+ expect(screen.queryByTestId('settings-tab-appearance')).toBeNull();
+ expect(screen.queryByText('appearance pane')).toBeNull();
+ });
+
+ it('renders the nav once the client is up, in the order the skeleton draws it', () => {
+ useAuthStore.setState({ isAuthenticated: true, isLoading: false, client: fullClient as never });
+ render();
+ expect(skeleton()).toBeNull();
+ const shown = [...document.querySelectorAll('[data-testid^="settings-tab-"]')]
+ .map((el) => el.getAttribute('data-testid')!.slice('settings-tab-'.length));
+ // Recovery waits for a server probe this test doesn't answer.
+ const drawn = SETTINGS_NAV_GROUPS.flatMap((g) => g.tabs).filter((tab) => tab !== 'recovery');
+ expect(shown).toEqual(drawn);
+ });
+
+ it('leaves the next load the pages this account has', () => {
+ useAuthStore.setState({ isAuthenticated: true, isLoading: false, client: bareClient as never });
+ render();
+ const hints = JSON.parse(localStorage.getItem(BOOT_HINTS_KEY) ?? 'null');
+ expect(hints?.settings).toContain('appearance');
+ expect(hints?.settings).not.toContain('filters');
+ expect(hints?.settings).not.toContain('vacation');
+ });
+});
+
+describe('a link to a section while the session restores', () => {
+ it('scrolls once the page is drawn, without switching Advanced on meanwhile', async () => {
+ vi.useFakeTimers();
+ try {
+ const scroll = vi.fn();
+ Element.prototype.scrollIntoView = scroll;
+ useSettingsStore.setState({ showAdvancedSettings: false });
+ useAuthStore.setState({ isAuthenticated: true, isLoading: true, client: null });
+ render();
+ await act(async () => { vi.advanceTimersByTime(3000); });
+ // The skeleton has no sections; nothing to find is not a hidden section.
+ expect(useSettingsStore.getState().showAdvancedSettings).toBe(false);
+ expect(scroll).not.toHaveBeenCalled();
+
+ act(() => { useAuthStore.setState({ isLoading: false, client: fullClient as never }); });
+ await act(async () => { vi.advanceTimersByTime(200); });
+ expect(scroll).toHaveBeenCalled();
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+});
diff --git a/components/settings/composing-settings.tsx b/components/settings/composing-settings.tsx
index 7c9016c94..0062e9776 100644
--- a/components/settings/composing-settings.tsx
+++ b/components/settings/composing-settings.tsx
@@ -37,6 +37,8 @@ export function ComposingSettings() {
mentionAddsRecipientTo,
sendDelaySeconds,
signaturePosition,
+ signatureNewMailEnabled,
+ signatureReplyForwardEnabled,
signatureSeparatorEnabled,
requestReadReceiptDefault,
readReceiptResponse,
@@ -127,6 +129,20 @@ export function ComposingSettings() {
/>
+
+ updateSetting('signatureNewMailEnabled', checked)}
+ />
+
+
+
+ updateSetting('signatureReplyForwardEnabled', checked)}
+ />
+
+
`, so its UI (e.g. S/MIME key import) is
@@ -130,51 +118,14 @@ interface TabDef {
sub?: boolean;
}
-const tabIcons: Record = {
- account: User,
- general: SettingsIcon,
- notifications: Bell,
- appearance: Palette,
- accessibility: Accessibility,
- message_list: LayoutList,
- reading: BookOpen,
- composing: PenLine,
- vacation: PalmtreeIcon,
- filters: Filter,
- organize: FolderOpen,
- security: Shield,
- sharing: Users,
- recovery: RotateCcw,
- calendar: Calendar,
- contacts: BookUser,
- files: HardDrive,
- addons: Puzzle,
- backup: Database,
-};
+// Shared with the boot skeleton, which draws the same nav before any script runs.
+const tabIcons = SETTINGS_TAB_ICONS;
const tabGroupOrder: TabGroup[] = ['top', 'mail', 'apps', 'bottom'];
-// Map legacy tab IDs to current ones; runs once on read of localStorage and
-// for every deep link, so old bookmarks, connector links and sidebar gears
-// land on the page that now holds the setting.
-export const LEGACY_TAB_MAP: Record = {
- email: 'reading',
- advanced: 'backup',
- layout: 'appearance',
- themes: 'appearance',
- content_senders: 'reading',
- identities: 'account',
- language: 'general',
- protocol_handlers: 'general',
- templates: 'composing',
- folders: 'organize',
- keywords: 'organize',
- downloads: 'reading',
- sidebar_apps: 'addons',
- plugins: 'addons',
- about_data: 'backup',
- debug: 'backup',
-};
+// Legacy tab ids and their current pages live in lib/settings-tabs.ts, which
+// the pre-paint boot script reads too.
+export { LEGACY_TAB_MAP };
// Where on the new page a legacy tab's settings now sit, so an old link
// lands on that section rather than at the top of the page.
@@ -282,6 +233,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
const tShell = useTranslations('shell');
const tDelegates = useTranslations('delegates');
const tPalette = useTranslations('command_palette');
+ const skeletonLabels = useSkeletonLabels();
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const isEmbedded = useIsEmbedded();
@@ -494,8 +446,12 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
// an advanced section stays hidden until the switch is on, so after a
// short wait turn it on rather than give up.
const updateSetting = useSettingsStore((s) => s.updateSetting);
+ // The page itself rather than its skeleton (see the gate below).
+ const pageShown = isAuthenticated && !!client;
useEffect(() => {
if (!pendingSection || pendingSection.tab !== activeTab) return;
+ // The skeleton has no sections: wait until the page is drawn.
+ if (!pageShown) return;
if (typeof window === 'undefined') return;
let cancelled = false;
let timer: ReturnType | undefined;
@@ -516,7 +472,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
window.cancelAnimationFrame(raf);
if (timer) clearTimeout(timer);
};
- }, [pendingSection, activeTab, updateSetting]);
+ }, [pendingSection, activeTab, updateSetting, pageShown]);
// Leaving the settings panel drops any shared-account scope so it never
// leaks into the next visit or another session.
@@ -842,8 +798,24 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
isAuthenticated,
);
- if (!isAuthenticated) {
- return null;
+ // Which pages this account has, for the next load's skeleton nav
+ // (lib/boot-skeleton/hints.ts). Only the account's own list, once its
+ // capabilities are known; plugin entries are left out.
+ const hintedTabs = client && !managedAccountId
+ ? visibleTabs.filter((tab) => !tab.id.includes(':')).map((tab) => tab.id).join(' ')
+ : null;
+ useEffect(() => {
+ if (hintedTabs) writeSettingsHints(hintedTabs.split(' '));
+ }, [hintedTabs]);
+
+ // Until the session is up, the page's skeleton (the same one the server
+ // drew). Waiting for the client, not only the persisted sign-in, means the
+ // nav renders once with the account's pages instead of growing as the
+ // capabilities arrive.
+ if (!isAuthenticated || !client) {
+ return isEmbedded
+ ?
+ : ;
}
const renderTabContent = () => (
diff --git a/components/settings/settings-tab-icons.ts b/components/settings/settings-tab-icons.ts
new file mode 100644
index 000000000..a46bc21c4
--- /dev/null
+++ b/components/settings/settings-tab-icons.ts
@@ -0,0 +1,65 @@
+import {
+ Accessibility,
+ Settings as SettingsIcon,
+ Palette,
+ User,
+ Shield,
+ PalmtreeIcon,
+ Calendar,
+ Filter,
+ FolderOpen,
+ HardDrive,
+ BookUser,
+ Bell,
+ Puzzle,
+ BookOpen,
+ PenLine,
+ LayoutList,
+ Database,
+ Users,
+ RotateCcw,
+ type AppIcon,
+} from '@/components/icons';
+import type { SettingsSearchTab } from '@/lib/settings-search';
+
+/**
+ * The icon of each settings page in the nav. Shared by the settings page and
+ * its boot skeleton (components/boot-skeleton/settings-skeleton.tsx), which
+ * draws the same icons before any script runs.
+ */
+export const SETTINGS_TAB_ICONS: Record = {
+ account: User,
+ general: SettingsIcon,
+ notifications: Bell,
+ appearance: Palette,
+ accessibility: Accessibility,
+ message_list: LayoutList,
+ reading: BookOpen,
+ composing: PenLine,
+ vacation: PalmtreeIcon,
+ filters: Filter,
+ organize: FolderOpen,
+ security: Shield,
+ sharing: Users,
+ recovery: RotateCcw,
+ calendar: Calendar,
+ contacts: BookUser,
+ files: HardDrive,
+ addons: Puzzle,
+ backup: Database,
+};
+
+export type SettingsTabGroup = 'top' | 'mail' | 'apps' | 'bottom';
+
+/**
+ * The settings nav in order, by group, with every page a server can offer.
+ * The settings page leaves out what the account or the admin doesn't have
+ * (see the `tabs` list in settings-app.tsx); the skeleton hides those rows
+ * from the last session's hints.
+ */
+export const SETTINGS_NAV_GROUPS: readonly { group: SettingsTabGroup; tabs: readonly SettingsSearchTab[] }[] = [
+ { group: 'top', tabs: ['account', 'security', 'sharing', 'notifications', 'general', 'appearance', 'accessibility'] },
+ { group: 'mail', tabs: ['message_list', 'reading', 'composing', 'organize', 'filters', 'vacation', 'recovery'] },
+ { group: 'apps', tabs: ['calendar', 'contacts', 'files', 'addons'] },
+ { group: 'bottom', tabs: ['backup'] },
+];
diff --git a/hooks/__tests__/use-calendar-alerts-hooks.test.tsx b/hooks/__tests__/use-calendar-alerts-hooks.test.tsx
index 9a17672d9..331953d64 100644
--- a/hooks/__tests__/use-calendar-alerts-hooks.test.tsx
+++ b/hooks/__tests__/use-calendar-alerts-hooks.test.tsx
@@ -8,7 +8,12 @@ import { useCalendarNotificationStore } from '@/stores/calendar-notification-sto
import { useSettingsStore } from '@/stores/settings-store';
import { useToastStore } from '@/stores/toast-store';
import { calendarHooks, clearAllHooks } from '@/lib/plugin-hooks';
-import type { CalendarEvent } from '@/lib/jmap/types';
+import { useTaskStore } from '@/stores/task-store';
+import type { CalendarEvent, CalendarTask } from '@/lib/jmap/types';
+
+const sounds = vi.hoisted(() => ({ play: vi.fn(), title: vi.fn() }));
+vi.mock('@/lib/notification-sound', () => ({ playNotificationSound: sounds.play }));
+vi.mock('@/lib/title-alert', () => ({ startTitleAlert: sounds.title }));
// A reminder that fires tells plugins (onCalendarAlert); the user closing
// its toast tells them it was dismissed (onCalendarAlertAcknowledge). A
@@ -30,6 +35,21 @@ const event = {
},
} as unknown as CalendarEvent;
+const task = {
+ id: 't1',
+ '@type': 'Task',
+ uid: 'uid-t1',
+ title: 'Call back Anna',
+ calendarIds: { c1: true },
+ due: new Date(Date.now() + 60 * 60_000).toISOString().slice(0, 19),
+ start: null,
+ progress: 'needs-action',
+ useDefaultAlerts: false,
+ alerts: {
+ 'follow-up': { '@type': 'Alert', action: 'display', trigger: { '@type': 'AbsoluteTrigger', when: fireTime } },
+ },
+} as unknown as CalendarTask;
+
function Toasts() {
const toasts = useToastStore((s) => s.toasts);
const removeToast = useToastStore((s) => s.removeToast);
@@ -41,12 +61,19 @@ beforeEach(() => {
useToastStore.setState({ toasts: [] });
useCalendarNotificationStore.getState().clearAll();
useAuthStore.setState({ isAuthenticated: true, client: null });
- useSettingsStore.setState({ calendarNotificationsEnabled: true, calendarNotificationSound: false, enableCalendarTasks: false });
+ useSettingsStore.setState({
+ calendarNotificationsEnabled: true, calendarNotificationSound: false, enableCalendarTasks: false,
+ doNotDisturb: false, visualAlerts: false,
+ });
useCalendarStore.setState({ events: [event], calendars: [] });
+ useTaskStore.setState({ tasks: [] });
+ sounds.play.mockClear();
+ sounds.title.mockClear();
});
afterEach(() => {
vi.useRealTimers();
+ vi.restoreAllMocks();
});
describe('reminder hooks', () => {
@@ -76,6 +103,92 @@ describe('reminder hooks', () => {
});
});
+describe('a task reminder', () => {
+ beforeEach(() => {
+ useSettingsStore.setState({ enableCalendarTasks: true });
+ useCalendarStore.setState({ events: [] });
+ useTaskStore.setState({ tasks: [task] });
+ });
+
+ it('reaches plugins with its task id, and its dismissal too', async () => {
+ const fired = vi.fn();
+ const acknowledged = vi.fn();
+ calendarHooks.onCalendarAlert.register('t', fired);
+ calendarHooks.onCalendarAlertAcknowledge.register('t', acknowledged);
+
+ renderHook(() => useCalendarAlerts());
+ render();
+
+ await waitFor(() => expect(fired).toHaveBeenCalledTimes(1));
+ expect(fired).toHaveBeenCalledWith({
+ id: 'follow-up', taskId: 't1', taskTitle: 'Call back Anna', triggerTime: new Date(fireTime).toISOString(),
+ });
+
+ const close = screen.getByText('Call back Anna').closest('.toast-item')!.querySelector('button:last-of-type')!;
+ fireEvent.click(close);
+ expect(acknowledged).toHaveBeenCalledWith({ alertId: 'follow-up', taskId: 't1' });
+ });
+});
+
+describe('a plugin that shows the reminder itself', () => {
+ beforeEach(() => {
+ useSettingsStore.setState({ calendarNotificationSound: true, visualAlerts: true, enableCalendarTasks: true });
+ useTaskStore.setState({ tasks: [task] });
+ });
+
+ it('takes over a task reminder: no toast, sound or title alert, the event reminder as before', async () => {
+ const fired = vi.fn();
+ const before = vi.fn((alert: { taskId?: string }) => (alert.taskId ? false : undefined));
+ calendarHooks.onCalendarAlert.register('t', fired);
+ calendarHooks.onBeforeCalendarAlert.register('t', before);
+
+ renderHook(() => useCalendarAlerts());
+ render();
+
+ await waitFor(() => expect(screen.queryByText('Dentist')).not.toBeNull());
+ expect(before).toHaveBeenCalledWith({
+ id: 'follow-up', taskId: 't1', taskTitle: 'Call back Anna', triggerTime: new Date(fireTime).toISOString(),
+ });
+ expect(before).toHaveBeenCalledWith({
+ id: 'a1', eventId: 'e1', eventTitle: 'Dentist', triggerTime: new Date(fireTime).toISOString(),
+ });
+ // Both still reported as fired, so the plugin knows.
+ expect(fired).toHaveBeenCalledTimes(2);
+ await act(async () => { await Promise.resolve(); });
+ expect(screen.queryByText('Call back Anna')).toBeNull();
+ expect(sounds.play).toHaveBeenCalledTimes(1);
+ expect(sounds.title).toHaveBeenCalledTimes(1);
+ });
+
+ it('takes over an event reminder too', async () => {
+ useTaskStore.setState({ tasks: [] });
+ const before = vi.fn(() => false);
+ calendarHooks.onBeforeCalendarAlert.register('t', before);
+
+ renderHook(() => useCalendarAlerts());
+ render();
+
+ await waitFor(() => expect(before).toHaveBeenCalledTimes(1));
+ await act(async () => { await Promise.resolve(); });
+ expect(screen.queryByText('Dentist')).toBeNull();
+ expect(sounds.play).not.toHaveBeenCalled();
+ expect(sounds.title).not.toHaveBeenCalled();
+ });
+
+ it('leaves the reminder to Bulwark when the handler fails', async () => {
+ useTaskStore.setState({ tasks: [] });
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ calendarHooks.onBeforeCalendarAlert.register('t', () => { throw new Error('broken'); });
+
+ renderHook(() => useCalendarAlerts());
+ render();
+
+ await waitFor(() => expect(screen.queryByText('Dentist')).not.toBeNull());
+ expect(sounds.play).toHaveBeenCalledTimes(1);
+ expect(sounds.title).toHaveBeenCalledTimes(1);
+ });
+});
+
describe('toast onDismiss', () => {
it('runs when the user closes the toast, not when it times out', () => {
vi.useFakeTimers();
diff --git a/hooks/use-calendar-alerts.ts b/hooks/use-calendar-alerts.ts
index 4735ef116..7915de752 100644
--- a/hooks/use-calendar-alerts.ts
+++ b/hooks/use-calendar-alerts.ts
@@ -15,6 +15,7 @@ import { lastingToastDuration } from '@/lib/toast-duration';
import { startTitleAlert } from '@/lib/title-alert';
import { getPathPrefix } from '@/lib/browser-navigation';
import type { CalendarEvent } from '@/lib/jmap/types';
+import type { CalendarAlertView } from '@/lib/plugin-types';
import { calendarHooks } from '@/lib/plugin-hooks';
import { changesAnyType, subscribePushStateChanges } from '@/lib/push-events';
@@ -53,6 +54,23 @@ function reminderTitleAlert(text: string): void {
if (useSettingsStore.getState().visualAlerts) startTitleAlert(text);
}
+/**
+ * Tells plugins a reminder fired, then shows it unless a plugin takes it
+ * over (onBeforeCalendarAlert returning false), as one that shows reminders
+ * itself does. onCalendarAlert fires either way. With no plugin asked, the
+ * reminder is shown at once.
+ */
+function remind(view: CalendarAlertView, show: () => void): void {
+ void calendarHooks.onCalendarAlert.emit(view);
+ if (calendarHooks.onBeforeCalendarAlert.size === 0) {
+ show();
+ return;
+ }
+ void calendarHooks.onBeforeCalendarAlert.intercept(view).then((allowed) => {
+ if (allowed) show();
+ });
+}
+
export function useCalendarAlerts() {
const { isAuthenticated, client } = useAuthStore();
const { events, calendars, supportsCalendar } = useCalendarStore();
@@ -95,20 +113,9 @@ export function useCalendarAlerts() {
shownKeysRef.current.add(key);
acknowledgeAlert(key, alert.fireTimeMs);
- if (reminderSoundOn(calendarNotificationSound)) {
- playNotificationSound(notificationSoundChoice);
- }
-
const diffMs = (alert.event.utcStart ? new Date(alert.event.utcStart).getTime() : parseISO(alert.event.start).getTime()) - now;
const diffMin = Math.round(diffMs / 60000);
- void calendarHooks.onCalendarAlert.emit({
- id: alert.alertId,
- eventId: alert.eventId,
- eventTitle: alert.event.title || '',
- triggerTime: new Date(alert.fireTimeMs).toISOString(),
- });
-
const timeLabel = diffMin <= 0
? t('alert_now')
: t('alert_in_minutes', { count: diffMin });
@@ -117,18 +124,28 @@ export function useCalendarAlerts() {
? `${timeLabel} · ${alert.calendarName}`
: timeLabel;
- reminderTitleAlert(t('title_alert', { title: alert.event.title || t('alert_title') }));
- addToast({
- type: 'info',
- title: alert.event.title || t('alert_title'),
- message,
- duration: reminderToastDuration(),
- onClick: () => {
- window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`;
- },
- onDismiss: () => {
- void calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: alert.alertId, eventId: alert.eventId });
- },
+ remind({
+ id: alert.alertId,
+ eventId: alert.eventId,
+ eventTitle: alert.event.title || '',
+ triggerTime: new Date(alert.fireTimeMs).toISOString(),
+ }, () => {
+ if (reminderSoundOn(calendarNotificationSound)) {
+ playNotificationSound(notificationSoundChoice);
+ }
+ reminderTitleAlert(t('title_alert', { title: alert.event.title || t('alert_title') }));
+ addToast({
+ type: 'info',
+ title: alert.event.title || t('alert_title'),
+ message,
+ duration: reminderToastDuration(),
+ onClick: () => {
+ window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`;
+ },
+ onDismiss: () => {
+ void calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: alert.alertId, eventId: alert.eventId });
+ },
+ });
});
}
@@ -142,23 +159,32 @@ export function useCalendarAlerts() {
shownKeysRef.current.add(key);
acknowledgeAlert(key, taskAlert.fireTimeMs);
- if (reminderSoundOn(calendarNotificationSound)) {
- playNotificationSound(notificationSoundChoice);
- }
-
const taskMsg = taskAlert.calendarName
? `${t('task_due')} · ${taskAlert.calendarName}`
: t('task_due');
- reminderTitleAlert(t('title_alert', { title: taskAlert.task.title || t('alert_title') }));
- addToast({
- type: 'info',
- title: taskAlert.task.title || t('alert_title'),
- message: taskMsg,
- duration: reminderToastDuration(),
- onClick: () => {
- window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`;
- },
+ remind({
+ id: taskAlert.alertId,
+ taskId: taskAlert.taskId,
+ taskTitle: taskAlert.task.title || '',
+ triggerTime: new Date(taskAlert.fireTimeMs).toISOString(),
+ }, () => {
+ if (reminderSoundOn(calendarNotificationSound)) {
+ playNotificationSound(notificationSoundChoice);
+ }
+ reminderTitleAlert(t('title_alert', { title: taskAlert.task.title || t('alert_title') }));
+ addToast({
+ type: 'info',
+ title: taskAlert.task.title || t('alert_title'),
+ message: taskMsg,
+ duration: reminderToastDuration(),
+ onClick: () => {
+ window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`;
+ },
+ onDismiss: () => {
+ void calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: taskAlert.alertId, taskId: taskAlert.taskId });
+ },
+ });
});
}
}
diff --git a/integration/tests/20-push-owner.spec.ts b/integration/tests/20-push-owner.spec.ts
new file mode 100644
index 000000000..b51725917
--- /dev/null
+++ b/integration/tests/20-push-owner.spec.ts
@@ -0,0 +1,75 @@
+import { test, expect } from '@playwright/test';
+import { ACCOUNTS, GROUP, WEBMAIL_URL } from './helpers/config';
+import { JmapClient } from './helpers/jmap';
+import { login, seedSettings, folderRow, expandSharedFolders, emailItem, addAccount, switchAccount } from './helpers/app';
+import { pushClick } from '../../lib/__tests__/helpers/push-click';
+
+test.describe('notification target across personal and shared accounts (#1491)', () => {
+ const cases = [false, true].flatMap(shared => [1, 3].flatMap(unread =>
+ [false, true].map(warm => ({ shared, unread, warm, secondary: false })),
+ ));
+ cases.push(...[1, 3].map(unread => ({ shared: true, unread, warm: true, secondary: true })));
+ for (const { shared, unread, warm, secondary } of cases) {
+ test(`${shared ? 'shared' : 'personal'}, ${unread} unread, ${warm ? 'existing' : 'new'} window${secondary ? ', second login' : ''}`, async ({ page }) => {
+ const member = ACCOUNTS.carol;
+ const client = await JmapClient.connect(member.email, member.password);
+ const sharedId = client.accountIdByName(GROUP.team.email);
+ await client.reset();
+ await client.reset(sharedId);
+ const targetId = shared ? sharedId : client.accountId;
+ let deliveredId = '';
+ const subject = `Push target ${shared ? 'shared' : 'personal'} ${unread}`;
+ for (const owner of [client.accountId, sharedId]) {
+ const inbox = (await client.mailboxByRole('inbox', owner))!;
+ for (let i = 0; i < unread; i++) {
+ const result = await client.request([['Email/set', {
+ accountId: owner,
+ create: { mail: {
+ mailboxIds: { [inbox.id]: true }, keywords: {},
+ from: [{ email: ACCOUNTS.alice.email }],
+ to: [{ email: owner === sharedId ? GROUP.team.email : member.email }],
+ subject: owner === targetId ? `${subject} ${i}` : `Other mailbox ${i}`,
+ receivedAt: new Date(Date.now() + i * 1000).toISOString(),
+ textBody: [{ partId: 'b', type: 'text/plain' }],
+ bodyValues: { b: { value: `Body in ${owner}` } },
+ } },
+ }, '0']]);
+ expect(result.methodResponses[0][1].created?.mail).toBeTruthy();
+ if (owner === targetId) deliveredId = result.methodResponses[0][1].created.mail.id;
+ }
+ }
+ await seedSettings(page, { enableUnifiedMailbox: false, mailLayout: 'split' });
+ if (secondary) {
+ await login(page, ACCOUNTS.bob);
+ await addAccount(page, member);
+ await switchAccount(page, ACCOUNTS.bob.email);
+ } else {
+ await login(page, member);
+ }
+ await expect(folderRow(page, { role: 'inbox', shared: false }).first()).toHaveAttribute('data-selected', 'true');
+ const target = await pushClick(targetId, async (url) => {
+ const response = await page.request.get(new URL(url, WEBMAIL_URL).href);
+ expect(response.status()).toBe(200);
+ const preview = await response.json();
+ expect(preview.unreadTotal).toBe(unread);
+ expect(preview.slot).toBe(secondary ? 1 : 0);
+ expect(preview.email.subject).toBe(`${subject} ${unread - 1}`);
+ return preview;
+ }, { warm, emailId: deliveredId });
+ if (!warm) {
+ const nextPage = await page.context().newPage();
+ await page.close();
+ page = nextPage;
+ }
+ await page.goto(target);
+ if (unread === 1) {
+ await expect(page.locator('[data-tour="email-viewer"]')).toContainText(`${subject} 0`);
+ }
+ if (shared) await expandSharedFolders(page, GROUP.team.email);
+ await expect(folderRow(page, { role: 'inbox', shared }).first()).toHaveAttribute('data-selected', 'true');
+ if (unread > 1) {
+ await expect(emailItem(page, `${subject} ${unread - 1}`).first()).toBeVisible();
+ }
+ });
+ }
+});
diff --git a/lib/__tests__/helpers/push-click.ts b/lib/__tests__/helpers/push-click.ts
new file mode 100644
index 000000000..369efd6a2
--- /dev/null
+++ b/lib/__tests__/helpers/push-click.ts
@@ -0,0 +1,61 @@
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import vm from 'node:vm';
+
+export async function pushClick(
+ accountId: string,
+ preview: (url: string) => Promise,
+ options: { warm?: boolean; basePath?: string; legacy?: boolean; emailId?: string; noParamsSize?: boolean } = {},
+): Promise {
+ const source = readFileSync(resolve('public/sw.js'), 'utf8');
+ let notification: { data: unknown } | undefined;
+ let target: string | undefined;
+ const listeners = new Map void>();
+ const cache = new Map();
+ const client = {
+ id: 'window-1', url: 'https://mail.example.test/',
+ postMessage() {},
+ async focus() { return this; },
+ async navigate(url: string) { target = url; return this; },
+ };
+ // Safari before 17 has no URLSearchParams.size.
+ const SearchParams = options.noParamsSize
+ ? class extends URLSearchParams { get size(): number { return undefined as unknown as number; } }
+ : URLSearchParams;
+ const context = vm.createContext({
+ URL, URLSearchParams: SearchParams, Response, AbortController, setTimeout, clearTimeout, console,
+ self: {
+ location: { href: `https://mail.example.test${options.basePath ?? ''}/sw.js`, origin: 'https://mail.example.test' },
+ addEventListener: (type: string, fn: (event: unknown) => void) => listeners.set(type, fn),
+ clients: {
+ matchAll: async () => options.warm ? [client] : [],
+ openWindow: async (url: string) => { target = url; },
+ },
+ registration: {
+ showNotification: async (_title: string, data: { data: unknown }) => { notification = data; },
+ getNotifications: async () => [],
+ },
+ },
+ caches: { open: async () => ({
+ match: async (key: string) => cache.has(key) ? new Response(cache.get(key)) : undefined,
+ put: async (key: string, response: Response) => cache.set(key, await response.text()),
+ }) },
+ fetch: async (url: string) => ({ ok: true, json: async () => preview(url) }),
+ });
+ vm.runInContext(source, context);
+ async function dispatch(type: string, event: Record) {
+ const waits: Promise[] = [];
+ const listener = listeners.get(type);
+ if (!listener) throw new Error(`Missing worker handler: ${type}`);
+ listener({ ...event, waitUntil: (p: Promise) => waits.push(p) });
+ await Promise.all(waits);
+ }
+ const payload = options.legacy
+ ? { changed: { [accountId]: { EmailDelivery: 'new' } } }
+ : { kind: 'jmap-email-push', accountId, emailIds: [options.emailId ?? 'new-email'] };
+ await dispatch('push', { data: { json: () => payload } });
+ if (!notification) throw new Error('No notification shown');
+ await dispatch('notificationclick', { notification: { ...notification, close() {} } });
+ if (!target) throw new Error('Notification did not navigate');
+ return target;
+}
diff --git a/lib/__tests__/jmap-archived-items.test.ts b/lib/__tests__/jmap-archived-items.test.ts
index 794124849..78e2d04ca 100644
--- a/lib/__tests__/jmap-archived-items.test.ts
+++ b/lib/__tests__/jmap-archived-items.test.ts
@@ -69,15 +69,16 @@ describe('JMAPClient archived items', () => {
expect(queries(spy).every(([, args]) => (args.filter as { accountId?: string })?.accountId === ACCOUNT)).toBe(true);
});
- it('drops the other accounts in an unfiltered mixed answer', async () => {
- // A server that ignores the filter and answers with every account.
+ it('drops the other accounts in an unfiltered mixed answer and keeps items without accountId', async () => {
+ // A server that ignores the filter and answers with every account. 'n' has
+ // no accountId, the way Stalwart sends items to a user without Impersonate (#1480).
respondWith((body) => ({
methodResponses: body.methodCalls.map(([method, , id]) => method === 'x:ArchivedItem/query'
? [method, { ids: ['a', 'z', 'n'], total: 3 }, id]
: [method, { list: [archivedEmail('a'), archivedEmail('z', { accountId: 'cz' }), archivedEmail('n', { accountId: undefined })] }, id]),
}));
const result = await client().listArchivedEmails(undefined, { days: 0 });
- expect(result.items.map((i) => i.id)).toEqual(['a']);
+ expect(result.items.map((i) => i.id)).toEqual(['a', 'n']);
});
it('throws the refusal with its type when the server has no license', async () => {
diff --git a/lib/__tests__/jmap-mailbox-pagination.test.ts b/lib/__tests__/jmap-mailbox-pagination.test.ts
new file mode 100644
index 000000000..4dea54e23
--- /dev/null
+++ b/lib/__tests__/jmap-mailbox-pagination.test.ts
@@ -0,0 +1,230 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { JMAPClient } from '../jmap/client';
+
+type RawMailbox = { id: string; name: string; parentId?: string };
+type MethodCall = [string, Record, string];
+type RequestTarget = { request(calls: MethodCall[]): Promise };
+
+function createClient(
+ maxObjectsInGet = 2,
+ accounts: Record = { primary: { name: 'user@example.com' } },
+): JMAPClient {
+ const client = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass');
+ Object.assign(client, {
+ apiUrl: 'https://jmap.example.com/api',
+ accountId: 'primary',
+ username: 'user@example.com',
+ accounts,
+ capabilities: { 'urn:ietf:params:jmap:core': { maxObjectsInGet } },
+ });
+ return client;
+}
+
+function serve(client: JMAPClient, mailboxes: Record, clamp = Infinity, withTotal = true): MethodCall[][] {
+ const requests: MethodCall[][] = [];
+ vi.spyOn(client as unknown as RequestTarget, 'request').mockImplementation(async (calls) => {
+ if (calls[0][0] === 'Mailbox/get') {
+ return { methodResponses: calls.map(([, args, tag]) => [
+ 'Mailbox/get', { list: (mailboxes[args.accountId as string] ?? []).slice(0, client.getMaxObjectsInGet()), state: 'm' }, tag,
+ ]) };
+ }
+ requests.push(calls);
+ const accountId = calls[0][1].accountId as string;
+ const position = calls[0][1].position as number;
+ const limit = calls[0][1].limit as number;
+ const all = mailboxes[accountId] ?? [];
+ const page = all.slice(position, position + Math.min(limit, clamp));
+ return { methodResponses: [
+ ['Mailbox/query', { ids: page.map(({ id }) => id), queryState: 's', ...(withTotal && calls[0][1].calculateTotal ? { total: all.length } : {}) }, 'query'],
+ ['Mailbox/get', { list: page, state: 'm', notFound: [] }, 'get'],
+ ] };
+ });
+ return requests;
+}
+
+afterEach(() => vi.restoreAllMocks());
+
+describe('JMAP mailbox pagination', () => {
+ it('loads a complete hierarchy across server-clamped pages', async () => {
+ const client = createClient(2);
+ const requests = serve(client, { primary: [
+ { id: 'grandchild', name: 'Grandchild', parentId: 'child' },
+ { id: 'other', name: 'Other' },
+ { id: 'child', name: 'Child', parentId: 'root' },
+ { id: 'root', name: 'Root' },
+ ] }, 1);
+
+ const mailboxes = await client.getMailboxes();
+
+ expect(mailboxes.map(({ id, parentId }) => [id, parentId])).toEqual([
+ ['grandchild', 'child'], ['other', undefined], ['child', 'root'], ['root', undefined],
+ ]);
+ expect(requests.map(([query]) => query[1].position)).toEqual([0, 1, 2, 3]);
+ expect(requests.map(([query]) => query[1].calculateTotal)).toEqual([true, false, false, false]);
+ expect(requests.every((calls) => calls.length === 2 && calls[1][1]['#ids'] && !calls[1][1].ids)).toBe(true);
+ });
+
+ it.each([
+ ['missing ids', undefined, 's', []],
+ ['mixed ids', ['third', 4], 's', []],
+ ['duplicate ids', ['parent'], 's', [{ id: 'parent', name: 'Parent' }]],
+ ['early empty page', [], 's', []],
+ ['missing object', ['third'], 's', []],
+ ])('rejects %s instead of publishing a partial hierarchy', async (_label, ids, queryState, list) => {
+ const client = createClient();
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ vi.spyOn(client as unknown as RequestTarget, 'request')
+ .mockResolvedValueOnce({ methodResponses: [
+ ['Mailbox/get', { list: [{ id: 'child' }, { id: 'parent' }] }, '0'],
+ ] })
+ .mockResolvedValueOnce({ methodResponses: [
+ ['Mailbox/query', { ids: ['child', 'parent'], queryState: 's', total: 3 }, 'query'],
+ ['Mailbox/get', { list: [{ id: 'child', name: 'Child' }, { id: 'parent', name: 'Parent' }] }, 'get'],
+ ] })
+ .mockResolvedValueOnce({ methodResponses: [
+ ['Mailbox/query', { ids, queryState }, 'query'],
+ ['Mailbox/get', { list }, 'get'],
+ ] });
+
+ await expect(client.getMailboxes()).rejects.toThrow();
+ });
+
+ it('prefixes shared mailbox and parent ids', async () => {
+ const client = createClient(1, { primary: { name: 'user@example.com' }, shared: { name: 'team@example.com' } });
+ serve(client, {
+ primary: [{ id: 'inbox', name: 'Inbox' }],
+ shared: [{ id: 'child', name: 'Child', parentId: 'parent' }, { id: 'parent', name: 'Parent' }],
+ });
+
+ expect((await client.getAllMailboxes()).find(({ name }) => name === 'Child')).toMatchObject({
+ id: 'shared:child', originalId: 'child', parentId: 'shared:parent', accountId: 'shared', isShared: true,
+ });
+ });
+
+ it('keeps small accounts on the existing single-request fast path', async () => {
+ const client = createClient(3);
+ const requests = serve(client, { primary: [{ id: 'inbox', name: 'Inbox' }] });
+ expect(await client.getMailboxes()).toHaveLength(1);
+ expect(requests).toHaveLength(0);
+ expect(client['request']).toHaveBeenCalledTimes(1);
+ });
+
+ it.each([true, false])('loads an exact page multiple (total returned: %s)', async (withTotal) => {
+ const client = createClient(2);
+ const list = Array.from({ length: 4 }, (_, i) => ({ id: String(i), name: String(i) }));
+ const requests = serve(client, { primary: list }, Infinity, withTotal);
+ expect(await client.getMailboxes()).toHaveLength(4);
+ expect(requests.map(([query]) => query[1].position)).toEqual(withTotal ? [0, 2] : [0, 2, 4]);
+ expect(requests.map(([query]) => query[1].calculateTotal)).toEqual(withTotal ? [true, false] : [true, false, false]);
+ });
+
+ it('retains Mailbox collection state and packed small-account retrieval', async () => {
+ const client = createClient(2, { primary: { name: 'Self' }, shared: { name: 'Shared' } });
+ serve(client, {
+ primary: [{ id: 'inbox', name: 'Inbox' }],
+ shared: [{ id: 'parent', name: 'Parent' }, { id: 'child', name: 'Child', parentId: 'parent' }, { id: 'other', name: 'Other' }],
+ });
+ const result = await client.getAllMailboxesWithState();
+ expect(result.mailboxes.map(({ id }) => id)).toEqual(['inbox', 'shared:parent', 'shared:child', 'shared:other']);
+ expect(result.states).toEqual({ primary: 'm', shared: 'm' });
+ expect(vi.mocked(client['request']).mock.calls[0][0].map(([name]) => name)).toEqual(['Mailbox/get', 'Mailbox/get']);
+ });
+
+ it('rejects a partial refresh when one paginated account fails', async () => {
+ const client = createClient(2, { primary: { name: 'Self' }, shared: { name: 'Shared' } });
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ vi.spyOn(client as unknown as RequestTarget, 'request')
+ .mockResolvedValueOnce({ methodResponses: [
+ ['Mailbox/get', { list: [{ id: 'a' }, { id: 'b' }] }, '0'],
+ ['Mailbox/get', { state: 'shared-state', list: [{ id: 'inbox', name: 'Shared Inbox' }] }, '1'],
+ ] })
+ .mockRejectedValueOnce(new Error('page failed'));
+ await expect(client.getAllMailboxesWithState()).rejects.toThrow('complete mailbox list');
+ });
+
+ it('pages when an unbounded get is rejected as requestTooLarge (Stalwart 1.0)', async () => {
+ const client = createClient();
+ const list = [{ id: 'a', name: 'A' }, { id: 'b', name: 'B' }, { id: 'c', name: 'C' }];
+ serve(client, { primary: list });
+ vi.mocked(client['request']).mockResolvedValueOnce({ methodResponses: [
+ ['error', { type: 'requestTooLarge' }, '0'],
+ ] });
+ expect((await client.getMailboxes()).map(({ id }) => id)).toEqual(['a', 'b', 'c']);
+ });
+
+ it('pages a shared account whose packed get is rejected as requestTooLarge', async () => {
+ const client = createClient(2, { primary: { name: 'Self' }, shared: { name: 'Shared' } });
+ const shared = [{ id: 'a', name: 'A' }, { id: 'b', name: 'B', parentId: 'a' }, { id: 'c', name: 'C', parentId: 'b' }];
+ serve(client, { primary: [{ id: 'inbox', name: 'Inbox' }], shared });
+ vi.mocked(client['request']).mockResolvedValueOnce({ methodResponses: [
+ ['Mailbox/get', { list: [{ id: 'inbox', name: 'Inbox' }], state: 'm' }, '0'],
+ ['error', { type: 'requestTooLarge' }, '1'],
+ ] });
+ const result = await client.getAllMailboxesWithState();
+ expect(result.mailboxes.map(({ id }) => id)).toEqual(['inbox', 'shared:a', 'shared:b', 'shared:c']);
+ expect(result.states).toEqual({ primary: 'm', shared: 'm' });
+ });
+
+ it('starts over when the mailbox state moves between pages', async () => {
+ const client = createClient();
+ const page = (ids: string[], queryState: string, total?: number) => ({ methodResponses: [
+ ['Mailbox/query', { ids, queryState, ...(total ? { total } : {}) }, 'query'],
+ ['Mailbox/get', { list: ids.map((id) => ({ id, name: id })), state: queryState }, 'get'],
+ ] });
+ const request = vi.spyOn(client as unknown as RequestTarget, 'request')
+ .mockResolvedValueOnce({ methodResponses: [['Mailbox/get', { list: [{ id: 'a' }, { id: 'b' }] }, '0']] })
+ .mockResolvedValueOnce(page(['a', 'b'], 's1', 3))
+ .mockResolvedValueOnce(page(['c'], 's2'))
+ .mockResolvedValueOnce(page(['a', 'b'], 's2', 3))
+ .mockResolvedValueOnce(page(['c'], 's2'));
+
+ const result = await client.getAllMailboxesWithState();
+ expect(result.mailboxes.map(({ id }) => id)).toEqual(['a', 'b', 'c']);
+ expect(result.states).toEqual({ primary: 's2' });
+ expect(request).toHaveBeenCalledTimes(5);
+ });
+
+ it('gives up when the mailbox state keeps moving', async () => {
+ const client = createClient();
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ let n = 0;
+ const request = vi.spyOn(client as unknown as RequestTarget, 'request').mockImplementation(async (calls) => {
+ if (calls[0][0] === 'Mailbox/get') return { methodResponses: [['Mailbox/get', { list: [{ id: 'a' }, { id: 'b' }] }, '0']] };
+ const position = calls[0][1].position as number;
+ const ids = position === 0 ? ['a', 'b'] : ['c'];
+ const state = `s${n++}`;
+ return { methodResponses: [
+ ['Mailbox/query', { ids, queryState: state, ...(position === 0 ? { total: 3 } : {}) }, 'query'],
+ ['Mailbox/get', { list: ids.map((id) => ({ id, name: id })), state }, 'get'],
+ ] };
+ });
+
+ await expect(client.getMailboxes()).rejects.toThrow('changed while it was being loaded');
+ expect(request).toHaveBeenCalledTimes(1 + 3 * 2);
+ });
+
+ it.each([false, true])('respects maxCallsInRequest=1 (shared: %s)', async (shared) => {
+ const client = createClient(2, shared ? { primary: { name: 'Self' }, shared: { name: 'Shared' } } : undefined);
+ Object.assign(client, { capabilities: { 'urn:ietf:params:jmap:core': { maxObjectsInGet: 2, maxCallsInRequest: 1 } } });
+ const boxes = [{ id: 'a', name: 'A' }, { id: 'b', name: 'B' }, { id: 'c', name: 'C' }];
+ const spy = vi.spyOn(client as unknown as RequestTarget, 'request').mockImplementation(async (calls) => {
+ expect(calls).toHaveLength(1);
+ const [name, args, tag] = calls[0];
+ if (name === 'Mailbox/query') {
+ const ids = boxes.slice(args.position as number, (args.position as number) + 2).map(box => box.id);
+ return { methodResponses: [[name, { ids, queryState: 'q', total: 3 }, tag]] };
+ }
+ const ids = args.ids as string[] | undefined;
+ expect(args['#ids']).toBeUndefined();
+ const list = ids ? boxes.filter(box => ids.includes(box.id)) : boxes.slice(0, 2);
+ return { methodResponses: [[name, { list, state: 'm', notFound: [] }, tag]] };
+ });
+ const result = shared ? await client.getAllMailboxes() : await client.getMailboxes();
+ expect(result).toHaveLength(shared ? 6 : 3);
+ const gets = spy.mock.calls.flatMap(([calls]) => calls).filter(([name, args]) => name === 'Mailbox/get' && args.ids);
+ for (const accountId of shared ? ['primary', 'shared'] : ['primary']) {
+ expect(gets.filter(([, args]) => args.accountId === accountId).map(([, args]) => args.ids))
+ .toEqual([['a', 'b'], ['c']]);
+ }
+ });
+});
diff --git a/lib/__tests__/plugin-actions-proxy-api.test.ts b/lib/__tests__/plugin-actions-proxy-api.test.ts
index 6202f506b..127418b5b 100644
--- a/lib/__tests__/plugin-actions-proxy-api.test.ts
+++ b/lib/__tests__/plugin-actions-proxy-api.test.ts
@@ -18,6 +18,9 @@ import { dayLabelsByDate, resetDayLabels } from '../plugin-sandbox/day-labels';
import { allPages, resetPages } from '../plugin-sandbox/pages';
import { onPanelRequest, resetPanelRequests } from '../plugin-sandbox/panel-requests';
import { onPageNavigation, resetPageNavigation } from '../plugin-sandbox/page-navigation';
+import { onOpenTask, resetOpenTask } from '../plugin-sandbox/open-task';
+import { useCalendarStore } from '@/stores/calendar-store';
+import { useSettingsStore } from '@/stores/settings-store';
import type { StreamSink } from '../plugin-sandbox/proxy-client';
import '../plugin-sandbox/loader';
@@ -169,6 +172,77 @@ describe('day labels and pages', () => {
await expect(dispatchApiCall(paged, 'ui.openPage', ['files'])).resolves.toBe(false);
});
});
+
+ describe('ui.openTask', () => {
+ beforeEach(() => { resetOpenTask(); });
+
+ it('opens the task in the calendar\'s editor and says whether it did', async () => {
+ const seen: string[] = [];
+ onOpenTask((id) => { seen.push(id); return id === 't1'; });
+ const tasky = plugin({ id: 'ot1', permissions: ['ui:tasks'] });
+ await expect(dispatchApiCall(tasky, 'ui.openTask', ['t1'])).resolves.toBe(true);
+ await expect(dispatchApiCall(plugin({ id: 'ot1', permissions: ['tasks:read'] }), 'ui.openTask', ['gone'])).resolves.toBe(false);
+ expect(seen).toEqual(['t1', 'gone']);
+ });
+
+ it('answers false while no calendar is on screen', async () => {
+ await expect(dispatchApiCall(plugin({ id: 'ot2', permissions: ['ui:tasks'] }), 'ui.openTask', ['t1'])).resolves.toBe(false);
+ });
+
+ it('needs ui:tasks or tasks:read and a task id', async () => {
+ const seen: string[] = [];
+ onOpenTask((id) => { seen.push(id); return true; });
+ await expect(dispatchApiCall(plugin({ id: 'ot3', permissions: ['calendar:read'] }), 'ui.openTask', ['t1'])).rejects.toThrow(/lacks permission/);
+ await expect(dispatchApiCall(plugin({ id: 'ot3', permissions: ['ui:tasks'] }), 'ui.openTask', [42])).rejects.toThrow(/taskId/);
+ expect(seen).toEqual([]);
+ });
+ });
+
+ describe('ui.openCalendar', () => {
+ const seen: string[] = [];
+ beforeEach(() => {
+ resetPageNavigation();
+ seen.length = 0;
+ useSettingsStore.setState({ enableCalendarTasks: true });
+ useCalendarStore.setState({ viewMode: 'month' });
+ });
+
+ it('opens the task view: the calendar\'s view set, the tab moved to its permalink', async () => {
+ onPageNavigation((href) => { seen.push(href); });
+ await expect(dispatchApiCall(plugin({ id: 'tv1', permissions: ['ui:tasks'] }), 'ui.openCalendar', [{ view: 'tasks' }])).resolves.toBe(true);
+ expect(useCalendarStore.getState().viewMode).toBe('tasks');
+ await expect(dispatchApiCall(plugin({ id: 'tv1', permissions: ['calendar:read'] }), 'ui.openCalendar', [{ view: 'week' }])).resolves.toBe(true);
+ expect(seen).toEqual(['/calendar/tasks', '/calendar/week']);
+ });
+
+ it('needs the view\'s permission and refuses any other view', async () => {
+ onPageNavigation((href) => { seen.push(href); });
+ await expect(dispatchApiCall(plugin({ id: 'tv2', permissions: ['calendar:read'] }), 'ui.openCalendar', [{ view: 'tasks' }])).rejects.toThrow(/lacks permission/);
+ await expect(dispatchApiCall(plugin({ id: 'tv2', permissions: ['ui:tasks'] }), 'ui.openCalendar', [{ view: 'month' }])).rejects.toThrow(/lacks permission/);
+ await expect(dispatchApiCall(plugin({ id: 'tv2', permissions: ['ui:tasks'] }), 'ui.openCalendar', [{ view: 'settings' }])).rejects.toThrow(/view must be one of/);
+ await expect(dispatchApiCall(plugin({ id: 'tv2', permissions: ['ui:tasks'] }), 'ui.openCalendar', [{ view: 'tasks', date: 'x' }])).rejects.toThrow(/unknown property/);
+ expect(seen).toEqual([]);
+ expect(useCalendarStore.getState().viewMode).toBe('month');
+ });
+
+ it('answers false while nothing can move the tab or the user has tasks off', async () => {
+ const tasky = plugin({ id: 'tv3', permissions: ['ui:tasks'] });
+ await expect(dispatchApiCall(tasky, 'ui.openCalendar', [{ view: 'tasks' }])).resolves.toBe(false);
+ onPageNavigation((href) => { seen.push(href); });
+ useSettingsStore.setState({ enableCalendarTasks: false });
+ await expect(dispatchApiCall(tasky, 'ui.openCalendar', [{ view: 'tasks' }])).resolves.toBe(false);
+ expect(seen).toEqual([]);
+ expect(useCalendarStore.getState().viewMode).toBe('month');
+ });
+
+ it('moves the tab at most 10 times a minute', async () => {
+ onPageNavigation((href) => { seen.push(href); });
+ const busy = plugin({ id: 'tv4', permissions: ['ui:tasks'] });
+ for (let i = 0; i < 10; i++) await dispatchApiCall(busy, 'ui.openCalendar', [{ view: 'tasks' }]);
+ await expect(dispatchApiCall(busy, 'ui.openCalendar', [{ view: 'tasks' }])).rejects.toThrow(/a minute/);
+ expect(seen).toHaveLength(10);
+ });
+ });
});
describe('proxy.fetch', () => {
diff --git a/lib/__tests__/plugin-dialog-anchor.test.ts b/lib/__tests__/plugin-dialog-anchor.test.ts
new file mode 100644
index 000000000..77a9c71d3
--- /dev/null
+++ b/lib/__tests__/plugin-dialog-anchor.test.ts
@@ -0,0 +1,136 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { InstalledPlugin } from '../plugin-types';
+import { dispatchApiCall } from '../plugin-sandbox/host-api';
+import { head, resetDialogLimits, resolveHead } from '../plugin-sandbox/host-dialog';
+import {
+ forgetTrigger, noteTrigger, placePopover, trackTriggers, triggerAnchor, POPOVER_GAP, POPOVER_MARGIN, TRIGGER_WINDOW_MS,
+} from '../plugin-sandbox/dialog-anchor';
+
+// api.ui.openDialog({ anchor: 'trigger' }): the dialog beside the control the
+// user activated last, when that was a moment ago.
+
+function plugin(): InstalledPlugin {
+ return {
+ id: 'p1', name: 'Plugin One', version: '1.0.0', author: '', description: '', type: 'hook',
+ permissions: [], entrypoint: 'index.js', enabled: true, status: 'running', settings: {}, managed: true,
+ };
+}
+
+/** A control on the page standing at this rect. */
+function control(html: string, rect: { left: number; top: number; width: number; height: number }): HTMLElement {
+ const host = document.createElement('div');
+ host.innerHTML = html;
+ document.body.appendChild(host);
+ const el = host.querySelector('[data-at]') ?? host.firstElementChild as HTMLElement;
+ el.getBoundingClientRect = () => ({
+ left: rect.left, top: rect.top, right: rect.left + rect.width, bottom: rect.top + rect.height,
+ width: rect.width, height: rect.height, x: rect.left, y: rect.top, toJSON: () => ({}),
+ });
+ return el;
+}
+
+let stop: () => void = () => {};
+beforeEach(() => {
+ forgetTrigger();
+ resetDialogLimits();
+ while (head()) resolveHead(null);
+ stop = trackTriggers();
+});
+afterEach(() => {
+ stop();
+ document.body.innerHTML = '';
+});
+
+describe('the trigger a dialog opens beside', () => {
+ it('is the button under the pointer, measured again when the dialog opens', () => {
+ const button = control('', { left: 100, top: 50, width: 24, height: 24 });
+ button.querySelector('span')!.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 110, clientY: 60 }));
+ expect(triggerAnchor()).toEqual({ left: 100, top: 50, right: 124, bottom: 74 });
+ });
+
+ it('is the point a menu entry was chosen at, the menu being gone', () => {
+ const entry = control('
Follow up
', { left: 300, top: 200, width: 180, height: 28 });
+ entry.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 320, clientY: 210 }));
+ entry.parentElement!.remove();
+ expect(triggerAnchor()).toEqual({ left: 320, top: 210, right: 320, bottom: 210 });
+ });
+
+ it('is the focused control on Enter, and nothing after another key', () => {
+ const button = control('', { left: 10, top: 20, width: 20, height: 20 });
+ button.focus();
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
+ expect(triggerAnchor()).toEqual({ left: 10, top: 20, right: 30, bottom: 40 });
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Shift' }));
+ expect(triggerAnchor()).not.toBeNull();
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 's' }));
+ expect(triggerAnchor()).toBeNull();
+ });
+
+ it('is forgotten after the window', () => {
+ const button = control('', { left: 10, top: 20, width: 20, height: 20 });
+ noteTrigger(button, undefined, 1000);
+ expect(triggerAnchor(1000 + TRIGGER_WINDOW_MS)).not.toBeNull();
+ expect(triggerAnchor(1001 + TRIGGER_WINDOW_MS)).toBeNull();
+ });
+
+ it('keeps its rect when the control has gone', () => {
+ const button = control('', { left: 10, top: 20, width: 20, height: 20 });
+ noteTrigger(button);
+ button.remove();
+ expect(triggerAnchor()).toEqual({ left: 10, top: 20, right: 30, bottom: 40 });
+ });
+});
+
+describe('placePopover', () => {
+ const viewport = { width: 1000, height: 800 };
+ const size = { width: 320, height: 200 };
+
+ it('stands below the anchor, at its start', () => {
+ expect(placePopover({ left: 100, top: 50, right: 124, bottom: 74 }, size, viewport))
+ .toEqual({ left: 100, top: 74 + POPOVER_GAP });
+ });
+
+ it('lines up with the end in RTL', () => {
+ expect(placePopover({ left: 600, top: 50, right: 624, bottom: 74 }, size, viewport, true).left).toBe(624 - 320);
+ });
+
+ it('goes above when it does not fit below', () => {
+ expect(placePopover({ left: 100, top: 700, right: 124, bottom: 724 }, size, viewport).top).toBe(700 - POPOVER_GAP - 200);
+ });
+
+ it('stays inside the window', () => {
+ expect(placePopover({ left: 950, top: 10, right: 980, bottom: 30 }, size, viewport).left).toBe(1000 - POPOVER_MARGIN - 320);
+ expect(placePopover({ left: 100, top: 300, right: 100, bottom: 300 }, { width: 320, height: 700 }, viewport).top)
+ .toBe(800 - POPOVER_MARGIN - 700);
+ });
+});
+
+describe("ui.openDialog's anchor", () => {
+ it("is the trigger's place when the plugin asks for it", async () => {
+ const button = control('', { left: 100, top: 50, width: 24, height: 24 });
+ button.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 110, clientY: 60 }));
+ const result = dispatchApiCall(plugin(), 'ui.openDialog', [{ slot: 'plugin-dialog', anchor: 'trigger', width: 320 }]);
+ await vi.waitFor(() => expect(head()).not.toBeNull());
+ expect(head()?.anchor).toEqual({ left: 100, top: 50, right: 124, bottom: 74 });
+ resolveHead(null);
+ await expect(result).resolves.toBeNull();
+ });
+
+ it('is none without the option, without a trigger, or with a place the plugin names', async () => {
+ const button = control('', { left: 100, top: 50, width: 24, height: 24 });
+ button.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }));
+ for (const opts of [{ slot: 'plugin-dialog' }, { slot: 'plugin-dialog', anchor: { left: 0, top: 0, right: 0, bottom: 0 } }]) {
+ const result = dispatchApiCall(plugin(), 'ui.openDialog', [opts]);
+ await vi.waitFor(() => expect(head()).not.toBeNull());
+ expect(head()?.anchor).toBeUndefined();
+ resolveHead(null);
+ await result;
+ }
+ forgetTrigger();
+ const result = dispatchApiCall(plugin(), 'ui.openDialog', [{ slot: 'plugin-dialog', anchor: 'trigger' }]);
+ await vi.waitFor(() => expect(head()).not.toBeNull());
+ expect(head()?.anchor).toBeUndefined();
+ resolveHead(null);
+ await result;
+ });
+});
diff --git a/lib/__tests__/plugin-hook-permissions.test.ts b/lib/__tests__/plugin-hook-permissions.test.ts
index 4f6557044..550be16bb 100644
--- a/lib/__tests__/plugin-hook-permissions.test.ts
+++ b/lib/__tests__/plugin-hook-permissions.test.ts
@@ -43,7 +43,7 @@ vi.mock('../plugin-sandbox/shortcuts', () => ({ registerShortcuts: () => () => {
import { loadSandboxedPlugin, unloadSandboxedPlugin, setSandboxStoreAccessor } from '../plugin-sandbox/loader';
import { get as getActive } from '../plugin-sandbox/registry';
-import { emailHooks, renderHooks, appLifecycleHooks } from '../plugin-hooks';
+import { emailHooks, renderHooks, appLifecycleHooks, calendarHooks } from '../plugin-hooks';
import { hookPermission, mayOfferSlot, mayRegisterHook, pluginHasPermission } from '../plugin-sandbox/permissions';
import { useEmailStore } from '@/stores/email-store';
import type { Email } from '../jmap/types';
@@ -143,6 +143,74 @@ describe('a message that is already open', () => {
});
});
+describe('reminders', () => {
+ const eventAlert = { id: 'a1', eventId: 'e1', eventTitle: 'Dentist', triggerTime: '2026-10-31T08:00:00.000Z' };
+ const taskAlert = { id: 'follow-up', taskId: 't1', taskTitle: 'Call back Anna', triggerTime: '2026-10-31T08:00:00.000Z' };
+ const reminderHooks = ['onBeforeCalendarAlert', 'onCalendarAlert', 'onCalendarAlertAcknowledge'];
+
+ function calls(): unknown[][] {
+ return (mocks.invokeHook as ReturnType).mock.calls;
+ }
+
+ it.each(['onCalendarAlert', 'onCalendarAlertAcknowledge'])('%s may be registered with calendar:read or tasks:read', (hook) => {
+ expect(mayRegisterHook(plugin({ permissions: ['calendar:read'] }), hook)).toBe(true);
+ expect(mayRegisterHook(plugin({ permissions: ['tasks:read'] }), hook)).toBe(true);
+ expect(mayRegisterHook(plugin({ permissions: ['email:read'] }), hook)).toBe(false);
+ });
+
+ it('lets only a plugin that may change the calendar or the tasks take a reminder over', () => {
+ expect(mayRegisterHook(plugin({ permissions: ['calendar:read', 'tasks:read'] }), 'onBeforeCalendarAlert')).toBe(false);
+ expect(mayRegisterHook(plugin({ permissions: ['calendar:write'] }), 'onBeforeCalendarAlert')).toBe(true);
+ expect(mayRegisterHook(plugin({ permissions: ['tasks:write'] }), 'onBeforeCalendarAlert')).toBe(true);
+ });
+
+ it("hands a plugin with tasks:read a task's reminder only", async () => {
+ mocks.background.hooks = reminderHooks;
+ await loadSandboxedPlugin(plugin({ permissions: ['tasks:read'] }));
+
+ await calendarHooks.onCalendarAlert.emit(eventAlert);
+ await calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: 'a1', eventId: 'e1' });
+ expect(calls()).toHaveLength(0);
+
+ await calendarHooks.onCalendarAlert.emit(taskAlert);
+ await calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: 'follow-up', taskId: 't1' });
+ expect(calls().map((c) => c[0])).toEqual(['onCalendarAlert', 'onCalendarAlertAcknowledge']);
+ });
+
+ it("lets a plugin with tasks:write silence a task's reminder, never an event's", async () => {
+ mocks.background.hooks = ['onBeforeCalendarAlert'];
+ await loadSandboxedPlugin(plugin({ permissions: ['tasks:read', 'tasks:write', 'calendar:read'] }));
+ (mocks.invokeHook as ReturnType).mockResolvedValue(false);
+
+ expect(await calendarHooks.onBeforeCalendarAlert.intercept(eventAlert)).toBe(true);
+ expect(calls()).toHaveLength(0);
+ expect(await calendarHooks.onBeforeCalendarAlert.intercept(taskAlert)).toBe(false);
+ expect(calls().map((c) => c[0])).toEqual(['onBeforeCalendarAlert']);
+ });
+
+ it('does not let a plugin that only reads silence a reminder', async () => {
+ mocks.background.hooks = ['onBeforeCalendarAlert'];
+ await loadSandboxedPlugin(plugin({ permissions: ['calendar:read', 'tasks:read'] }));
+ (mocks.invokeHook as ReturnType).mockResolvedValue(false);
+
+ expect(await calendarHooks.onBeforeCalendarAlert.intercept(eventAlert)).toBe(true);
+ expect(await calendarHooks.onBeforeCalendarAlert.intercept(taskAlert)).toBe(true);
+ expect(calls()).toHaveLength(0);
+ });
+
+ it("hands a plugin with calendar:read an event's reminder only", async () => {
+ mocks.background.hooks = reminderHooks;
+ await loadSandboxedPlugin(plugin({ permissions: ['calendar:read'] }));
+
+ await calendarHooks.onCalendarAlert.emit(taskAlert);
+ await calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: 'follow-up', taskId: 't1' });
+ expect(calls()).toHaveLength(0);
+
+ await calendarHooks.onCalendarAlert.emit(eventAlert);
+ expect(calls().map((c) => c[0])).toEqual(['onCalendarAlert']);
+ });
+});
+
describe('permission tables', () => {
it('refuses a hook it does not know', () => {
expect(hookPermission('onSomethingNew')).toBeNull();
diff --git a/lib/__tests__/plugin-hooks.test.ts b/lib/__tests__/plugin-hooks.test.ts
index 68e7749ef..eb67afeb4 100644
--- a/lib/__tests__/plugin-hooks.test.ts
+++ b/lib/__tests__/plugin-hooks.test.ts
@@ -269,6 +269,25 @@ describe('onBeforeBlobUpload offload budget', () => {
});
});
+describe('onBeforeCalendarAlert budget', () => {
+ it('shows the reminder after 5s when a plugin hangs, not after the dialog budget', async () => {
+ vi.useFakeTimers();
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ try {
+ calendarHooks.onBeforeCalendarAlert.register('hung', () => new Promise(() => {}));
+ let allowed: boolean | undefined;
+ void calendarHooks.onBeforeCalendarAlert.intercept({ id: 'a1', taskId: 't1' }).then((a) => { allowed = a; });
+ await vi.advanceTimersByTimeAsync(4_900);
+ expect(allowed).toBeUndefined();
+ await vi.advanceTimersByTimeAsync(200);
+ expect(allowed).toBe(true);
+ } finally {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ }
+ });
+});
+
describe('isExternalAttachmentResult', () => {
const valid = { externalAttachment: true, html: 'y', text: 'https://x/y' };
diff --git a/lib/__tests__/plugin-host-calendar-tasks.test.ts b/lib/__tests__/plugin-host-calendar-tasks.test.ts
index 2ae3d3d97..03d20d6c3 100644
--- a/lib/__tests__/plugin-host-calendar-tasks.test.ts
+++ b/lib/__tests__/plugin-host-calendar-tasks.test.ts
@@ -7,6 +7,8 @@ import { useCalendarStore } from '@/stores/calendar-store';
import { useTaskStore } from '@/stores/task-store';
import { useIdentityStore } from '@/stores/identity-store';
import { useSettingsStore } from '@/stores/settings-store';
+import { useContactStore } from '@/stores/contact-store';
+import { BIRTHDAY_CALENDAR_ID, BIRTHDAY_CALENDAR_COLOR } from '@/lib/birthday-calendar';
import { calendarHooks, taskHooks } from '../plugin-hooks';
vi.mock('@/stores/toast-store', () => ({
@@ -24,7 +26,9 @@ function plugin(perms: string[]): InstalledPlugin {
}
const READ = plugin(['calendar:read', 'tasks:read']);
+const READ_WITH_CONTACTS = plugin(['calendar:read', 'tasks:read', 'contacts:read']);
const WRITE = plugin(['calendar:read', 'calendar:write', 'tasks:read', 'tasks:write']);
+const WRITE_WITH_CONTACTS = plugin(['calendar:read', 'calendar:write', 'tasks:read', 'tasks:write', 'contacts:read']);
const NONE = plugin([]);
const work = { id: 'cal-work', name: 'Work', color: '#f00', isVisible: true, isDefault: true, isTasksOnly: false } as Calendar;
@@ -73,7 +77,8 @@ beforeEach(() => {
identities: [{ id: 'id1', name: 'Ann', email: 'ann@example.com' }] as never,
preferredPrimaryId: null,
});
- useSettingsStore.setState({ timeZone: 'Europe/Berlin' } as never);
+ useSettingsStore.setState({ timeZone: 'Europe/Berlin', showBirthdayCalendar: false, birthdayCalendarColor: BIRTHDAY_CALENDAR_COLOR } as never);
+ useContactStore.setState({ contacts: [], contactsLoaded: true });
});
afterEach(() => {
@@ -81,6 +86,8 @@ afterEach(() => {
useCalendarStore.setState({ calendars: [], events: [] });
useTaskStore.setState({ tasks: [] });
useIdentityStore.setState({ identities: [] });
+ useSettingsStore.setState({ showBirthdayCalendar: false });
+ useContactStore.setState({ contacts: [] });
calendarHooks.onBeforeEventCreate.removePlugin('veto');
taskHooks.onBeforeTaskDelete.removePlugin('veto');
});
@@ -196,6 +203,105 @@ describe('api.calendar', () => {
await expect(call(WRITE, 'calendar.deleteEvent', 'missing')).rejects.toThrow(/no event with id "missing"/);
await expect(call(WRITE, 'calendar.updateEvent', 'e1', {})).rejects.toThrow(/patch is empty/);
});
+
+ it('includes the virtual birthday calendar in listCalendars only when contacts:read is held and showBirthdayCalendar is enabled', async () => {
+ useSettingsStore.setState({ showBirthdayCalendar: true, birthdayCalendarColor: '#f59e0b' });
+ // Plugin without contacts:read does not see the birthday calendar
+ const calsWithoutContacts = await call(READ, 'calendar.listCalendars') as { id: string }[];
+ expect(calsWithoutContacts.some((c) => c.id === BIRTHDAY_CALENDAR_ID)).toBe(false);
+
+ // Plugin with contacts:read sees the birthday calendar
+ const cals = await call(READ_WITH_CONTACTS, 'calendar.listCalendars') as { id: string; name: string; color: string }[];
+ expect(cals).toEqual([
+ { id: 'cal-work', name: 'Work', color: '#f00', isVisible: true, isDefault: true, isTasksOnly: false },
+ { id: 'cal-todo', name: 'To do', color: '', isVisible: true, isDefault: false, isTasksOnly: true },
+ { id: BIRTHDAY_CALENDAR_ID, name: 'Birthdays', color: '#f59e0b', isVisible: true, isDefault: false, isTasksOnly: false },
+ ]);
+ });
+
+ it('returns contact birthdays in getEvents only when contacts:read is held and showBirthdayCalendar is enabled', async () => {
+ useSettingsStore.setState({ showBirthdayCalendar: true });
+ useContactStore.setState({
+ contacts: [
+ {
+ id: 'c1',
+ name: { full: 'Alice Smith' },
+ anniversaries: { b1: { kind: 'birth', date: '1990-05-15' } },
+ } as never,
+ ],
+ });
+
+ // Plugin without contacts:read does not see birthday events
+ const eventsWithoutContacts = await call(READ, 'calendar.getEvents', { from: '2026-05-01', to: '2026-06-01' }) as { id: string; calendarId: string }[];
+ expect(eventsWithoutContacts.some((e) => e.calendarId === BIRTHDAY_CALENDAR_ID)).toBe(false);
+
+ // Plugin with contacts:read sees birthday events
+ const events = await call(READ_WITH_CONTACTS, 'calendar.getEvents', { from: '2026-05-01', to: '2026-06-01' }) as {
+ id: string; title: string; calendarId: string; isAllDay: boolean; start: string;
+ }[];
+
+ const birthday = events.find((e) => e.calendarId === BIRTHDAY_CALENDAR_ID);
+ expect(birthday).toBeDefined();
+ expect(birthday).toEqual(expect.objectContaining({
+ id: 'birthday-c1-b1-2026',
+ title: '🎂 Alice Smith (36)',
+ calendarId: BIRTHDAY_CALENDAR_ID,
+ isAllDay: true,
+ start: '2026-05-15T00:00:00',
+ }));
+
+ // Respects date range bounds using same start/end as regular events
+ const beforeBirthday = await call(READ_WITH_CONTACTS, 'calendar.getEvents', {
+ from: '2026-05-01', to: '2026-05-10',
+ }) as { calendarId: string }[];
+ expect(beforeBirthday.some((e) => e.calendarId === BIRTHDAY_CALENDAR_ID)).toBe(false);
+
+ const afterBirthday = await call(READ_WITH_CONTACTS, 'calendar.getEvents', {
+ from: '2026-05-20', to: '2026-05-31',
+ }) as { calendarId: string }[];
+ expect(afterBirthday.some((e) => e.calendarId === BIRTHDAY_CALENDAR_ID)).toBe(false);
+
+ // The end is exclusive, as for regular events: a week ending at the
+ // birthday's midnight does not contain it.
+ const weekEndingOnBirthday = await call(READ_WITH_CONTACTS, 'calendar.getEvents', {
+ from: '2026-05-08', to: '2026-05-15',
+ }) as { calendarId: string }[];
+ expect(weekEndingOnBirthday.some((e) => e.calendarId === BIRTHDAY_CALENDAR_ID)).toBe(false);
+
+ // A birthday covers its whole day, so a range inside that day finds it.
+ const middayOnBirthday = await call(READ_WITH_CONTACTS, 'calendar.getEvents', {
+ from: '2026-05-15T10:00', to: '2026-05-15T12:00',
+ }) as { id: string }[];
+ expect(middayOnBirthday.map((e) => e.id)).toContain('birthday-c1-b1-2026');
+
+ // Respects calendarIds filtering
+ const onlyWork = await call(READ_WITH_CONTACTS, 'calendar.getEvents', {
+ from: '2026-05-01', to: '2026-06-01', calendarIds: ['cal-work'],
+ }) as { calendarId: string }[];
+ expect(onlyWork.some((e) => e.calendarId === BIRTHDAY_CALENDAR_ID)).toBe(false);
+
+ const onlyBirthdays = await call(READ_WITH_CONTACTS, 'calendar.getEvents', {
+ from: '2026-05-01', to: '2026-06-01', calendarIds: [BIRTHDAY_CALENDAR_ID],
+ }) as { calendarId: string }[];
+ expect(onlyBirthdays.every((e) => e.calendarId === BIRTHDAY_CALENDAR_ID)).toBe(true);
+
+ // Fetching a birthday event by ID requires contacts:read
+ expect(await call(READ, 'calendar.getEvent', 'birthday-c1-b1-2026')).toBeNull();
+ const single = await call(READ_WITH_CONTACTS, 'calendar.getEvent', 'birthday-c1-b1-2026');
+ expect(single).toEqual(expect.objectContaining({
+ id: 'birthday-c1-b1-2026',
+ title: '🎂 Alice Smith (36)',
+ }));
+
+ // Birthday events cannot be mutated
+ await expect(call(WRITE_WITH_CONTACTS, 'calendar.updateEvent', 'birthday-c1-b1-2026', { title: 'New' }))
+ .rejects.toThrow(/birthday events cannot be modified/);
+ await expect(call(WRITE_WITH_CONTACTS, 'calendar.deleteEvent', 'birthday-c1-b1-2026'))
+ .rejects.toThrow(/birthday events cannot be modified/);
+ await expect(call(WRITE_WITH_CONTACTS, 'calendar.createEvent', {
+ title: 'New', calendarId: BIRTHDAY_CALENDAR_ID, start: '2026-05-15T10:00', end: '2026-05-15T11:00',
+ })).rejects.toThrow(/no calendar with id "__birthday-calendar__"/);
+ });
});
describe('api.tasks', () => {
diff --git a/lib/__tests__/plugin-permission-coverage.test.ts b/lib/__tests__/plugin-permission-coverage.test.ts
index adfa971ca..ececac6f5 100644
--- a/lib/__tests__/plugin-permission-coverage.test.ts
+++ b/lib/__tests__/plugin-permission-coverage.test.ts
@@ -37,7 +37,10 @@ function gatedPermissions(): Map {
where.set(perm, [...(where.get(perm) ?? []), by]);
};
for (const [method, perm] of Object.entries(PERM_PER_METHOD)) add(perm, `method ${method}`);
- for (const [hook, spec] of Object.entries(HOOK_CATALOG)) add(spec.permission, `hook ${hook}`);
+ for (const [hook, spec] of Object.entries(HOOK_CATALOG)) {
+ add(spec.permission, `hook ${hook}`);
+ if ('alsoPermission' in spec) add(spec.alsoPermission, `hook ${hook}`);
+ }
for (const [slot, spec] of Object.entries(SLOT_CATALOG)) {
for (const perm of spec.permissions) add(perm, `slot ${slot}`);
if (spec.props === '*') continue;
diff --git a/lib/__tests__/settings-search.test.ts b/lib/__tests__/settings-search.test.ts
index 94d83011a..c4728bd46 100644
--- a/lib/__tests__/settings-search.test.ts
+++ b/lib/__tests__/settings-search.test.ts
@@ -77,6 +77,12 @@ describe('settings search index (English messages)', () => {
expect(resultsFor('composing', 'reply signature').map((r) => r.label)).toContain('Signature position');
});
+ it('finds the newer signature settings on their tabs', () => {
+ expect(labelsFor('composing')).toContain('Signature on new mail');
+ expect(labelsFor('composing')).toContain('Signature on replies & forwards');
+ expect(labelsFor('composing')).toContain('Undo send / send delay');
+ });
+
it('marks the rows the Advanced switch hides', () => {
const advanced = settingsSubResults(en, 'message_list', 'Bulwark').filter((r) => r.advanced).map((r) => r.label);
expect(advanced).toContain('Emails per page');
diff --git a/lib/__tests__/sw-push-owner.test.ts b/lib/__tests__/sw-push-owner.test.ts
new file mode 100644
index 000000000..ade1e246a
--- /dev/null
+++ b/lib/__tests__/sw-push-owner.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, it } from 'vitest';
+import { parseMailPath, resolveFolderRef } from '@/lib/deep-links';
+import type { Mailbox } from '@/lib/jmap/types';
+import { pushClick } from './helpers/push-click';
+
+const folders = [
+ { id: 'a', name: 'Inbox', role: 'inbox', accountId: 'personal', totalEmails: 0, unreadEmails: 0 },
+ { id: 'team:a', originalId: 'a', name: 'Inbox', role: 'inbox', isShared: true, accountId: 'team', totalEmails: 0, unreadEmails: 0 },
+] as Mailbox[];
+
+function parseTarget(target: string) {
+ const url = new URL(target, 'https://mail.example.test');
+ return parseMailPath(url.pathname.split('/mail/')[1].split('/'), url.searchParams);
+}
+
+describe('push click keeps the owning mailbox (#1491)', () => {
+ for (const owner of ['personal', 'team']) {
+ for (const unreadTotal of [1, 3]) {
+ for (const warm of [false, true]) {
+ it(`${owner}, ${unreadTotal} unread, ${warm ? 'existing' : 'new'} window`, async () => {
+ const target = await pushClick(owner, async (url) => {
+ expect(new URL(url, 'https://mail.example.test').searchParams.get('accountId')).toBe(owner);
+ return { slot: 0, unreadTotal, email: { id: 'same-id', threadId: 't', subject: 'Mail' } };
+ }, { warm });
+ const link = parseTarget(target);
+ expect(link?.slot).toBe(0);
+ if (unreadTotal === 1) {
+ expect(link).toMatchObject({ kind: 'message', id: 'same-id', ownerAccountId: owner });
+ } else {
+ expect(link).toMatchObject({ kind: 'folder', ref: 'inbox', ownerAccountId: owner });
+ if (link?.kind !== 'folder') throw new Error('Expected folder link');
+ expect(resolveFolderRef(link.ref, folders, link.ownerAccountId)).toBe(owner === 'team' ? 'team:a' : 'a');
+ }
+ });
+ }
+ }
+ }
+
+ it('keeps the owner when organisation policy hides message details', async () => {
+ const target = await pushClick('team', async () => ({ slot: 2, unreadTotal: 1, redacted: true, email: { id: 'same-id' } }));
+ expect(parseTarget(target)).toMatchObject({ kind: 'message', ownerAccountId: 'team', slot: 2 });
+ });
+
+ it('keeps the login slot and owner with a mount prefix and legacy payload', async () => {
+ const target = await pushClick('team', async () => ({ slot: 2, unreadTotal: 3, email: { id: 'same-id' } }), { basePath: '/webmail', legacy: true });
+ expect(target).toMatch(/^\/webmail\/mail\/folder\/inbox\?/);
+ expect(parseTarget(target)).toMatchObject({ kind: 'folder', ownerAccountId: 'team', slot: 2 });
+ });
+
+ it('keeps the slot and owner where URLSearchParams has no size', async () => {
+ const target = await pushClick('team', async () => ({ slot: 1, unreadTotal: 1, email: { id: 'same-id' } }), { noParamsSize: true });
+ expect(parseTarget(target)).toMatchObject({ kind: 'message', ownerAccountId: 'team', slot: 1 });
+ });
+
+ it('does not fall back to a personal folder for an unavailable owner', () => {
+ expect(resolveFolderRef('inbox', folders, 'unavailable')).toBeNull();
+ });
+
+ it('keeps old unscoped folder links working', () => {
+ expect(resolveFolderRef('inbox', folders)).toBe('a');
+ });
+});
diff --git a/lib/__tests__/template-utils.test.ts b/lib/__tests__/template-utils.test.ts
index cab6e7adb..05e677966 100644
--- a/lib/__tests__/template-utils.test.ts
+++ b/lib/__tests__/template-utils.test.ts
@@ -12,6 +12,7 @@ import {
importTemplates,
spliceTemplateAboveSignature,
composeBodyHasUserContent,
+ embedTemplateImages,
} from '../template-utils';
import type { EmailTemplate } from '../template-types';
@@ -437,3 +438,51 @@ describe('composeBodyHasUserContent', () => {
expect(composeBodyHasUserContent(`
Hello Mr.
${openEnded}`)).toBe(true);
});
});
+
+describe('embedTemplateImages', () => {
+ // A 1×1 PNG.
+ const PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
+ const DATA = `data:image/png;base64,${PNG}`;
+ const uploader = () => {
+ let n = 0;
+ return vi.fn(async (_file: File) => ({ cid: `img${++n}@webmail` }));
+ };
+
+ it('uploads a data: image and tags it with the cid', async () => {
+ const upload = uploader();
+ const html = await embedTemplateImages(`
Plan
`, upload);
+ expect(upload).toHaveBeenCalledTimes(1);
+ const file = upload.mock.calls[0][0];
+ expect(file).toMatchObject({ name: 'image-1.png', type: 'image/png' });
+ expect(new Uint8Array(await file.arrayBuffer())).toEqual(Uint8Array.from(atob(PNG), (c) => c.charCodeAt(0)));
+ const img = new DOMParser().parseFromString(html, 'text/html').querySelector('img')!;
+ expect(img.getAttribute('data-cid')).toBe('img1@webmail');
+ expect(img.getAttribute('src')).toBe(DATA);
+ expect(img.getAttribute('alt')).toBe('Floor plan');
+ });
+
+ it('uploads an image shown twice once', async () => {
+ const upload = uploader();
+ const html = await embedTemplateImages(``, upload);
+ expect(upload).toHaveBeenCalledTimes(1);
+ expect(html.match(/data-cid="img1@webmail"/g)).toHaveLength(2);
+ });
+
+ it('replaces a cid the template was saved with', async () => {
+ const html = await embedTemplateImages(``, uploader());
+ expect(html).toContain('data-cid="img1@webmail"');
+ expect(html).not.toContain('old@webmail');
+ });
+
+ it('leaves remote, cid: and non-raster images alone', async () => {
+ const upload = uploader();
+ const html = '';
+ expect(await embedTemplateImages(html, upload)).toBe(html);
+ expect(upload).not.toHaveBeenCalled();
+ });
+
+ it('keeps an image as it is when its upload fails', async () => {
+ const html = ``;
+ expect(await embedTemplateImages(html, async () => null)).toBe(html);
+ });
+});
diff --git a/lib/boot-skeleton/__tests__/boot-script.test.ts b/lib/boot-skeleton/__tests__/boot-script.test.ts
new file mode 100644
index 000000000..9e0bcef60
--- /dev/null
+++ b/lib/boot-skeleton/__tests__/boot-script.test.ts
@@ -0,0 +1,669 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { bootConfig, bootSkeletonScript, type BootLayoutConfig } from '../boot-script';
+import { THEME_CACHE_KEY } from '../keys';
+
+/**
+ * Runs the exact string the root layout inlines into , in a scope with
+ * none of this module's imports: a function that reached for one would throw
+ * here (and in the browser), and the test would see default attributes.
+ */
+function boot(path: string, config: Partial = {}) {
+ window.history.replaceState(null, '', path);
+ new Function(bootSkeletonScript({ ...bootConfig(), ...config }))();
+}
+
+const html = () => document.documentElement;
+const attr = (name: string) => html().getAttribute(`data-boot-${name}`);
+const cssVar = (name: string) => html().style.getPropertyValue(name);
+const settings = (state: Record) =>
+ localStorage.setItem('settings-storage', JSON.stringify({ state, version: 12 }));
+const signedIn = () =>
+ localStorage.setItem('auth-storage', JSON.stringify({ state: { isAuthenticated: true }, version: 0 }));
+
+function desktop(matches: boolean) {
+ window.matchMedia = vi.fn().mockImplementation((query: string) => ({
+ matches: matches && query.includes('64rem'),
+ media: query,
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ })) as unknown as typeof window.matchMedia;
+}
+
+beforeEach(() => {
+ localStorage.clear();
+ sessionStorage.clear();
+ for (const a of [...html().attributes]) {
+ if (a.name.startsWith('data-boot-') || a.name === 'data-interface-layout' || a.name === 'data-theme-skin' || a.name === 'style') {
+ html().removeAttribute(a.name);
+ }
+ }
+ document.head.querySelectorAll('#active-theme, #active-theme-skin').forEach((el) => el.remove());
+ desktop(true);
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe('boot script: defaults', () => {
+ it('draws the split inbox with the regular density and default widths when nothing is stored', () => {
+ boot('/en/mail/folder/inbox');
+ expect(attr('surface')).toBe('mail');
+ expect(attr('mail-layout')).toBe('split');
+ expect(html().getAttribute('data-interface-layout')).toBe('default');
+ expect(cssVar('--list-item-height')).toBe('48px');
+ expect(cssVar('--density-item-py')).toBe('12px');
+ expect(cssVar('--boot-mail-sidebar-w')).toBe('256px');
+ expect(cssVar('--boot-mail-list-w')).toBe('384px');
+ expect(cssVar('--boot-mail-list-h')).toBe('320px');
+ expect(attr('pane')).toBeNull();
+ expect(attr('pro')).toBeNull();
+ });
+
+ it('treats the bare root as mail', () => {
+ boot('/');
+ expect(attr('surface')).toBe('mail');
+ });
+
+ it('survives corrupt JSON in every key it reads', () => {
+ for (const key of ['settings-storage', 'column-widths', 'calendar-storage', 'auth-storage', 'account-registry', 'bulwark-boot-hints', THEME_CACHE_KEY]) {
+ localStorage.setItem(key, '{not json');
+ }
+ boot('/en/calendar');
+ expect(attr('surface')).toBe('calendar');
+ expect(attr('calendar-view')).toBe('month');
+ expect(cssVar('--list-item-height')).toBe('48px');
+ expect(cssVar('--boot-mail-sidebar-w')).toBe('256px');
+ expect(attr('rail')).toBeNull();
+ expect(document.getElementById('active-theme')).toBeNull();
+ });
+
+ it('survives storage that throws on access', () => {
+ const throwing = { get: () => { throw new Error('SecurityError'); } };
+ const local = Object.getOwnPropertyDescriptor(window, 'localStorage');
+ const session = Object.getOwnPropertyDescriptor(window, 'sessionStorage');
+ Object.defineProperty(window, 'localStorage', { configurable: true, ...throwing });
+ Object.defineProperty(window, 'sessionStorage', { configurable: true, ...throwing });
+ try {
+ boot('/en/settings/filters');
+ expect(attr('surface')).toBe('settings');
+ expect(attr('settings-tab')).toBe('filters');
+ expect(cssVar('--boot-settings-nav-w')).toBe('256px');
+ } finally {
+ Object.defineProperty(window, 'localStorage', local!);
+ Object.defineProperty(window, 'sessionStorage', session!);
+ }
+ });
+
+ it('ignores state that is not an object', () => {
+ localStorage.setItem('settings-storage', JSON.stringify({ state: 'oops' }));
+ localStorage.setItem('column-widths', JSON.stringify([1, 2]));
+ boot('/en/mail');
+ expect(attr('mail-layout')).toBe('split');
+ expect(cssVar('--boot-mail-list-w')).toBe('384px');
+ });
+});
+
+describe('boot script: layout settings', () => {
+ it('applies Mountain View, its density rhythm and forces the focus layout', () => {
+ settings({ interfaceLayout: 'mountain-view', density: 'compact', mailLayout: 'split' });
+ boot('/en/mail');
+ expect(html().getAttribute('data-interface-layout')).toBe('mountain-view');
+ expect(cssVar('--list-item-height')).toBe('32px');
+ expect(cssVar('--density-item-py')).toBe('6px');
+ expect(cssVar('--density-item-gap')).toBe('8px');
+ expect(attr('mail-layout')).toBe('focus');
+ });
+
+ it.each([
+ ['extra-compact', 'auto', '2px', '0px'],
+ ['compact', 'auto', '4px', '1px'],
+ ['regular', '48px', '12px', '4px'],
+ ['comfortable', '64px', '16px', '6px'],
+ ])('writes the %s density variables', (density, height, py, sidebar) => {
+ settings({ density });
+ boot('/en/mail');
+ expect(cssVar('--list-item-height')).toBe(height);
+ expect(cssVar('--density-item-py')).toBe(py);
+ expect(cssVar('--density-sidebar-py')).toBe(sidebar);
+ expect(attr('density')).toBe(density);
+ });
+
+ it('names the density and whether previews are off, for the mail rows', () => {
+ boot('/en/mail');
+ expect(attr('density')).toBe('regular');
+ expect(attr('preview')).toBeNull();
+ settings({ density: 'extra-compact', showPreview: false });
+ boot('/en/mail');
+ expect(attr('density')).toBe('extra-compact');
+ expect(attr('preview')).toBe('off');
+ settings({ density: 'cosy', showPreview: true });
+ boot('/en/mail');
+ expect(attr('density')).toBe('regular');
+ expect(attr('preview')).toBeNull();
+ });
+
+ it('falls back to regular for an unknown density', () => {
+ settings({ density: 'cosy' });
+ boot('/en/mail');
+ expect(cssVar('--list-item-height')).toBe('48px');
+ expect(attr('density')).toBe('regular');
+ });
+
+ it('keeps the horizontal and focus layouts, and simple mode forces focus', () => {
+ settings({ mailLayout: 'horizontal' });
+ boot('/en/mail');
+ expect(attr('mail-layout')).toBe('horizontal');
+ settings({ mailLayout: 'focus' });
+ boot('/en/mail');
+ expect(attr('mail-layout')).toBe('focus');
+ settings({ mailLayout: 'horizontal', simpleMode: true });
+ boot('/en/mail');
+ expect(attr('mail-layout')).toBe('focus');
+ });
+
+ it('clamps stored column widths the way the mail store does', () => {
+ localStorage.setItem('column-widths', JSON.stringify({ sidebarWidth: 1000, emailListWidth: 100, emailListHeight: 500 }));
+ boot('/en/mail');
+ expect(cssVar('--boot-mail-sidebar-w')).toBe('400px');
+ expect(cssVar('--boot-mail-list-w')).toBe('240px');
+ expect(cssVar('--boot-mail-list-h')).toBe('500px');
+ });
+
+ it('reads the other surfaces\' widths, defaulting bad values', () => {
+ localStorage.setItem('calendar-sidebar-width', '300');
+ localStorage.setItem('contacts-sidebar-width', 'abc');
+ localStorage.setItem('contacts-list-width', '450');
+ localStorage.setItem('files-sidebar-width', '999');
+ localStorage.setItem('settings-sidebar-width', '210');
+ boot('/en/contacts');
+ expect(cssVar('--boot-cal-sidebar-w')).toBe('300px');
+ expect(cssVar('--boot-contacts-sidebar-w')).toBe('256px');
+ expect(cssVar('--boot-contacts-list-w')).toBe('450px');
+ expect(cssVar('--boot-files-sidebar-w')).toBe('400px');
+ expect(cssVar('--boot-settings-nav-w')).toBe('210px');
+ });
+
+ it('marks the files view and details panel', () => {
+ settings({ filesSettings: { defaultViewMode: 'grid' } });
+ localStorage.setItem('files-show-details', '1');
+ boot('/en/files/Documents');
+ expect(attr('surface')).toBe('files');
+ expect(attr('files-view')).toBe('grid');
+ expect(attr('files-details')).toBe('on');
+ });
+});
+
+describe('boot script: mail and contacts panes', () => {
+ it.each([
+ ['/en/mail/message/M123', 'message'],
+ ['/en/mail/thread/T9', 'thread'],
+ ['/en/mail?email=M1', 'message'],
+ ['/en/mail/folder/inbox', null],
+ ['/en/mail/message', null],
+ ])('%s opens pane %s', (path, pane) => {
+ boot(path);
+ expect(attr('pane')).toBe(pane);
+ });
+
+ it.each([
+ ['/en/contacts', null],
+ ['/en/contacts/new', 'create'],
+ ['/en/contacts?addEmail=a%40example.com', 'create'],
+ ['/en/contacts/abc/edit', 'detail'],
+ ['/en/contacts?contactId=abc', 'detail'],
+ ])('%s opens pane %s', (path, pane) => {
+ boot(path);
+ expect(attr('surface')).toBe('contacts');
+ expect(attr('pane')).toBe(pane);
+ });
+});
+
+describe('boot script: calendar', () => {
+ it.each(['month', 'week', 'day', 'agenda', 'tasks'])('takes the %s view from the URL', (view) => {
+ localStorage.setItem('calendar-storage', JSON.stringify({ state: { viewMode: 'agenda' } }));
+ boot(`/en/calendar/${view}/2026-11-04`);
+ expect(attr('calendar-view')).toBe(view);
+ });
+
+ it('opens a bare date in month view, like parseCalendarPath', () => {
+ localStorage.setItem('calendar-storage', JSON.stringify({ state: { viewMode: 'week' } }));
+ boot('/en/calendar/2026-11-04');
+ expect(attr('calendar-view')).toBe('month');
+ });
+
+ it('uses the stored view for /calendar and event links, month without one', () => {
+ localStorage.setItem('calendar-storage', JSON.stringify({ state: { viewMode: 'day' } }));
+ boot('/en/calendar');
+ expect(attr('calendar-view')).toBe('day');
+ boot('/en/calendar/event/E1');
+ expect(attr('calendar-view')).toBe('day');
+ localStorage.setItem('calendar-storage', JSON.stringify({ state: { viewMode: 'year' } }));
+ boot('/en/calendar');
+ expect(attr('calendar-view')).toBe('month');
+ });
+
+ it('counts week columns and display hours from the settings', () => {
+ boot('/en/calendar/week');
+ expect(cssVar('--boot-cal-cols')).toBe('7');
+ expect(cssVar('--boot-cal-hours')).toBe('12');
+ settings({ calendarHideNonWorkingDays: true, calendarWorkingDays: [1, 2, 3, 4, 5], calendarDayStartHour: 7, calendarDayEndHour: 19 });
+ boot('/en/calendar/week');
+ expect(cssVar('--boot-cal-cols')).toBe('5');
+ expect(cssVar('--boot-cal-hours')).toBe('12');
+ settings({ calendarLimitHours: false, calendarHideNonWorkingDays: true, calendarWorkingDays: [] });
+ boot('/en/calendar/week');
+ expect(cssVar('--boot-cal-cols')).toBe('7');
+ expect(cssVar('--boot-cal-hours')).toBe('24');
+ settings({ calendarDayStartHour: 20, calendarDayEndHour: 8 });
+ boot('/en/calendar/week');
+ expect(cssVar('--boot-cal-hours')).toBe('24');
+ });
+
+ it('marks a collapsed calendar sidebar', () => {
+ localStorage.setItem('calendar-sidebar-collapsed', '1');
+ boot('/en/calendar');
+ expect(attr('collapsed')).toBe('calendar');
+ });
+
+ it('gives the first shown hour, 0 for the whole day', () => {
+ boot('/en/calendar/week');
+ expect(cssVar('--boot-cal-start')).toBe('8');
+ settings({ calendarDayStartHour: 6, calendarDayEndHour: 22 });
+ boot('/en/calendar/week');
+ expect(cssVar('--boot-cal-start')).toBe('6');
+ settings({ calendarLimitHours: false, calendarDayStartHour: 6, calendarDayEndHour: 22 });
+ boot('/en/calendar/week');
+ expect(cssVar('--boot-cal-start')).toBe('0');
+ });
+
+ describe('with a fixed clock', () => {
+ afterEach(() => vi.useRealTimers());
+
+ it('opens the hour grid an hour before now, at the top when now is outside the shown hours', () => {
+ vi.useFakeTimers({ toFake: ['Date'] });
+ vi.setSystemTime(new Date(2026, 10, 4, 15, 30));
+ boot('/en/calendar/week');
+ expect(cssVar('--boot-cal-scroll')).toBe('6');
+ settings({ calendarLimitHours: false });
+ boot('/en/calendar/day');
+ expect(cssVar('--boot-cal-scroll')).toBe('14');
+ vi.setSystemTime(new Date(2026, 10, 4, 21, 0));
+ settings({});
+ boot('/en/calendar/week');
+ expect(cssVar('--boot-cal-scroll')).toBe('-1');
+ });
+
+ it('reads the clock in the time zone the settings name', () => {
+ vi.useFakeTimers({ toFake: ['Date'] });
+ vi.setSystemTime(new Date(Date.UTC(2026, 10, 4, 14, 0)));
+ settings({ calendarLimitHours: false, timeZone: 'Asia/Tokyo' });
+ boot('/en/calendar/week');
+ // 23:00 in Tokyo.
+ expect(cssVar('--boot-cal-scroll')).toBe('22');
+ settings({ calendarLimitHours: false, timeZone: 'Not/AZone' });
+ boot('/en/calendar/week');
+ expect(cssVar('--boot-cal-scroll')).toBe(String(new Date().getHours() - 1));
+ });
+
+ it('counts the mini month rows of the linked month, else this one', () => {
+ vi.useFakeTimers({ toFake: ['Date'] });
+ vi.setSystemTime(new Date(2026, 1, 10, 12, 0));
+ // February 2026 starts on a Sunday and has 28 days.
+ boot('/en/calendar');
+ expect(cssVar('--boot-cal-mini-rows')).toBe('5');
+ settings({ firstDayOfWeek: 0 });
+ boot('/en/calendar');
+ expect(cssVar('--boot-cal-mini-rows')).toBe('4');
+ // November 2026 starts on a Sunday: six rows from Monday, five from Sunday.
+ settings({});
+ boot('/en/calendar/week/2026-11-04');
+ expect(cssVar('--boot-cal-mini-rows')).toBe('6');
+ boot('/en/calendar/2026-11-04');
+ expect(cssVar('--boot-cal-mini-rows')).toBe('6');
+ settings({ firstDayOfWeek: 0 });
+ boot('/en/calendar/week/2026-11-04');
+ expect(cssVar('--boot-cal-mini-rows')).toBe('5');
+ });
+ });
+
+ it('marks week numbers in the mini month', () => {
+ boot('/en/calendar');
+ expect(attr('cal-weeknums')).toBeNull();
+ settings({ showWeekNumbers: true });
+ boot('/en/calendar');
+ expect(attr('cal-weeknums')).toBe('on');
+ boot('/en/mail');
+ expect(attr('cal-weeknums')).toBeNull();
+ });
+
+ it('marks calendar tasks as on', () => {
+ boot('/en/calendar');
+ expect(attr('cal-tasks')).toBeNull();
+ settings({ enableCalendarTasks: true });
+ boot('/en/calendar');
+ expect(attr('cal-tasks')).toBe('on');
+ boot('/en/contacts');
+ expect(attr('cal-tasks')).toBeNull();
+ });
+});
+
+describe('boot script: settings', () => {
+ it('opens the page a URL names, migrating legacy ids', () => {
+ boot('/en/settings/themes');
+ expect(attr('settings-tab')).toBe('appearance');
+ expect(attr('pane')).toBe('detail');
+ boot('/en/settings/filters/rules');
+ expect(attr('settings-tab')).toBe('filters');
+ });
+
+ it('falls back to the stored tab for an unknown or missing URL tab', () => {
+ localStorage.setItem('settings-active-tab', 'keywords');
+ boot('/en/settings/no-such-tab');
+ expect(attr('settings-tab')).toBe('organize');
+ expect(attr('pane')).toBeNull();
+ boot('/en/settings');
+ expect(attr('settings-tab')).toBe('organize');
+ });
+
+ it('defaults to appearance, and honours the one-shot sidebar gear link', () => {
+ boot('/en/settings');
+ expect(attr('settings-tab')).toBe('appearance');
+ sessionStorage.setItem('settings-deep-link-tab', 'folders');
+ boot('/en/settings');
+ expect(attr('settings-tab')).toBe('organize');
+ expect(attr('pane')).toBe('detail');
+ });
+});
+
+describe('boot script: pages that lead elsewhere', () => {
+ it.each([
+ ['/connector/app', 'mail', null],
+ ['/connector/mail_folder?ref=inbox', 'mail', null],
+ ['/connector/contacts', 'contacts', null],
+ ['/connector/files?path=/Docs', 'files', null],
+ ['/connector/setup', 'setup', null],
+ ['/connector/admin?tab=logs', 'admin', 'detail'],
+ ['/connector/admin', 'admin', null],
+ ['/connector/admin_plugin?id=x', 'admin', 'detail'],
+ ])('%s draws %s', (path, surface, pane) => {
+ boot(path);
+ expect(attr('surface')).toBe(surface);
+ expect(attr('via')).toBe('connector');
+ expect(attr('pane')).toBe(pane);
+ });
+
+ it('draws a connector calendar link in its view and a settings link on its page', () => {
+ boot('/connector/calendar?view=agenda&date=2026-11-04');
+ expect(attr('surface')).toBe('calendar');
+ expect(attr('calendar-view')).toBe('agenda');
+ boot('/connector/calendar');
+ expect(attr('calendar-view')).toBe('month');
+ boot('/connector/settings?tab=templates');
+ expect(attr('surface')).toBe('settings');
+ expect(attr('settings-tab')).toBe('composing');
+ });
+
+ it('leaves an unknown connector target without a surface', () => {
+ boot('/connector/nope');
+ expect(attr('surface')).toBeNull();
+ });
+
+ it('draws mail behind mailto and the calendar behind webcal', () => {
+ boot('/protocol/mailto?url=mailto%3Aa%40b.c');
+ expect(attr('surface')).toBe('mail');
+ expect(attr('via')).toBe('mailto');
+ boot('/protocol/webcal?url=webcal%3A%2F%2Fx');
+ expect(attr('surface')).toBe('calendar');
+ expect(attr('via')).toBe('webcal');
+ });
+
+ it.each([
+ ['/admin', 'admin', null],
+ ['/admin/branding', 'admin', 'detail'],
+ ['/admin/login', 'admin-login', null],
+ ['/setup', 'setup', null],
+ ['/en/login', 'login', null],
+ ['/en/pro', 'pro', null],
+ ['/en/apps/plugin-x/page-y', 'apps', null],
+ ])('%s is %s', (path, surface, pane) => {
+ boot(path);
+ expect(attr('surface')).toBe(surface);
+ expect(attr('pane')).toBe(pane);
+ });
+});
+
+describe('boot script: Pro interface', () => {
+ it('predicts the hop to /pro on a desktop with the Pro interface on', () => {
+ settings({ proInterface: true });
+ boot('/en/mail/folder/inbox');
+ expect(attr('pro')).toBe('on');
+ boot('/en/settings/appearance');
+ expect(attr('pro')).toBe('on');
+ boot('/en/pro');
+ expect(attr('pro')).toBe('on');
+ });
+
+ it('not on a narrow screen, not without the setting, not for other pages', () => {
+ settings({ proInterface: true });
+ desktop(false);
+ boot('/en/mail');
+ expect(attr('pro')).toBeNull();
+ desktop(true);
+ boot('/admin');
+ expect(attr('pro')).toBeNull();
+ settings({ proInterface: false });
+ boot('/en/mail');
+ expect(attr('pro')).toBeNull();
+ });
+});
+
+describe('boot script: Pro tabs', () => {
+ const tab = (id: string, kind: string, paneId = 'main') => ({ id, kind, labelKey: kind, closeable: id !== 'home-mail', paneId });
+ const proTabs = (state: Record) =>
+ localStorage.setItem('pro-tabs', JSON.stringify({ state, version: 4 }));
+ const pro = () => ({
+ split: attr('pro-split'),
+ focus: attr('pro-focus'),
+ kind: attr('pro-kind'),
+ main: [attr('pro-main-tabs'), attr('pro-main-active'), attr('pro-main-kind')],
+ side: [attr('pro-split-tabs'), attr('pro-split-active'), attr('pro-split-kind')],
+ });
+
+ beforeEach(() => settings({ proInterface: true }));
+
+ it('draws the single home mail tab when nothing is stored', () => {
+ boot('/en/pro');
+ expect(pro()).toEqual({ split: null, focus: 'main', kind: 'mail', main: ['1', '0', 'mail'], side: [null, null, null] });
+ });
+
+ it('falls back to the home mail tab for corrupt or empty tabs', () => {
+ localStorage.setItem('pro-tabs', '{not json');
+ boot('/en/pro');
+ expect(pro().main).toEqual(['1', '0', 'mail']);
+ proTabs({ tabs: 'nope', activeTabId: 3 });
+ boot('/en/pro');
+ expect(pro().main).toEqual(['1', '0', 'mail']);
+ proTabs({ tabs: [null, { id: 'x' }, 7], activeTabId: 'x' });
+ boot('/en/pro');
+ expect(pro().main).toEqual(['1', '0', 'mail']);
+ });
+
+ it('reads the tab count, the active tab and the split', () => {
+ proTabs({
+ tabs: [tab('home-mail', 'mail'), tab('c', 'calendar'), tab('f', 'folder'), tab('s', 'settings', 'split'), tab('e', 'email', 'split')],
+ activeTabId: 'c',
+ activeSplitTabId: 'e',
+ focusedPaneId: 'split',
+ splitSide: 'left',
+ });
+ boot('/en/pro');
+ expect(pro()).toEqual({
+ split: 'left', focus: 'split', kind: 'email',
+ main: ['3', '1', 'calendar'],
+ side: ['2', '1', 'other'],
+ });
+ });
+
+ it('gives the calendar and settings variants to a pane that shows them', () => {
+ proTabs({ tabs: [tab('home-mail', 'mail'), tab('c', 'calendar'), tab('s', 'settings', 'split')], activeTabId: 'c', activeSplitTabId: 's' });
+ localStorage.setItem('calendar-storage', JSON.stringify({ state: { viewMode: 'week' }, version: 0 }));
+ localStorage.setItem('settings-active-tab', 'filters');
+ boot('/en/pro');
+ expect(attr('calendar-view')).toBe('week');
+ expect(cssVar('--boot-cal-cols')).toBe('7');
+ expect(attr('settings-tab')).toBe('filters');
+ expect(attr('pane')).toBeNull();
+ });
+
+ it('merges a split pane into an empty main pane', () => {
+ proTabs({ tabs: [tab('c', 'calendar', 'split'), tab('f', 'files', 'split')], activeTabId: 'gone', activeSplitTabId: 'f', focusedPaneId: 'split' });
+ boot('/en/pro');
+ expect(pro()).toEqual({ split: null, focus: 'main', kind: 'files', main: ['2', '1', 'files'], side: [null, null, null] });
+ });
+
+ it('focuses the tab the redirect opens for a standard page', () => {
+ proTabs({
+ tabs: [tab('home-mail', 'mail'), tab('c', 'calendar', 'split')],
+ activeTabId: 'home-mail',
+ activeSplitTabId: 'c',
+ focusedPaneId: 'main',
+ });
+ boot('/en/calendar/week/2026-11-04');
+ expect(pro()).toMatchObject({ split: 'right', focus: 'split', kind: 'calendar', main: ['1', '0', 'mail'], side: ['1', '0', 'calendar'] });
+ expect(attr('calendar-view')).toBe('week');
+
+ // No contacts tab yet: a new one at the end of the focused pane.
+ boot('/en/contacts');
+ expect(pro()).toMatchObject({ focus: 'main', kind: 'contacts', main: ['2', '1', 'contacts'], side: ['1', '0', 'calendar'] });
+ });
+
+ it('caps the drawn tabs at eight', () => {
+ const many = Array.from({ length: 12 }, (_, i) => tab(`t${i}`, i === 11 ? 'files' : 'email'));
+ proTabs({ tabs: many, activeTabId: 't11' });
+ boot('/en/pro');
+ expect(pro().main).toEqual(['8', '7', 'files']);
+ });
+
+ it('sets nothing when Pro does not take the page over', () => {
+ proTabs({ tabs: [tab('home-mail', 'mail'), tab('c', 'calendar', 'split')], activeSplitTabId: 'c' });
+ desktop(false);
+ boot('/en/mail');
+ expect(pro()).toEqual({ split: null, focus: null, kind: null, main: [null, null, null], side: [null, null, null] });
+ expect(attr('calendar-view')).toBeNull();
+ });
+});
+
+describe('boot script: signed-out visitors', () => {
+ it('marks a visitor with nothing stored as signed out on app pages', () => {
+ boot('/en/mail');
+ expect(attr('auth')).toBe('out');
+ boot('/en/calendar/week');
+ expect(attr('auth')).toBe('out');
+ });
+
+ it('not on the login, admin or setup pages', () => {
+ for (const path of ['/en/login', '/admin', '/admin/login', '/setup']) {
+ boot(path);
+ expect(attr('auth')).toBeNull();
+ }
+ });
+
+ it('not with a persisted session, a remembered account, a demo account or an impersonation hand-off', () => {
+ signedIn();
+ boot('/en/mail');
+ expect(attr('auth')).toBeNull();
+ localStorage.clear();
+ localStorage.setItem('account-registry', JSON.stringify({ state: { accounts: [{ id: 'a', rememberMe: true }] } }));
+ boot('/en/mail');
+ expect(attr('auth')).toBeNull();
+ localStorage.setItem('account-registry', JSON.stringify({ state: { accounts: [{ id: 'd', rememberMe: false, serverUrl: 'https://demo.example.com' }] } }));
+ boot('/en/mail');
+ expect(attr('auth')).toBeNull();
+ localStorage.clear();
+ boot('/en/mail?impersonated=1');
+ expect(attr('auth')).toBeNull();
+ });
+
+ it('counts an account that will be dropped (no remember me) as signed out', () => {
+ localStorage.setItem('account-registry', JSON.stringify({ state: { accounts: [{ id: 'a', rememberMe: false }] } }));
+ boot('/en/mail');
+ expect(attr('auth')).toBe('out');
+ boot('/en/mail', { lite: true });
+ expect(attr('auth')).toBeNull();
+ });
+});
+
+describe('boot script: rail hints', () => {
+ it('passes the stored rail items on, dropping anything odd', () => {
+ localStorage.setItem('bulwark-boot-hints', JSON.stringify({ v: 1, rail: ['mail', 'calendar', 'quota', '', 3] }));
+ boot('/en/mail');
+ expect(attr('rail')).toBe('mail calendar quota');
+ });
+
+ it('passes the settings pages the nav listed on, known ids only', () => {
+ localStorage.setItem('bulwark-boot-hints', JSON.stringify({ v: 1, rail: ['mail'], settings: ['account', 'filters', 'recovery', 'plugin:x', 'nope', 7] }));
+ boot('/en/settings');
+ expect(attr('settings-tabs')).toBe('account filters recovery');
+ });
+
+ it('leaves the settings pages out without hints', () => {
+ boot('/en/settings');
+ expect(attr('settings-tabs')).toBeNull();
+ });
+});
+
+describe('boot script: Lite', () => {
+ it('reads a parked deep link instead of the surface root it was replayed on', () => {
+ sessionStorage.setItem('bulwark-lite:pending-path', '/de/calendar/day/2026-11-04');
+ boot('/de/calendar/', { lite: true });
+ expect(attr('surface')).toBe('calendar');
+ expect(attr('calendar-view')).toBe('day');
+ });
+
+ it('ignores the parked path outside Lite', () => {
+ sessionStorage.setItem('bulwark-lite:pending-path', '/de/calendar/day/2026-11-04');
+ localStorage.setItem('calendar-storage', JSON.stringify({ state: { viewMode: 'week' } }));
+ boot('/de/calendar/');
+ expect(attr('calendar-view')).toBe('week');
+ });
+
+ it('strips the base path and the Lite mount', () => {
+ boot('/webmail/en/files/Docs', { basePath: '/webmail' });
+ expect(attr('surface')).toBe('files');
+ (window as unknown as Record).__BULWARK_LITE_MOUNT__ = '/bulwark';
+ try {
+ boot('/bulwark/en/settings/filters', { lite: true });
+ expect(attr('surface')).toBe('settings');
+ expect(attr('settings-tab')).toBe('filters');
+ } finally {
+ delete (window as unknown as Record).__BULWARK_LITE_MOUNT__;
+ }
+ });
+});
+
+describe('boot script: cached theme', () => {
+ it('puts the cached theme and skin back before the page renders', () => {
+ localStorage.setItem(THEME_CACHE_KEY, JSON.stringify({ v: 1, css: ':root{--color-primary:red}', skin: 'html[data-theme-skin="t1"] body{font-family:x}', skinId: 't1' }));
+ boot('/en/mail');
+ expect(document.getElementById('active-theme')?.textContent).toBe(':root{--color-primary:red}');
+ expect(document.getElementById('active-theme-skin')?.textContent).toContain('font-family:x');
+ expect(html().getAttribute('data-theme-skin')).toBe('t1');
+ });
+
+ it('reuses existing elements instead of adding a second copy', () => {
+ localStorage.setItem(THEME_CACHE_KEY, JSON.stringify({ v: 1, css: 'a{}' }));
+ boot('/en/mail');
+ boot('/en/mail');
+ expect(document.querySelectorAll('#active-theme')).toHaveLength(1);
+ });
+
+ it('ignores a cache from another format version, or a skin without its id', () => {
+ localStorage.setItem(THEME_CACHE_KEY, JSON.stringify({ v: 2, css: 'a{}' }));
+ boot('/en/mail');
+ expect(document.getElementById('active-theme')).toBeNull();
+ localStorage.setItem(THEME_CACHE_KEY, JSON.stringify({ v: 1, skin: 'b{}' }));
+ boot('/en/mail');
+ expect(document.getElementById('active-theme-skin')).toBeNull();
+ });
+});
diff --git a/lib/boot-skeleton/__tests__/surfaces.test.ts b/lib/boot-skeleton/__tests__/surfaces.test.ts
new file mode 100644
index 000000000..d3b8a1268
--- /dev/null
+++ b/lib/boot-skeleton/__tests__/surfaces.test.ts
@@ -0,0 +1,86 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import { TARGET_NAMES, getTarget } from '@/lib/connector/registry';
+import { locales } from '@/i18n/routing';
+import { CONNECTOR_SURFACES, surfaceFromPath } from '../surfaces';
+import { BOOT_HINTS_KEY } from '../keys';
+import { resetRailHintsMemo, writeRailHints, writeSettingsHints } from '../hints';
+
+describe('connector targets', () => {
+ it('every target the connector can resolve has a skeleton surface', () => {
+ for (const name of TARGET_NAMES) expect(CONNECTOR_SURFACES[name], name).toBeTruthy();
+ expect(Object.keys(CONNECTOR_SURFACES).sort()).toEqual([...TARGET_NAMES].sort());
+ });
+
+ it('the surface matches where the target resolves to', () => {
+ const firstSegment: Record = { '': 'mail' };
+ for (const name of TARGET_NAMES) {
+ const target = getTarget(name)!;
+ const params: Record = {};
+ for (const [key, spec] of Object.entries(target.params ?? {})) {
+ if (!spec.required) continue;
+ params[key] = spec.type === 'enum' ? spec.values![0] : 'x';
+ }
+ const { path } = target.resolve(params);
+ const seg = path.split('/').filter(Boolean)[0] ?? '';
+ expect(CONNECTOR_SURFACES[name], `${name} -> ${path}`).toBe(firstSegment[seg] ?? seg);
+ }
+ });
+});
+
+describe('surfaceFromPath', () => {
+ it.each([
+ ['/', 'mail'],
+ ['/en', 'mail'],
+ ['/de/mail/folder/inbox', 'mail'],
+ ['/en/calendar/week/2026-11-04', 'calendar'],
+ ['/ar/contacts/abc', 'contacts'],
+ ['/en/files/de', 'files'],
+ ['/en/apps/p/q', 'apps'],
+ ['/en/settings/filters', 'settings'],
+ ['/en/pro', 'pro'],
+ ['/en/login', 'login'],
+ ['/admin', 'admin'],
+ ['/admin/logs', 'admin'],
+ ['/admin/login', 'admin-login'],
+ ['/setup', 'setup'],
+ ['/connector/calendar', 'calendar'],
+ ['/connector/admin_auth', 'admin'],
+ ['/connector/nope', null],
+ ['/protocol/mailto', 'mail'],
+ ['/protocol/webcal', 'calendar'],
+ ['/webmail/de/calendar', 'calendar'],
+ ['/en/auth/callback', null],
+ ['/en/plugins/oauth/callback', null],
+ ])('%s -> %s', (path, surface) => {
+ expect(surfaceFromPath(path, locales)).toBe(surface);
+ });
+});
+
+describe('rail hints', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ resetRailHintsMemo();
+ });
+
+ it('stores the rail items with mail first', () => {
+ writeRailHints(['calendar', 'quota']);
+ expect(JSON.parse(localStorage.getItem(BOOT_HINTS_KEY)!)).toEqual({ v: 1, rail: ['mail', 'calendar', 'quota'] });
+ });
+
+ it('skips writing the same value twice', () => {
+ writeRailHints(['files']);
+ localStorage.removeItem(BOOT_HINTS_KEY);
+ writeRailHints(['files']);
+ expect(localStorage.getItem(BOOT_HINTS_KEY)).toBeNull();
+ writeRailHints(['files', 'admin']);
+ expect(JSON.parse(localStorage.getItem(BOOT_HINTS_KEY)!).rail).toEqual(['mail', 'files', 'admin']);
+ });
+
+ it('keeps the rail and the settings pages side by side', () => {
+ writeRailHints(['calendar']);
+ writeSettingsHints(['account', 'filters']);
+ expect(JSON.parse(localStorage.getItem(BOOT_HINTS_KEY)!)).toEqual({ v: 1, rail: ['mail', 'calendar'], settings: ['account', 'filters'] });
+ writeRailHints(['files']);
+ expect(JSON.parse(localStorage.getItem(BOOT_HINTS_KEY)!)).toEqual({ v: 1, rail: ['mail', 'files'], settings: ['account', 'filters'] });
+ });
+});
diff --git a/lib/boot-skeleton/__tests__/theme-cache.test.ts b/lib/boot-skeleton/__tests__/theme-cache.test.ts
new file mode 100644
index 000000000..66c414eed
--- /dev/null
+++ b/lib/boot-skeleton/__tests__/theme-cache.test.ts
@@ -0,0 +1,75 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import {
+ bootSkinCSS,
+ injectThemeCSS,
+ injectThemeSkinCSS,
+ removeThemeCSS,
+ removeThemeSkinCSS,
+} from '@/lib/theme-loader';
+import { THEME_CACHE_KEY, THEME_CACHE_MAX } from '../keys';
+
+const cache = () => JSON.parse(localStorage.getItem(THEME_CACHE_KEY) || 'null');
+
+beforeEach(() => {
+ localStorage.clear();
+ removeThemeCSS();
+ removeThemeSkinCSS();
+ document.documentElement.removeAttribute('data-theme-skin');
+});
+
+describe('theme cache for the boot script', () => {
+ it('keeps a copy of the injected colour block and skin', () => {
+ injectThemeCSS(':root{--color-primary:#f00}');
+ injectThemeSkinCSS('body[data-theme-skin="t1"] .toolbar{gap:2px}', 't1');
+ expect(cache()).toEqual({
+ v: 1,
+ css: ':root{--color-primary:#f00}',
+ skin: 'html[data-theme-skin="t1"] body .toolbar{gap:2px}',
+ skinId: 't1',
+ });
+ });
+
+ it('drops the parts that are removed, and the whole entry when nothing is left', () => {
+ injectThemeCSS('a{}');
+ injectThemeSkinCSS('b{}', 't1');
+ removeThemeSkinCSS();
+ expect(cache()).toEqual({ v: 1, css: 'a{}' });
+ removeThemeCSS();
+ expect(localStorage.getItem(THEME_CACHE_KEY)).toBeNull();
+ });
+
+ it('does not cache a theme too large for localStorage', () => {
+ injectThemeCSS('a{}');
+ injectThemeCSS(`:root{--x:"${'y'.repeat(THEME_CACHE_MAX)}"}`);
+ expect(localStorage.getItem(THEME_CACHE_KEY)).toBeNull();
+ expect(document.getElementById('active-theme')?.textContent).toContain('--x');
+ });
+
+ it('clears the boot copy\'s attribute once the real skin is on ', () => {
+ document.documentElement.setAttribute('data-theme-skin', 't1');
+ injectThemeSkinCSS('b{}', 't1');
+ expect(document.body.getAttribute('data-theme-skin')).toBe('t1');
+ expect(document.documentElement.hasAttribute('data-theme-skin')).toBe(false);
+ });
+
+ it('reuses a