mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 08:16:22 +00:00
Merge main into feat/assistant-everywhere
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -11,9 +11,17 @@ permissions:
|
||||
|
||||
jobs:
|
||||
vitest:
|
||||
name: Vitest
|
||||
name: Vitest (${{ matrix.shard }}/4)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: 20
|
||||
# Most of a run is per-file overhead (imports and jsdom setup), not test
|
||||
# code, so spreading the files over parallel runners cuts the wall time
|
||||
# almost linearly. fail-fast is off so one red shard doesn't hide failures
|
||||
# in the others.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
@@ -33,7 +41,7 @@ jobs:
|
||||
# forks + a small worker count keeps the load-sensitive component tests
|
||||
# from timing out on the shared runners; the longer per-test timeout
|
||||
# covers the jsdom-heavy suites.
|
||||
run: npx vitest run --pool=forks --maxWorkers=2 --testTimeout=15000
|
||||
run: npx vitest run --pool=forks --maxWorkers=2 --testTimeout=15000 --shard=${{ matrix.shard }}/4
|
||||
|
||||
# The pre-commit hook lints only staged files and never runs on fork PRs,
|
||||
# so this is the one place the whole tree is checked.
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// While the sign-in is completed, the callback draws the mail page's
|
||||
// skeleton: that is where it lands, and the mail page's own gate draws the
|
||||
// same, so nothing changes when it gets there.
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
useParams: () => ({ locale: 'en' }),
|
||||
useSearchParams: () => new URLSearchParams('code=abc&state=xyz'),
|
||||
}));
|
||||
|
||||
const { default: OAuthCallbackPage } = await import('../page');
|
||||
|
||||
describe('OAuth callback while the sign-in completes', () => {
|
||||
it('server-renders the mail skeleton with the completing message, not a spinner', () => {
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = renderToString(<OAuthCallbackPage />);
|
||||
expect(container.querySelector('[data-boot-skeleton="mail"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-boot-skeleton="mail"] [role="status"]')?.textContent).toBe('oauth_completing');
|
||||
expect(container.querySelector('.animate-spin')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -7,18 +7,21 @@ import { useAuthStore } from "@/stores/auth-store";
|
||||
import { apiFetch, getPathPrefix, toRouterPath } from "@/lib/browser-navigation";
|
||||
import { buildSettingsPath } from "@/lib/deep-links";
|
||||
import { LITE_OAUTH_AVAILABLE, getLiteOAuthRedirectUri } from "@/lib/auth/lite-oauth";
|
||||
import { Loader2, AlertCircle } from "@/components/icons";
|
||||
import { AlertCircle } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useParams } from "next/navigation";
|
||||
import { buildMobileHandoffUrl, mobileHandoffTarget } from "@/lib/auth/mobile-handoff";
|
||||
import { confirmMobileHandoffCodeUrl } from "@/lib/auth/mobile-handoff-client";
|
||||
import { MobileHandoffConsent } from "@/components/auth/mobile-handoff-consent";
|
||||
import { MailSkeleton } from "@/components/boot-skeleton/mail-skeleton";
|
||||
import { useSkeletonLabels } from "@/components/boot-skeleton/use-skeleton-labels";
|
||||
|
||||
function OAuthCallbackInner() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const t = useTranslations("login");
|
||||
const { labels } = useSkeletonLabels();
|
||||
const { loginWithOAuth, loginWithServerSso } = useAuthStore();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// A sign-in started for the mobile app, waiting for the user to confirm
|
||||
@@ -300,27 +303,16 @@ function OAuthCallbackInner() {
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20">
|
||||
<div className="w-full max-w-sm mx-auto px-4 text-center" role="status">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary mx-auto mb-4" />
|
||||
<p className="text-muted-foreground text-sm">{t("oauth_completing")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
// A finished sign-in lands in the mailbox (or the page saved before it),
|
||||
// so the mail page's skeleton: it stays in place while the app loads there.
|
||||
return <MailSkeleton label={t("oauth_completing")} labels={labels} />;
|
||||
}
|
||||
|
||||
export default function OAuthCallbackPage() {
|
||||
const t = useTranslations("login");
|
||||
const { labels } = useSkeletonLabels();
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20">
|
||||
<div className="w-full max-w-sm mx-auto px-4 text-center" role="status">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary mx-auto mb-4" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<MailSkeleton label={t("oauth_completing")} labels={labels} />}>
|
||||
<OAuthCallbackInner />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Until /api/config answers (and in the server HTML, where it never has),
|
||||
// the login page draws its card's skeleton instead of a spinner, the same
|
||||
// one signed-out visitors of the app pages see first.
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: () => ({ locale: 'en' }),
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }),
|
||||
usePathname: () => '/login',
|
||||
}));
|
||||
vi.mock('@/i18n/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }),
|
||||
usePathname: () => '/login',
|
||||
Link: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
|
||||
const config = vi.hoisted(() => ({ loading: true, oauthOnly: false }));
|
||||
|
||||
vi.mock('@/hooks/use-config', () => ({
|
||||
useConfig: () => ({
|
||||
appName: 'Webmail',
|
||||
isLoading: config.loading,
|
||||
error: null,
|
||||
jmapServerUrl: 'https://mail.example.org',
|
||||
jmapServers: [],
|
||||
loginShowHeading: true,
|
||||
loginShowSubtitle: true,
|
||||
loginShowVersion: true,
|
||||
loginShowTotp: true,
|
||||
oauthEnabled: config.oauthOnly,
|
||||
oauthOnly: config.oauthOnly,
|
||||
}),
|
||||
}));
|
||||
|
||||
// The provider's metadata never arrives in these tests.
|
||||
vi.mock('@/lib/browser-navigation', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/lib/browser-navigation')>()),
|
||||
apiFetch: vi.fn(() => new Promise(() => {})),
|
||||
}));
|
||||
|
||||
const { default: LoginPage } = await import('../page');
|
||||
|
||||
describe('login page while the config loads', () => {
|
||||
it('server-renders the card skeleton, not a spinner', () => {
|
||||
config.loading = true;
|
||||
const html = renderToString(<LoginPage />);
|
||||
expect(html).toContain('data-boot-skeleton="login"');
|
||||
expect(html).not.toContain('animate-spin');
|
||||
});
|
||||
|
||||
it('keeps the skeleton on the client until the config is there', () => {
|
||||
config.loading = true;
|
||||
const { container } = render(<LoginPage />);
|
||||
const region = container.querySelector('[data-boot-skeleton="login"]');
|
||||
expect(region).not.toBeNull();
|
||||
expect(region!.getAttribute('aria-busy')).toBe('true');
|
||||
expect(container.querySelector('[role="status"]')?.textContent).toBe('loading');
|
||||
expect(container.querySelector('.animate-spin')).toBeNull();
|
||||
});
|
||||
|
||||
it('draws the sign-in button\'s place while the provider is looked up, not a spinner', () => {
|
||||
config.loading = false;
|
||||
config.oauthOnly = true;
|
||||
try {
|
||||
const { container } = render(<LoginPage />);
|
||||
expect(container.querySelector('[data-boot-skeleton]')).toBeNull();
|
||||
expect(container.querySelector('.animate-spin')).toBeNull();
|
||||
expect(container.querySelector('.sk-shape.h-11')).not.toBeNull();
|
||||
} finally {
|
||||
config.oauthOnly = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,8 @@ import { buildMobileHandoffUrl, mobileCodeChallenge, mobileHandoffTarget, signed
|
||||
import { requestMobileHandoffCodeUrl } from "@/lib/auth/mobile-handoff-client";
|
||||
import { MobileHandoffConsent } from "@/components/auth/mobile-handoff-consent";
|
||||
import { HelpLinks } from "@/components/layout/help-links";
|
||||
import { LoginSkeleton } from "@/components/boot-skeleton/login-skeleton";
|
||||
import { Block } from "@/components/boot-skeleton/kit";
|
||||
import { consumeSessionEnd, type SessionEndReason } from "@/lib/session-policy/sign-out-reason";
|
||||
|
||||
/** The domain of a complete address (`user@example.com`), or '' while it is still being typed. */
|
||||
@@ -698,15 +700,10 @@ function LoginPageContent() {
|
||||
setShowThemeMenu(false);
|
||||
}, [setTheme]);
|
||||
|
||||
// The server HTML, and every visit until /api/config answers: the card's
|
||||
// shape, the same one signed-out visitors of the app pages get first.
|
||||
if (configLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
|
||||
<div className="w-full max-w-sm mx-auto px-4 text-center" role="status">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary mx-auto" />
|
||||
<span className="sr-only">{t("loading")}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <LoginSkeleton label={t("loading")} />;
|
||||
}
|
||||
|
||||
if (configError) {
|
||||
@@ -1533,9 +1530,8 @@ function LoginPageContent() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-primary" />
|
||||
</div>
|
||||
// Where the sign-in button goes once the provider answers.
|
||||
<Block className="w-full h-11 rounded-xl" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
@@ -1928,8 +1924,9 @@ function LoginPageContent() {
|
||||
// useSearchParams() above must sit under a Suspense boundary for the route to
|
||||
// prerender (the static Lite build); at runtime the boundary never suspends.
|
||||
export default function LoginPage() {
|
||||
const t = useTranslations("login");
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<Suspense fallback={<LoginSkeleton label={t("loading")} />}>
|
||||
<LoginPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Until the session is up the Pro page draws the same skeleton the server
|
||||
// sent (the Pro frame), not a spinner and not a blank page, so the first
|
||||
// paint and the loaded page line up.
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => (key === 'common.loading' ? 'Loading…' : key),
|
||||
}));
|
||||
|
||||
const device = vi.hoisted(() => ({ isMobile: false, isTablet: false, isDesktop: true }));
|
||||
vi.mock('@/hooks/use-media-query', () => ({ useDeviceDetection: () => device }));
|
||||
|
||||
const auth = vi.hoisted(() => ({
|
||||
state: {
|
||||
isAuthenticated: false,
|
||||
client: null as unknown,
|
||||
isLoading: false,
|
||||
checkAuth: () => new Promise<void>(() => {}),
|
||||
logout: () => {},
|
||||
},
|
||||
}));
|
||||
vi.mock('@/stores/auth-store', () => {
|
||||
const useAuthStore = Object.assign(
|
||||
(selector: (s: typeof auth.state) => unknown) => selector(auth.state),
|
||||
{ getState: () => auth.state },
|
||||
);
|
||||
return { useAuthStore, redirectToLogin: vi.fn() };
|
||||
});
|
||||
|
||||
// The apps the panes host and the shell's dialogs are not under test.
|
||||
vi.mock('@/components/mail/mail-app', () => ({ MailApp: () => <div data-testid="mail-app" /> }));
|
||||
vi.mock('@/components/calendar/calendar-app', () => ({ CalendarApp: () => null }));
|
||||
vi.mock('@/components/contacts/contacts-app', () => ({ ContactsApp: () => null }));
|
||||
vi.mock('@/components/files/files-app', () => ({ FilesApp: () => null }));
|
||||
vi.mock('@/components/settings/settings-app', () => ({ SettingsApp: () => null }));
|
||||
vi.mock('@/components/pro/pro-compose-tab-body', () => ({ ProComposeTabBody: () => null }));
|
||||
vi.mock('@/components/pro/pro-email-tab-body', () => ({ ProEmailTabBody: () => null }));
|
||||
vi.mock('@/components/pro/pro-folder-tab-body', () => ({ ProFolderTabBody: () => null }));
|
||||
vi.mock('@/components/global-search/search-tab-body', () => ({ SearchTabBody: () => null }));
|
||||
vi.mock('@/components/global-search/global-search-palette', () => ({ GlobalSearchPalette: () => null }));
|
||||
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => <nav data-testid="rail" /> }));
|
||||
vi.mock('@/components/keyboard-shortcuts-modal', () => ({ KeyboardShortcutsModal: () => null }));
|
||||
vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null }));
|
||||
vi.mock('@/components/layout/inline-app-view', () => ({ InlineAppView: () => null }));
|
||||
vi.mock('@/components/org/org-announcement', () => ({ OrgAnnouncement: () => null }));
|
||||
vi.mock('@/components/pro/pro-tab-bar', () => ({ ProTabBar: () => <div role="tablist" /> }));
|
||||
vi.mock('@/hooks/use-sidebar-apps', () => ({
|
||||
useSidebarApps: () => ({
|
||||
showAppsModal: false,
|
||||
inlineApp: null,
|
||||
loadedApps: [],
|
||||
handleManageApps: () => {},
|
||||
handleInlineApp: () => {},
|
||||
closeInlineApp: () => {},
|
||||
closeAppsModal: () => {},
|
||||
}),
|
||||
}));
|
||||
vi.mock('@/hooks/use-deep-link-url', () => ({ useDeepLinkUrl: () => {} }));
|
||||
const { default: ProHome } = await import('../page');
|
||||
const { useSettingsStore } = await import('@/stores/settings-store');
|
||||
|
||||
const replace = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
auth.state.isAuthenticated = false;
|
||||
auth.state.client = null;
|
||||
auth.state.isLoading = false;
|
||||
useSettingsStore.setState({ proInterface: true });
|
||||
Object.assign(device, { isMobile: false, isTablet: false, isDesktop: true });
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: { ...window.location, replace, pathname: '/pro', search: '' },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Pro page gate', () => {
|
||||
it('draws the Pro skeleton while the session is restored', async () => {
|
||||
const { container } = render(<ProHome />);
|
||||
await act(async () => {});
|
||||
const region = container.querySelector('[data-boot-skeleton="pro"]');
|
||||
expect(region).not.toBeNull();
|
||||
expect(region?.querySelector('[data-pro-shell]')).not.toBeNull();
|
||||
expect(container.querySelectorAll('[data-sk-pro-pane]')).toHaveLength(2);
|
||||
expect(container.querySelector('.animate-spin')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="mail-app"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the skeleton up, not a blank page, below desktop width', async () => {
|
||||
auth.state.isAuthenticated = true;
|
||||
auth.state.client = {};
|
||||
Object.assign(device, { isMobile: false, isTablet: true, isDesktop: false });
|
||||
const { container } = render(<ProHome />);
|
||||
await act(async () => {});
|
||||
expect(container.querySelector('[data-boot-skeleton="pro"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="mail-app"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('marks the loaded shell so a skeleton inside a pane keeps its own frame', async () => {
|
||||
auth.state.isAuthenticated = true;
|
||||
auth.state.client = {};
|
||||
const { container } = render(<ProHome />);
|
||||
await act(async () => {});
|
||||
expect(container.querySelector('[data-boot-skeleton]')).toBeNull();
|
||||
const shell = container.querySelector('[data-pro-shell]');
|
||||
expect(shell).not.toBeNull();
|
||||
expect(shell?.querySelector('[data-testid="mail-app"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,8 @@ import { useProTabStore, type ProTab, type ProTabKind, type ProPaneId, type ProS
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getPathPrefix } from "@/lib/browser-navigation";
|
||||
import { onOpenEmail, proEmailTabOpener } from "@/lib/plugin-sandbox/open-email";
|
||||
import { ProSkeleton } from "@/components/boot-skeleton/pro-skeleton";
|
||||
import { useSkeletonLabels } from "@/components/boot-skeleton/use-skeleton-labels";
|
||||
|
||||
import { MailApp } from "@/components/mail/mail-app";
|
||||
import { CalendarApp } from "@/components/calendar/calendar-app";
|
||||
@@ -189,6 +191,7 @@ function Pane({ paneId, tabs, activeTabId, loadedTabIds, onPaneFocus, isFocused
|
||||
|
||||
export default function ProHome() {
|
||||
const t = useTranslations();
|
||||
const { label: skeletonLabel } = useSkeletonLabels();
|
||||
const { isMobile, isTablet, isDesktop } = useDeviceDetection();
|
||||
|
||||
const [initialCheckDone, setInitialCheckDone] = useState(
|
||||
@@ -448,20 +451,14 @@ export default function ProHome() {
|
||||
}
|
||||
};
|
||||
|
||||
// Loading state (matches standard page exactly)
|
||||
if (!initialCheckDone || authLoading || !isAuthenticated || !client) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-foreground mx-auto"></div>
|
||||
<p className="mt-4 text-sm text-muted-foreground">{t("common.loading")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
// Until the session is up: the same skeleton the server drew, so hydration
|
||||
// matches and nothing moves. Below desktop width the effect above is about
|
||||
// to send the visitor to the standard page; keep the skeleton up meanwhile
|
||||
// rather than a blank page.
|
||||
if (!initialCheckDone || authLoading || !isAuthenticated || !client || !isDesktop) {
|
||||
return <ProSkeleton label={skeletonLabel} />;
|
||||
}
|
||||
|
||||
if (!isDesktop) return null;
|
||||
|
||||
// Each pane renders as a column: its own tab strip directly above its
|
||||
// body, so a tab visibly belongs to the pane beneath it and clicking a
|
||||
// tab only ever changes that pane.
|
||||
@@ -511,7 +508,8 @@ export default function ProHome() {
|
||||
|
||||
return (
|
||||
<EmbeddedContext.Provider value={true}>
|
||||
<div className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
|
||||
{/* data-pro-shell: a page skeleton inside a pane draws its own frame, not the Pro one. */}
|
||||
<div data-pro-shell="" className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
|
||||
{/* Once for the whole shell: email and folder tabs have no app strip, and split panes would show it twice. */}
|
||||
<OrgAnnouncement />
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
|
||||
@@ -14,6 +14,7 @@ import { OrgTemplatesPage } from '../_pages/org-templates';
|
||||
import { OrgSecurityPage } from '../_pages/org-security';
|
||||
import { OrgSharingPage } from '../_pages/org-sharing';
|
||||
import { OrgLabelsPage } from '../_pages/org-labels';
|
||||
import { OrgProofreadingPage } from '../_pages/org-proofreading';
|
||||
import { OrgCalendarsPage } from '../_pages/org-calendars';
|
||||
import { OrgNoticesPage } from '../_pages/org-notices';
|
||||
import { OrgDevicesPage } from '../_pages/org-devices';
|
||||
@@ -57,6 +58,7 @@ export default function AdminPage() {
|
||||
case 'org-security': return <OrgSecurityPage />;
|
||||
case 'org-sharing': return <OrgSharingPage />;
|
||||
case 'org-labels': return <OrgLabelsPage />;
|
||||
case 'org-proofreading': return <OrgProofreadingPage />;
|
||||
case 'org-calendars': return <OrgCalendarsPage />;
|
||||
case 'org-notices': return <OrgNoticesPage />;
|
||||
case 'org-devices': return <OrgDevicesPage />;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// The admin sign-in page renders in full on the server, which can't know the
|
||||
// theme: it sends both logos and CSS shows the one for the theme, instead of
|
||||
// the light logo turning white after the first paint in dark mode.
|
||||
|
||||
vi.mock('@/hooks/use-config', () => ({
|
||||
useConfig: () => ({
|
||||
appName: 'Webmail',
|
||||
loginLogoLightUrl: '/branding/light.svg',
|
||||
loginLogoDarkUrl: '/branding/dark.svg',
|
||||
}),
|
||||
}));
|
||||
|
||||
const { default: AdminLoginPage } = await import('../login/page');
|
||||
|
||||
describe('admin sign-in logo', () => {
|
||||
it('sends the light and the dark logo, each for its theme', () => {
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = renderToString(<AdminLoginPage />);
|
||||
const logos = [...container.querySelectorAll('img')].map((img) => [img.getAttribute('src'), img.className]);
|
||||
expect(logos).toEqual([
|
||||
['/branding/light.svg', expect.stringContaining('dark:hidden')],
|
||||
['/branding/dark.svg', expect.stringContaining('hidden dark:block')],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// While the admin session is checked, the content column shows the page's
|
||||
// header and section placeholders where the boot skeleton drew them, not a
|
||||
// line of text at a different height.
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ replace: vi.fn(), push: vi.fn() }),
|
||||
usePathname: () => '/admin/settings',
|
||||
}));
|
||||
vi.mock('@/lib/browser-navigation', () => ({
|
||||
// The session check never answers.
|
||||
apiFetch: vi.fn(() => new Promise(() => {})),
|
||||
getPathPrefix: () => '',
|
||||
withBasePath: (href: string) => href,
|
||||
}));
|
||||
vi.mock('@/components/admin/admin-shell', () => ({
|
||||
AdminShell: ({ children }: { children: ReactNode }) => <main>{children}</main>,
|
||||
}));
|
||||
vi.mock('../_unsaved-changes-guard', () => ({ UnsavedChangesGuard: () => null }));
|
||||
|
||||
const { AdminLayoutClient } = await import('../_layout-client');
|
||||
|
||||
describe('admin layout while the session is checked', () => {
|
||||
it('draws the page header and section placeholders in the content slot', () => {
|
||||
render(<AdminLayoutClient><p>page</p></AdminLayoutClient>);
|
||||
const main = screen.getByRole('main');
|
||||
expect(main.querySelector('[data-testid="admin-body-skeleton"]')).not.toBeNull();
|
||||
// The open page's own title and description, as AdminPageHeader shows them.
|
||||
expect(main.textContent).toContain('How this Bulwark connects and what the login page offers');
|
||||
expect(screen.getByRole('status').textContent).toBe('Loading admin panel…');
|
||||
expect(screen.getByRole('status').className).toContain('sr-only');
|
||||
expect(screen.queryByText('page')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
// Organisation > Proofreading: the four states for everyone or a group, the
|
||||
// per-plugin lock, and the warning (with a shortcut) when proofreading is
|
||||
// required but no checker plugin is forced on.
|
||||
|
||||
const puts: Array<Record<string, unknown>> = [];
|
||||
const patches: Array<Record<string, unknown>> = [];
|
||||
let stored: Record<string, unknown> = {};
|
||||
let plugins: Array<Record<string, unknown>> = [];
|
||||
vi.mock('@/lib/browser-navigation', () => ({
|
||||
apiFetch: vi.fn(async (url: string, init?: RequestInit) => {
|
||||
if (url === '/api/admin/plugins') {
|
||||
if (init?.method === 'PATCH') {
|
||||
const body = JSON.parse(String(init.body));
|
||||
patches.push(body);
|
||||
plugins = plugins.map((p) => (p.id === body.id ? { ...p, ...body } : p));
|
||||
return new Response('{"ok":true}');
|
||||
}
|
||||
return new Response(JSON.stringify(plugins));
|
||||
}
|
||||
if (init?.method === 'PUT') {
|
||||
const body = JSON.parse(String(init.body));
|
||||
puts.push(body);
|
||||
stored = body;
|
||||
return new Response('{"ok":true}');
|
||||
}
|
||||
return new Response(JSON.stringify(stored));
|
||||
}),
|
||||
withBasePath: (p: string) => p,
|
||||
getPathPrefix: () => '',
|
||||
}));
|
||||
|
||||
import { OrgProofreadingPage } from '../_pages/org-proofreading';
|
||||
import { EVERYONE, useOrgScopeStore } from '@/components/admin/org/use-org-policy-draft';
|
||||
|
||||
const POLICY = {
|
||||
restrictions: {}, features: {}, defaults: {}, themePolicy: {},
|
||||
forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [], organisation: {},
|
||||
scopes: [{ id: 'sales', label: 'Sales', match: { groups: ['sales@example.org'] }, policy: {} }],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
puts.length = 0;
|
||||
patches.length = 0;
|
||||
stored = JSON.parse(JSON.stringify(POLICY));
|
||||
plugins = [
|
||||
{ id: 'languagetool', name: 'Proofreading (LanguageTool)', enabled: true, forceEnabled: false, permissions: ['compose:check', 'http:proxy'] },
|
||||
{ id: 'gravatar', name: 'Gravatar', enabled: true, permissions: ['http:fetch'] },
|
||||
];
|
||||
useOrgScopeStore.setState({ scopeId: EVERYONE });
|
||||
});
|
||||
|
||||
const mainSelect = () => screen.getAllByRole('combobox')[0] as HTMLSelectElement;
|
||||
const save = async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await waitFor(() => expect(puts).toHaveLength(1));
|
||||
return puts[0] as { restrictions: Record<string, unknown>; defaults: Record<string, unknown>; scopes: Array<{ id: string; policy: Record<string, Record<string, unknown>> }> };
|
||||
};
|
||||
|
||||
describe('Organisation > Proofreading', () => {
|
||||
it('lists only checker plugins', async () => {
|
||||
render(<OrgProofreadingPage />);
|
||||
expect(await screen.findByText('Proofreading (LanguageTool)')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Gravatar')).toBeNull();
|
||||
});
|
||||
|
||||
it('requires proofreading for everyone, warns until a checker is forced on, and forces it', async () => {
|
||||
render(<OrgProofreadingPage />);
|
||||
await screen.findByText('Proofreading (LanguageTool)');
|
||||
expect(screen.queryByTestId('proofreading-force-warning')).toBeNull();
|
||||
fireEvent.change(mainSelect(), { target: { value: 'on-locked' } });
|
||||
expect(await screen.findByTestId('proofreading-force-warning')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Force Proofreading (LanguageTool) on for everyone' }));
|
||||
await waitFor(() => expect(patches).toEqual([{ id: 'languagetool', enabled: true, forceEnabled: true }]));
|
||||
await waitFor(() => expect(screen.queryByTestId('proofreading-force-warning')).toBeNull());
|
||||
const policy = await save();
|
||||
expect(policy.restrictions.proofreading).toEqual({ locked: true, value: true });
|
||||
expect(policy.defaults.proofreading).toBe(true);
|
||||
});
|
||||
|
||||
it('turns it off and hides it for everyone', async () => {
|
||||
render(<OrgProofreadingPage />);
|
||||
await screen.findByText('Proofreading (LanguageTool)');
|
||||
fireEvent.change(mainSelect(), { target: { value: 'off-locked' } });
|
||||
const policy = await save();
|
||||
expect(policy.restrictions.proofreading).toEqual({ locked: true, value: false, hidden: true });
|
||||
});
|
||||
|
||||
it('presets it on, without a lock', async () => {
|
||||
stored = { ...JSON.parse(JSON.stringify(POLICY)), restrictions: { proofreading: { locked: true, value: false, hidden: true } } };
|
||||
render(<OrgProofreadingPage />);
|
||||
await screen.findByText('Proofreading (LanguageTool)');
|
||||
expect(mainSelect().value).toBe('off-locked');
|
||||
fireEvent.change(mainSelect(), { target: { value: 'on' } });
|
||||
const policy = await save();
|
||||
expect(policy.restrictions.proofreading).toBeUndefined();
|
||||
expect(policy.defaults.proofreading).toBe(true);
|
||||
});
|
||||
|
||||
it('requires it for one group only, and that group can inherit again', async () => {
|
||||
render(<OrgProofreadingPage />);
|
||||
await screen.findByText('Proofreading (LanguageTool)');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sales' }));
|
||||
expect(mainSelect().value).toBe('inherit');
|
||||
fireEvent.change(mainSelect(), { target: { value: 'on-locked' } });
|
||||
const policy = await save();
|
||||
expect(policy.restrictions.proofreading).toBeUndefined();
|
||||
const sales = policy.scopes.find((s) => s.id === 'sales')!;
|
||||
expect(sales.policy.restrictions.proofreading).toEqual({ locked: true, value: true });
|
||||
expect(sales.policy.defaults.proofreading).toBe(true);
|
||||
});
|
||||
|
||||
it('locks one plugin off', async () => {
|
||||
render(<OrgProofreadingPage />);
|
||||
await screen.findByText('Proofreading (LanguageTool)');
|
||||
fireEvent.change(screen.getByRole('combobox', { name: 'Proofreading with Proofreading (LanguageTool)' }), { target: { value: 'off-locked' } });
|
||||
const policy = await save();
|
||||
expect(policy.restrictions['proofreading.languagetool']).toEqual({ locked: true, value: false });
|
||||
});
|
||||
|
||||
it('locks the writing language with a value', async () => {
|
||||
render(<OrgProofreadingPage />);
|
||||
await screen.findByText('Proofreading (LanguageTool)');
|
||||
fireEvent.change(screen.getByRole('combobox', { name: 'Writing language' }), { target: { value: 'de-CH' } });
|
||||
fireEvent.click(screen.getByLabelText('Lock'));
|
||||
const policy = await save();
|
||||
expect(policy.restrictions.writingLanguage).toEqual({ locked: true, value: 'de-CH' });
|
||||
expect(policy.defaults.writingLanguage).toBe('de-CH');
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,8 @@ import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { useAdminUnsavedStore } from '@/stores/admin-unsaved-store';
|
||||
import { AdminShell } from '@/components/admin/admin-shell';
|
||||
import { AdminContentSkeleton } from '@/components/boot-skeleton/admin-skeleton';
|
||||
import { useAdminLocation } from '@/hooks/use-admin-navigation';
|
||||
import { UnsavedChangesGuard } from './_unsaved-changes-guard';
|
||||
import { ToastContainer } from '@/components/ui/toast';
|
||||
import { useToastStore } from '@/stores/toast-store';
|
||||
@@ -25,6 +27,7 @@ function AdminToasts() {
|
||||
export function AdminLayoutClient({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const location = useAdminLocation();
|
||||
const requestLeave = useAdminUnsavedStore((s) => s.requestLeave);
|
||||
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
|
||||
const [authError, setAuthError] = useState<string | null>(null);
|
||||
@@ -135,9 +138,9 @@ export function AdminLayoutClient({ children }: { children: React.ReactNode }) {
|
||||
<p className="mt-1 text-destructive/80">{authError}</p>
|
||||
</div>
|
||||
) : authenticated === null ? (
|
||||
<div className="py-12 text-center text-sm text-muted-foreground animate-pulse">
|
||||
Loading admin panel…
|
||||
</div>
|
||||
// The page's header and section placeholders, where the boot
|
||||
// skeleton drew them.
|
||||
<AdminContentSkeleton page={location.page} status="Loading admin panel…" />
|
||||
) : authenticated ? children : null}
|
||||
</AdminShell>
|
||||
</>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { SettingsSection, SettingItem, SectionNav } from '@/components/settings/
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog } from '@/components/ui/dialog';
|
||||
import { AdminPageHeader } from '@/components/admin/page-header';
|
||||
import { AdminBodySkeleton } from '@/components/boot-skeleton/admin-skeleton';
|
||||
import { AdminSaveBar } from '@/components/admin/save-bar';
|
||||
import { withEdit } from '@/lib/admin/edits';
|
||||
import { useAdminFeedback } from '@/components/admin/feedback';
|
||||
@@ -172,9 +173,7 @@ export function AuthPage() {
|
||||
return (
|
||||
<div>
|
||||
<AdminPageHeader title={PAGE.label} description={PAGE.description} />
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
|
||||
</div>
|
||||
<AdminBodySkeleton status="Loading" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { SettingsSection, SettingItem, SectionNav } from '@/components/settings/settings-section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AdminPageHeader } from '@/components/admin/page-header';
|
||||
import { AdminBodySkeleton } from '@/components/boot-skeleton/admin-skeleton';
|
||||
import { AdminSaveBar } from '@/components/admin/save-bar';
|
||||
import { withEdit } from '@/lib/admin/edits';
|
||||
import { Badge } from '@/components/admin/badge';
|
||||
@@ -390,9 +391,7 @@ export function BrandingPage() {
|
||||
return (
|
||||
<div>
|
||||
<AdminPageHeader title={PAGE.label} description={PAGE.description} />
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
|
||||
</div>
|
||||
<AdminBodySkeleton status="Loading" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -693,7 +693,7 @@ export function MarketplaceDetailPage({ slug }: { slug: string }) {
|
||||
title="Permissions"
|
||||
description={manifestPerms.length === 0
|
||||
? 'This plugin requests no permissions.'
|
||||
: `What ${preview ? `v${preview.version}` : 'the plugin'} may do in each user's mailbox.`}
|
||||
: `What ${ext.name}${preview ? ` ${preview.version}` : ''} may do in each user's mailbox.`}
|
||||
>
|
||||
{manifestPerms.length > 0 && (
|
||||
<ul className="space-y-1.5">
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Loader2 } from '@/components/icons';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { adminPageDef } from '@/lib/admin/admin-nav';
|
||||
import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
|
||||
import { SettingsSection, SectionNav } from '@/components/settings/settings-section';
|
||||
import { AdminPageHeader } from '@/components/admin/page-header';
|
||||
import { AdminBodySkeleton } from '@/components/boot-skeleton/admin-skeleton';
|
||||
import { AdminSaveBar } from '@/components/admin/save-bar';
|
||||
import { withEdit } from '@/lib/admin/edits';
|
||||
import { useAdminFeedback } from '@/components/admin/feedback';
|
||||
@@ -109,9 +109,7 @@ export function MonitoringPage() {
|
||||
return (
|
||||
<div>
|
||||
<AdminPageHeader title={PAGE.label} description={PAGE.description} />
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
|
||||
</div>
|
||||
<AdminBodySkeleton status="Loading" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { OrgPage } from '@/components/admin/org/org-page';
|
||||
import { useOrgPolicyDraft } from '@/components/admin/org/use-org-policy-draft';
|
||||
import { ProofreadingSection } from '@/components/admin/org/sections/proofreading-section';
|
||||
|
||||
export function OrgProofreadingPage() {
|
||||
const draft = useOrgPolicyDraft('org-proofreading');
|
||||
return (
|
||||
<OrgPage pageId="org-proofreading" draft={draft}>
|
||||
<ProofreadingSection draft={draft} />
|
||||
</OrgPage>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Loader2, Plus, Trash2, ArrowUp, ArrowDown } from '@/components/icons';
|
||||
import { Plus, Trash2, ArrowUp, ArrowDown } from '@/components/icons';
|
||||
import { iconForName } from '@/components/icons';
|
||||
import type { SettingsPolicy, FeatureGates, PushRelayOption, AdminSidebarApp } from '@/lib/admin/types';
|
||||
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
|
||||
@@ -20,6 +20,7 @@ import { SettingsSection, SettingItem, ToggleSwitch, SectionNav } from '@/compon
|
||||
import type { GlossaryId } from '@/components/ui/term';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AdminPageHeader } from '@/components/admin/page-header';
|
||||
import { AdminBodySkeleton } from '@/components/boot-skeleton/admin-skeleton';
|
||||
import { AdminSaveBar } from '@/components/admin/save-bar';
|
||||
import { Badge } from '@/components/admin/badge';
|
||||
import { useAdminFeedback } from '@/components/admin/feedback';
|
||||
@@ -261,9 +262,7 @@ export function PolicyPage() {
|
||||
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-10 text-sm text-muted-foreground">
|
||||
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
|
||||
</div>
|
||||
<AdminBodySkeleton status="Loading" />
|
||||
) : (
|
||||
<div className="space-y-10">
|
||||
<SectionNav />
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Loader2 } from '@/components/icons';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { adminPageDef } from '@/lib/admin/admin-nav';
|
||||
import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
|
||||
import { SettingsSection, SectionNav } from '@/components/settings/settings-section';
|
||||
import { AdminPageHeader } from '@/components/admin/page-header';
|
||||
import { AdminBodySkeleton } from '@/components/boot-skeleton/admin-skeleton';
|
||||
import { AdminSaveBar } from '@/components/admin/save-bar';
|
||||
import { withEdit } from '@/lib/admin/edits';
|
||||
import { useAdminFeedback } from '@/components/admin/feedback';
|
||||
@@ -100,9 +100,7 @@ export function SettingsPage() {
|
||||
return (
|
||||
<div>
|
||||
<AdminPageHeader title={PAGE.label} description={PAGE.description} />
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
|
||||
</div>
|
||||
<AdminBodySkeleton status="Loading" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Loader2, Send, ExternalLink } from '@/components/icons';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AdminPageHeader } from '@/components/admin/page-header';
|
||||
import { AdminBodySkeleton } from '@/components/boot-skeleton/admin-skeleton';
|
||||
import { AdminSaveBar } from '@/components/admin/save-bar';
|
||||
import { useAdminFeedback } from '@/components/admin/feedback';
|
||||
import { ConfigNote, inputClass } from '@/components/admin/config-items';
|
||||
@@ -149,9 +150,7 @@ export function TelemetryPage() {
|
||||
return (
|
||||
<div>
|
||||
{header}
|
||||
<div className="flex items-center justify-center py-10 text-sm text-muted-foreground">
|
||||
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
|
||||
</div>
|
||||
<AdminBodySkeleton status="Loading" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { SettingsSection, SettingItem } from '@/components/settings/settings-section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AdminPageHeader } from '@/components/admin/page-header';
|
||||
import { AdminBodySkeleton } from '@/components/boot-skeleton/admin-skeleton';
|
||||
import { Badge, type BadgeTone } from '@/components/admin/badge';
|
||||
import { useAdminFeedback } from '@/components/admin/feedback';
|
||||
import { adminPageDef } from '@/lib/admin/admin-nav';
|
||||
@@ -117,9 +118,7 @@ export function VersionPage() {
|
||||
return (
|
||||
<div>
|
||||
{header}
|
||||
<div className="flex items-center justify-center py-10 text-sm text-muted-foreground">
|
||||
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
|
||||
</div>
|
||||
<AdminBodySkeleton status="Loading" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { AlertCircle, Shield } from '@/components/icons';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { apiFetch, withBasePath } from '@/lib/browser-navigation';
|
||||
import { safeAdminNext } from '@/lib/connector/admin-next';
|
||||
|
||||
@@ -14,8 +13,10 @@ export default function AdminLoginPage() {
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { loginLogoLightUrl, loginLogoDarkUrl, appName } = useConfig();
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
const logoUrl = withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl);
|
||||
// Both logos, picked by CSS: the server can't know the theme, and a logo
|
||||
// chosen from the theme store swaps after the first paint.
|
||||
const logoLight = withBasePath(loginLogoLightUrl || loginLogoDarkUrl);
|
||||
const logoDark = withBasePath(loginLogoDarkUrl || loginLogoLightUrl);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `Sign in - Admin - ${appName}`;
|
||||
@@ -57,8 +58,11 @@ export default function AdminLoginPage() {
|
||||
<div className="min-h-screen flex items-center justify-center bg-background px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="" className="h-12 object-contain mb-4" />
|
||||
{logoLight ? (
|
||||
<>
|
||||
<img src={logoLight} alt="" className="h-12 object-contain mb-4 dark:hidden" />
|
||||
<img src={logoDark} alt="" className="h-12 object-contain mb-4 hidden dark:block" />
|
||||
</>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center mb-4">
|
||||
<Shield className="w-6 h-6 text-primary" />
|
||||
|
||||
@@ -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()}
|
||||
})();
|
||||
`,
|
||||
}}
|
||||
|
||||
@@ -3,6 +3,14 @@ import { MailtoProtocolClient } from "@/components/protocol/mailto-protocol-clie
|
||||
|
||||
export default async function MailtoProtocolPage() {
|
||||
const t = await getTranslations("protocol_handlers");
|
||||
const tSidebar = await getTranslations("sidebar");
|
||||
const labels = {
|
||||
mail: tSidebar("mail"),
|
||||
calendar: tSidebar("calendar"),
|
||||
contacts: tSidebar("contacts"),
|
||||
files: tSidebar("files"),
|
||||
settings: tSidebar("settings"),
|
||||
};
|
||||
|
||||
return <MailtoProtocolClient openingText={t("opening_mailto")} />;
|
||||
return <MailtoProtocolClient openingText={t("opening_mailto")} labels={labels} />;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { WebcalProtocolClient } from "@/components/protocol/webcal-protocol-client";
|
||||
import { calendarSkeletonText } from "@/components/boot-skeleton/calendar-skeleton";
|
||||
|
||||
export default async function WebcalProtocolPage() {
|
||||
const t = await getTranslations("protocol_handlers");
|
||||
const tSidebar = await getTranslations("sidebar");
|
||||
const tCalendar = await getTranslations("calendar");
|
||||
|
||||
return <WebcalProtocolClient openingText={t("opening_webcal")} />;
|
||||
return (
|
||||
<WebcalProtocolClient
|
||||
openingText={t("opening_webcal")}
|
||||
labels={{
|
||||
mail: tSidebar("mail"),
|
||||
calendar: tSidebar("calendar"),
|
||||
contacts: tSidebar("contacts"),
|
||||
files: tSidebar("files"),
|
||||
settings: tSidebar("settings"),
|
||||
}}
|
||||
text={calendarSkeletonText(tCalendar)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import en from '@/locales/en/common.json';
|
||||
|
||||
// Until /api/setup/status answers (and in the server HTML), the wizard
|
||||
// draws its own frame with the welcome step, not a card saying "Loading".
|
||||
|
||||
vi.unmock('next-intl');
|
||||
vi.mock('next/navigation', () => ({ useRouter: () => ({ replace: vi.fn(), push: vi.fn() }) }));
|
||||
vi.mock('@/lib/browser-navigation', () => ({
|
||||
apiFetch: vi.fn(() => new Promise(() => {})),
|
||||
getPathPrefix: () => '',
|
||||
withBasePath: (p: string) => p,
|
||||
}));
|
||||
vi.mock('@/lib/setup/url-token', () => ({ takeSetupTokenFromUrl: () => '' }));
|
||||
|
||||
const { default: SetupWizardPage } = await import('../page');
|
||||
|
||||
const page = (
|
||||
<NextIntlClientProvider locale="en" messages={en}>
|
||||
<SetupWizardPage />
|
||||
</NextIntlClientProvider>
|
||||
);
|
||||
|
||||
describe('setup wizard while the status loads', () => {
|
||||
it('server-renders the wizard frame', () => {
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = renderToString(page);
|
||||
const region = container.querySelector('[data-boot-skeleton="setup"]');
|
||||
expect(region).not.toBeNull();
|
||||
expect(region!.querySelector('[role="status"]')?.textContent).toBe(en.setup.loading);
|
||||
// Header, all seven steps and the welcome card, in the page's words.
|
||||
expect(region!.textContent).toContain(en.setup.title);
|
||||
for (const step of Object.values(en.setup.steps)) expect(region!.textContent).toContain(step);
|
||||
expect(region!.textContent).toContain(en.setup.welcome.token_label);
|
||||
expect(container.querySelectorAll('a[href], button, input')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps it on the client until the status is there', () => {
|
||||
const { container } = render(page);
|
||||
expect(container.querySelector('[data-boot-skeleton="setup"]')).not.toBeNull();
|
||||
expect(container.querySelector('.max-w-md')).toBeNull();
|
||||
});
|
||||
});
|
||||
+20
-10
@@ -6,6 +6,8 @@ import { useTranslations } from 'next-intl';
|
||||
import { Check, CheckCircle2, AlertTriangle, AlertCircle, Server, ShieldCheck, KeyRound, FileText, Palette, Lock, ShieldAlert } from '@/components/icons';
|
||||
import { apiFetch, getPathPrefix, withBasePath } from '@/lib/browser-navigation';
|
||||
import { takeSetupTokenFromUrl } from '@/lib/setup/url-token';
|
||||
import { SetupSkeleton } from '@/components/boot-skeleton/setup-skeleton';
|
||||
import { SETUP_STEPS } from '@/lib/setup/steps';
|
||||
|
||||
type State = 'bootstrap' | 'configured' | 'env-managed';
|
||||
|
||||
@@ -85,15 +87,7 @@ const EMPTY_CONFIG: WizardConfig = {
|
||||
|
||||
// The wizard follows the browser's language, like the login page; the
|
||||
// labels are setup.steps.<id>.
|
||||
const STEPS = [
|
||||
{ id: 'welcome' },
|
||||
{ id: 'server' },
|
||||
{ id: 'auth' },
|
||||
{ id: 'security' },
|
||||
{ id: 'logging' },
|
||||
{ id: 'branding' },
|
||||
{ id: 'review' },
|
||||
] as const;
|
||||
const STEPS = SETUP_STEPS;
|
||||
|
||||
type SetupT = ReturnType<typeof useTranslations<'setup'>>;
|
||||
|
||||
@@ -201,8 +195,24 @@ export default function SetupWizardPage() {
|
||||
return <InsecureContextScreen onContinue={() => setInsecureAcknowledged(true)} />;
|
||||
}
|
||||
|
||||
// The server HTML, and every visit until the status answers: the wizard's
|
||||
// frame with the welcome step, where a fresh install starts.
|
||||
if (bootstrapping) {
|
||||
return <CenteredCard><p className="text-muted-foreground">{t('loading')}</p></CenteredCard>;
|
||||
return (
|
||||
<SetupSkeleton
|
||||
label={t('loading')}
|
||||
strings={{
|
||||
title: t('title'),
|
||||
subtitle: t('subtitle'),
|
||||
steps: STEPS.map((step) => t(`steps.${step.id}`)),
|
||||
welcomeTitle: t('welcome.title'),
|
||||
welcomeBody: t('welcome.body'),
|
||||
tokenLabel: t('welcome.token_label'),
|
||||
tokenPlaceholder: t('welcome.token_placeholder'),
|
||||
continueLabel: t('welcome.continue'),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (completed) {
|
||||
|
||||
@@ -256,6 +256,29 @@ html.dark[data-contrast="more"] {
|
||||
:root[data-contrast="more"] a[href]:not([role]) {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
/* Proofreading underlines: darker in light mode, lighter in dark mode. */
|
||||
@media (prefers-contrast: more) {
|
||||
:root:not([data-contrast="normal"]) {
|
||||
--text-issue-grammar: #1d4ed8;
|
||||
--text-issue-style: #92400e;
|
||||
--text-issue-other: #374151;
|
||||
}
|
||||
.dark:not([data-contrast="normal"]) {
|
||||
--text-issue-grammar: #93c5fd;
|
||||
--text-issue-style: #fcd34d;
|
||||
--text-issue-other: #e5e7eb;
|
||||
}
|
||||
}
|
||||
:root[data-contrast="more"] {
|
||||
--text-issue-grammar: #1d4ed8;
|
||||
--text-issue-style: #92400e;
|
||||
--text-issue-other: #374151;
|
||||
}
|
||||
html.dark[data-contrast="more"] {
|
||||
--text-issue-grammar: #93c5fd;
|
||||
--text-issue-style: #fcd34d;
|
||||
--text-issue-other: #e5e7eb;
|
||||
}
|
||||
|
||||
/* ---------- Reduced transparency ---------- */
|
||||
|
||||
@@ -483,3 +506,16 @@ html.dark[data-contrast="more"] {
|
||||
:root[data-simple-mode="on"] [data-simple-hide] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Proofreading underlines in forced colours: one colour, so the line styles tell the kinds apart. */
|
||||
@media (forced-colors: active) {
|
||||
.tiptap .text-issue {
|
||||
text-decoration-color: CanvasText;
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
.tiptap .text-issue-active {
|
||||
forced-color-adjust: none;
|
||||
background-color: Highlight;
|
||||
color: HighlightText;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
type DirectoryVersion,
|
||||
} from '@/lib/admin/marketplace-directory';
|
||||
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE } from '@/lib/plugin-types';
|
||||
import { productName } from '@/lib/product-name';
|
||||
import { withAppName } from '@/lib/settings-search';
|
||||
|
||||
const MAX_PREVIEW_SOURCE_LEN = 100_000;
|
||||
|
||||
@@ -173,6 +175,7 @@ export async function GET(
|
||||
const installed = await installedExtensions();
|
||||
const state = installedStateOf(installed, slug, type);
|
||||
const history = await historyFor(type, slug);
|
||||
const appName = productName(configManager.get<string>('appName'));
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
@@ -185,7 +188,8 @@ export async function GET(
|
||||
screenshots: detail.screenshots ?? [],
|
||||
themePreviews: detail.themePreviews ?? [],
|
||||
advisories: detail.advisories ?? [],
|
||||
permissionDetails: detail.permissions ?? [],
|
||||
// The directory ships the webmail's permission texts unfilled.
|
||||
permissionDetails: (detail.permissions ?? []).map((p) => withAppName(p, appName) as typeof p),
|
||||
source: detail.source,
|
||||
createdAt: detail.createdAt ?? null,
|
||||
updatedAt: detail.updatedAt ?? null,
|
||||
|
||||
@@ -5,6 +5,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
import JSZip from 'jszip';
|
||||
|
||||
const appName = vi.hoisted(() => ({ value: 'Webmail' }));
|
||||
|
||||
vi.mock('@/lib/admin/session', () => ({
|
||||
requireAdminAuth: vi.fn(async () => ({ session: { sub: 'admin' } })),
|
||||
getClientIP: () => '127.0.0.1',
|
||||
@@ -12,7 +14,7 @@ vi.mock('@/lib/admin/session', () => ({
|
||||
vi.mock('@/lib/admin/config-manager', () => ({
|
||||
configManager: {
|
||||
ensureLoaded: vi.fn(async () => {}),
|
||||
get: vi.fn((key: string) => (key === 'extensionDirectoryUrl' ? 'https://extensions.test' : undefined)),
|
||||
get: vi.fn((key: string) => (key === 'extensionDirectoryUrl' ? 'https://extensions.test' : key === 'appName' ? appName.value : undefined)),
|
||||
},
|
||||
}));
|
||||
vi.mock('@/lib/admin/plugin-registry', () => ({
|
||||
@@ -54,7 +56,9 @@ function directory() {
|
||||
const u = new URL(String(url));
|
||||
calls.push(u.pathname + u.search);
|
||||
if (u.pathname === '/api/v2/extensions/pretty-dates') {
|
||||
return Response.json({ data: { ...summary, latest: versions[0], newest: versions[0], longDescription: 'long', versions: versions.slice(0, 2), screenshots: [], themePreviews: [], advisories: [], permissions: [] } });
|
||||
return Response.json({ data: { ...summary, latest: versions[0], newest: versions[0], longDescription: 'long', versions: versions.slice(0, 2), screenshots: [], themePreviews: [], advisories: [], permissions: [
|
||||
{ id: 'http:proxy', title: 'Use accounts you connect', body: "Send requests through {appName}'s server.", highRisk: true },
|
||||
] } });
|
||||
}
|
||||
if (u.pathname === '/api/v2/extensions/pretty-dates/versions') return Response.json({ data: versions });
|
||||
const m = /\/versions\/([^/]+)\/bundle$/.exec(u.pathname);
|
||||
@@ -105,6 +109,19 @@ describe('GET /api/admin/marketplace/[slug]', () => {
|
||||
expect(body.preview.version).toBe('1.2.0');
|
||||
});
|
||||
|
||||
it('fills the app name into the permission texts the directory sends', async () => {
|
||||
directory();
|
||||
appName.value = 'Acme Mail';
|
||||
let body = await (await preview()).json();
|
||||
expect(body.extension.permissionDetails).toEqual([
|
||||
{ id: 'http:proxy', title: 'Use accounts you connect', body: "Send requests through Acme Mail's server.", highRisk: true },
|
||||
]);
|
||||
|
||||
appName.value = 'Webmail';
|
||||
body = await (await preview()).json();
|
||||
expect(body.extension.permissionDetails[0].body).toBe("Send requests through Bulwark's server.");
|
||||
});
|
||||
|
||||
it('refuses a malformed version instead of forwarding it', async () => {
|
||||
directory();
|
||||
expect((await preview('?version=..%2F..%2Fetc')).status).toBe(400);
|
||||
|
||||
@@ -68,8 +68,13 @@ export async function POST(request: NextRequest) {
|
||||
if (thirdPartyOff) {
|
||||
const plugin = await getPlugin(pluginId);
|
||||
const spec = (plugin as { credentials?: Record<string, CredentialSpec> } | null)?.credentials?.[credentialId];
|
||||
if (!credentialAllowedInMode(spec)) return thirdPartyRefusal('The plugin proxy');
|
||||
if (!adminEnteredOrigin(spec, await getPluginConfig(pluginId), plugin?.configSchema)) return thirdPartyRefusal('The plugin proxy');
|
||||
if (!credentialAllowedInMode(spec) || !adminEnteredOrigin(spec, await getPluginConfig(pluginId), plugin?.configSchema)) {
|
||||
// Marked as the proxy's own refusal, like its other errors, so a
|
||||
// plugin can tell it from the service answering 403.
|
||||
const refused = thirdPartyRefusal('The plugin proxy');
|
||||
refused.headers.set('x-bulwark-proxy-error', 'no-third-party-traffic');
|
||||
return refused;
|
||||
}
|
||||
}
|
||||
const capture = parseCapture(request.headers.get('x-bulwark-capture'));
|
||||
if (capture === null) {
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
/*
|
||||
* Page skeletons (components/boot-skeleton).
|
||||
*
|
||||
* The server draws every variant of a skeleton; these variants show the one
|
||||
* that matches the attributes the boot script set on <html> before the first
|
||||
* paint (lib/boot-skeleton/boot-script.ts). Part of the first stylesheet, so
|
||||
* nothing here arrives late.
|
||||
*/
|
||||
|
||||
/* Mountain View shell. Its geometry only applies on desktop, so pair it with
|
||||
lg: in markup (lg:mv:hidden), except the phone bottom bar, which follows
|
||||
the layout at every width. */
|
||||
@custom-variant mv (&:where([data-interface-layout="mountain-view"] *));
|
||||
|
||||
/* Mail layout (effectiveMailLayout: Mountain View and simple mode are focus). */
|
||||
@custom-variant boot-split (&:where([data-boot-mail-layout="split"] *));
|
||||
@custom-variant boot-horizontal (&:where([data-boot-mail-layout="horizontal"] *));
|
||||
@custom-variant boot-focus (&:where([data-boot-mail-layout="focus"] *));
|
||||
|
||||
/* A message, thread, contact, settings page or admin page is open. */
|
||||
@custom-variant boot-open (&:where([data-boot-pane] *));
|
||||
@custom-variant boot-thread (&:where([data-boot-pane="thread"] *));
|
||||
|
||||
/* The Pro interface takes this page over on a desktop. */
|
||||
@custom-variant boot-pro (&:where([data-boot-pro="on"] *));
|
||||
|
||||
/* Nothing stored that checkAuth could restore: draw the login card. */
|
||||
@custom-variant boot-out (&:where([data-boot-auth="out"] *));
|
||||
|
||||
/* Calendar view. */
|
||||
@custom-variant boot-cal-month (&:where([data-boot-calendar-view="month"] *));
|
||||
@custom-variant boot-cal-week (&:where([data-boot-calendar-view="week"] *));
|
||||
@custom-variant boot-cal-day (&:where([data-boot-calendar-view="day"] *));
|
||||
@custom-variant boot-cal-agenda (&:where([data-boot-calendar-view="agenda"] *));
|
||||
@custom-variant boot-cal-tasks (&:where([data-boot-calendar-view="tasks"] *));
|
||||
@custom-variant boot-cal-collapsed (&:where([data-boot-collapsed~="calendar"] *));
|
||||
|
||||
/* Files. */
|
||||
@custom-variant boot-files-grid (&:where([data-boot-files-view="grid"] *));
|
||||
@custom-variant boot-files-details (&:where([data-boot-files-details="on"] *));
|
||||
|
||||
/* What the rail showed last time (bulwark-boot-hints). Without hints every
|
||||
app is drawn: the rail of a server with all of them. */
|
||||
@custom-variant boot-no-calendar (&:where([data-boot-rail]:not([data-boot-rail~="calendar"]) *));
|
||||
@custom-variant boot-no-contacts (&:where([data-boot-rail]:not([data-boot-rail~="contacts"]) *));
|
||||
@custom-variant boot-no-files (&:where([data-boot-rail]:not([data-boot-rail~="files"]) *));
|
||||
@custom-variant boot-rail-add-app (&:where([data-boot-rail~="add-app"] *));
|
||||
@custom-variant boot-rail-admin (&:where([data-boot-rail~="admin"] *));
|
||||
@custom-variant boot-rail-a11y (&:where([data-boot-rail~="a11y"] *));
|
||||
@custom-variant boot-rail-quota (&:where([data-boot-rail~="quota"] *));
|
||||
|
||||
/* A placeholder shape. A slow opacity pulse rather than a moving shimmer:
|
||||
opacity runs on the compositor, so the skeleton costs the main thread
|
||||
nothing while the app's JS is still starting up. The colour is a tint of
|
||||
the text colour rather than --color-muted, which equals the sidebar
|
||||
colour in dark mode (and nearly so in light mode), so shapes show on every
|
||||
panel and follow a theme's palette. */
|
||||
.sk-shape {
|
||||
background-color: color-mix(in oklab, var(--color-foreground) 9%, transparent);
|
||||
animation: sk-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes sk-pulse {
|
||||
50% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion: no pulse at all (the global rule only shortens it). */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root:not([data-motion="full"]) .sk-shape {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
:root[data-motion="reduce"] .sk-shape {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* More contrast: shapes that stand out from the panels they sit on. */
|
||||
@media (prefers-contrast: more) {
|
||||
:root:not([data-contrast="normal"]) .sk-shape {
|
||||
background-color: color-mix(in oklab, var(--color-foreground) 28%, transparent);
|
||||
}
|
||||
}
|
||||
:root[data-contrast="more"] .sk-shape {
|
||||
background-color: color-mix(in oklab, var(--color-foreground) 28%, transparent);
|
||||
}
|
||||
|
||||
/* Forced colours drop backgrounds; keep the shapes as system grey. */
|
||||
@media (forced-colors: active) {
|
||||
.sk-shape {
|
||||
forced-color-adjust: none;
|
||||
background-color: GrayText;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* pro */
|
||||
|
||||
/* The Pro shell takes a standard page over (components/boot-skeleton/shell.tsx):
|
||||
on a desktop with the Pro interface on, unless the visitor is signed out,
|
||||
and never again inside the Pro shell itself (an embedded page's gate). */
|
||||
@custom-variant boot-pro-frame (&:where([data-boot-pro="on"]:not([data-boot-auth="out"]) *):not(:where([data-pro-shell] *)));
|
||||
|
||||
/* The Pro split pane, on its stored side. */
|
||||
@custom-variant boot-pro-split (&:where([data-boot-pro-split] *));
|
||||
@custom-variant boot-pro-split-left (&:where([data-boot-pro-split="left"] *));
|
||||
|
||||
/* Each pane's tab strip draws as many tabs as the store holds (up to eight),
|
||||
and its body the active tab's page (lib/boot-skeleton/boot-script.ts,
|
||||
data-boot-pro-<pane>-tabs / -active / -kind). One tab and mail without them. */
|
||||
[data-sk-pro-tab]:nth-child(n+2),
|
||||
[data-sk-pro-body] {
|
||||
display: none;
|
||||
}
|
||||
:root[data-boot-pro-main-tabs="2"] [data-sk-pro-pane="main"] [data-sk-pro-tab]:nth-child(-n+2),
|
||||
:root[data-boot-pro-main-tabs="3"] [data-sk-pro-pane="main"] [data-sk-pro-tab]:nth-child(-n+3),
|
||||
:root[data-boot-pro-main-tabs="4"] [data-sk-pro-pane="main"] [data-sk-pro-tab]:nth-child(-n+4),
|
||||
:root[data-boot-pro-main-tabs="5"] [data-sk-pro-pane="main"] [data-sk-pro-tab]:nth-child(-n+5),
|
||||
:root[data-boot-pro-main-tabs="6"] [data-sk-pro-pane="main"] [data-sk-pro-tab]:nth-child(-n+6),
|
||||
:root[data-boot-pro-main-tabs="7"] [data-sk-pro-pane="main"] [data-sk-pro-tab]:nth-child(-n+7),
|
||||
:root[data-boot-pro-main-tabs="8"] [data-sk-pro-pane="main"] [data-sk-pro-tab]:nth-child(-n+8),
|
||||
:root[data-boot-pro-split-tabs="2"] [data-sk-pro-pane="split"] [data-sk-pro-tab]:nth-child(-n+2),
|
||||
:root[data-boot-pro-split-tabs="3"] [data-sk-pro-pane="split"] [data-sk-pro-tab]:nth-child(-n+3),
|
||||
:root[data-boot-pro-split-tabs="4"] [data-sk-pro-pane="split"] [data-sk-pro-tab]:nth-child(-n+4),
|
||||
:root[data-boot-pro-split-tabs="5"] [data-sk-pro-pane="split"] [data-sk-pro-tab]:nth-child(-n+5),
|
||||
:root[data-boot-pro-split-tabs="6"] [data-sk-pro-pane="split"] [data-sk-pro-tab]:nth-child(-n+6),
|
||||
:root[data-boot-pro-split-tabs="7"] [data-sk-pro-pane="split"] [data-sk-pro-tab]:nth-child(-n+7),
|
||||
:root[data-boot-pro-split-tabs="8"] [data-sk-pro-pane="split"] [data-sk-pro-tab]:nth-child(-n+8) {
|
||||
display: flex;
|
||||
}
|
||||
:root:not([data-boot-pro-main-active]) [data-sk-pro-pane="main"] [data-sk-pro-tab]:first-child,
|
||||
:root:not([data-boot-pro-split-active]) [data-sk-pro-pane="split"] [data-sk-pro-tab]:first-child,
|
||||
:root[data-boot-pro-main-active="0"] [data-sk-pro-pane="main"] [data-sk-pro-tab]:nth-child(1),
|
||||
:root[data-boot-pro-main-active="1"] [data-sk-pro-pane="main"] [data-sk-pro-tab]:nth-child(2),
|
||||
:root[data-boot-pro-main-active="2"] [data-sk-pro-pane="main"] [data-sk-pro-tab]:nth-child(3),
|
||||
:root[data-boot-pro-main-active="3"] [data-sk-pro-pane="main"] [data-sk-pro-tab]:nth-child(4),
|
||||
:root[data-boot-pro-main-active="4"] [data-sk-pro-pane="main"] [data-sk-pro-tab]:nth-child(5),
|
||||
:root[data-boot-pro-main-active="5"] [data-sk-pro-pane="main"] [data-sk-pro-tab]:nth-child(6),
|
||||
:root[data-boot-pro-main-active="6"] [data-sk-pro-pane="main"] [data-sk-pro-tab]:nth-child(7),
|
||||
:root[data-boot-pro-main-active="7"] [data-sk-pro-pane="main"] [data-sk-pro-tab]:nth-child(8),
|
||||
:root[data-boot-pro-split-active="0"] [data-sk-pro-pane="split"] [data-sk-pro-tab]:nth-child(1),
|
||||
:root[data-boot-pro-split-active="1"] [data-sk-pro-pane="split"] [data-sk-pro-tab]:nth-child(2),
|
||||
:root[data-boot-pro-split-active="2"] [data-sk-pro-pane="split"] [data-sk-pro-tab]:nth-child(3),
|
||||
:root[data-boot-pro-split-active="3"] [data-sk-pro-pane="split"] [data-sk-pro-tab]:nth-child(4),
|
||||
:root[data-boot-pro-split-active="4"] [data-sk-pro-pane="split"] [data-sk-pro-tab]:nth-child(5),
|
||||
:root[data-boot-pro-split-active="5"] [data-sk-pro-pane="split"] [data-sk-pro-tab]:nth-child(6),
|
||||
:root[data-boot-pro-split-active="6"] [data-sk-pro-pane="split"] [data-sk-pro-tab]:nth-child(7),
|
||||
:root[data-boot-pro-split-active="7"] [data-sk-pro-pane="split"] [data-sk-pro-tab]:nth-child(8) {
|
||||
/* ProTabBar's active tab; its bottom edge covers the strip's border. */
|
||||
background-color: var(--color-background);
|
||||
border-inline-color: rgba(128, 128, 128, 0.3);
|
||||
--sk-pro-tab-accent: var(--sk-pro-pane-accent);
|
||||
}
|
||||
:root:not([data-boot-pro-focus="split"]) [data-sk-pro-pane="main"],
|
||||
:root[data-boot-pro-focus="split"] [data-sk-pro-pane="split"] {
|
||||
/* The focused pane's active tab carries the accent top edge. */
|
||||
--sk-pro-pane-accent: var(--color-primary);
|
||||
}
|
||||
:root:not([data-boot-pro-main-kind]) [data-sk-pro-pane="main"] [data-sk-pro-body="mail"],
|
||||
:root:not([data-boot-pro-split-kind]) [data-sk-pro-pane="split"] [data-sk-pro-body="mail"],
|
||||
:root[data-boot-pro-main-kind="mail"] [data-sk-pro-pane="main"] [data-sk-pro-body="mail"],
|
||||
:root[data-boot-pro-main-kind="calendar"] [data-sk-pro-pane="main"] [data-sk-pro-body="calendar"],
|
||||
:root[data-boot-pro-main-kind="contacts"] [data-sk-pro-pane="main"] [data-sk-pro-body="contacts"],
|
||||
:root[data-boot-pro-main-kind="files"] [data-sk-pro-pane="main"] [data-sk-pro-body="files"],
|
||||
:root[data-boot-pro-main-kind="settings"] [data-sk-pro-pane="main"] [data-sk-pro-body="settings"],
|
||||
:root[data-boot-pro-main-kind="other"] [data-sk-pro-pane="main"] [data-sk-pro-body="other"],
|
||||
:root[data-boot-pro-split-kind="mail"] [data-sk-pro-pane="split"] [data-sk-pro-body="mail"],
|
||||
:root[data-boot-pro-split-kind="calendar"] [data-sk-pro-pane="split"] [data-sk-pro-body="calendar"],
|
||||
:root[data-boot-pro-split-kind="contacts"] [data-sk-pro-pane="split"] [data-sk-pro-body="contacts"],
|
||||
:root[data-boot-pro-split-kind="files"] [data-sk-pro-pane="split"] [data-sk-pro-body="files"],
|
||||
:root[data-boot-pro-split-kind="settings"] [data-sk-pro-pane="split"] [data-sk-pro-body="settings"],
|
||||
:root[data-boot-pro-split-kind="other"] [data-sk-pro-pane="split"] [data-sk-pro-body="other"] {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* The rail marks the focused tab's app (pro/page.tsx, railActiveItemId). */
|
||||
:root:not([data-boot-pro-kind]) [data-pro-shell] [data-sk-rail="mail"],
|
||||
:root[data-boot-pro-kind="mail"] [data-pro-shell] [data-sk-rail="mail"],
|
||||
:root[data-boot-pro-kind="calendar"] [data-pro-shell] [data-sk-rail="calendar"],
|
||||
:root[data-boot-pro-kind="contacts"] [data-pro-shell] [data-sk-rail="contacts"],
|
||||
:root[data-boot-pro-kind="files"] [data-pro-shell] [data-sk-rail="files"],
|
||||
:root[data-boot-pro-kind="settings"] [data-pro-shell] [data-sk-rail="settings"] {
|
||||
background-color: color-mix(in oklab, var(--color-primary) 10%, transparent);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* settings */
|
||||
|
||||
/* The open settings page in the skeleton nav: the row whose
|
||||
data-sk-settings-tab matches data-boot-settings-tab. */
|
||||
@custom-variant boot-settings-active (&:where(
|
||||
[data-boot-settings-tab="account"] [data-sk-settings-tab="account"],
|
||||
[data-boot-settings-tab="general"] [data-sk-settings-tab="general"],
|
||||
[data-boot-settings-tab="notifications"] [data-sk-settings-tab="notifications"],
|
||||
[data-boot-settings-tab="appearance"] [data-sk-settings-tab="appearance"],
|
||||
[data-boot-settings-tab="accessibility"] [data-sk-settings-tab="accessibility"],
|
||||
[data-boot-settings-tab="message_list"] [data-sk-settings-tab="message_list"],
|
||||
[data-boot-settings-tab="reading"] [data-sk-settings-tab="reading"],
|
||||
[data-boot-settings-tab="composing"] [data-sk-settings-tab="composing"],
|
||||
[data-boot-settings-tab="vacation"] [data-sk-settings-tab="vacation"],
|
||||
[data-boot-settings-tab="filters"] [data-sk-settings-tab="filters"],
|
||||
[data-boot-settings-tab="organize"] [data-sk-settings-tab="organize"],
|
||||
[data-boot-settings-tab="security"] [data-sk-settings-tab="security"],
|
||||
[data-boot-settings-tab="sharing"] [data-sk-settings-tab="sharing"],
|
||||
[data-boot-settings-tab="recovery"] [data-sk-settings-tab="recovery"],
|
||||
[data-boot-settings-tab="calendar"] [data-sk-settings-tab="calendar"],
|
||||
[data-boot-settings-tab="contacts"] [data-sk-settings-tab="contacts"],
|
||||
[data-boot-settings-tab="files"] [data-sk-settings-tab="files"],
|
||||
[data-boot-settings-tab="addons"] [data-sk-settings-tab="addons"],
|
||||
[data-boot-settings-tab="backup"] [data-sk-settings-tab="backup"]
|
||||
));
|
||||
|
||||
/* Settings pages the nav listed last time (bulwark-boot-hints). Without
|
||||
hints every page is drawn except recovery (an Enterprise feature). */
|
||||
@custom-variant boot-no-settings-security (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="security"]) *));
|
||||
@custom-variant boot-no-settings-sharing (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="sharing"]) *));
|
||||
@custom-variant boot-no-settings-filters (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="filters"]) *));
|
||||
@custom-variant boot-no-settings-vacation (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="vacation"]) *));
|
||||
@custom-variant boot-no-settings-calendar (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="calendar"]) *));
|
||||
@custom-variant boot-no-settings-contacts (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="contacts"]) *));
|
||||
@custom-variant boot-no-settings-files (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="files"]) *));
|
||||
@custom-variant boot-no-settings-addons (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="addons"]) *));
|
||||
@custom-variant boot-settings-recovery (&:where([data-boot-settings-tabs~="recovery"] *));
|
||||
|
||||
/* Settings pages with one section have no section jump row (SectionNav). */
|
||||
@custom-variant boot-settings-one-section (&:where([data-boot-settings-tab="security"] *, [data-boot-settings-tab="filters"] *, [data-boot-settings-tab="vacation"] *, [data-boot-settings-tab="recovery"] *, [data-boot-settings-tab="addons"] *));
|
||||
/* Accessibility has eleven sections: its jump row wraps to a second line. */
|
||||
@custom-variant boot-settings-two-rows (&:where([data-boot-settings-tab="accessibility"] *));
|
||||
|
||||
/* contacts, files, apps */
|
||||
|
||||
/* List density (data-boot-density, the settings' density): contact rows drop
|
||||
the avatar and the second line in extra compact. */
|
||||
@custom-variant boot-density-xc (&:where([data-boot-density="extra-compact"] *));
|
||||
|
||||
/* /contacts/new: the new-contact form (data-boot-pane="create"). */
|
||||
@custom-variant boot-create (&:where([data-boot-pane="create"] *));
|
||||
|
||||
/* calendar */
|
||||
|
||||
/* Week numbers beside the sidebar's mini month (showWeekNumbers). */
|
||||
@custom-variant boot-cal-weeknums (&:where([data-boot-cal-weeknums="on"] *));
|
||||
|
||||
/* Calendar tasks are on (enableCalendarTasks): a view pill and a sidebar row. */
|
||||
@custom-variant boot-cal-tasks-on (&:where([data-boot-cal-tasks="on"] *));
|
||||
|
||||
/* The views measure their pane to size their rows; the skeleton does the
|
||||
same with the pane as a size container. --sk-head is the header's
|
||||
offsetHeight (whole pixels, as the views read it). */
|
||||
.sk-cal-view {
|
||||
container-type: size;
|
||||
}
|
||||
.sk-cal-week,
|
||||
.sk-cal-day {
|
||||
/* pt-2 + weekday line + gap-0.5 + date circle + pb-1.5 + border */
|
||||
--sk-head: round(nearest, 3.5rem + 1.03125rem + 1px, 1px);
|
||||
--sk-hour-min: 60px;
|
||||
/* fillHourHeight takes the fitted height from ceil(min * 0.9) up. */
|
||||
--sk-hour-floor: 54px;
|
||||
--sk-hour: var(--sk-hour-min);
|
||||
--sk-shift: 0px;
|
||||
}
|
||||
.sk-cal-day {
|
||||
--sk-hour-min: 64px;
|
||||
--sk-hour-floor: 58px;
|
||||
}
|
||||
.sk-cal-month {
|
||||
/* py-2 + text-xs line + border */
|
||||
--sk-head: calc(2rem + 1px);
|
||||
--sk-week-min: 146px;
|
||||
--sk-week: var(--sk-week-min);
|
||||
}
|
||||
@media (width < 48rem) {
|
||||
.sk-cal-week {
|
||||
--sk-head: round(nearest, 3rem + 1.03125rem + 1px, 1px);
|
||||
}
|
||||
.sk-cal-month {
|
||||
--sk-head: round(nearest, 0.75rem + 1.03125rem + 1px, 1px);
|
||||
--sk-week-min: 52px;
|
||||
}
|
||||
}
|
||||
@supports (height: round(down, 1.5px, 1px)) and (height: 1cqh) {
|
||||
.sk-cal-week,
|
||||
.sk-cal-day {
|
||||
/* clientHeight, a whole number of pixels */
|
||||
--sk-pane: round(nearest, 100cqh, 1px);
|
||||
--sk-fit: round(down, (var(--sk-pane) - var(--sk-head)) / var(--boot-cal-hours, 12), 1px);
|
||||
--sk-hour: max(var(--sk-fit), clamp(0px, (var(--sk-hour-floor) - var(--sk-fit)) * 1000, var(--sk-hour-min)));
|
||||
/* The scroll the grid opens at: an hour before now, at most to the end. */
|
||||
--sk-shift: clamp(
|
||||
0px,
|
||||
var(--boot-cal-scroll, -1) * var(--sk-hour-min) - 8px,
|
||||
var(--boot-cal-hours, 12) * var(--sk-hour) - (var(--sk-pane) - var(--sk-head))
|
||||
);
|
||||
}
|
||||
.sk-cal-month {
|
||||
--sk-week: max(var(--sk-week-min), round(down, (round(nearest, 100cqh, 1px) - var(--sk-head)) / 6, 1px));
|
||||
}
|
||||
}
|
||||
.sk-cal-hours {
|
||||
height: calc(var(--boot-cal-hours, 12) * var(--sk-hour));
|
||||
}
|
||||
.sk-cal-hours > * {
|
||||
top: calc(-1 * var(--sk-shift));
|
||||
}
|
||||
.sk-cal-hour {
|
||||
height: var(--sk-hour);
|
||||
}
|
||||
/* The slots' border-b border-border/50, one per hour, as a background. */
|
||||
.sk-cal-slots {
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent calc(var(--sk-hour) - 1px),
|
||||
color-mix(in oklab, var(--color-border) 50%, transparent) calc(var(--sk-hour) - 1px)
|
||||
);
|
||||
background-size: 100% var(--sk-hour);
|
||||
}
|
||||
/* A range that starts at midnight leaves its first hour unlabelled. */
|
||||
.sk-cal-first-label {
|
||||
opacity: min(1, var(--boot-cal-start, 8));
|
||||
}
|
||||
/* The all-hours toggle shows while the hours are limited. */
|
||||
.sk-cal-hours-toggle {
|
||||
opacity: min(1, 24 - var(--boot-cal-hours, 12));
|
||||
}
|
||||
.sk-cal-month-week {
|
||||
height: var(--sk-week);
|
||||
}
|
||||
.sk-cal-mini-days {
|
||||
height: calc(var(--boot-cal-mini-rows, 6) * 1.75rem);
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
.sk-cal-mini-days {
|
||||
height: calc(var(--boot-cal-mini-rows, 6) * 2.75rem);
|
||||
}
|
||||
}
|
||||
/* Week columns: floor((pane - gutter) / columns), at least 80px; 120px on
|
||||
phones (calendar-week-view.tsx, measureColWidth). Columns past the pane
|
||||
are the next days of the strip, cut off. */
|
||||
.sk-cal-cols {
|
||||
--sk-col: calc((100cqw - 3.5rem) / var(--boot-cal-cols, 7));
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: var(--sk-col);
|
||||
}
|
||||
@supports (width: round(down, 1.5px, 1px)) and (width: 1cqw) {
|
||||
.sk-cal-cols {
|
||||
--sk-col: max(80px, round(down, (100cqw - 3.5rem) / var(--boot-cal-cols, 7), 1px));
|
||||
}
|
||||
}
|
||||
@media (width < 48rem) {
|
||||
.sk-cal-cols {
|
||||
--sk-col: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
/* mail */
|
||||
|
||||
/* List density (the density setting, data-boot-density): extra-compact rows
|
||||
(boot-density-xc, defined with contacts above) have no avatar, compact and
|
||||
extra-compact ones no preview. */
|
||||
@custom-variant boot-dense (&:where([data-boot-density="compact"] *, [data-boot-density="extra-compact"] *));
|
||||
/* Message previews turned off (showPreview). */
|
||||
@custom-variant boot-no-preview (&:where([data-boot-preview="off"] *));
|
||||
/* The default interface layout, the opposite of mv: (both can't be told apart by order). */
|
||||
@custom-variant boot-std (&:where(:root:not([data-interface-layout="mountain-view"]) *));
|
||||
/* Mountain View's side panel shows only with calendar or contacts. */
|
||||
@custom-variant boot-no-mv-side (&:where([data-boot-rail]:not([data-boot-rail~="calendar"]):not([data-boot-rail~="contacts"]) *));
|
||||
|
||||
/* Message list rows (MailListRowsSkeleton), in short classes because a
|
||||
skeleton draws dozens of them. Geometry as ThreadListItem's: a 1px rule,
|
||||
the cell's density padding and gap, its minimum height on stacked rows.
|
||||
In the components layer, so utilities on the same element still win. */
|
||||
@layer components {
|
||||
.sk-mrow {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background-color: var(--color-background);
|
||||
}
|
||||
.sk-mcell {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--density-item-gap);
|
||||
padding: var(--density-item-py) 0.75rem;
|
||||
}
|
||||
.sk-mcell-min {
|
||||
min-height: var(--list-item-height);
|
||||
}
|
||||
/* The avatar's column: centred on the row. */
|
||||
.sk-mav {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-self: stretch;
|
||||
align-items: center;
|
||||
}
|
||||
/* A line of text as tall as the real one (font size and leading from its
|
||||
classes), with its bar centred on it. */
|
||||
.sk-ml {
|
||||
position: relative;
|
||||
display: block;
|
||||
height: 1lh;
|
||||
}
|
||||
.sk-ml > .sk-shape {
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
inset-inline-start: 0;
|
||||
height: 0.75rem;
|
||||
margin-block: auto;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Panels drawn in the shapes' own colour (the sidebar in dark themes, the
|
||||
Mountain View phone header): lift the shapes off them. Below the contrast
|
||||
rules' specificity, and not in forced colours. */
|
||||
@media not (forced-colors: active) {
|
||||
:where(.dark) .sk-lift-dark .sk-shape,
|
||||
:where([data-interface-layout="mountain-view"]) .sk-lift-mv .sk-shape,
|
||||
.sk-lift .sk-shape {
|
||||
background-color: color-mix(in oklab, var(--color-foreground) 10%, transparent);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -913,6 +914,52 @@ optgroup {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
* Proofreading underlines (components/email/proofreading). The kind shows in
|
||||
* the line's style as well as its colour, so it survives forced colours and
|
||||
* colour blindness: spelling wavy, grammar double, style dotted, other dashed.
|
||||
*/
|
||||
:root {
|
||||
--text-issue-spelling: var(--color-destructive);
|
||||
--text-issue-grammar: #2563eb;
|
||||
--text-issue-style: #b45309;
|
||||
--text-issue-other: #6b7280;
|
||||
}
|
||||
.dark {
|
||||
--text-issue-grammar: #60a5fa;
|
||||
--text-issue-style: #fbbf24;
|
||||
--text-issue-other: #9ca3af;
|
||||
}
|
||||
.tiptap .text-issue {
|
||||
text-decoration-line: underline;
|
||||
text-decoration-thickness: 1.5px;
|
||||
text-underline-offset: 3px;
|
||||
text-decoration-skip-ink: none;
|
||||
}
|
||||
.tiptap .text-issue-spelling {
|
||||
text-decoration-style: wavy;
|
||||
text-decoration-color: var(--text-issue-spelling);
|
||||
}
|
||||
.tiptap .text-issue-grammar {
|
||||
text-decoration-style: double;
|
||||
text-decoration-color: var(--text-issue-grammar);
|
||||
}
|
||||
.tiptap .text-issue-style {
|
||||
text-decoration-style: dotted;
|
||||
text-decoration-thickness: 2px;
|
||||
text-decoration-color: var(--text-issue-style);
|
||||
}
|
||||
.tiptap .text-issue-other {
|
||||
text-decoration-style: dashed;
|
||||
text-decoration-color: var(--text-issue-other);
|
||||
}
|
||||
.tiptap .text-issue-active {
|
||||
background-color: color-mix(in srgb, var(--color-primary) 18%, transparent);
|
||||
outline: 1px solid var(--color-primary);
|
||||
outline-offset: 1px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.tiptap hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
|
||||
@@ -59,6 +59,24 @@ describe("AppUpdatePrompt", () => {
|
||||
expect(toasts()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps one toast when several builds go out while the tab is open", async () => {
|
||||
render(<AppUpdatePrompt />);
|
||||
for (const id of ["build-b", "build-c", "build-d"]) {
|
||||
update.newerBuildId.mockResolvedValue(id);
|
||||
await act(async () => { vi.advanceTimersByTime(UPDATE_CHECK_MS); });
|
||||
await flush();
|
||||
}
|
||||
expect(update.newerBuildId).toHaveBeenCalledTimes(3);
|
||||
expect(toasts()).toHaveLength(1);
|
||||
|
||||
// Dismissed, the next build is offered again.
|
||||
act(() => { useToastStore.getState().removeToast(toasts()[0].id); });
|
||||
update.newerBuildId.mockResolvedValue("build-e");
|
||||
await act(async () => { vi.advanceTimersByTime(UPDATE_CHECK_MS); });
|
||||
await flush();
|
||||
expect(toasts()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("checks when a chunk of this build fails to load", async () => {
|
||||
update.newerBuildId.mockResolvedValue("build-b");
|
||||
render(<AppUpdatePrompt />);
|
||||
|
||||
@@ -37,10 +37,17 @@ describe('AdminShell', () => {
|
||||
Element.prototype.scrollTo = () => {};
|
||||
});
|
||||
|
||||
it('does not server-render the phone layout, which desktop would show until hydration', () => {
|
||||
// The server can't know the width: it draws the panel's skeleton, whose
|
||||
// phone and desktop layouts CSS picks, rather than either real layout.
|
||||
it('server-renders the panel skeleton, not a real layout', () => {
|
||||
const html = renderToString(shell({ authenticated: false, isStalwartAutoLogin: false }));
|
||||
expect(html).not.toContain('back_to_mail');
|
||||
expect(html).not.toContain('admin_search');
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = html;
|
||||
const region = container.querySelector('[data-boot-skeleton="admin"]');
|
||||
expect(region).not.toBeNull();
|
||||
expect(region!.querySelector('[role="status"]')?.textContent).toBe('common.loading');
|
||||
expect(container.querySelectorAll('a[href], button, input')).toHaveLength(0);
|
||||
expect(html).not.toContain('<p>page</p>');
|
||||
});
|
||||
|
||||
// The shell's own names come from the translations, and the tab says
|
||||
@@ -59,7 +66,10 @@ describe('AdminShell', () => {
|
||||
|
||||
it('hides the session links until the check says which kind of admin this is', () => {
|
||||
const { rerender } = render(shell({ authenticated: false, isStalwartAutoLogin: false }));
|
||||
expect(screen.queryByText('Sign out of admin')).toBeNull();
|
||||
// Only their shapes hold the place while the check runs.
|
||||
expect(screen.queryByRole('button', { name: 'Sign out of admin' })).toBeNull();
|
||||
expect(screen.queryByRole('link', { name: 'Change password' })).toBeNull();
|
||||
expect(screen.getByText('Sign out of admin').closest('[aria-hidden="true"]')).not.toBeNull();
|
||||
rerender(shell({ authenticated: true, isStalwartAutoLogin: true }));
|
||||
expect(screen.queryByText('Sign out of admin')).toBeNull();
|
||||
rerender(shell({ authenticated: true, isStalwartAutoLogin: false }));
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* The admin sidebar's icons and group headings. A plain module (no
|
||||
* "use client"), so the admin shell and its page skeleton, which the server
|
||||
* renders, draw the same list.
|
||||
*/
|
||||
import {
|
||||
Activity,
|
||||
ArrowUpCircle,
|
||||
Bell,
|
||||
Building2,
|
||||
CalendarDays,
|
||||
HeartRateMonitor,
|
||||
LayoutDashboard,
|
||||
LayoutTemplate,
|
||||
Palette,
|
||||
PenLine,
|
||||
Puzzle,
|
||||
Scale,
|
||||
ScrollText,
|
||||
Server,
|
||||
Share2,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
Smartphone,
|
||||
Store,
|
||||
SwatchBook,
|
||||
Tag,
|
||||
SpellCheck,
|
||||
type AppIcon,
|
||||
} from '@/components/icons';
|
||||
import type { AdminPageDef, AdminPageId } from '@/lib/admin/admin-nav';
|
||||
|
||||
export const ADMIN_PAGE_ICONS: Record<AdminPageId, AppIcon> = {
|
||||
dashboard: LayoutDashboard,
|
||||
settings: Server,
|
||||
auth: Shield,
|
||||
branding: Palette,
|
||||
policy: Scale,
|
||||
organisation: Building2,
|
||||
'org-signatures': PenLine,
|
||||
'org-templates': LayoutTemplate,
|
||||
'org-security': ShieldCheck,
|
||||
'org-sharing': Share2,
|
||||
'org-labels': Tag,
|
||||
'org-proofreading': SpellCheck,
|
||||
'org-calendars': CalendarDays,
|
||||
'org-notices': Bell,
|
||||
'org-devices': Smartphone,
|
||||
plugins: Puzzle,
|
||||
themes: SwatchBook,
|
||||
marketplace: Store,
|
||||
version: ArrowUpCircle,
|
||||
telemetry: Activity,
|
||||
monitoring: HeartRateMonitor,
|
||||
logs: ScrollText,
|
||||
};
|
||||
|
||||
/** In sidebar order; `top` has no heading. */
|
||||
export const ADMIN_GROUPS = ['top', 'organisation', 'extensions', 'system'] as const;
|
||||
|
||||
export const ADMIN_GROUP_LABELS: Record<AdminPageDef['group'], string | null> = {
|
||||
top: null,
|
||||
organisation: 'Organisation',
|
||||
extensions: 'Extensions',
|
||||
system: 'System',
|
||||
};
|
||||
@@ -3,36 +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,
|
||||
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';
|
||||
@@ -44,41 +15,13 @@ import { useUpdateStore, selectHasUpdate } from '@/stores/update-store';
|
||||
import { useAdminUnsavedStore } from '@/stores/admin-unsaved-store';
|
||||
import { apiFetch, getPathPrefix } from '@/lib/browser-navigation';
|
||||
import { ADMIN_PAGES, adminPath, type AdminPageDef, type AdminPageId, type AdminSectionDef } from '@/lib/admin/admin-nav';
|
||||
import { AdminSessionLinksSkeleton, AdminSkeleton } from '@/components/boot-skeleton/admin-skeleton';
|
||||
import { useSkeletonLabels } from '@/components/boot-skeleton/use-skeleton-labels';
|
||||
import { ADMIN_GROUPS, ADMIN_GROUP_LABELS, ADMIN_PAGE_ICONS as PAGE_ICONS } from './admin-page-icons';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AdminLink } from './admin-link';
|
||||
import { AdminShellContext } from './shell-context';
|
||||
|
||||
const PAGE_ICONS: Record<AdminPageId, AppIcon> = {
|
||||
dashboard: LayoutDashboard,
|
||||
settings: Server,
|
||||
auth: Shield,
|
||||
branding: Palette,
|
||||
policy: Scale,
|
||||
organisation: Building2,
|
||||
'org-signatures': PenLine,
|
||||
'org-templates': LayoutTemplate,
|
||||
'org-security': ShieldCheck,
|
||||
'org-sharing': Share2,
|
||||
'org-labels': Tag,
|
||||
'org-calendars': CalendarDays,
|
||||
'org-notices': Bell,
|
||||
'org-devices': Smartphone,
|
||||
plugins: Puzzle,
|
||||
themes: SwatchBook,
|
||||
marketplace: Store,
|
||||
version: ArrowUpCircle,
|
||||
telemetry: Activity,
|
||||
monitoring: HeartRateMonitor,
|
||||
logs: ScrollText,
|
||||
};
|
||||
|
||||
const GROUP_LABELS: Record<AdminPageDef['group'], string | null> = {
|
||||
top: null,
|
||||
organisation: 'Organisation',
|
||||
extensions: 'Extensions',
|
||||
system: 'System',
|
||||
};
|
||||
|
||||
interface AdminShellProps {
|
||||
/** Whether the admin session is in place; the sidebar counts load then. */
|
||||
authenticated: boolean;
|
||||
@@ -107,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
|
||||
@@ -186,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]);
|
||||
|
||||
@@ -299,7 +243,19 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!hydrated) return <div className="h-dvh bg-background" />;
|
||||
// The server HTML and the hydration pass: the panel's skeleton, both
|
||||
// layouts drawn and picked by CSS until the width is known.
|
||||
if (!hydrated) {
|
||||
return (
|
||||
<AdminSkeleton
|
||||
page={location.page}
|
||||
label={loadingLabel}
|
||||
backLabel={tSettings('back_to_mail')}
|
||||
searchLabel={tShell('admin_search')}
|
||||
labels={railLabels}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isDesktop) {
|
||||
const showIndex = location.page === null && !onChangePassword;
|
||||
@@ -380,7 +336,11 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{showSessionLinks && <div className="border-t border-border px-2 py-2">{sessionLinks(true)}</div>}
|
||||
{(showSessionLinks || !authenticated) && (
|
||||
<div className="border-t border-border px-2 py-2">
|
||||
{showSessionLinks ? sessionLinks(true) : <AdminSessionLinksSkeleton mobile />}
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t border-border px-2 py-2"><AboutFooter /></div>
|
||||
</>
|
||||
) : (
|
||||
@@ -471,7 +431,12 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border px-1 py-1">
|
||||
{showSessionLinks && <div className="space-y-0.5 px-1 py-1">{sessionLinks(false)}</div>}
|
||||
{/* Held in place while the session is checked, as the boot skeleton draws them. */}
|
||||
{(showSessionLinks || !authenticated) && (
|
||||
<div className="space-y-0.5 px-1 py-1">
|
||||
{showSessionLinks ? sessionLinks(false) : <AdminSessionLinksSkeleton mobile={false} />}
|
||||
</div>
|
||||
)}
|
||||
<AboutFooter />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
'use client';
|
||||
|
||||
// Organisation > Proofreading: whether checker plugins may check what users
|
||||
// write, for everyone or a scope, per checker plugin, and the writing
|
||||
// language. Requiring proofreading only holds while a checker plugin can't
|
||||
// be turned off, so the page warns when none is forced on and offers to
|
||||
// force one (that part is global: plugins can't be forced per scope).
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { SettingItem, SettingsSection, Select } from '@/components/settings/settings-section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AlertTriangle } from '@/components/icons';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import type { SettingRestriction } from '@/lib/admin/types';
|
||||
import {
|
||||
PLUGIN_STATES, PROOFREADING_STATES, checkersToForce, pluginProofreadingState, pluginRestrictionFor,
|
||||
proofreadingPolicyFor, proofreadingState, type PluginProofreadingState, type ProofreadingState,
|
||||
} from '@/lib/admin/proofreading-policy';
|
||||
import { PROOFREADING_KEY, pluginPolicyKey } from '@/lib/proofreading/gate';
|
||||
import { writingLanguageOptions } from '@/lib/proofreading/languages';
|
||||
import { InheritNote } from '../org-page';
|
||||
import type { OrgPolicyDraft } from '../use-org-policy-draft';
|
||||
|
||||
const LANGUAGE_KEY = 'writingLanguage';
|
||||
const INHERIT = 'inherit';
|
||||
|
||||
interface CheckerPlugin {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
forceEnabled?: boolean;
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
/** A default at the selected scope: its own, or Everyone's. */
|
||||
function presetAt(draft: OrgPolicyDraft, key: string): { value: unknown; own: boolean; everyone: unknown } {
|
||||
const everyone = draft.policy?.defaults?.[key];
|
||||
if (!draft.scope) return { value: everyone, own: true, everyone };
|
||||
const defaults = draft.scope.policy.defaults ?? {};
|
||||
return Object.prototype.hasOwnProperty.call(defaults, key)
|
||||
? { value: defaults[key], own: true, everyone }
|
||||
: { value: everyone, own: false, everyone };
|
||||
}
|
||||
|
||||
function setPreset(draft: OrgPolicyDraft, key: string, value: unknown | undefined) {
|
||||
if (!draft.scope) {
|
||||
draft.update((d) => {
|
||||
if (value === undefined) delete d.defaults[key];
|
||||
else d.defaults[key] = value as never;
|
||||
});
|
||||
return;
|
||||
}
|
||||
draft.updateScope(draft.scope.id, (s) => {
|
||||
const defaults = (s.policy.defaults ??= {});
|
||||
if (value === undefined) delete defaults[key];
|
||||
else defaults[key] = value;
|
||||
if (Object.keys(defaults).length === 0) delete s.policy.defaults;
|
||||
});
|
||||
}
|
||||
|
||||
export function ProofreadingSection({ draft }: { draft: OrgPolicyDraft }) {
|
||||
const [plugins, setPlugins] = useState<CheckerPlugin[] | null>(null);
|
||||
const [forcing, setForcing] = useState<string | null>(null);
|
||||
const [forceError, setForceError] = useState<string | null>(null);
|
||||
|
||||
const loadPlugins = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/plugins');
|
||||
const list = res.ok ? ((await res.json()) as CheckerPlugin[]) : [];
|
||||
setPlugins(list.filter((p) => p.permissions?.includes('compose:check')));
|
||||
} catch {
|
||||
setPlugins([]);
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => { void loadPlugins(); }, [loadPlugins]);
|
||||
|
||||
const main = draft.restriction(PROOFREADING_KEY);
|
||||
const mainPreset = presetAt(draft, PROOFREADING_KEY);
|
||||
const mainState = proofreadingState(main.value, mainPreset.value);
|
||||
const everyoneState = proofreadingState(main.everyone, mainPreset.everyone);
|
||||
const mainOwn = main.own || mainPreset.own;
|
||||
|
||||
const writeMain = (state: ProofreadingState | typeof INHERIT) => {
|
||||
if (state === INHERIT) {
|
||||
draft.setRestriction(PROOFREADING_KEY, undefined);
|
||||
setPreset(draft, PROOFREADING_KEY, undefined);
|
||||
return;
|
||||
}
|
||||
const { restriction, preset } = proofreadingPolicyFor(state);
|
||||
// In a scope, "no lock" must undo a lock Everyone has: an empty restriction.
|
||||
draft.setRestriction(PROOFREADING_KEY, restriction ?? (draft.scope ? {} : undefined));
|
||||
setPreset(draft, PROOFREADING_KEY, preset);
|
||||
};
|
||||
|
||||
const pluginStates: Record<string, PluginProofreadingState> = {};
|
||||
for (const p of plugins ?? []) pluginStates[p.id] = pluginProofreadingState(draft.restriction(pluginPolicyKey(p.id)).value);
|
||||
const toForce = plugins ? checkersToForce(mainState, pluginStates, plugins) : [];
|
||||
|
||||
const force = async (id: string) => {
|
||||
setForcing(id);
|
||||
setForceError(null);
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/plugins', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, enabled: true, forceEnabled: true }),
|
||||
});
|
||||
if (!res.ok) setForceError('Could not force the plugin on. Try it on its page under Plugins.');
|
||||
await loadPlugins();
|
||||
} finally {
|
||||
setForcing(null);
|
||||
}
|
||||
};
|
||||
|
||||
const language = draft.restriction(LANGUAGE_KEY);
|
||||
const languagePreset = presetAt(draft, LANGUAGE_KEY);
|
||||
const languageValue = (language.value?.locked ? language.value.value : languagePreset.value) as string | undefined;
|
||||
const languageLocked = language.value?.locked === true;
|
||||
const writeLanguage = (value: string, locked: boolean) => {
|
||||
if (!value) {
|
||||
draft.setRestriction(LANGUAGE_KEY, draft.scope ? {} : undefined);
|
||||
setPreset(draft, LANGUAGE_KEY, undefined);
|
||||
return;
|
||||
}
|
||||
draft.setRestriction(LANGUAGE_KEY, locked ? { locked: true, value } as SettingRestriction : (draft.scope ? {} : undefined));
|
||||
setPreset(draft, LANGUAGE_KEY, value);
|
||||
};
|
||||
|
||||
const stateLabel = (s: ProofreadingState) => PROOFREADING_STATES.find((o) => o.value === s)?.label ?? s;
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
id="proofreading"
|
||||
title="Proofreading"
|
||||
description="Proofreading plugins underline spelling and grammar mistakes while users write. The text users write (not their signature or quoted messages) goes to the service the plugin uses. Nothing is sent while a message is set to be signed or encrypted, also when proofreading is required; text written before the user turned that on may already have been checked."
|
||||
>
|
||||
<SettingItem label="Proofreading" description="Off and locked hides the setting. On and required keeps it on in every composer.">
|
||||
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||
<Select
|
||||
value={draft.scope && !mainOwn ? INHERIT : mainState}
|
||||
onChange={(v) => writeMain(v as ProofreadingState | typeof INHERIT)}
|
||||
options={[
|
||||
...(draft.scope ? [{ value: INHERIT, label: `Same as Everyone (${stateLabel(everyoneState)})` }] : []),
|
||||
...PROOFREADING_STATES.map((o) => ({ value: o.value, label: o.label })),
|
||||
]}
|
||||
/>
|
||||
<InheritNote draft={draft} own={mainOwn} everyone={stateLabel(everyoneState)} onReset={() => writeMain(INHERIT)} />
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
{plugins && plugins.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No proofreading plugin is installed. Install one, such as Proofreading (LanguageTool), under Marketplace or Plugins.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{plugins?.map((p) => {
|
||||
const r = draft.restriction(pluginPolicyKey(p.id));
|
||||
const state = pluginProofreadingState(r.value);
|
||||
return (
|
||||
<SettingItem key={p.id} nested label={p.name} description={p.enabled ? undefined : 'Turned off under Plugins: it checks nothing.'}>
|
||||
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||
<Select
|
||||
value={draft.scope && !r.own ? INHERIT : state}
|
||||
onChange={(v) => draft.setRestriction(
|
||||
pluginPolicyKey(p.id),
|
||||
v === INHERIT ? undefined : pluginRestrictionFor(v as PluginProofreadingState) ?? (draft.scope ? {} : undefined),
|
||||
)}
|
||||
options={[
|
||||
...(draft.scope ? [{ value: INHERIT, label: 'Same as Everyone' }] : []),
|
||||
...PLUGIN_STATES.map((o) => ({ value: o.value, label: o.label })),
|
||||
]}
|
||||
ariaLabel={`Proofreading with ${p.name}`}
|
||||
/>
|
||||
</div>
|
||||
</SettingItem>
|
||||
);
|
||||
})}
|
||||
|
||||
{toForce.length > 0 && (
|
||||
<div role="status" data-testid="proofreading-force-warning" className="flex flex-col gap-2 rounded-md border border-warning/50 bg-warning/10 p-3 text-sm">
|
||||
<p className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<span>
|
||||
Proofreading is required, but no proofreading plugin is forced on, so users can turn the plugin off under
|
||||
Settings > Add-ons and the check with it. Forcing a plugin on applies to everyone, not only this scope.
|
||||
</span>
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{toForce.map((id) => {
|
||||
const p = plugins!.find((x) => x.id === id)!;
|
||||
return (
|
||||
<Button key={id} variant="outline" size="xs" disabled={forcing !== null} onClick={() => void force(id)}>
|
||||
Force {p.name} on for everyone
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{forceError && <p className="text-xs text-destructive">{forceError}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SettingItem label="Writing language" description="Presets the language users write in; proofreading and the browser's spell check use it. Locked, users can't change it.">
|
||||
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Select
|
||||
value={languageValue ?? ''}
|
||||
onChange={(v) => writeLanguage(v, languageLocked)}
|
||||
options={[{ value: '', label: 'Users choose' }, { value: 'auto', label: 'Detect automatically' }, ...writingLanguageOptions('en')]}
|
||||
ariaLabel="Writing language"
|
||||
/>
|
||||
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-input"
|
||||
checked={languageLocked}
|
||||
disabled={!languageValue}
|
||||
onChange={(e) => writeLanguage(languageValue ?? '', e.target.checked)}
|
||||
/>
|
||||
Lock
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
For a fully mandatory setup: force the plugin on, set Proofreading to "On, required" and, in the plugin's
|
||||
settings, turn off users changing its language and rules. A plugin that reaches a server on your network needs that
|
||||
address in PLUGIN_PROXY_PRIVATE_ORIGINS.
|
||||
</p>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { useTranslations } from "next-intl";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { productName } from "@/lib/product-name";
|
||||
import { isChunkLoadFailure, newerBuildId, reloadToNewBuild } from "@/lib/app-update";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { toast, useToastStore } from "@/stores/toast-store";
|
||||
|
||||
/** How often an open tab asks whether a new build was deployed. */
|
||||
export const UPDATE_CHECK_MS = 15 * 60 * 1000;
|
||||
@@ -26,6 +26,7 @@ export function AppUpdatePrompt() {
|
||||
|
||||
useEffect(() => {
|
||||
let shownFor: string | null = null;
|
||||
let toastId: string | null = null;
|
||||
let lastCheck = 0;
|
||||
let checking = false;
|
||||
const check = async (force: boolean) => {
|
||||
@@ -38,7 +39,10 @@ export function AppUpdatePrompt() {
|
||||
const id = await newerBuildId();
|
||||
if (!id || id === shownFor) return;
|
||||
shownFor = id;
|
||||
toast.info(text.current.ready, {
|
||||
// Reload loads whatever build is newest, so a toast for an older one
|
||||
// still does the job. One per tab, however many builds go out.
|
||||
if (toastId && useToastStore.getState().toasts.some((t) => t.id === toastId)) return;
|
||||
toastId = toast.info(text.current.ready, {
|
||||
duration: 0,
|
||||
action: { label: text.current.reload, onClick: () => { void reloadToNewBuild(); } },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { RouteSkeleton } from '../route-skeleton';
|
||||
import { BottomBarSkeleton, RailSkeleton } from '../shell';
|
||||
import { AdminSkeleton } from '../admin-skeleton';
|
||||
import type { BootSurface } from '@/lib/boot-skeleton/surfaces';
|
||||
|
||||
function dom(html: string) {
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = html;
|
||||
return container;
|
||||
}
|
||||
|
||||
const css = readFileSync(join(process.cwd(), 'app/boot-skeleton.css'), 'utf8');
|
||||
|
||||
// A signed-in page drawn for a visitor with nothing stored: the boot script
|
||||
// sets data-boot-auth="out", and the CSS swaps the app's frame for the
|
||||
// login card, the page the app is about to send them to.
|
||||
describe('signed-in pages for a signed-out visitor', () => {
|
||||
it('boot-out matches data-boot-auth="out"', () => {
|
||||
expect(css).toMatch(/@custom-variant boot-out \(&:where\(\[data-boot-auth="out"\] \*\)\);/);
|
||||
});
|
||||
|
||||
it.each<BootSurface>(['mail', 'calendar', 'contacts', 'files', 'apps', 'settings'])('%s carries the login card, shown only with boot-out', (surface) => {
|
||||
const container = dom(renderToString(<RouteSkeleton surface={surface} label="Loading" />));
|
||||
const login = container.querySelector('[data-boot-skeleton="login"]');
|
||||
expect(login).not.toBeNull();
|
||||
// Hidden unless the page is marked signed out.
|
||||
expect(login!.parentElement!.className.split(' ')).toEqual(expect.arrayContaining(['hidden', 'boot-out:block']));
|
||||
// The card itself: logo, fields, button.
|
||||
expect(login!.querySelectorAll('.h-11').length).toBeGreaterThanOrEqual(3);
|
||||
// And the accessibility button the login page will have in its corner.
|
||||
expect(login!.querySelector('.fixed.bottom-4.end-4')).not.toBeNull();
|
||||
// And the app's own frame goes away then.
|
||||
const app = container.querySelector(`[data-boot-skeleton="${surface}"]`);
|
||||
expect(app!.className.split(' ')).toContain('boot-out:hidden');
|
||||
});
|
||||
});
|
||||
|
||||
describe('login skeleton', () => {
|
||||
// The login page renders the real accessibility button itself.
|
||||
it('leaves the accessibility button to the login page', () => {
|
||||
const container = dom(renderToString(<RouteSkeleton surface="login" label="Loading" />));
|
||||
expect(container.querySelector('.fixed')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin skeleton', () => {
|
||||
it('draws the page list with its real names and the open page current', () => {
|
||||
const container = dom(renderToString(<AdminSkeleton page="branding" label="Loading" />));
|
||||
const text = container.textContent ?? '';
|
||||
for (const name of ['Overview', 'Server', 'Branding', 'Organisation', 'Extensions', 'System', 'Audit log']) {
|
||||
expect(text).toContain(name);
|
||||
}
|
||||
const current = [...container.querySelectorAll('.bg-accent')].map((el) => el.textContent);
|
||||
expect(current).toEqual(['Branding']);
|
||||
});
|
||||
|
||||
it('shows the phone list at /admin and the page below it', () => {
|
||||
const index = dom(renderToString(<AdminSkeleton page={null} />));
|
||||
const detail = dom(renderToString(<AdminSkeleton page="logs" />));
|
||||
const phone = (c: HTMLElement) => c.querySelector('.lg\\:hidden')!;
|
||||
// Index: the list (with its chevrons) is shown, the page column hidden.
|
||||
expect(phone(index).querySelector('.p-4')!.className).toContain('hidden');
|
||||
expect(phone(detail).querySelector('.p-4')!.className).not.toContain('hidden');
|
||||
// Unknown page (a connector link): the boot script's pane attribute decides.
|
||||
const unknown = dom(renderToString(<AdminSkeleton />));
|
||||
expect(phone(unknown).querySelector('.p-4')!.className).toContain('boot-open:block');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rail with the admin panel current', () => {
|
||||
it('always draws the admin entry, as current', () => {
|
||||
const rail = dom(renderToString(<RailSkeleton active="admin" />));
|
||||
const shield = rail.querySelector('.bg-primary\\/10')!;
|
||||
expect(shield).not.toBeNull();
|
||||
expect(shield.className).not.toContain('hidden');
|
||||
// Without it, only the last session's hint shows the entry.
|
||||
const plain = dom(renderToString(<RailSkeleton active="mail" />));
|
||||
expect(plain.innerHTML).toContain('boot-rail-admin:flex');
|
||||
});
|
||||
|
||||
it('has no account or quota circle: the admin panel has no mailbox', () => {
|
||||
const circles = (html: string) => dom(html).querySelectorAll('.rounded-full.w-8.h-8').length;
|
||||
expect(circles(renderToString(<RailSkeleton active="admin" />))).toBe(0);
|
||||
expect(circles(renderToString(<RailSkeleton active="mail" />))).toBe(2);
|
||||
});
|
||||
|
||||
it('adds Admin to the bottom bar before Settings', () => {
|
||||
const bar = dom(renderToString(<BottomBarSkeleton active="admin" labels={{ mail: 'Mail', admin: 'Admin', settings: 'Settings' }} />));
|
||||
const labels = [...bar.querySelectorAll('span.truncate')].map((el) => el.textContent);
|
||||
expect(labels.slice(-2)).toEqual(['Admin', 'Settings']);
|
||||
const plain = dom(renderToString(<BottomBarSkeleton active="mail" labels={{ admin: 'Admin' }} />));
|
||||
expect(plain.textContent).not.toContain('Admin');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
// Contacts, Files and plugin pages until the session is restored: each draws
|
||||
// the page skeleton the server drew (not a blank page or a spinner), and the
|
||||
// first screens after it keep that shape.
|
||||
|
||||
const nav = vi.hoisted(() => ({ suspend: false }));
|
||||
const never = new Promise<never>(() => {});
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn(), back: vi.fn(), replace: vi.fn() }),
|
||||
useParams: () => ({ locale: 'en' }),
|
||||
usePathname: () => '/en/contacts',
|
||||
useSearchParams: () => {
|
||||
// The static Lite export prerenders without search params: the hook
|
||||
// suspends there and the Suspense fallback is what lands in the HTML.
|
||||
if (nav.suspend) throw never;
|
||||
return new URLSearchParams();
|
||||
},
|
||||
}));
|
||||
vi.mock('@/i18n/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn(), back: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => '/',
|
||||
Link: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a>,
|
||||
}));
|
||||
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
|
||||
vi.mock('@/components/contacts/contact-activity', () => ({ ContactActivity: () => null }));
|
||||
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => <nav data-testid="rail" /> }));
|
||||
vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null }));
|
||||
vi.mock('@/components/layout/inline-app-view', () => ({ InlineAppView: () => null }));
|
||||
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
|
||||
vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null }));
|
||||
vi.mock('@/hooks/use-sidebar-apps', () => ({
|
||||
useSidebarApps: () => ({
|
||||
showAppsModal: false, inlineApp: null, loadedApps: [], handleManageApps: vi.fn(),
|
||||
handleInlineApp: vi.fn(), closeInlineApp: vi.fn(), closeAppsModal: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { ContactsApp } = await import('@/components/contacts/contacts-app');
|
||||
const { FilesApp } = await import('@/components/files/files-app');
|
||||
const { PluginPageApp } = await import('@/components/plugins/plugin-page-app');
|
||||
const { useAuthStore } = await import('@/stores/auth-store');
|
||||
const { useContactStore } = await import('@/stores/contact-store');
|
||||
const { usePluginStore } = await import('@/stores/plugin-store');
|
||||
|
||||
/** Signed out as far as the page knows, with checkAuth still running. */
|
||||
function restoring() {
|
||||
act(() => {
|
||||
useAuthStore.setState({ isAuthenticated: false, client: null, isLoading: true, checkAuth: () => never });
|
||||
});
|
||||
}
|
||||
|
||||
const skeleton = (container: HTMLElement, surface: string) => container.querySelector(`[data-boot-skeleton="${surface}"]`);
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
nav.suspend = false;
|
||||
});
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
useAuthStore.setState({ isAuthenticated: false, client: null, isLoading: false });
|
||||
useContactStore.setState({ contacts: [], supportsSync: false, contactsLoaded: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('gates draw the page skeleton while the session is restored', () => {
|
||||
it('contacts', () => {
|
||||
restoring();
|
||||
const { container } = render(<ContactsApp />);
|
||||
expect(skeleton(container, 'contacts')).not.toBeNull();
|
||||
expect(container.querySelector('.animate-spin')).toBeNull();
|
||||
});
|
||||
|
||||
it('files', () => {
|
||||
restoring();
|
||||
const { container } = render(<FilesApp />);
|
||||
expect(skeleton(container, 'files')).not.toBeNull();
|
||||
expect(container.querySelector('.animate-spin')).toBeNull();
|
||||
});
|
||||
|
||||
it('plugin pages', () => {
|
||||
restoring();
|
||||
const { container } = render(<PluginPageApp linkSegments={['kanban', 'board']} />);
|
||||
expect(skeleton(container, 'apps')).not.toBeNull();
|
||||
expect(container.querySelector('.animate-spin')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('contacts', () => {
|
||||
it('puts the skeleton, not nothing, into a prerendered page whose search params suspend', () => {
|
||||
nav.suspend = true;
|
||||
restoring();
|
||||
const { container } = render(<ContactsApp />);
|
||||
expect(skeleton(container, 'contacts')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows the rows loading, not an empty address book, before the session is back', () => {
|
||||
// A remembered session: signed in from storage, no client yet, and on a
|
||||
// first visit nothing stored about the server's contacts support.
|
||||
act(() => {
|
||||
useAuthStore.setState({ isAuthenticated: true, client: null, isLoading: true, checkAuth: () => never });
|
||||
useContactStore.setState({ contacts: [], supportsSync: false, contactsLoaded: false });
|
||||
});
|
||||
render(<ContactsApp />);
|
||||
expect(screen.getByTestId('contact-list-skeleton')).toBeInTheDocument();
|
||||
expect(screen.queryByText('empty_state_title')).toBeNull();
|
||||
});
|
||||
|
||||
it('still says the book is empty once a server without contacts is known', () => {
|
||||
const client = { supportsPrincipals: () => false } as unknown as IJMAPClient;
|
||||
act(() => {
|
||||
useAuthStore.setState({ isAuthenticated: true, client, isLoading: false });
|
||||
useContactStore.setState({ contacts: [], supportsSync: false, contactsLoaded: false });
|
||||
});
|
||||
render(<ContactsApp />);
|
||||
expect(screen.queryByTestId('contact-list-skeleton')).toBeNull();
|
||||
expect(screen.getByText('empty_state_title')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('plugin pages', () => {
|
||||
it('keeps the empty content area, not a spinner, while the plugins start', () => {
|
||||
act(() => {
|
||||
useAuthStore.setState({ isAuthenticated: true, client: {} as never, isLoading: false });
|
||||
usePluginStore.setState({ initialized: false, initializePlugins: vi.fn().mockResolvedValue(undefined) });
|
||||
});
|
||||
const { container } = render(<PluginPageApp linkSegments={['kanban', 'board']} />);
|
||||
expect(container.querySelector('.animate-spin')).toBeNull();
|
||||
expect(screen.getByRole('status')).toHaveTextContent('loading');
|
||||
expect(screen.queryByText('not_available_title')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { act } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import { hydrateRoot } from 'react-dom/client';
|
||||
import { RouteSkeleton } from '../route-skeleton';
|
||||
import { CalendarSkeleton, CalendarViewSkeleton } from '../calendar-skeleton';
|
||||
import type { BootSurface } from '@/lib/boot-skeleton/surfaces';
|
||||
|
||||
/**
|
||||
* Every page skeleton is rendered by the server and then hydrated by the same
|
||||
* component on the client. They must produce identical markup, or React throws
|
||||
* the server HTML away and the page flashes.
|
||||
*/
|
||||
|
||||
const SURFACES: BootSurface[] = [
|
||||
'mail', 'calendar', 'contacts', 'files', 'apps', 'settings', 'pro', 'admin', 'admin-login', 'login', 'setup',
|
||||
];
|
||||
|
||||
const LABELS = { mail: 'Mail', calendar: 'Calendar', contacts: 'Contacts', files: 'Files', settings: 'Settings' };
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('page skeletons hydrate without a mismatch', () => {
|
||||
it.each(SURFACES)('%s', async (surface) => {
|
||||
const element = <RouteSkeleton surface={surface} label="Loading..." labels={LABELS} />;
|
||||
const html = renderToString(element);
|
||||
expect(html).toContain(`data-boot-skeleton="${surface}"`);
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = html;
|
||||
document.body.appendChild(container);
|
||||
const errors: unknown[] = [];
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation((...args) => { errors.push(args); });
|
||||
await act(async () => {
|
||||
hydrateRoot(container, element, { onRecoverableError: (e) => { errors.push(e); } });
|
||||
});
|
||||
consoleError.mockRestore();
|
||||
expect(errors).toEqual([]);
|
||||
expect(container.innerHTML).toBe(html);
|
||||
});
|
||||
|
||||
it.each(SURFACES)('%s: one status message, busy, and nothing focusable', (surface) => {
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = renderToString(<RouteSkeleton surface={surface} label="Loading..." labels={LABELS} />);
|
||||
// A signed-in page also carries the login card for signed-out visitors,
|
||||
// hidden by CSS; count per region.
|
||||
for (const region of container.querySelectorAll('[data-boot-skeleton]')) {
|
||||
expect(region.getAttribute('aria-busy')).toBe('true');
|
||||
expect(region.querySelectorAll('[role="status"]').length).toBeLessThanOrEqual(1);
|
||||
}
|
||||
expect(container.querySelectorAll('[role="status"]').length).toBeGreaterThan(0);
|
||||
expect(container.querySelectorAll('a[href], button, input, select, textarea, [tabindex]')).toHaveLength(0);
|
||||
for (const shape of container.querySelectorAll('.sk-shape')) {
|
||||
expect(shape.closest('[aria-hidden="true"]'), shape.outerHTML).not.toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the calendar skeleton with its words', () => {
|
||||
const text = {
|
||||
title: 'Calendar', today: 'Today', search: 'Search events', tasks: 'Tasks',
|
||||
views: { month: 'Month', week: 'Week', day: 'Day', agenda: 'Agenda', tasks: 'Tasks' },
|
||||
taskFilters: ['All', 'Pending', 'Completed', 'Overdue'] as [string, string, string, string],
|
||||
newTask: 'New task', quickAdd: 'Add a task',
|
||||
};
|
||||
|
||||
it('hydrates without a mismatch and reads out only its status', async () => {
|
||||
const element = <CalendarSkeleton label="Loading..." labels={LABELS} text={text} />;
|
||||
const html = renderToString(element);
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = html;
|
||||
document.body.appendChild(container);
|
||||
const errors: unknown[] = [];
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation((...args) => { errors.push(args); });
|
||||
await act(async () => {
|
||||
hydrateRoot(container, element, { onRecoverableError: (e) => { errors.push(e); } });
|
||||
});
|
||||
consoleError.mockRestore();
|
||||
expect(errors).toEqual([]);
|
||||
expect(container.innerHTML).toBe(html);
|
||||
|
||||
const region = container.querySelector('[data-boot-skeleton="calendar"]')!;
|
||||
expect(region.textContent).toContain('Search events');
|
||||
const walker = document.createTreeWalker(region, NodeFilter.SHOW_TEXT);
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
if (!node.textContent?.trim()) continue;
|
||||
const el = node.parentElement!;
|
||||
expect(el.closest('[aria-hidden="true"]') || el.closest('[role="status"]'), el.outerHTML).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('draws one view, with its status, for the loaded page', () => {
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = renderToString(<CalendarViewSkeleton view="week" label="Loading calendars" text={text} />);
|
||||
expect(container.querySelectorAll('[role="status"]')).toHaveLength(1);
|
||||
expect(container.querySelector('.sk-cal-week')).not.toBeNull();
|
||||
expect(container.querySelector('.sk-cal-month')).toBeNull();
|
||||
expect(container.querySelectorAll('button, [tabindex]')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MailListRowsSkeleton, MailSkeleton } from '../mail-skeleton';
|
||||
|
||||
function html(element: React.ReactElement): HTMLElement {
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = renderToString(element);
|
||||
return container;
|
||||
}
|
||||
|
||||
describe('mail skeleton', () => {
|
||||
// The app hides its bottom bar while a message is open on a phone; the
|
||||
// skeleton of /mail/message/<id> must not draw one either.
|
||||
it('hides the phone bottom bar when a message or conversation is open', () => {
|
||||
const page = html(<MailSkeleton label="Loading" />);
|
||||
// The bar's items are its 64px-basis tabs; it is the last such row on the page.
|
||||
const tabs = page.querySelectorAll('.basis-\\[64px\\]');
|
||||
const bar = tabs[tabs.length - 1]?.parentElement as HTMLElement;
|
||||
expect(bar.querySelector('svg')).not.toBeNull();
|
||||
expect(bar).toBeTruthy();
|
||||
expect(bar.className.split(' ')).toContain('max-md:boot-open:hidden');
|
||||
});
|
||||
|
||||
it('draws only the body inside a Pro pane: no frame, no sidebar header', () => {
|
||||
const pane = html(<MailSkeleton label="Loading" embedded />);
|
||||
const region = pane.querySelector('[data-boot-skeleton="mail"]')!;
|
||||
expect(region.className).toContain('h-full');
|
||||
expect(region.className).not.toContain('h-dvh');
|
||||
expect(pane.querySelector('.boot-out\\:block')).toBeNull();
|
||||
expect(pane.querySelector('.lg\\:mv\\:hidden.border-b')).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['extra-compact', 0, 0],
|
||||
['compact', 16, 0],
|
||||
['regular', 16, 16],
|
||||
] as const)('draws %s stacked rows with %i avatars and %i previews', (density, avatars, previews) => {
|
||||
const rows = html(<MailListRowsSkeleton shape="stacked" density={density} preview />);
|
||||
expect(rows.querySelectorAll('.w-10.h-10').length).toBe(avatars);
|
||||
expect(rows.querySelectorAll('.leading-relaxed').length).toBe(previews * 2);
|
||||
});
|
||||
|
||||
it('leaves the preview out when previews are turned off', () => {
|
||||
const rows = html(<MailListRowsSkeleton shape="stacked" density="comfortable" preview={false} />);
|
||||
expect(rows.querySelectorAll('.leading-relaxed').length).toBe(0);
|
||||
});
|
||||
|
||||
it('draws every shape for the boot page and lets the boot attributes pick', () => {
|
||||
const rows = html(<MailListRowsSkeleton />);
|
||||
expect(rows.querySelector('.boot-density-xc\\:hidden')).not.toBeNull();
|
||||
expect(rows.querySelector('.boot-dense\\:hidden')).not.toBeNull();
|
||||
expect(rows.querySelector('.md\\:mv\\:block')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MvTopBarSkeleton } from '../shell';
|
||||
|
||||
// The Mountain View top bar as the pages draw it (components/layout/mv-top-bar.tsx).
|
||||
|
||||
describe('MvTopBarSkeleton', () => {
|
||||
it('starts with the sidebar toggle unless the page leaves it out, as contacts, files and plugin pages do', () => {
|
||||
const withMenu = render(<MvTopBarSkeleton />).container;
|
||||
expect(withMenu.querySelector('.tabler-icon-menu-2, .tabler-icon-menu')).not.toBeNull();
|
||||
|
||||
const without = render(<MvTopBarSkeleton menu={false} />).container;
|
||||
expect(without.querySelector('.tabler-icon-menu-2, .tabler-icon-menu')).toBeNull();
|
||||
// MvTopBar showMenuButton={false} pads the brand instead.
|
||||
expect(without.firstElementChild?.firstElementChild).toHaveClass('ps-3');
|
||||
});
|
||||
|
||||
it('ends with the account avatar, as the real bar does', () => {
|
||||
const bar = render(<MvTopBarSkeleton />).container.firstElementChild!;
|
||||
const cluster = bar.lastElementChild!;
|
||||
expect(cluster.lastElementChild?.querySelector('.rounded-full.w-8.h-8')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import { AppShellSkeleton } from '../shell';
|
||||
import { ProSkeleton } from '../pro-skeleton';
|
||||
|
||||
// The Pro interface takes a standard page over on a desktop (redirect to
|
||||
// /pro after mount). The page's skeleton has to be the Pro frame from the
|
||||
// first paint, or the hop moves everything. The swap is CSS on attributes the
|
||||
// boot script sets; these tests pin the markup the CSS works on.
|
||||
|
||||
function html(element: React.ReactElement) {
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = renderToString(element);
|
||||
return container;
|
||||
}
|
||||
|
||||
const classes = (el: Element | null | undefined) => (el?.getAttribute('class') ?? '').split(/\s+/);
|
||||
|
||||
describe('AppShellSkeleton under the Pro interface', () => {
|
||||
const page = () =>
|
||||
html(
|
||||
<AppShellSkeleton surface="calendar" label="Loading" railBelow="md" active="calendar">
|
||||
<div data-testid="calendar-body" />
|
||||
</AppShellSkeleton>,
|
||||
);
|
||||
|
||||
it('draws the Pro frame next to the standard one, swapped by boot-pro-frame', () => {
|
||||
const container = page();
|
||||
const pro = container.querySelector('[data-boot-skeleton="pro"]');
|
||||
const standard = container.querySelector('[data-boot-skeleton="calendar"]');
|
||||
expect(pro).not.toBeNull();
|
||||
expect(standard).not.toBeNull();
|
||||
// Hidden unless Pro takes over; the standard frame hides when it does.
|
||||
expect(classes(pro?.parentElement)).toEqual(expect.arrayContaining(['hidden', 'boot-pro-frame:block']));
|
||||
expect(classes(standard)).toEqual(expect.arrayContaining(['boot-pro-frame:hidden', 'boot-out:hidden']));
|
||||
// The page's own body stays in the standard frame only.
|
||||
expect(pro?.querySelector('[data-testid="calendar-body"]')).toBeNull();
|
||||
expect(standard?.querySelector('[data-testid="calendar-body"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('gives the Pro frame the shell root, the rail with search, and two panes', () => {
|
||||
const pro = page().querySelector('[data-boot-skeleton="pro"]');
|
||||
expect(classes(pro)).toEqual(expect.arrayContaining(['flex', 'flex-col', 'h-dvh']));
|
||||
const shell = pro?.querySelector('[data-pro-shell]');
|
||||
const rail = shell?.firstElementChild;
|
||||
expect(classes(rail)).toEqual(expect.arrayContaining(['w-14', 'flex']));
|
||||
expect(rail?.querySelectorAll('[data-sk-rail]')).toHaveLength(5);
|
||||
const panes = pro?.querySelectorAll('[data-sk-pro-pane]') ?? [];
|
||||
expect([...panes].map((p) => p.getAttribute('data-sk-pro-pane'))).toEqual(['main', 'split']);
|
||||
for (const pane of panes) {
|
||||
// The tab strip, with eight tab slots for the CSS to pick from, right
|
||||
// above the pane body.
|
||||
const [strip, body] = [...pane.children];
|
||||
expect(strip.querySelectorAll('[data-sk-pro-tab]')).toHaveLength(8);
|
||||
expect(classes(strip)).toEqual(expect.arrayContaining(['h-9', 'bg-secondary']));
|
||||
// A page Pro takes over draws its panes empty, to keep its HTML small.
|
||||
// (The /pro page draws every body, see ProSkeleton below.)
|
||||
expect([...body.children].map((b) => b.getAttribute('data-sk-pro-body'))).toEqual(['other']);
|
||||
}
|
||||
// The split pane only shows with a split.
|
||||
expect(classes(panes[1])).toEqual(expect.arrayContaining(['hidden', 'boot-pro-split:flex']));
|
||||
});
|
||||
|
||||
it('lets the login card win for a signed-out visitor', () => {
|
||||
const container = page();
|
||||
const login = container.querySelector('[data-boot-skeleton="login"]');
|
||||
expect(classes(login?.parentElement)).toEqual(expect.arrayContaining(['hidden', 'boot-out:block']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProSkeleton', () => {
|
||||
it('is the Pro frame, or the login card for a signed-out visitor', () => {
|
||||
const container = html(<ProSkeleton label="Loading" />);
|
||||
const pro = container.querySelector('[data-boot-skeleton="pro"]');
|
||||
expect(classes(pro)).toContain('boot-out:hidden');
|
||||
expect(pro?.querySelector('[data-pro-shell]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-boot-skeleton="login"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('draws every page body in both panes on /pro, where the stored tabs decide', () => {
|
||||
const container = html(<ProSkeleton label="Loading" />);
|
||||
for (const pane of container.querySelectorAll('[data-sk-pro-pane]')) {
|
||||
const body = pane.children[1];
|
||||
expect([...body.children].map((b) => b.getAttribute('data-sk-pro-body'))).toEqual([
|
||||
'mail', 'calendar', 'contacts', 'files', 'settings', 'other',
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Pro CSS picks the stored tabs and pages', () => {
|
||||
// The "pro" section of app/boot-skeleton.css, minus the Tailwind variants,
|
||||
// applied to the real markup with the attributes the boot script sets.
|
||||
const css = readFileSync(path.resolve(__dirname, '../../../app/boot-skeleton.css'), 'utf8')
|
||||
.split('/* pro */')[1]
|
||||
.replace(/@custom-variant[^;]*;/g, '');
|
||||
|
||||
let container: HTMLElement;
|
||||
beforeEach(() => {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'pro-css';
|
||||
style.textContent = css;
|
||||
document.head.appendChild(style);
|
||||
container = document.createElement('div');
|
||||
container.innerHTML = renderToString(<ProSkeleton label="Loading" />);
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
afterEach(() => {
|
||||
document.getElementById('pro-css')?.remove();
|
||||
container.remove();
|
||||
for (const a of [...document.documentElement.attributes]) {
|
||||
if (a.name.startsWith('data-boot-')) document.documentElement.removeAttribute(a.name);
|
||||
}
|
||||
});
|
||||
|
||||
const boot = (attrs: Record<string, string>) => {
|
||||
for (const [k, v] of Object.entries(attrs)) document.documentElement.setAttribute(`data-boot-${k}`, v);
|
||||
};
|
||||
const pane = (which: string) => container.querySelector(`[data-sk-pro-pane="${which}"]`)!;
|
||||
const shown = (els: Iterable<Element>) => [...els].filter((el) => getComputedStyle(el).display !== 'none');
|
||||
const tabs = (which: string) => shown(pane(which).querySelectorAll('[data-sk-pro-tab]'));
|
||||
const active = (which: string) =>
|
||||
[...pane(which).querySelectorAll('[data-sk-pro-tab]')].findIndex(
|
||||
(el) => !['', 'transparent', 'rgba(0, 0, 0, 0)'].includes(getComputedStyle(el).backgroundColor),
|
||||
);
|
||||
const body = (which: string) =>
|
||||
shown(pane(which).querySelectorAll('[data-sk-pro-body]')).map((el) => el.getAttribute('data-sk-pro-body'));
|
||||
|
||||
it('draws one active mail tab without attributes', () => {
|
||||
expect(tabs('main')).toHaveLength(1);
|
||||
expect(active('main')).toBe(0);
|
||||
expect(body('main')).toEqual(['mail']);
|
||||
});
|
||||
|
||||
it('draws the stored count, the active tab and its page in each pane', () => {
|
||||
boot({ pro: 'on', 'pro-main-tabs': '3', 'pro-main-active': '2', 'pro-main-kind': 'calendar', 'pro-split-tabs': '2', 'pro-split-active': '0', 'pro-split-kind': 'other', 'pro-split': 'right' });
|
||||
expect(tabs('main')).toHaveLength(3);
|
||||
expect(active('main')).toBe(2);
|
||||
expect(body('main')).toEqual(['calendar']);
|
||||
expect(tabs('split')).toHaveLength(2);
|
||||
expect(active('split')).toBe(0);
|
||||
expect(body('split')).toEqual(['other']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import { SettingsSkeleton, SettingsSkeletonBody } from '../settings-skeleton';
|
||||
import { SETTINGS_TAB_IDS } from '@/lib/settings-tabs';
|
||||
|
||||
/**
|
||||
* The settings skeleton marks the open page and hides the pages the last
|
||||
* session's nav didn't have, both from attributes the boot script sets on
|
||||
* <html>. The selectors live in app/boot-skeleton.css as Tailwind variants;
|
||||
* read them from there and run them against the rendered rows.
|
||||
*/
|
||||
const css = readFileSync(join(process.cwd(), 'app/boot-skeleton.css'), 'utf8');
|
||||
|
||||
/** The selector inside `@custom-variant <name> (&:where(<selector>))`. */
|
||||
function variantSelector(name: string): string {
|
||||
const match = css.match(new RegExp(`@custom-variant ${name} \\(&:where\\(([\\s\\S]*?)\\)\\);`));
|
||||
if (!match) throw new Error(`no variant ${name}`);
|
||||
return match[1].trim();
|
||||
}
|
||||
|
||||
/** The standard settings frame (the hidden Pro frame carries its own copy of the body). */
|
||||
function mount(html: string) {
|
||||
document.body.innerHTML = html;
|
||||
return document.body.querySelector<HTMLElement>('[data-boot-skeleton="settings"]') ?? document.body;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
document.documentElement.removeAttribute('data-boot-settings-tab');
|
||||
document.documentElement.removeAttribute('data-boot-settings-tabs');
|
||||
});
|
||||
|
||||
describe('settings skeleton nav', () => {
|
||||
it('draws a row for every settings page', () => {
|
||||
const root = mount(renderToString(<SettingsSkeleton label="Loading" />));
|
||||
const ids = [...root.querySelectorAll('[data-sk-settings-tab]')].map((el) => el.getAttribute('data-sk-settings-tab'));
|
||||
expect([...ids].sort()).toEqual([...SETTINGS_TAB_IDS].sort());
|
||||
});
|
||||
|
||||
it.each(SETTINGS_TAB_IDS)('highlights only the %s row when that page is open', (tab) => {
|
||||
const root = mount(renderToString(<SettingsSkeleton label="Loading" />));
|
||||
const active = variantSelector('boot-settings-active');
|
||||
document.documentElement.setAttribute('data-boot-settings-tab', tab);
|
||||
const rows = [...root.querySelectorAll<HTMLElement>('[data-sk-settings-tab]')];
|
||||
const matching = rows.filter((row) => row.matches(active)).map((row) => row.getAttribute('data-sk-settings-tab'));
|
||||
expect(matching).toEqual([tab]);
|
||||
const row = rows.find((r) => r.getAttribute('data-sk-settings-tab') === tab)!;
|
||||
expect(row.className).toContain('boot-settings-active:bg-accent');
|
||||
});
|
||||
|
||||
it('hides the optional pages the last nav did not list, and shows recovery only when it did', () => {
|
||||
const root = mount(renderToString(<SettingsSkeletonBody />));
|
||||
const row = (tab: string) => root.querySelector<HTMLElement>(`[data-sk-settings-tab="${tab}"]`)!;
|
||||
const hidesFilters = variantSelector('boot-no-settings-filters');
|
||||
const showsRecovery = variantSelector('boot-settings-recovery');
|
||||
expect(row('filters').className).toContain('boot-no-settings-filters:hidden');
|
||||
expect(row('recovery').className).toContain('boot-settings-recovery:flex');
|
||||
|
||||
// No hints: everything but recovery.
|
||||
expect(row('filters').matches(hidesFilters)).toBe(false);
|
||||
expect(row('recovery').matches(showsRecovery)).toBe(false);
|
||||
|
||||
document.documentElement.setAttribute('data-boot-settings-tabs', 'account appearance recovery');
|
||||
expect(row('filters').matches(hidesFilters)).toBe(true);
|
||||
expect(row('recovery').matches(showsRecovery)).toBe(true);
|
||||
|
||||
document.documentElement.setAttribute('data-boot-settings-tabs', 'account filters');
|
||||
expect(row('filters').matches(hidesFilters)).toBe(false);
|
||||
expect(row('recovery').matches(showsRecovery)).toBe(false);
|
||||
});
|
||||
|
||||
it('drops the section jump row on pages with one section', () => {
|
||||
const root = mount(renderToString(<SettingsSkeleton label="Loading" />));
|
||||
const row = root.querySelector<HTMLElement>('.boot-settings-one-section\\:hidden')!;
|
||||
expect(row).not.toBeNull();
|
||||
const oneSection = variantSelector('boot-settings-one-section');
|
||||
document.documentElement.setAttribute('data-boot-settings-tab', 'filters');
|
||||
expect(row.matches(oneSection)).toBe(true);
|
||||
document.documentElement.setAttribute('data-boot-settings-tab', 'appearance');
|
||||
expect(row.matches(oneSection)).toBe(false);
|
||||
});
|
||||
|
||||
it('has the back-to-mail box only outside a Pro pane', () => {
|
||||
const page = mount(renderToString(<SettingsSkeleton label="Loading" />));
|
||||
expect(page.querySelector('.boot-pro\\:hidden')).not.toBeNull();
|
||||
const pane = mount(renderToString(<SettingsSkeletonBody embedded />));
|
||||
expect(pane.querySelector('.boot-pro\\:hidden')).toBeNull();
|
||||
// A Pro pane is desktop only: no phone list.
|
||||
expect(pane.querySelector('.boot-open\\:hidden')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* The admin panel as it loads (components/admin/admin-shell.tsx): the rail
|
||||
* with Admin current, the page list with its group headings, and the
|
||||
* content column; on phones the top bar, the page list or a page, and the
|
||||
* bottom bar. Page names, group headings and page descriptions are fixed
|
||||
* English text in lib/admin/admin-nav.ts, so they are drawn for real; the
|
||||
* rest is placeholders.
|
||||
*/
|
||||
import { ArrowLeft, ChevronRight, KeyRound, LogOut, Search, Shield } from "@/components/icons";
|
||||
import { ADMIN_GROUPS, ADMIN_GROUP_LABELS, ADMIN_PAGE_ICONS } from "@/components/admin/admin-page-icons";
|
||||
import { ADMIN_PAGES, type AdminPageDef, type AdminPageId } from "@/lib/admin/admin-nav";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Block, SkeletonRegion, repeat, textWidth } from "./kit";
|
||||
import { TextLine } from "./login-skeleton";
|
||||
import { BottomBarSkeleton, RailSkeleton, type RailLabels } from "./shell";
|
||||
|
||||
const GROUPS = ADMIN_GROUPS.map((group) => ({
|
||||
group,
|
||||
label: ADMIN_GROUP_LABELS[group],
|
||||
pages: ADMIN_PAGES.filter((page) => page.group === group),
|
||||
}));
|
||||
|
||||
function pageDef(id: AdminPageId | null | undefined): AdminPageDef | null {
|
||||
return id ? ADMIN_PAGES.find((page) => page.id === id) ?? null : null;
|
||||
}
|
||||
|
||||
/** The search field above the page list (an Input at h-9 with the icon inside). */
|
||||
function SearchField({ placeholder }: { placeholder?: string }) {
|
||||
return (
|
||||
<span aria-hidden="true" className="relative block">
|
||||
<Search className="pointer-events-none absolute start-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<span className="flex h-9 w-full items-center rounded-md border border-input bg-background ps-9 pe-3 text-sm text-muted-foreground">
|
||||
{placeholder}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Change password" and "Sign out of admin", which a password admin gets
|
||||
* once the session check is done. Drawn while it runs (here and in the
|
||||
* shell), so the foot of the list doesn't grow under the pointer then; a
|
||||
* Stalwart admin signed in automatically loses them.
|
||||
*/
|
||||
export function AdminSessionLinksSkeleton({ mobile }: { mobile: boolean }) {
|
||||
const item = cn("flex w-full items-center gap-2.5 rounded-md text-sm text-foreground", mobile ? "px-3 py-2.5" : "px-3 py-2");
|
||||
return (
|
||||
<>
|
||||
<span aria-hidden="true" className={item}>
|
||||
<KeyRound className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
Change password
|
||||
</span>
|
||||
<span aria-hidden="true" className={item}>
|
||||
<LogOut className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
Sign out of admin
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The about card at the foot of the list (AboutFooter): logo, name and version, source link. */
|
||||
function AboutFooterSkeleton() {
|
||||
return (
|
||||
<div aria-hidden="true" className="px-3 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<Block className="w-7 h-7 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<TextLine className="text-xs font-medium" w="5rem" />
|
||||
<TextLine className="text-[0.6875rem]" w="7rem" />
|
||||
</div>
|
||||
</div>
|
||||
<TextLine className="shrink-0 text-[0.6875rem] w-12" w="100%" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The top of an admin page and placeholders for its sections: what the
|
||||
* content column shows while the admin session is checked. `page` gives the
|
||||
* real title and description (the title is desktop-only, as in
|
||||
* AdminPageHeader); left out, bars. `status` is announced; the boot
|
||||
* skeleton leaves it out, its region carries the one status message.
|
||||
*/
|
||||
export function AdminContentSkeleton({ page, status }: { page?: AdminPageId | null; status?: string }) {
|
||||
const def = pageDef(page ?? "dashboard");
|
||||
return (
|
||||
<div>
|
||||
<div aria-hidden="true" className="mb-6 space-y-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<div className="min-w-0">
|
||||
{def && page !== undefined ? (
|
||||
<>
|
||||
<span className="hidden lg:block text-xl font-semibold text-foreground leading-tight">{def.label}</span>
|
||||
<span className="block text-sm text-muted-foreground mt-1">{def.description}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TextLine className="text-xl font-semibold leading-tight hidden lg:block" w="10rem" />
|
||||
<TextLine className="text-sm mt-1" w="22rem" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AdminBodySkeleton status={status} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholders for a page's sections (SettingsSection: a heading and rows of
|
||||
* settings), below its header: the pages show it while they fetch, so the
|
||||
* boot skeleton's shapes stay where they are. `status` is announced.
|
||||
*/
|
||||
export function AdminBodySkeleton({ status }: { status?: string }) {
|
||||
return (
|
||||
<div data-testid="admin-body-skeleton">
|
||||
{status ? <span role="status" className="sr-only">{status}</span> : null}
|
||||
<div aria-hidden="true" className="space-y-10">
|
||||
{repeat(2, (s) => (
|
||||
<div className="space-y-4">
|
||||
<TextLine className="text-lg font-medium" w={s === 0 ? "8rem" : "11rem"} />
|
||||
<div className="space-y-4">
|
||||
{repeat(s === 0 ? 4 : 3, (i) => (
|
||||
<div className="flex items-center justify-between gap-4 py-1">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<TextLine className="text-sm" w={textWidth(i + s * 4, 25, 25)} />
|
||||
<TextLine className="text-xs" w={textWidth(i + s * 4 + 2, 40, 35)} />
|
||||
</div>
|
||||
<Block className={i % 2 === 0 ? "h-6 w-11 rounded-full shrink-0" : "h-9 w-48 shrink-0"} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface AdminSkeletonProps {
|
||||
/** common.loading */
|
||||
label?: string;
|
||||
/**
|
||||
* The open page, as the shell reads it from the URL: null for /admin.
|
||||
* Leave it out where the URL isn't the admin's own (a connector link):
|
||||
* then the boot script's data-boot-pane picks the phone list or a page.
|
||||
*/
|
||||
page?: AdminPageId | null;
|
||||
/** settings.back_to_mail, for the box above the page list. */
|
||||
backLabel?: string;
|
||||
/** shell.admin_search, the search field's placeholder. */
|
||||
searchLabel?: string;
|
||||
/** The phone bottom bar's labels. */
|
||||
labels?: RailLabels;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AdminSkeleton({ label, page, backLabel, searchLabel, labels, className }: AdminSkeletonProps) {
|
||||
const active: AdminPageId | null = page === undefined ? null : page ?? "dashboard";
|
||||
const def = pageDef(page);
|
||||
// Phone: the list at /admin, the page below it.
|
||||
const showIndex = page === undefined ? "boot-open:hidden" : page === null ? "" : "hidden";
|
||||
const showPage = page === undefined ? "hidden boot-open:block" : page === null ? "hidden" : "";
|
||||
|
||||
return (
|
||||
<SkeletonRegion
|
||||
surface="admin"
|
||||
label={label}
|
||||
className={cn("h-dvh bg-background pt-[env(safe-area-inset-top)]", className)}
|
||||
>
|
||||
{/* Desktop */}
|
||||
<div className="hidden lg:flex h-full">
|
||||
<RailSkeleton active="admin" className="flex" />
|
||||
|
||||
<div aria-hidden="true" className="flex w-64 shrink-0 flex-col border-e border-border bg-secondary">
|
||||
<div className="border-b border-border">
|
||||
<div className="flex h-14 items-center px-2">
|
||||
<span className="flex h-9 w-full items-center rounded-md px-3 text-sm font-medium text-foreground">
|
||||
<ArrowLeft className="me-2 h-4 w-4" />
|
||||
{backLabel ?? <TextLine className="flex-1" w="6rem" />}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden py-2">
|
||||
<div className="px-3 pb-1 pt-1"><SearchField placeholder={searchLabel} /></div>
|
||||
<div className="space-y-0.5 px-2">
|
||||
{GROUPS.map(({ group, label: heading, pages }, groupIndex) => (
|
||||
<div key={group}>
|
||||
{groupIndex > 0 && <div className="mx-1 my-2 border-t border-border" />}
|
||||
{heading && (
|
||||
<div className="px-3 pb-1 pt-2.5">
|
||||
<span className="text-[0.6875rem] font-semibold text-muted-foreground">{heading}</span>
|
||||
</div>
|
||||
)}
|
||||
{pages.map((p) => {
|
||||
const Icon = ADMIN_PAGE_ICONS[p.id];
|
||||
const on = active === p.id || (page === undefined && p.id === "dashboard");
|
||||
return (
|
||||
<div key={p.id}>
|
||||
<span
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2.5 rounded-md py-2 pe-3 ps-3 text-start text-sm",
|
||||
on ? "bg-accent font-medium text-accent-foreground" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("h-4 w-4 shrink-0", on ? "text-accent-foreground" : "text-muted-foreground")} />
|
||||
<span className="truncate">{p.label}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border px-1 py-1">
|
||||
<div className="space-y-0.5 px-1 py-1"><AdminSessionLinksSkeleton mobile={false} /></div>
|
||||
<AboutFooterSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="mx-auto max-w-3xl px-6 py-6">
|
||||
<AdminContentSkeleton page={page} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phone and tablet */}
|
||||
<div className="flex lg:hidden h-full flex-col">
|
||||
<div aria-hidden="true" className="flex h-14 shrink-0 items-center gap-1 border-b border-border bg-background px-2">
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-md text-foreground">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</span>
|
||||
<div className={cn("flex min-w-0 items-center gap-2", showIndex)}>
|
||||
<Shield className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="truncate text-lg font-semibold">Admin</span>
|
||||
</div>
|
||||
<div className={cn("min-w-0 items-center gap-2", page === undefined ? "hidden boot-open:flex" : page === null ? "hidden" : "flex")}>
|
||||
{def ? (
|
||||
<span className="truncate text-lg font-semibold">{def.label}</span>
|
||||
) : (
|
||||
<TextLine className="text-lg font-semibold w-32" w="100%" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div aria-hidden="true" className={showIndex}>
|
||||
<div className="px-4 pb-1 pt-3"><SearchField placeholder={searchLabel} /></div>
|
||||
<div className="py-2">
|
||||
{GROUPS.map(({ group, label: heading, pages }, groupIndex) => (
|
||||
<div key={group}>
|
||||
{groupIndex > 0 && <div className="mx-5 my-2 border-t border-border" />}
|
||||
{heading && (
|
||||
<div className="px-5 pb-1.5 pt-3">
|
||||
<span className="text-xs font-semibold text-muted-foreground">{heading}</span>
|
||||
</div>
|
||||
)}
|
||||
{pages.map((p) => {
|
||||
const Icon = ADMIN_PAGE_ICONS[p.id];
|
||||
return (
|
||||
<div key={p.id}>
|
||||
<span className="flex w-full items-center justify-between py-3.5 pe-5 ps-5 text-sm text-foreground">
|
||||
<span className="flex items-center gap-3">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
{p.label}
|
||||
</span>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn("p-4 pb-6", showPage)}>
|
||||
<AdminContentSkeleton page={page} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BottomBarSkeleton active="admin" labels={labels} className="flex" />
|
||||
</div>
|
||||
</SkeletonRegion>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { BootSurface } from "@/lib/boot-skeleton/surfaces";
|
||||
import { MailSkeletonBody } from "./mail-skeleton";
|
||||
import { CalendarSkeletonBody } from "./calendar-skeleton";
|
||||
import { ContactsSkeletonBody } from "./contacts-skeleton";
|
||||
import { FilesSkeletonBody } from "./files-skeleton";
|
||||
import { SettingsSkeletonBody } from "./settings-skeleton";
|
||||
|
||||
/**
|
||||
* A surface's content without its frame, for the Pro shell's panes, which
|
||||
* host the apps embedded (no rail, no Mountain View shell).
|
||||
*/
|
||||
export function SkeletonBody({ surface, embedded = true }: { surface: BootSurface; embedded?: boolean }) {
|
||||
switch (surface) {
|
||||
case "calendar":
|
||||
return <CalendarSkeletonBody embedded={embedded} />;
|
||||
case "contacts":
|
||||
return <ContactsSkeletonBody embedded={embedded} />;
|
||||
case "files":
|
||||
return <FilesSkeletonBody embedded={embedded} />;
|
||||
case "settings":
|
||||
return <SettingsSkeletonBody embedded={embedded} />;
|
||||
default:
|
||||
return <MailSkeletonBody embedded={embedded} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,635 @@
|
||||
/**
|
||||
* The calendar page as a skeleton: sidebar with the mini month, toolbar and
|
||||
* the grid of the view the URL or the last visit picked
|
||||
* (`data-boot-calendar-view`). Class lists mirror the real components
|
||||
* (components/calendar/calendar-app.tsx, calendar-toolbar.tsx,
|
||||
* mini-calendar.tsx, calendar-sidebar-panel.tsx and the views), and the
|
||||
* sizes the views measure at run time (hour rows, week rows, columns) come
|
||||
* from the `.sk-cal-*` rules in app/boot-skeleton.css.
|
||||
*
|
||||
* Dates, weekday names, hour labels and the title depend on the time zone,
|
||||
* the first day of the week, the time format and the language, so they are
|
||||
* placeholders; the icons are drawn for real.
|
||||
*/
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
CalendarDays,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ListTodo,
|
||||
Menu,
|
||||
MoreVertical,
|
||||
Plus,
|
||||
Search,
|
||||
UnfoldVertical,
|
||||
} from "@/components/icons";
|
||||
import type { CalendarViewMode } from "@/stores/calendar-store";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Bar, Block, Circle, repeat, textWidth } from "./kit";
|
||||
import { AppShellSkeleton, type RailLabels } from "./shell";
|
||||
|
||||
export interface CalendarSkeletonProps {
|
||||
/** common.loading */
|
||||
label?: string;
|
||||
/** Rail labels for the phone bottom bar (sidebar.*). */
|
||||
labels?: RailLabels;
|
||||
/** The calendar's own words, translated by the caller. */
|
||||
text?: CalendarSkeletonText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Words of the calendar's chrome that are the same before and after the app
|
||||
* loads (they depend on the language only). Placeholders where one is missing.
|
||||
*/
|
||||
export interface CalendarSkeletonText {
|
||||
/** calendar.title: the sidebar's heading. */
|
||||
title?: string;
|
||||
/** calendar.views.today */
|
||||
today?: string;
|
||||
/** calendar.search.title: the search field's placeholder. */
|
||||
search?: string;
|
||||
/** calendar.views.<view>: the view menu button and the phone's pills. */
|
||||
views?: Record<CalendarViewMode, string>;
|
||||
/** calendar.tasks.label: the sidebar's tasks entry. */
|
||||
tasks?: string;
|
||||
/** calendar.tasks.filter_all, _pending, _completed, _overdue */
|
||||
taskFilters?: [string, string, string, string];
|
||||
/** calendar.tasks.create */
|
||||
newTask?: string;
|
||||
/** calendar.tasks.quick_add_placeholder */
|
||||
quickAdd?: string;
|
||||
}
|
||||
|
||||
const MODES: CalendarViewMode[] = ["month", "week", "day", "agenda", "tasks"];
|
||||
|
||||
/** The skeleton's words from a translator for the "calendar" namespace. */
|
||||
export function calendarSkeletonText(t: (key: string) => string): CalendarSkeletonText {
|
||||
return {
|
||||
title: t("title"),
|
||||
today: t("views.today"),
|
||||
search: t("search.title"),
|
||||
views: {
|
||||
month: t("views.month"),
|
||||
week: t("views.week"),
|
||||
day: t("views.day"),
|
||||
agenda: t("views.agenda"),
|
||||
tasks: t("views.tasks"),
|
||||
},
|
||||
tasks: t("tasks.label"),
|
||||
taskFilters: [t("tasks.filter_all"), t("tasks.filter_pending"), t("tasks.filter_completed"), t("tasks.filter_overdue")],
|
||||
newTask: t("tasks.create"),
|
||||
quickAdd: t("tasks.quick_add_placeholder"),
|
||||
};
|
||||
}
|
||||
|
||||
/** Shown for the view on <html>. */
|
||||
const VIEW_SHOWN: Record<CalendarViewMode, string> = {
|
||||
month: "hidden boot-cal-month:flex",
|
||||
week: "hidden boot-cal-week:flex",
|
||||
day: "hidden boot-cal-day:flex",
|
||||
agenda: "hidden boot-cal-agenda:flex",
|
||||
tasks: "hidden boot-cal-tasks:flex",
|
||||
};
|
||||
|
||||
/** The view menu button's label for the view on <html>. */
|
||||
const VIEW_LABEL: Record<CalendarViewMode, string> = {
|
||||
month: "hidden boot-cal-month:inline",
|
||||
week: "hidden boot-cal-week:inline",
|
||||
day: "hidden boot-cal-day:inline",
|
||||
agenda: "hidden boot-cal-agenda:inline",
|
||||
tasks: "hidden boot-cal-tasks:inline",
|
||||
};
|
||||
|
||||
/** The phone's view pill drawn as current. */
|
||||
const PILL_ON: Record<CalendarViewMode, string> = {
|
||||
month: "boot-cal-month:bg-primary boot-cal-month:text-primary-foreground",
|
||||
week: "boot-cal-week:bg-primary boot-cal-week:text-primary-foreground",
|
||||
day: "boot-cal-day:bg-primary boot-cal-day:text-primary-foreground",
|
||||
agenda: "boot-cal-agenda:bg-primary boot-cal-agenda:text-primary-foreground",
|
||||
// Only there when tasks are on.
|
||||
tasks: "hidden boot-cal-tasks-on:flex boot-cal-tasks:bg-primary boot-cal-tasks:text-primary-foreground",
|
||||
};
|
||||
|
||||
/** Tasks have no dates to step through and no events to search. */
|
||||
const NOT_TASKS = "boot-cal-tasks:hidden";
|
||||
|
||||
/** ToolbarButton (components/ui/toolbar.tsx): a 32px muted icon button. */
|
||||
function ToolbarIcon({ className, children }: { className?: string; children: ReactNode }) {
|
||||
return (
|
||||
<span className={cn("flex items-center justify-center h-8 w-8 shrink-0 rounded-md text-muted-foreground [&_svg]:size-4", className)}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** IconButton size icon-lg: the phone toolbar's 44px buttons. */
|
||||
function PhoneIcon({ outline, className, children }: { outline?: boolean; className?: string; children: ReactNode }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center justify-center h-11 w-11 shrink-0 rounded-md [&_svg]:size-5",
|
||||
outline && "border border-input bg-background",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** An outline xs button (Today, the view menu). */
|
||||
function OutlineButton({ className, children }: { className?: string; children?: ReactNode }) {
|
||||
return (
|
||||
<span className={cn("flex items-center gap-1 h-8 px-2.5 shrink-0 rounded-md border border-input bg-background text-sm font-medium", className)}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** A word when the caller has it, else a placeholder as wide as a short one. */
|
||||
function Word({ text, w }: { text?: string; w: string }) {
|
||||
return text ? <span className="whitespace-nowrap">{text}</span> : <Bar className="h-2.5" w={w} />;
|
||||
}
|
||||
|
||||
/** A placeholder bar in a line box of the given height class. */
|
||||
function Line({ className, bar, w }: { className: string; bar: string; w: string }) {
|
||||
return (
|
||||
<span className={cn("flex items-center", className)}>
|
||||
<Bar className={bar} w={w} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The toolbar's title: a date range (a placeholder), or "Tasks" in the tasks view. */
|
||||
function Title({ tasks, w }: { tasks?: string; w: string }) {
|
||||
return (
|
||||
<>
|
||||
<Bar className={cn("h-3", tasks && "boot-cal-tasks:hidden")} w={w} />
|
||||
{tasks ? <span className="hidden boot-cal-tasks:inline truncate">{tasks}</span> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** CalendarToolbar: the 57px desktop and tablet row, the two-row phone header. */
|
||||
function ToolbarSkeleton({ text }: { text?: CalendarSkeletonText }) {
|
||||
const views = text?.views;
|
||||
return (
|
||||
<>
|
||||
<div className="hidden md:flex items-center gap-1 px-3 h-14 box-content border-b border-border flex-shrink-0">
|
||||
<ToolbarIcon className="me-1">
|
||||
<Menu />
|
||||
</ToolbarIcon>
|
||||
{/* The sidebar's "+" stands in for this one while the sidebar shows. */}
|
||||
<ToolbarIcon className="lg:hidden lg:boot-cal-collapsed:flex">
|
||||
<Plus />
|
||||
</ToolbarIcon>
|
||||
<OutlineButton className={cn("me-1", NOT_TASKS)}>
|
||||
<Word text={text?.today} w="2.5rem" />
|
||||
</OutlineButton>
|
||||
<ToolbarIcon className={NOT_TASKS}>
|
||||
<ChevronLeft />
|
||||
</ToolbarIcon>
|
||||
<ToolbarIcon className={NOT_TASKS}>
|
||||
<ChevronRight />
|
||||
</ToolbarIcon>
|
||||
<span className="flex items-center ms-2 h-6 min-w-0 text-base font-semibold">
|
||||
<Title tasks={views?.tasks} w="9rem" />
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<span
|
||||
className={cn(
|
||||
"relative flex items-center w-40 lg:w-56 xl:w-64 me-1 h-8 ps-8 pe-8 shrink-0 rounded-md border border-input bg-background",
|
||||
NOT_TASKS,
|
||||
)}
|
||||
>
|
||||
<Search className="absolute start-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
{text?.search ? (
|
||||
<span className="text-sm text-muted-foreground truncate">{text.search}</span>
|
||||
) : (
|
||||
<Bar className="h-2.5" w="6rem" />
|
||||
)}
|
||||
<span className="absolute end-1 top-1/2 -translate-y-1/2 mx-1 rounded border border-border px-1.5 font-sans text-[0.6875rem] leading-4 text-muted-foreground pointer-coarse:hidden">
|
||||
/
|
||||
</span>
|
||||
</span>
|
||||
<ToolbarIcon>
|
||||
<MoreVertical />
|
||||
</ToolbarIcon>
|
||||
<OutlineButton className="ms-1">
|
||||
{views ? MODES.map((v) => <span key={v} className={VIEW_LABEL[v]}>{views[v]}</span>) : <Bar className="h-2.5" w="2.25rem" />}
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</OutlineButton>
|
||||
</div>
|
||||
|
||||
<div className="md:hidden border-b border-border">
|
||||
<div className="flex flex-col gap-1 px-2 py-2">
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<PhoneIcon className="-ms-1">
|
||||
<Menu />
|
||||
</PhoneIcon>
|
||||
<PhoneIcon className={NOT_TASKS}>
|
||||
<ChevronLeft />
|
||||
</PhoneIcon>
|
||||
{/* Below 400px the title takes a line of its own. */}
|
||||
<span className="flex flex-1 items-center justify-center h-5 min-w-0 text-sm font-semibold max-[25rem]:order-first max-[25rem]:basis-full max-[25rem]:ps-1 max-[25rem]:justify-start">
|
||||
<Title tasks={views?.tasks} w="5rem" />
|
||||
</span>
|
||||
<PhoneIcon className={NOT_TASKS}>
|
||||
<ChevronRight />
|
||||
</PhoneIcon>
|
||||
<span className={cn("flex items-center h-8 px-2.5 ms-0.5 shrink-0 text-sm font-medium", NOT_TASKS)}>
|
||||
<Word text={text?.today} w="2.25rem" />
|
||||
</span>
|
||||
{/* PhoneHeaderActions: the shell's ⋮ and the account avatar. */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0 ms-auto">
|
||||
<PhoneIcon>
|
||||
<MoreVertical />
|
||||
</PhoneIcon>
|
||||
<span className="flex items-center justify-center w-10 h-10 pointer-coarse:w-11 pointer-coarse:h-11">
|
||||
<Circle className="w-8 h-8" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex flex-1 border border-border rounded-md overflow-hidden">
|
||||
{MODES.map((v) => (
|
||||
<span
|
||||
key={v}
|
||||
className={cn("flex flex-auto items-center justify-center min-h-11 px-1.5 text-[0.6875rem] font-medium text-muted-foreground", PILL_ON[v])}
|
||||
>
|
||||
{views ? views[v] : <Bar className="h-2" w="2rem" />}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<PhoneIcon outline className={NOT_TASKS}>
|
||||
<Search />
|
||||
</PhoneIcon>
|
||||
<PhoneIcon outline>
|
||||
<CalendarDays />
|
||||
</PhoneIcon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The inline sidebar of a desktop: header, mini month and calendar list. */
|
||||
function SidebarSkeleton({ text }: { text?: CalendarSkeletonText }) {
|
||||
const weekNumberCell = <span className="hidden boot-cal-weeknums:block" />;
|
||||
const columns = "grid grid-cols-7 boot-cal-weeknums:grid-cols-[1.25rem_repeat(7,minmax(0,1fr))]";
|
||||
const chevron = "flex items-center justify-center h-8 w-8 [&_svg]:size-4";
|
||||
return (
|
||||
<div
|
||||
className="hidden lg:flex lg:boot-cal-collapsed:hidden flex-col border-e border-border bg-secondary overflow-hidden flex-shrink-0"
|
||||
style={{ width: "var(--boot-cal-sidebar-w, 256px)" }}
|
||||
>
|
||||
{/* CalendarSidebarHeader */}
|
||||
<div className="px-3 h-14 box-content border-b border-border flex items-center justify-between flex-shrink-0">
|
||||
{text?.title ? (
|
||||
<span className="text-sm font-semibold truncate">{text.title}</span>
|
||||
) : (
|
||||
<Bar className="h-3" w="4.5rem" />
|
||||
)}
|
||||
<span className="flex items-center justify-center h-8 w-8 shrink-0">
|
||||
<Plus className="w-4 h-4" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden py-3">
|
||||
{/* MiniCalendar */}
|
||||
<div className="px-3">
|
||||
<div className="flex items-center justify-between mb-2 text-muted-foreground">
|
||||
<span className={chevron}>
|
||||
<ChevronLeft />
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 h-7 px-2">
|
||||
<Bar className="h-3" w="6.5rem" />
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
<span className={chevron}>
|
||||
<ChevronRight />
|
||||
</span>
|
||||
</div>
|
||||
<div className={columns}>
|
||||
{weekNumberCell}
|
||||
{repeat(7, () => (
|
||||
<Line className="justify-center h-[0.9375rem] my-1" bar="h-1.5" w="0.875rem" />
|
||||
))}
|
||||
</div>
|
||||
{/* 4 to 6 weeks: as many as the month has (--boot-cal-mini-rows). */}
|
||||
<div className={cn(columns, "sk-cal-mini-days overflow-hidden auto-rows-[1.75rem] pointer-coarse:auto-rows-[2.75rem] items-center justify-items-center")}>
|
||||
{repeat(6, () => (
|
||||
<>
|
||||
{weekNumberCell}
|
||||
{repeat(7, () => <Bar className="h-2" w="0.75rem" />)}
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* CalendarSidebarPanel: one section of calendars. */}
|
||||
<div className="mt-4">
|
||||
{/* The tasks entry, when tasks are on. */}
|
||||
<div
|
||||
className="hidden boot-cal-tasks-on:flex items-center gap-2 px-3 mb-2 min-h-8 pointer-coarse:min-h-11 text-sm"
|
||||
style={{ paddingBlock: "var(--density-sidebar-py, 4px)" }}
|
||||
>
|
||||
<ListTodo className="w-4 h-4 text-muted-foreground" />
|
||||
<Word text={text?.tasks} w="3rem" />
|
||||
</div>
|
||||
{/* A section header, its add button taking the row to 32px. */}
|
||||
<div className="flex items-center gap-1 px-3 h-8 text-muted-foreground">
|
||||
<ChevronDown className="w-3 h-3 flex-shrink-0" />
|
||||
<Bar className="h-2" w="4rem" />
|
||||
</div>
|
||||
{repeat(4, (i) => (
|
||||
<div
|
||||
className="flex items-center gap-2 ps-5 pe-3 min-h-8 pointer-coarse:min-h-11"
|
||||
style={{ paddingBlock: "var(--density-sidebar-py, 4px)" }}
|
||||
>
|
||||
<Block className="w-3.5 h-3.5 rounded-sm flex-shrink-0" />
|
||||
<Bar className="h-2.5" w={textWidth(i, 30, 35)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The hour labels down the gutter, one per hour row. Each sits centred on
|
||||
* its row's top line; a range that starts later than midnight labels its
|
||||
* first hour inside the row instead (calendar-week-view.tsx). The gutter
|
||||
* stops short of the last line, which has no label.
|
||||
*/
|
||||
function HourGutter({ className, labelW }: { className: string; labelW: string }) {
|
||||
return (
|
||||
<div className={cn("relative flex-shrink-0 h-[calc(100%-5px)] overflow-hidden bg-background", className)}>
|
||||
{repeat(24, (i) => (
|
||||
<div className="sk-cal-hour relative">
|
||||
<Bar
|
||||
className={cn("absolute end-2 h-2", i === 0 ? "sk-cal-first-label top-1.5" : "top-0 -translate-y-1/2")}
|
||||
w={labelW}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The all-hours toggle in the gutter's header cell, while the hours are limited. */
|
||||
function HoursToggle({ className }: { className: string }) {
|
||||
return (
|
||||
<div className={cn("flex items-end justify-center pb-1 flex-shrink-0", className)}>
|
||||
<span className="sk-cal-hours-toggle flex items-center justify-center rounded p-1 text-muted-foreground">
|
||||
<UnfoldVertical className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The weekday and date of a column header. */
|
||||
function DayHead({ className, date }: { className?: string; date: string }) {
|
||||
return (
|
||||
<>
|
||||
<Line className={cn("h-[1.03125rem]", className)} bar="h-2" w="1.5rem" />
|
||||
<Line className={cn("justify-center", date)} bar="h-3.5" w="1.125rem" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** CalendarWeekView: gutter, one column per day, hour rows that fill the pane. */
|
||||
function WeekSkeleton({ className }: { className: string }) {
|
||||
return (
|
||||
<div aria-hidden="true" className={cn("sk-cal-view sk-cal-week flex-col flex-1 min-h-0 min-w-0 overflow-hidden", className)}>
|
||||
<div className="relative z-10 flex flex-shrink-0 border-b border-border bg-background">
|
||||
<HoursToggle className="w-14 max-md:w-10" />
|
||||
<div className="sk-cal-cols grid border-s border-border">
|
||||
{repeat(8, () => (
|
||||
<div className="flex flex-col items-center gap-0.5 pt-2 pb-1.5 border-e border-border">
|
||||
<DayHead date="w-10 h-10 max-md:w-8 max-md:h-8" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sk-cal-hours flex flex-shrink-0">
|
||||
<HourGutter className="w-14 max-md:w-10" labelW="1.5rem" />
|
||||
<div className="sk-cal-cols relative grid h-full border-s border-border">
|
||||
{repeat(8, () => <div className="sk-cal-slots border-e border-border" />)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** CalendarDayView: the same grid with one wide column. */
|
||||
function DaySkeleton({ className }: { className: string }) {
|
||||
return (
|
||||
<div aria-hidden="true" className={cn("sk-cal-view sk-cal-day flex-col flex-1 min-h-0 min-w-0 overflow-hidden", className)}>
|
||||
<div className="relative z-10 flex flex-shrink-0 border-b border-border bg-background">
|
||||
<HoursToggle className="w-16 max-md:w-10" />
|
||||
<div className="flex flex-col flex-1 min-w-0 items-start gap-0.5 px-3 max-md:px-2 pt-2 pb-1.5 border-e border-border">
|
||||
<DayHead className="w-10 justify-center" date="w-10 h-10" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="sk-cal-hours flex flex-shrink-0">
|
||||
<HourGutter className="w-16 max-md:w-10" labelW="1.75rem" />
|
||||
<div className="sk-cal-slots relative flex-1 h-full border-e border-border" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** CalendarMonthView: weekday header and six week rows filling the pane. */
|
||||
function MonthSkeleton({ className }: { className: string }) {
|
||||
const cell = "border-e border-border last:border-e-0";
|
||||
return (
|
||||
<div aria-hidden="true" className={cn("sk-cal-view sk-cal-month flex-col flex-1 min-h-0 min-w-0 overflow-hidden", className)}>
|
||||
<div className="grid grid-cols-7 flex-shrink-0 border-b border-border">
|
||||
{repeat(7, () => (
|
||||
<div className={cn(cell, "flex justify-center py-2 max-md:py-1.5")}>
|
||||
<Line className="h-4 max-md:h-[1.03125rem]" bar="h-2" w="1.5rem" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{repeat(6, () => (
|
||||
<div className="sk-cal-month-week grid grid-cols-7 flex-shrink-0 border-b border-border">
|
||||
{repeat(7, () => (
|
||||
<div className={cn(cell, "p-1")}>
|
||||
<Line className="justify-center h-6 max-md:h-7" bar="h-2.5" w="0.875rem" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** CalendarAgendaView: the range line, then day rows with their events. */
|
||||
function AgendaSkeleton({ className }: { className: string }) {
|
||||
return (
|
||||
<div aria-hidden="true" className={cn("flex-col flex-1 min-h-0 overflow-hidden", className)}>
|
||||
<div className="flex justify-center px-4 py-2">
|
||||
<Line className="h-6" bar="h-2.5" w="7rem" />
|
||||
</div>
|
||||
{repeat(6, (i) => (
|
||||
<div className="flex items-start gap-2 px-2 sm:px-4 py-1.5 border-b border-border">
|
||||
<div className="flex items-center gap-2 w-24 sm:w-32 flex-shrink-0 py-1.5">
|
||||
<Line className="justify-center w-9 h-9 flex-shrink-0" bar="h-3.5" w="1.125rem" />
|
||||
<Bar className="h-2" w="2.5rem" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 py-0.5">
|
||||
{repeat(1 + (i % 2), (j) => (
|
||||
<div className="flex items-start gap-3 px-2" style={{ paddingBlock: "var(--density-item-py)" }}>
|
||||
<Line className="w-28 sm:w-32 h-5 flex-shrink-0" bar="h-2.5" w="5.5rem" />
|
||||
<Circle className="w-[9px] h-[9px] mt-[5.5px]" />
|
||||
<Line className="flex-1 min-w-0 h-5" bar="h-3" w={textWidth(i * 2 + j, 30, 40)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The tasks view: TaskToolbar and TaskListView. */
|
||||
function TasksSkeleton({ className, text }: ViewProps) {
|
||||
const filters = text?.taskFilters;
|
||||
return (
|
||||
<div aria-hidden="true" className={cn("flex-col flex-1 min-h-0 overflow-hidden", className)}>
|
||||
<div className="flex items-center gap-2 px-4 min-h-14 box-content border-b border-border flex-wrap">
|
||||
{/* The filter starts at "all" on every load (stores/task-store.ts). */}
|
||||
<div className="flex border border-border rounded-md overflow-hidden">
|
||||
{repeat(4, (i) => (
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center h-7 px-3 text-xs font-medium",
|
||||
i === 0 ? "bg-primary text-primary-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{filters ? filters[i] : <Bar className="h-2" w="2.5rem" />}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
{text?.newTask ? (
|
||||
<span className="flex items-center h-9 px-3 rounded-md bg-primary text-primary-foreground text-sm font-medium shadow-sm">
|
||||
<Plus className="w-4 h-4 me-1" />
|
||||
{text.newTask}
|
||||
</span>
|
||||
) : (
|
||||
<Block className="h-9" w="6.5rem" />
|
||||
)}
|
||||
</div>
|
||||
<div className="px-4 py-2 border-b border-border">
|
||||
<div className="flex items-center gap-2 h-5">
|
||||
<Plus className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
{text?.quickAdd ? (
|
||||
<span className="text-sm text-muted-foreground truncate">{text.quickAdd}</span>
|
||||
) : (
|
||||
<Bar className="h-2.5" w="10rem" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{repeat(8, (i) => (
|
||||
<div className="flex items-start gap-3 px-4 py-3">
|
||||
<span className="mt-0.5 flex-shrink-0 w-5 h-5 rounded-full border-2 border-muted-foreground/40" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<Line className="h-5" bar="h-3" w={textWidth(i, 25, 35)} />
|
||||
<Line className="h-4 mt-0.5" bar="h-2" w="6rem" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ViewProps {
|
||||
className: string;
|
||||
text?: CalendarSkeletonText;
|
||||
}
|
||||
|
||||
const VIEWS: Record<CalendarViewMode, (props: ViewProps) => ReactNode> = {
|
||||
month: MonthSkeleton,
|
||||
week: WeekSkeleton,
|
||||
day: DaySkeleton,
|
||||
agenda: AgendaSkeleton,
|
||||
tasks: TasksSkeleton,
|
||||
};
|
||||
|
||||
/**
|
||||
* The grid of a calendar view where calendar-app's renderView() puts it.
|
||||
* With `view` it draws that one (the loaded page while its calendars load,
|
||||
* with `label` as its status); without, the one `data-boot-calendar-view`
|
||||
* names.
|
||||
*/
|
||||
export function CalendarViewSkeleton({
|
||||
view,
|
||||
label,
|
||||
text,
|
||||
}: {
|
||||
view?: CalendarViewMode;
|
||||
label?: string;
|
||||
text?: CalendarSkeletonText;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden" data-calendar-view-skeleton={view ?? ""} aria-busy="true">
|
||||
{label ? <span role="status" className="sr-only">{label}</span> : null}
|
||||
{view
|
||||
? VIEWS[view]({ className: "flex", text })
|
||||
: (Object.keys(VIEWS) as CalendarViewMode[]).map((v) => {
|
||||
const View = VIEWS[v];
|
||||
return <View key={v} className={VIEW_SHOWN[v]} text={text} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The calendar page's own content: everything in the row next to the rail.
|
||||
* `embedded` is the variant drawn inside a Pro pane (useIsEmbedded()), which
|
||||
* has no rail, no Mountain View shell and fills its pane (h-full). Pro runs
|
||||
* on desktops only, so the breakpoints pick the desktop layout there too.
|
||||
*/
|
||||
export function CalendarSkeletonBody({ embedded = false, text }: { embedded?: boolean; text?: CalendarSkeletonText }) {
|
||||
return (
|
||||
<div aria-hidden="true" className="flex flex-col flex-1 min-w-0 min-h-0" data-embedded={embedded ? "" : undefined}>
|
||||
<div className="flex flex-1 min-w-0 min-h-0">
|
||||
<SidebarSkeleton text={text} />
|
||||
<div className="flex flex-col flex-1 min-w-0 min-h-0">
|
||||
<ToolbarSkeleton text={text} />
|
||||
<div className="relative flex flex-1 overflow-hidden isolate">
|
||||
<div className="flex flex-1 min-w-0">
|
||||
<CalendarViewSkeleton text={text} />
|
||||
</div>
|
||||
{/* The floating create button of a phone. */}
|
||||
<span className="md:hidden absolute bottom-4 end-4 z-40 flex items-center justify-center h-14 w-14 rounded-full shadow-lg bg-primary text-primary-foreground">
|
||||
<Plus className="h-6 w-6" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Skeleton of the calendar page. */
|
||||
export function CalendarSkeleton({ label, labels, text }: CalendarSkeletonProps) {
|
||||
return (
|
||||
<AppShellSkeleton
|
||||
surface="calendar"
|
||||
label={label}
|
||||
labels={labels}
|
||||
railBelow="md"
|
||||
active="calendar"
|
||||
mvMenu={false}
|
||||
rowClassName="lg:mv:mb-4 lg:mv:mx-4 lg:mv:rounded-[var(--mv-card-radius)] lg:mv:bg-background"
|
||||
>
|
||||
<CalendarSkeletonBody text={text} />
|
||||
</AppShellSkeleton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import { ArrowLeft, Book, BookUser, ChevronDown, Filter, Menu, Plus, Printer, Square, X } from "@/components/icons";
|
||||
import type { Density } from "@/lib/density";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Bar, Block, Circle, repeat, textWidth } from "./kit";
|
||||
import { AppShellSkeleton, type RailLabels } from "./shell";
|
||||
import { PhoneActionsSkeleton, SearchFieldSkeleton, ToolIcon } from "./toolbar-skeleton";
|
||||
|
||||
export interface ContactsSkeletonProps {
|
||||
/** common.loading */
|
||||
label?: string;
|
||||
/** Rail labels for the phone bottom bar (sidebar.*). */
|
||||
labels?: RailLabels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mountain View's content card on a desktop: the data-mv-card row of the
|
||||
* contacts, files and plugin pages, inset 16px from the canvas.
|
||||
*/
|
||||
export const MV_CARD_ROW = "lg:mv:mb-4 lg:mv:mx-4 lg:mv:rounded-[var(--mv-card-radius)] lg:mv:bg-background";
|
||||
|
||||
const SIDEBAR_ROW_PY = { paddingBlock: "var(--density-sidebar-py, 4px)" } as const;
|
||||
const CONTACT_ROW_STYLE = {
|
||||
gap: "var(--density-item-gap)",
|
||||
paddingInline: "16px",
|
||||
paddingBlock: "var(--density-item-py)",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Contact rows as ContactListItem draws them (components/contacts/
|
||||
* contact-list-item.tsx): the density's padding and gap, a 32px avatar, a
|
||||
* name line and an email line; extra compact drops the avatar and the email.
|
||||
* Shared by the page skeleton and the list's own loading state, so the one
|
||||
* hands over to the other without a jump.
|
||||
*
|
||||
* `density` comes from the settings store once the app runs; without it (the
|
||||
* server HTML) the boot script's data-boot-density picks the variant. With
|
||||
* `letters`, a letter heading leads, as when the list groups by letter.
|
||||
*/
|
||||
export function ContactRowsSkeleton({
|
||||
density,
|
||||
letters = true,
|
||||
count = 18,
|
||||
}: {
|
||||
density?: Density;
|
||||
letters?: boolean;
|
||||
count?: number;
|
||||
}) {
|
||||
const compactHide = density === undefined ? "boot-density-xc:hidden" : density === "extra-compact" ? "hidden" : undefined;
|
||||
return (
|
||||
<div aria-hidden="true" data-testid="contact-list-skeleton">
|
||||
{letters ? (
|
||||
// A block, not a flex box: the shape sits on a line of the heading's
|
||||
// own font size, so the heading is as tall as a real letter.
|
||||
<div className="px-4 py-0.5 text-[0.6875rem] font-medium">
|
||||
<span className="sk-shape inline-block align-middle w-2 h-2 rounded-sm" />
|
||||
</div>
|
||||
) : null}
|
||||
{repeat(count, (i) => (
|
||||
<div className="w-full flex items-center border-b border-border" style={CONTACT_ROW_STYLE}>
|
||||
<Circle className={cn("w-8 h-8", compactHide)} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center h-5">
|
||||
<Bar className="h-3.5" w={textWidth(i, 35, 30)} />
|
||||
</div>
|
||||
<div className={cn("flex items-center h-4", compactHide)}>
|
||||
<Bar className="h-3" w={textWidth(i + 3, 45, 30)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The categories sidebar (components/contacts/contacts-sidebar.tsx). */
|
||||
function ContactsSidebarSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-secondary">
|
||||
<div className="px-3 h-14 box-content border-b border-border flex items-center justify-between flex-shrink-0">
|
||||
<Bar className="h-3.5" w="4.5rem" />
|
||||
<span className="grid place-items-center w-8 h-8 shrink-0">
|
||||
<Plus className="w-4 h-4" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden py-1">
|
||||
{/* All contacts: always first, and selected on arrival. */}
|
||||
<div
|
||||
className="w-full flex items-center gap-2 px-3 text-sm min-h-8 pointer-coarse:min-h-11 bg-accent text-accent-foreground"
|
||||
style={SIDEBAR_ROW_PY}
|
||||
>
|
||||
<BookUser className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex items-center h-5 flex-1 min-w-0">
|
||||
<Bar className="h-3" w="2.5rem" />
|
||||
</span>
|
||||
<Bar className="h-2.5" w="1rem" />
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center gap-1 px-3 py-1 min-h-10 text-muted-foreground">
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
<Bar className="h-2.5" w="6rem" />
|
||||
</div>
|
||||
{repeat(3, (i) => (
|
||||
<div className="flex items-center gap-2 ps-5 pe-3 min-h-10 text-muted-foreground">
|
||||
<Book className="w-4 h-4 flex-shrink-0" />
|
||||
<Bar className="h-3" w={textWidth(i, 30, 30)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The list's toolbar (contact-list.tsx): menu on narrow panes, select, search, print, phone actions. */
|
||||
function ContactListToolbarSkeleton({ embedded }: { embedded: boolean }) {
|
||||
return (
|
||||
<div className="border-b border-border bg-background">
|
||||
<div className="px-3 h-14 flex items-center">
|
||||
<div className="flex items-center gap-1 w-full">
|
||||
<ToolIcon icon={Menu} className={embedded ? "hidden" : "lg:hidden"} />
|
||||
<ToolIcon icon={Square} />
|
||||
<SearchFieldSkeleton trailing={Filter} className="flex-1 min-w-0" />
|
||||
<ToolIcon icon={Printer} className={embedded ? undefined : "hidden md:grid"} />
|
||||
{embedded ? null : <PhoneActionsSkeleton className="flex md:hidden" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** ContactDetail with nothing open (contact-detail.tsx). */
|
||||
function EmptyDetailSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<BookUser className="w-16 h-16 mb-4 opacity-20" />
|
||||
<span className="flex items-center h-5">
|
||||
<Bar className="h-3.5" w="12rem" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ContactForm for a new contact (contact-form.tsx): its title bar, the photo
|
||||
* row, the first fields and the Cancel/Save footer. /contacts/new opens it at
|
||||
* once, without waiting for any data.
|
||||
*/
|
||||
function NewContactFormSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
||||
<span className="flex items-center h-7">
|
||||
<Bar className="h-4" w="7rem" />
|
||||
</span>
|
||||
<span className="grid place-items-center w-8 h-8 text-muted-foreground">
|
||||
<X className="w-4 h-4" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="px-6 py-4 max-w-3xl">
|
||||
<div className="flex items-center gap-4 pb-4">
|
||||
<Circle className="w-20 h-20" />
|
||||
<span className="flex items-center h-4 flex-1 min-w-0">
|
||||
<Bar className="h-3" w="60%" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="py-5 space-y-3">
|
||||
<span className="flex items-center gap-2 h-5">
|
||||
<Bar className="h-3.5" w="8rem" />
|
||||
</span>
|
||||
{repeat(3, () => (
|
||||
<div className="space-y-1.5">
|
||||
<Bar className="h-3" w="5rem" />
|
||||
<Block className="h-10 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border flex-shrink-0">
|
||||
<Block className="h-10" w="5.5rem" />
|
||||
<Block className="h-10" w="5rem" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The contacts page's own content: everything in the row next to the rail.
|
||||
* `embedded` is the variant drawn inside a Pro pane (useIsEmbedded()), which
|
||||
* has no rail, no Mountain View shell and fills its pane (h-full).
|
||||
*
|
||||
* Desktop: sidebar, list and the empty detail. Tablet: the sidebar is a
|
||||
* closed drawer and the list toolbar gets its Menu button. Phone: the list
|
||||
* with its create button, or, with a contact (data-boot-pane="detail") or the
|
||||
* new-contact form ("create") in the URL, the detail with its back bar.
|
||||
*/
|
||||
export function ContactsSkeletonBody({ embedded = false }: { embedded?: boolean }) {
|
||||
return (
|
||||
<div aria-hidden="true" className={cn("flex flex-col flex-1 min-w-0 min-h-0", embedded && "h-full")} data-embedded={embedded ? "" : undefined}>
|
||||
<div className="relative flex flex-1 min-h-0 overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"flex-col flex-shrink-0 border-e border-border bg-background w-[var(--boot-contacts-sidebar-w,256px)]",
|
||||
embedded ? "flex" : "hidden lg:flex",
|
||||
)}
|
||||
>
|
||||
<ContactsSidebarSkeleton />
|
||||
</div>
|
||||
<div className="relative flex flex-1 min-w-0 min-h-0">
|
||||
<div
|
||||
className={cn(
|
||||
"relative border-e border-border bg-background flex flex-col flex-shrink-0",
|
||||
embedded
|
||||
? "w-[var(--boot-contacts-list-w,384px)]"
|
||||
: "max-md:w-full max-md:boot-open:hidden md:w-[var(--boot-contacts-list-w,384px)]",
|
||||
)}
|
||||
>
|
||||
<ContactListToolbarSkeleton embedded={embedded} />
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ContactRowsSkeleton />
|
||||
</div>
|
||||
{embedded ? null : (
|
||||
<span className="md:hidden absolute bottom-4 end-4 z-20 grid place-items-center w-11 h-11 rounded-full shadow-lg bg-primary text-primary-foreground">
|
||||
<Plus className="w-5 h-5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn("flex-1 min-w-0 flex flex-col", !embedded && "max-md:[:root:not([data-boot-pane])_&]:hidden")}>
|
||||
{embedded ? null : (
|
||||
<div className="hidden max-md:block px-3 py-2 border-b border-border">
|
||||
<span className="flex items-center h-9 px-3">
|
||||
<ArrowLeft className="w-4 h-4 me-2" />
|
||||
<Bar className="h-3.5" w="7rem" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={cn("flex-1 min-h-0", !embedded && "boot-create:hidden")}>
|
||||
<EmptyDetailSkeleton />
|
||||
</div>
|
||||
{embedded ? null : (
|
||||
<div className="hidden flex-1 min-h-0 boot-create:block">
|
||||
<NewContactFormSkeleton />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Skeleton of the contacts page. */
|
||||
export function ContactsSkeleton({ label, labels }: ContactsSkeletonProps) {
|
||||
return (
|
||||
<AppShellSkeleton
|
||||
surface="contacts"
|
||||
label={label}
|
||||
labels={labels}
|
||||
railBelow="md"
|
||||
active="contacts"
|
||||
mvMenu={false}
|
||||
rowClassName={MV_CARD_ROW}
|
||||
>
|
||||
<ContactsSkeletonBody />
|
||||
</AppShellSkeleton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
import {
|
||||
ArrowUpDown,
|
||||
FolderOpen,
|
||||
HardDrive,
|
||||
LayoutGrid,
|
||||
LayoutList,
|
||||
Menu,
|
||||
MoreVertical,
|
||||
PanelRight,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Trash2,
|
||||
X,
|
||||
} from "@/components/icons";
|
||||
import { FILE_LIST_COLUMNS } from "@/components/files/file-list-layout";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Bar, Block, Circle, repeat, textWidth } from "./kit";
|
||||
import { MV_CARD_ROW } from "./contacts-skeleton";
|
||||
import { AppShellSkeleton, type RailLabels } from "./shell";
|
||||
import { PhoneActionsSkeleton, SearchFieldSkeleton, ToolIcon } from "./toolbar-skeleton";
|
||||
|
||||
export interface FilesSkeletonProps {
|
||||
/** common.loading */
|
||||
label?: string;
|
||||
/** Rail labels for the phone bottom bar (sidebar.*). */
|
||||
labels?: RailLabels;
|
||||
}
|
||||
|
||||
const SIDEBAR_ROW = { paddingBlock: "var(--density-sidebar-py, 4px)", paddingInlineStart: "12px" } as const;
|
||||
/** An unticked SelectBox (file-views.tsx). */
|
||||
const SELECT_BOX = "block w-4 h-4 shrink-0 rounded border border-muted-foreground/50 bg-background";
|
||||
/** A row's or tile's ⋮ button (IconButton icon-sm). */
|
||||
function MoreButton({ className }: { className?: string }) {
|
||||
return (
|
||||
<span className={cn("grid place-items-center w-8 h-8 shrink-0 text-muted-foreground", className)}>
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The list view (FileListView): the column header and h-10 rows on the
|
||||
* shared column grid; a phone with a touch screen gets the taller two-line
|
||||
* rows without a select column.
|
||||
*/
|
||||
function FileListSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div aria-hidden="true" data-testid="files-list-skeleton" className={cn("text-sm", className)}>
|
||||
<div className={cn(FILE_LIST_COLUMNS, "border-b border-border px-4 h-9 text-xs")}>
|
||||
<div className="flex items-center">
|
||||
<span className={SELECT_BOX} />
|
||||
</div>
|
||||
<Bar className="h-2.5" w="2.5rem" />
|
||||
<div className="hidden md:block">
|
||||
<Bar className="h-2.5" w="3.5rem" />
|
||||
</div>
|
||||
<div className="hidden sm:flex justify-end">
|
||||
<Bar className="h-2.5" w="2rem" />
|
||||
</div>
|
||||
<div className="w-[4.75rem]" />
|
||||
</div>
|
||||
{repeat(18, (i) => (
|
||||
<div className={cn(FILE_LIST_COLUMNS, "px-4 border-b border-border/60 h-10 max-md:pointer-coarse:h-auto max-md:pointer-coarse:min-h-[52px] max-md:pointer-coarse:py-1.5")}>
|
||||
<div className={"flex items-center max-md:pointer-coarse:hidden"}>
|
||||
<span className={cn(SELECT_BOX, "opacity-0 pointer-coarse:opacity-100")} />
|
||||
</div>
|
||||
<div className={"flex items-center gap-3 min-w-0 max-md:pointer-coarse:col-span-2"}>
|
||||
<Block className="w-5 h-5 rounded" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center h-5">
|
||||
<Bar className="h-3.5" w={textWidth(i, 25, 35)} />
|
||||
</div>
|
||||
<div className={"hidden items-center h-4 max-md:pointer-coarse:flex"}>
|
||||
<Bar className="h-3" w="6rem" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<Bar className="h-3" w="3.5rem" />
|
||||
</div>
|
||||
<div className="hidden sm:flex justify-end">
|
||||
<Bar className="h-3" w="2.5rem" />
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-0.5 w-[4.75rem]">
|
||||
<MoreButton />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The grid view (FileGridView): folder tiles, then file tiles with their previews. */
|
||||
function FileGridSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div aria-hidden="true" data-testid="files-grid-skeleton" className={cn("p-4 space-y-5", className)}>
|
||||
<div>
|
||||
<div className="flex items-center h-4 mb-2 px-0.5">
|
||||
<Bar className="h-2.5" w="3.5rem" />
|
||||
</div>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-2">
|
||||
{repeat(4, (i) => (
|
||||
<div className="flex items-center gap-2.5 h-11 ps-3 pe-1 rounded-lg border border-border bg-background">
|
||||
<Block className="w-5 h-5 rounded" />
|
||||
<span className="flex-1 min-w-0">
|
||||
<Bar className="h-3.5" w={textWidth(i, 40, 40)} />
|
||||
</span>
|
||||
<MoreButton className="opacity-0" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center h-4 mb-2 px-0.5">
|
||||
<Bar className="h-2.5" w="2.5rem" />
|
||||
</div>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(9.5rem,1fr))] gap-3">
|
||||
{repeat(8, (i) => (
|
||||
<div className="flex flex-col rounded-lg border border-border bg-background overflow-hidden">
|
||||
<div className="aspect-[4/3] bg-muted/50 flex items-center justify-center border-b border-border/60">
|
||||
<Block className="w-14 h-14 rounded-lg" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1 ps-2.5 pe-1 py-1.5 min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center h-5">
|
||||
<Bar className="h-3.5" w={textWidth(i, 45, 40)} />
|
||||
</div>
|
||||
<div className="flex items-center h-4">
|
||||
<Bar className="h-3" w="60%" />
|
||||
</div>
|
||||
</div>
|
||||
<MoreButton className="opacity-0 max-md:pointer-coarse:opacity-100" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The listing while the first folder listing loads, in the view the user
|
||||
* picked. Shared by the page skeleton and FileBrowser's own loading state.
|
||||
* Without `view` (the server HTML) the boot script's data-boot-files-view
|
||||
* picks it.
|
||||
*/
|
||||
export function FilesListingSkeleton({ view }: { view?: "list" | "grid" }) {
|
||||
if (view === "grid") return <FileGridSkeleton />;
|
||||
if (view === "list") return <FileListSkeleton />;
|
||||
return (
|
||||
<>
|
||||
<FileListSkeleton className="boot-files-grid:hidden" />
|
||||
<FileGridSkeleton className="hidden boot-files-grid:block" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The folder sidebar (files-sidebar.tsx) before its folders are listed: My files and Trash. */
|
||||
function FilesSidebarSkeleton() {
|
||||
const row = "w-full flex items-center gap-2 pe-3 text-sm min-h-8 pointer-coarse:min-h-11";
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-secondary">
|
||||
<div className="px-3 h-14 box-content border-b border-border flex items-center justify-between flex-shrink-0">
|
||||
<Bar className="h-3.5" w="3rem" />
|
||||
<span className="grid place-items-center w-8 h-8 shrink-0">
|
||||
<Plus className="w-4 h-4" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden py-1">
|
||||
<div className={cn(row, "bg-accent text-accent-foreground")} style={SIDEBAR_ROW}>
|
||||
<span className="w-3.5 -ms-1 shrink-0" />
|
||||
<HardDrive className="w-4 h-4 shrink-0" />
|
||||
<span className="flex items-center h-5">
|
||||
<Bar className="h-3" w="4rem" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className={cn(row, "text-muted-foreground")} style={SIDEBAR_ROW}>
|
||||
<span className="w-3.5 -ms-1 shrink-0" />
|
||||
<Trash2 className="w-4 h-4 shrink-0" />
|
||||
<span className="flex items-center h-5">
|
||||
<Bar className="h-3" w="2.5rem" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* FilesHeader (files-header.tsx): the drawer button on narrow panes, the
|
||||
* breadcrumb, then search (an icon on a phone), sort, the list/grid switch,
|
||||
* refresh (not on a phone), the details toggle and the phone actions. The
|
||||
* Pro account picker shows the breadcrumb alone.
|
||||
*/
|
||||
function FilesHeaderSkeleton({ picker }: { picker: boolean }) {
|
||||
const on = "bg-primary/10 text-primary";
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-3 h-14 box-content border-b border-border min-w-0 flex-shrink-0">
|
||||
{picker ? null : <ToolIcon icon={Menu} className="lg:hidden" />}
|
||||
<div className="flex-1 min-w-0 flex items-center text-sm">
|
||||
<span className="flex items-center px-1.5 py-1 h-7">
|
||||
<Bar className="h-3.5" w="4rem" />
|
||||
</span>
|
||||
</div>
|
||||
{picker ? null : (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<ToolIcon icon={Search} className="md:hidden" />
|
||||
<SearchFieldSkeleton className="hidden md:flex w-56" />
|
||||
<ToolIcon icon={ArrowUpDown} />
|
||||
<span className="flex items-center h-8 rounded-md border border-input p-0.5 shrink-0">
|
||||
<span className={cn("h-full w-7 grid place-items-center rounded", on, "boot-files-grid:bg-transparent boot-files-grid:text-muted-foreground")}>
|
||||
<LayoutList className="w-4 h-4" />
|
||||
</span>
|
||||
<span className="h-full w-7 grid place-items-center rounded text-muted-foreground boot-files-grid:bg-primary/10 boot-files-grid:text-primary">
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
</span>
|
||||
</span>
|
||||
<ToolIcon icon={RefreshCw} className="hidden md:grid" />
|
||||
<ToolIcon icon={PanelRight} className="boot-files-details:bg-primary/10 boot-files-details:text-primary" />
|
||||
<PhoneActionsSkeleton className="flex md:hidden" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** FileDetailsPanel with nothing selected: the open folder's summary. */
|
||||
function FileDetailsSkeleton() {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"hidden boot-files-details:flex flex-col flex-shrink-0 bg-background border-s border-border",
|
||||
"lg:w-72 max-lg:absolute max-lg:inset-y-0 max-lg:end-0 max-lg:z-30 max-lg:w-80 max-lg:max-w-[85%] max-lg:shadow-xl",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 h-11 border-b border-border flex-shrink-0">
|
||||
<Bar className="h-3.5" w="3.5rem" />
|
||||
<span className="grid place-items-center h-7 w-7 -me-1.5 text-muted-foreground">
|
||||
<X className="w-4 h-4" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="p-4 space-y-5">
|
||||
<div className="aspect-[4/3] rounded-lg bg-muted/50 flex items-center justify-center">
|
||||
<FolderOpen className="w-14 h-14 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center h-6">
|
||||
<Bar className="h-4" w="6rem" />
|
||||
</div>
|
||||
<div className="flex items-center h-5">
|
||||
<Bar className="h-3" w="8rem" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{repeat(2, (i) => (
|
||||
<div>
|
||||
<div className="flex items-center h-4">
|
||||
<Bar className="h-2.5" w="2.5rem" />
|
||||
</div>
|
||||
<div className="flex items-center h-5">
|
||||
<Bar className="h-3" w={i === 0 ? "3rem" : "1rem"} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The Pro account picker (FileBrowser accountPickerMode): a heading and account cards. */
|
||||
function AccountPickerSkeleton() {
|
||||
return (
|
||||
<div aria-hidden="true" className="p-4">
|
||||
<div className="flex items-center h-4 mb-2 px-0.5">
|
||||
<Bar className="h-2.5" w="7rem" />
|
||||
</div>
|
||||
<div className="grid gap-3 grid-cols-[repeat(auto-fill,minmax(14rem,1fr))]">
|
||||
{repeat(2, (i) => (
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg border border-border min-w-0">
|
||||
<Circle className="w-10 h-10" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center h-5">
|
||||
<Bar className="h-3.5" w={textWidth(i, 50, 30)} />
|
||||
</div>
|
||||
<div className="flex items-center h-4">
|
||||
<Bar className="h-3" w="75%" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The files page's own content: everything in the row next to the rail.
|
||||
* `embedded` is the variant drawn inside a Pro pane (useIsEmbedded()), which
|
||||
* has no rail, no Mountain View shell and fills its pane (h-full); there the
|
||||
* page opens on the account picker, without a sidebar or tools.
|
||||
*
|
||||
* Desktop: sidebar, header, listing and, when left open, the details panel.
|
||||
* Below 64rem the sidebar is a closed drawer, the header gets its Menu button,
|
||||
* the details panel lies over the listing and a create button floats.
|
||||
*/
|
||||
export function FilesSkeletonBody({ embedded = false }: { embedded?: boolean }) {
|
||||
return (
|
||||
<div aria-hidden="true" className={cn("flex flex-col flex-1 min-w-0", embedded && "h-full")} data-embedded={embedded ? "" : undefined}>
|
||||
<div className="relative flex flex-1 min-h-0 overflow-hidden">
|
||||
{embedded ? null : (
|
||||
<div className="hidden lg:block border-e border-border flex-shrink-0 w-[var(--boot-files-sidebar-w,256px)]">
|
||||
<FilesSidebarSkeleton />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<FilesHeaderSkeleton picker={embedded} />
|
||||
<div className="relative flex flex-1 min-h-0">
|
||||
<div className="relative flex-1 min-w-0 overflow-hidden">
|
||||
{embedded ? <AccountPickerSkeleton /> : <FilesListingSkeleton />}
|
||||
</div>
|
||||
{embedded ? null : (
|
||||
<>
|
||||
<div className="hidden max-lg:boot-files-details:block absolute inset-0 z-20 bg-black/30" />
|
||||
<FileDetailsSkeleton />
|
||||
<span className="lg:hidden absolute bottom-4 end-4 z-20 grid place-items-center w-11 h-11 rounded-full shadow-lg bg-primary text-primary-foreground">
|
||||
<Plus className="w-5 h-5" />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Skeleton of the files page. */
|
||||
export function FilesSkeleton({ label, labels }: FilesSkeletonProps) {
|
||||
return (
|
||||
<AppShellSkeleton
|
||||
surface="files"
|
||||
label={label}
|
||||
labels={labels}
|
||||
railBelow="md"
|
||||
active="files"
|
||||
mvMenu={false}
|
||||
rowClassName={MV_CARD_ROW}
|
||||
>
|
||||
<FilesSkeletonBody />
|
||||
</AppShellSkeleton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* The page skeleton kit.
|
||||
*
|
||||
* Plain markup with no hooks, no store reads and no next-intl, so the same
|
||||
* component renders on the server (before any JS runs), during hydration and
|
||||
* inside a loaded page's own loading state, and Lite pages without a message
|
||||
* catalog can draw it too. Variants are picked by CSS from the `data-boot-*`
|
||||
* attributes the boot script sets on <html> (lib/boot-skeleton/boot-script.ts);
|
||||
* the classes behind them live in app/boot-skeleton.css.
|
||||
*/
|
||||
import { Fragment, type CSSProperties, type ReactNode } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
export type SkeletonSurface =
|
||||
| "mail"
|
||||
| "calendar"
|
||||
| "contacts"
|
||||
| "files"
|
||||
| "apps"
|
||||
| "settings"
|
||||
| "pro"
|
||||
| "admin"
|
||||
| "admin-login"
|
||||
| "login"
|
||||
| "setup";
|
||||
|
||||
interface RegionProps {
|
||||
/** Which page this is the skeleton of; tests and the probes look for it. */
|
||||
surface: SkeletonSurface;
|
||||
/**
|
||||
* The one polite "Loading" announcement for the whole page. Leave it out
|
||||
* only where no translation is at hand (Lite before its catalog loads).
|
||||
*/
|
||||
label?: string;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The outermost element of a page skeleton: marks the page busy and carries
|
||||
* the single status message. Everything visual inside is aria-hidden.
|
||||
*/
|
||||
export function SkeletonRegion({ surface, label, className, style, children }: RegionProps) {
|
||||
return (
|
||||
<div data-boot-skeleton={surface} aria-busy="true" className={className} style={style}>
|
||||
{label ? <span role="status" className="sr-only">{label}</span> : null}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ShapeProps {
|
||||
className?: string;
|
||||
/** Width as any CSS length, e.g. "60%" or "8rem". */
|
||||
w?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
/** A placeholder for a line of text: give it a height (h-3, h-4) and a width. */
|
||||
export function Bar({ className, w, style }: ShapeProps) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn("sk-shape block h-3 rounded", className)}
|
||||
style={w ? { ...style, width: w } : style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** A placeholder for an avatar or a round button: give it a size (w-8 h-8). */
|
||||
export function Circle({ className, style }: Omit<ShapeProps, "w">) {
|
||||
return <span aria-hidden="true" className={cn("sk-shape block shrink-0 rounded-full", className)} style={style} />;
|
||||
}
|
||||
|
||||
/** A placeholder for a box: a button, an input, a card. */
|
||||
export function Block({ className, w, style }: ShapeProps) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn("sk-shape block rounded-md", className)}
|
||||
style={w ? { ...style, width: w } : style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Widths for placeholder text that look like text: varied, but the same on
|
||||
* every render (no randomness, so server and client markup agree).
|
||||
*/
|
||||
export function textWidth(i: number, min = 40, spread = 45): string {
|
||||
return `${min + ((i * 37 + 11) % spread)}%`;
|
||||
}
|
||||
|
||||
/** `count` items from `render`, keyed by index. */
|
||||
export function repeat(count: number, render: (i: number) => ReactNode): ReactNode[] {
|
||||
const out: ReactNode[] = [];
|
||||
for (let i = 0; i < count; i++) out.push(<Fragment key={i}>{render(i)}</Fragment>);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* The sign-in page as it loads: the card with logo, heading, the user name
|
||||
* and password fields, the 2FA link and the button, and the version line
|
||||
* under it (app/(main)/[locale]/login/page.tsx, the default form). Class
|
||||
* lists mirror the page's, so the form takes the card's place without a
|
||||
* jump.
|
||||
*
|
||||
* What the card holds depends on /api/config, which arrives after the first
|
||||
* paint: SSO-only, demo and dev mode, a server list, the remember-me and
|
||||
* token switches, a hidden heading or subtitle and a custom logo size give a
|
||||
* card of a different height. This draws the default form.
|
||||
*
|
||||
* Signed-in pages draw it too, for a visitor with nothing stored that could
|
||||
* sign them back in (AppShellSkeleton, data-boot-auth="out").
|
||||
*/
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Bar, Block, SkeletonRegion } from "./kit";
|
||||
|
||||
/**
|
||||
* A placeholder for one line of text in `className`'s font: exactly as tall
|
||||
* as that line (a zero-width space sets it), with a bar across `w`
|
||||
* of it. Give it a width class where it sits in a flex row.
|
||||
*/
|
||||
export function TextLine({ className, w, bar }: { className?: string; w: string; bar?: string }) {
|
||||
return (
|
||||
<span aria-hidden="true" className={cn("relative block", className)}>
|
||||
{"\u200b"}
|
||||
<Bar className={cn("absolute top-1/2 -translate-y-1/2", bar)} w={w} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const CENTERED = "inset-x-0 mx-auto";
|
||||
|
||||
/** An empty text field with its label, as the login form draws them. */
|
||||
function Field({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<TextLine className="text-sm font-medium" w={label} />
|
||||
<span aria-hidden="true" className="block h-11 rounded-xl border border-border/60 bg-muted/40" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface LoginSkeletonProps {
|
||||
/** common.loading */
|
||||
label?: string;
|
||||
/**
|
||||
* Draw the accessibility button the login page has in its corner. The
|
||||
* login page itself renders the real one (AccessibilityQuickPanelHost);
|
||||
* a signed-in page drawing the card for a signed-out visitor doesn't.
|
||||
*/
|
||||
accessibilityButton?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LoginSkeleton({ label, accessibilityButton = false, className }: LoginSkeletonProps) {
|
||||
return (
|
||||
<SkeletonRegion
|
||||
surface="login"
|
||||
label={label}
|
||||
className={cn(
|
||||
"min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-background via-muted/10 to-muted/30 relative px-4",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* The theme menu button: its icon, and the theme's name from sm up. */}
|
||||
<div aria-hidden="true" className="absolute top-5 end-5">
|
||||
<span className="flex items-center gap-2 px-3 py-2 rounded-xl border text-sm bg-background/60 border-border/50">
|
||||
<Block className="w-4 h-4 rounded" />
|
||||
<TextLine className="hidden sm:block w-11" w="100%" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{accessibilityButton ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="fixed bottom-4 end-4 z-40 inline-flex items-center gap-2 rounded-md border border-border bg-background px-3 py-2 text-sm shadow-sm"
|
||||
style={{ marginBottom: "env(safe-area-inset-bottom, 0px)" }}
|
||||
>
|
||||
<Block className="w-4 h-4 rounded" />
|
||||
<TextLine className="w-[4.625rem]" w="100%" />
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<div className="w-full max-w-[400px] mx-auto">
|
||||
<div className="rounded-2xl border border-border/60 bg-background/80 backdrop-blur-sm shadow-xl shadow-black/5 dark:shadow-black/20 overflow-hidden">
|
||||
<div className="px-8 pt-10 pb-6 text-center">
|
||||
<div aria-hidden="true" className="inline-flex items-center justify-center mb-5 w-16 h-16">
|
||||
<Block className="w-16 h-16 rounded-2xl" />
|
||||
</div>
|
||||
<TextLine className="text-2xl font-semibold tracking-tight" w="9rem" bar={cn("h-6", CENTERED)} />
|
||||
<TextLine className="text-sm mt-1.5" w="11rem" bar={CENTERED} />
|
||||
</div>
|
||||
|
||||
<div className="px-8 pb-8">
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-4">
|
||||
<Field label="3rem" />
|
||||
<Field label="4.5rem" />
|
||||
{/* "I have a 2FA code": its icon and a line of small text. */}
|
||||
<span aria-hidden="true" className="flex items-center gap-1.5 text-xs">
|
||||
<Block className="w-3.5 h-3.5 rounded" />
|
||||
<TextLine className="w-24" w="100%" />
|
||||
</span>
|
||||
</div>
|
||||
<Block className="w-full h-11 rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* The version line under the card. */}
|
||||
<div className="mt-6 flex flex-col items-center gap-2">
|
||||
<TextLine className="text-xs w-12" w="100%" />
|
||||
</div>
|
||||
</div>
|
||||
</SkeletonRegion>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The admin sign-in page (app/(main)/admin/login/page.tsx): no card, the
|
||||
* logo, the fixed English heading and line, one password field and the
|
||||
* button. The page itself renders in full on the server; this is for
|
||||
* the places that draw a page by its surface.
|
||||
*/
|
||||
export function AdminLoginSkeleton({ label, className }: LoginSkeletonProps) {
|
||||
return (
|
||||
<SkeletonRegion
|
||||
surface="admin-login"
|
||||
label={label}
|
||||
className={cn("min-h-screen flex items-center justify-center bg-background px-4", className)}
|
||||
>
|
||||
<div aria-hidden="true" className="w-full max-w-sm">
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<Block className="w-12 h-12 rounded-xl mb-4" />
|
||||
<span className="text-xl font-semibold text-foreground">Admin Dashboard</span>
|
||||
<span className="text-sm text-muted-foreground mt-1">Enter your admin password to continue</span>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-foreground mb-1.5">Password</span>
|
||||
<span className="block h-10 w-full rounded-md border border-input bg-background" />
|
||||
</div>
|
||||
<Block className="w-full h-10" />
|
||||
</div>
|
||||
</div>
|
||||
</SkeletonRegion>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,702 @@
|
||||
/**
|
||||
* The mail page as a skeleton: folder sidebar, message list and reading pane,
|
||||
* in each layout the app has (split, horizontal, focus, Mountain View, tablet,
|
||||
* phone). Class lists follow the real components (components/mail/mail-app.tsx,
|
||||
* components/layout/sidebar.tsx, components/layout/mobile-header.tsx,
|
||||
* components/email/thread-list-item.tsx, components/email/email-viewer.tsx),
|
||||
* so the page fills in over it without anything moving.
|
||||
*
|
||||
* The pieces with a `density`/`shape` prop are shared with the loaded app's
|
||||
* own loading states (the list's skeleton rows, the sidebar before the
|
||||
* folders arrive, the viewer while a message loads). Without those props they
|
||||
* pick their variant by CSS from the boot attributes, which is what the page
|
||||
* skeleton needs; with them they draw exactly the one the app asks for.
|
||||
*/
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
CalendarEvent,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Forward,
|
||||
Reply,
|
||||
ReplyAll,
|
||||
ChevronDown,
|
||||
ChevronsLeft,
|
||||
Filter,
|
||||
Mail,
|
||||
Menu,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings,
|
||||
Square,
|
||||
Users,
|
||||
} from "@/components/icons";
|
||||
import { cn } from "@/lib/cn";
|
||||
import type { Density } from "@/lib/density";
|
||||
import { Bar, Block, Circle, SkeletonRegion, repeat, textWidth } from "./kit";
|
||||
import { AppShellSkeleton, type RailLabels } from "./shell";
|
||||
|
||||
export interface MailSkeletonProps {
|
||||
/** common.loading */
|
||||
label?: string;
|
||||
/** Rail labels for the phone bottom bar (sidebar.*). */
|
||||
labels?: RailLabels;
|
||||
}
|
||||
|
||||
/** Folder row indent: the row's own start padding and the expand chevron's slot (sidebar.tsx). */
|
||||
export const FOLDER_ROW_PX_BASE = 8;
|
||||
export const FOLDER_CHEVRON_SLOT = 20;
|
||||
|
||||
/**
|
||||
* One line of placeholder text that is exactly as tall as a line of the real
|
||||
* text (h-lh: one line-height of its own font size and leading), with the bar
|
||||
* centred on it. `className` carries the font size and leading.
|
||||
*/
|
||||
function TextLine({ className, w, bar = "h-3" }: { className?: string; w: string; bar?: string }) {
|
||||
return (
|
||||
<span aria-hidden="true" className={cn("relative block h-lh", className)}>
|
||||
<Bar className={cn("absolute inset-y-0 start-0 my-auto", bar)} w={w} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Sidebar -----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Folder rows as SidebarRow draws them (sidebar.tsx): text-sm, the density's
|
||||
* block padding, a 2px leading stripe, the chevron slot and a 16px icon.
|
||||
* Under Mountain View the rows are 32px pills with 20px icons, as
|
||||
* globals.css makes the real ones.
|
||||
*/
|
||||
export function FolderRowsSkeleton({ count = 7 }: { count?: number }) {
|
||||
return (
|
||||
// On the sidebar's panel colour, which dark themes share with the shapes.
|
||||
<div aria-hidden="true" className="sk-lift-dark">
|
||||
{repeat(count, (i) => (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full flex items-center max-lg:min-h-[44px] text-sm pe-2 border-s-2 border-transparent",
|
||||
"mv:min-h-[var(--mv-nav-row-h)] mv:me-3 mv:pe-4 mv:border-s-0",
|
||||
)}
|
||||
style={{ paddingBlock: "var(--density-sidebar-py)" }}
|
||||
>
|
||||
<span className="flex-shrink-0" style={{ width: FOLDER_ROW_PX_BASE + FOLDER_CHEVRON_SLOT }} />
|
||||
<span className="flex items-center gap-2 mv:gap-[18px] min-w-0 flex-1">
|
||||
<span className="flex items-center justify-center flex-shrink-0 w-4 h-4 mv:w-5 mv:h-5">
|
||||
<Block className="w-full h-full rounded" />
|
||||
</span>
|
||||
<TextLine className="flex-1" w={textWidth(i, 35, 30)} />
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The "Folders" section header (SidebarSectionHeader, first). */
|
||||
function FolderSectionHeaderSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div aria-hidden="true" className={cn("w-full flex items-center pb-1 px-3 pt-3", className)}>
|
||||
<span className="flex flex-1 min-w-0 items-center">
|
||||
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||
<TextLine className="ms-1.5 text-sm font-semibold w-16" w="100%" />
|
||||
</span>
|
||||
<span className="-my-0.5 h-6 w-6 grid place-items-center text-muted-foreground/70">
|
||||
<Settings className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarSkeleton({ embedded }: { embedded: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col h-full border-e overflow-hidden bg-secondary border-border",
|
||||
// Dark themes draw the panel in the shapes' own colour: lift them off it.
|
||||
"sk-lift-dark",
|
||||
!embedded && "lg:mv:bg-[var(--mv-canvas)] lg:mv:border-transparent",
|
||||
)}
|
||||
>
|
||||
{/* Header: collapse button and account switcher. Hidden in a Pro pane and under Mountain View. */}
|
||||
{!embedded && (
|
||||
<div className="border-b border-border lg:mv:hidden">
|
||||
<div className="flex items-center h-14 gap-1 px-2">
|
||||
<span className="hidden lg:flex items-center justify-center h-8 w-8 flex-shrink-0 text-muted-foreground">
|
||||
<ChevronsLeft className="w-4 h-4" />
|
||||
</span>
|
||||
<span className="flex flex-1 min-w-0 items-center gap-2 px-2 py-1.5">
|
||||
<Circle className="w-9 h-9" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<TextLine className="text-sm" w="70%" />
|
||||
<TextLine className="text-xs" w="85%" bar="h-2.5" />
|
||||
</span>
|
||||
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Mountain View: the Compose button heads the navigation. */}
|
||||
{!embedded && (
|
||||
<div className="hidden lg:mv:block pt-2 pb-3 px-3">
|
||||
<span className="flex w-fit items-center gap-3 ps-4 pe-6 h-[var(--mv-compose-h,56px)] rounded-2xl bg-[var(--mv-compose-bg)] text-[var(--mv-compose-fg)]">
|
||||
<Pencil className="w-6 h-6 flex-shrink-0" />
|
||||
<TextLine className="text-sm w-14" w="100%" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{/* Mountain View lists the folders without a heading. */}
|
||||
<FolderSectionHeaderSkeleton className={embedded ? undefined : "lg:mv:hidden"} />
|
||||
<FolderRowsSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Message list ------------------------------------------------------------
|
||||
|
||||
/** How the list draws its rows (useListRowShape, plus Mountain View's own rows). */
|
||||
export type MailRowShape = "stacked" | "one-line" | "mv";
|
||||
|
||||
interface RowOptions {
|
||||
/** Live: the density setting. Left out, the boot attributes decide by CSS. */
|
||||
density?: Density;
|
||||
/** Live: whether rows show a preview (showPreview). */
|
||||
preview?: boolean;
|
||||
}
|
||||
|
||||
/** Classes for the avatar, or null where the density has none. */
|
||||
function avatarClass(o: RowOptions): string | null {
|
||||
if (o.density === undefined) return "boot-density-xc:hidden";
|
||||
return o.density === "extra-compact" ? null : "";
|
||||
}
|
||||
|
||||
/** Classes for the two preview lines, or null where the row has none. */
|
||||
function previewClass(o: RowOptions): string | null {
|
||||
if (o.density === undefined) return "boot-dense:hidden boot-no-preview:hidden";
|
||||
return o.preview === false || o.density === "compact" || o.density === "extra-compact" ? null : "";
|
||||
}
|
||||
|
||||
/*
|
||||
* The rows below are drawn dozens of times, so they use the short classes in
|
||||
* app/boot-skeleton.css (mail section) for what every row repeats: sk-mrow
|
||||
* (the row and its rule), sk-mcell (the cell: the density's padding and gap,
|
||||
* and its minimum height), sk-mav (the avatar's column) and sk-ml (a line of
|
||||
* text, with its bar).
|
||||
*/
|
||||
|
||||
/** One line of row text, as tall as the real line; `w` is the bar's width. */
|
||||
function L({ className, w }: { className?: string; w: string }) {
|
||||
return (
|
||||
<span className={cn("sk-ml", className)}>
|
||||
<span className="sk-shape" style={{ width: w }} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** A stacked row (ThreadListItem on phones and in the split list). */
|
||||
function StackedRow({ i, o }: { i: number; o: RowOptions }) {
|
||||
const avatar = avatarClass(o);
|
||||
const preview = previewClass(o);
|
||||
return (
|
||||
<div className="sk-mrow max-md:mv:border-b-transparent">
|
||||
<div className="sk-mcell sk-mcell-min">
|
||||
{avatar !== null && (
|
||||
<span className={cn("sk-mav", avatar)}>
|
||||
<span className="sk-shape block rounded-full w-10 h-10" />
|
||||
</span>
|
||||
)}
|
||||
<span className="block flex-1 min-w-0">
|
||||
<span className="flex items-center justify-between gap-2 mb-1">
|
||||
<L className="text-sm flex-1" w={textWidth(i, 30, 30)} />
|
||||
<L className="text-xs w-10" w="100%" />
|
||||
</span>
|
||||
<L className="mb-1 text-sm" w={textWidth(i + 3, 55, 35)} />
|
||||
{preview !== null && (
|
||||
<span className={cn("block", preview)}>
|
||||
{/* Mountain View's phone rows keep the preview to one line, beside a 32px star. */}
|
||||
<L className="text-sm leading-relaxed max-md:mv:h-8" w="95%" />
|
||||
<L className="text-sm leading-relaxed max-md:mv:hidden" w={textWidth(i + 5, 45, 40)} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A one-line row (the focused list and the bottom reading pane on a desktop). */
|
||||
function OneLineRow({ i, o }: { i: number; o: RowOptions }) {
|
||||
const avatar = avatarClass(o);
|
||||
return (
|
||||
<div className="sk-mrow">
|
||||
<div className="sk-mcell items-center">
|
||||
{avatar !== null && (
|
||||
<span className={cn("sk-mav", avatar)}>
|
||||
<span className="sk-shape block rounded-full w-8 h-8" />
|
||||
</span>
|
||||
)}
|
||||
<span className="flex flex-1 min-w-0 gap-3">
|
||||
<L className="w-32 lg:w-40 text-sm" w={textWidth(i, 50, 40)} />
|
||||
<L className="flex-1 text-sm" w={textWidth(i + 2, 35, 45)} />
|
||||
<L className="w-10 text-xs self-center" w="100%" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Mountain View's row on a tablet or desktop: check box and star, then one 40px line. */
|
||||
function MvRow({ i }: { i: number }) {
|
||||
return (
|
||||
<div className="sk-mrow">
|
||||
<div className="sk-mcell items-center py-0 min-h-10">
|
||||
{/* The check box and the star, 26px each (an 18px icon in p-1). */}
|
||||
<span className="flex flex-shrink-0 items-center gap-[2px]">
|
||||
<span className="block w-3.5 h-3.5 m-[6px] rounded-[3px] border-2 border-muted-foreground/70" />
|
||||
<span className="sk-shape block w-3.5 h-3.5 m-[6px] rounded-full" />
|
||||
</span>
|
||||
<span className="flex flex-1 min-w-0 gap-3">
|
||||
<L className="w-32 lg:w-40 text-sm" w={textWidth(i, 45, 40)} />
|
||||
<L className="flex-1 text-sm" w={textWidth(i + 2, 35, 45)} />
|
||||
<L className="w-10 text-xs self-center" w="100%" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Enough rows of each shape to fill a tall viewport. */
|
||||
const ROWS: Record<MailRowShape, number> = { stacked: 16, "one-line": 32, mv: 22 };
|
||||
|
||||
/**
|
||||
* Message list rows. With `shape`, the rows the loaded list is about to draw
|
||||
* (EmailList's loading state); without, every shape, shown by CSS from the
|
||||
* boot attributes (one-line where useListRowShape says so, Mountain View's
|
||||
* rows from a tablet up).
|
||||
*/
|
||||
export function MailListRowsSkeleton({
|
||||
shape,
|
||||
density,
|
||||
preview,
|
||||
className,
|
||||
}: { shape?: MailRowShape; className?: string } & RowOptions) {
|
||||
const o: RowOptions = { density, preview };
|
||||
if (shape) {
|
||||
return (
|
||||
<div aria-hidden="true" className={className} data-mail-row-skeleton={shape}>
|
||||
{repeat(ROWS[shape], (i) =>
|
||||
shape === "stacked" ? <StackedRow i={i} o={o} /> : shape === "one-line" ? <OneLineRow i={i} o={o} /> : <MvRow i={i} />,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div aria-hidden="true" className={className}>
|
||||
<div className="md:boot-focus:hidden lg:boot-horizontal:hidden md:mv:hidden">
|
||||
{repeat(ROWS.stacked, (i) => <StackedRow i={i} o={o} />)}
|
||||
</div>
|
||||
<div className="hidden md:boot-std:boot-focus:block lg:boot-std:boot-horizontal:block">
|
||||
{repeat(ROWS["one-line"], (i) => <OneLineRow i={i} o={o} />)}
|
||||
</div>
|
||||
<div className="hidden md:mv:block">{repeat(ROWS.mv, (i) => <MvRow i={i} />)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The list header row: select toggle, search field, folder menu (mail-app.tsx). */
|
||||
function ListToolbarSkeleton({ embedded }: { embedded: boolean }) {
|
||||
const icon = "flex items-center justify-center h-8 w-8 shrink-0 text-muted-foreground";
|
||||
const round = "grid place-items-center w-10 h-10 rounded-full shrink-0 text-muted-foreground";
|
||||
return (
|
||||
<div className="border-b border-border bg-background max-md:hidden">
|
||||
<div className={cn("px-3 h-14 flex items-center", !embedded && "lg:mv:hidden")}>
|
||||
<div className="flex items-center gap-1 w-full">
|
||||
<span className={icon}>
|
||||
<Square className="w-4 h-4" />
|
||||
</span>
|
||||
<span className="relative flex-1 flex items-center h-8 rounded-md border border-input bg-background ps-8 pe-16">
|
||||
<Search className="absolute start-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<TextLine className="flex-1 text-sm" w="6rem" />
|
||||
<span className="absolute end-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 text-muted-foreground">
|
||||
<span className="pointer-coarse:hidden mx-1 rounded border border-border px-1.5 font-sans text-[0.6875rem] leading-4">
|
||||
/
|
||||
</span>
|
||||
<span className="p-1">
|
||||
<Filter className="w-4 h-4" />
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className={icon}>
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Mountain View's list toolbar (MvListToolbar). */}
|
||||
{!embedded && (
|
||||
<div className="hidden lg:mv:flex items-center gap-1 h-12 ps-[5px] pe-2 shrink-0">
|
||||
<span className={round}>
|
||||
<Square className="w-[18px] h-[18px]" />
|
||||
</span>
|
||||
<span className={round}>
|
||||
<RefreshCw className="w-[18px] h-[18px]" />
|
||||
</span>
|
||||
<span className={round}>
|
||||
<MoreVertical className="w-[18px] h-[18px]" />
|
||||
</span>
|
||||
<span className="flex-1 min-w-0" />
|
||||
<TextLine className="w-24 text-xs pe-2 shrink-0" w="100%" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The phone and tablet header (MobileHeader): menu, the search pill on
|
||||
* phones or the folder name on tablets, the ⋮ menu and the account.
|
||||
*/
|
||||
function MobileHeaderSkeleton() {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"lg:hidden flex items-center justify-between h-14 shrink-0",
|
||||
"border-b border-border bg-background px-4",
|
||||
"mv:mx-2 mv:mt-2 mv:mb-1 mv:rounded-full mv:border-0 mv:bg-muted mv:px-2 mv:shadow-sm",
|
||||
// Mountain View's pill is the shapes' own colour: lift them off it.
|
||||
"sk-lift-mv",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0 flex-shrink-0">
|
||||
<span className="flex items-center justify-center h-11 w-11">
|
||||
<Menu className="h-5 w-5" />
|
||||
</span>
|
||||
<TextLine className="max-md:hidden w-28 font-semibold text-lg" w="100%" bar="h-4" />
|
||||
</span>
|
||||
<span className="md:hidden flex-1 min-w-0 h-10 flex items-center gap-2 rounded-full pe-1 mx-1 bg-muted ps-3 mv:mx-0 mv:bg-transparent mv:ps-2 text-muted-foreground sk-lift">
|
||||
<Search className="h-4 w-4 flex-shrink-0" />
|
||||
<TextLine className="flex-1 text-sm" w="7rem" />
|
||||
</span>
|
||||
<span className="flex items-center gap-1 flex-shrink-0">
|
||||
<span className="flex items-center justify-center h-11 w-11">
|
||||
<MoreVertical className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="flex items-center justify-center w-10 h-10 pointer-coarse:w-11 pointer-coarse:h-11">
|
||||
<Circle className="w-8 h-8" />
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Reading pane --------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The viewer while a message loads (EmailViewer's loading state draws this
|
||||
* same component): the toolbar, the subject block and the sender block of the
|
||||
* default reader, then the message body's tinted sheet.
|
||||
*/
|
||||
/**
|
||||
* The reader's top toolbar while a message loads. The loaded reader lifts its
|
||||
* toolbar above the message row on a tablet or desktop (EmailViewer's
|
||||
* `liftToolbar`); its loading state draws this there too, so the row below
|
||||
* doesn't move when the message arrives. `end` is a real control at the end
|
||||
* of the row (the reader's plugin panel button), where the loaded toolbar has it.
|
||||
*/
|
||||
export function MailMessageToolbarSkeleton({ end }: { end?: ReactNode } = {}) {
|
||||
const button = "h-8 w-20 rounded-md";
|
||||
return (
|
||||
<div className="bg-background flex-shrink-0 border-b border-border">
|
||||
<div className="px-2 sm:px-4 lg:px-6 flex items-center h-14">
|
||||
{/* Phones: back, then icon-over-label actions. */}
|
||||
<div className="sm:hidden flex items-center justify-between gap-0.5 w-full">
|
||||
<span className="flex items-center justify-center h-9 w-9 -ms-1 flex-shrink-0">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</span>
|
||||
{repeat(6, (i) => (
|
||||
<span className="flex flex-col items-center gap-1 px-1 flex-1">
|
||||
<Block className="w-5 h-5 rounded" />
|
||||
<Bar className="h-2" w={i === 5 ? "3.5rem" : "2.25rem"} />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="max-sm:hidden flex items-center justify-between gap-2 w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Back to the list, where the reader has the list's place (tablet, focus). */}
|
||||
<span className="hidden md:max-lg:flex md:boot-focus:flex items-center justify-center h-9 w-9 -ms-1 flex-shrink-0">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</span>
|
||||
<Block className={button} />
|
||||
<Block className={button} />
|
||||
<Block className={button} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Block className={button} />
|
||||
<Block className={button} />
|
||||
<Block className={cn(button, "max-md:hidden")} />
|
||||
<Block className="h-8 w-8 rounded-md" />
|
||||
{end}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MailMessageSkeleton({ className, toolbar = true }: { className?: string; toolbar?: boolean }) {
|
||||
return (
|
||||
<div aria-hidden="true" className={cn("flex-1 min-w-0 flex flex-col h-full bg-background overflow-hidden", className)}>
|
||||
{toolbar ? <MailMessageToolbarSkeleton /> : null}
|
||||
<div className="bg-background border-b border-border">
|
||||
<div className="px-4 lg:px-6" style={{ paddingBlock: "var(--density-header-py)" }}>
|
||||
<div className="flex items-start justify-between gap-2 lg:gap-4">
|
||||
<TextLine className="flex-1 min-w-0 text-lg lg:text-xl font-bold" w="70%" bar="h-4 lg:h-5" />
|
||||
{/* Date and size. */}
|
||||
<span className="w-28 max-sm:hidden flex flex-col items-end">
|
||||
<TextLine className="w-full text-sm" w="100%" />
|
||||
<TextLine className="w-8 text-xs" w="100%" bar="h-2.5" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-background px-4 lg:px-6 border-b border-border" style={{ paddingBlock: "var(--density-header-py)" }}>
|
||||
<div className="flex items-start" style={{ gap: "var(--density-item-gap)" }}>
|
||||
<Circle className="w-10 h-10" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<TextLine className="font-semibold" w="10rem" />
|
||||
<TextLine className="text-sm leading-6" w="13rem" />
|
||||
<TextLine className="text-sm leading-6" w="16rem" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden bg-muted/30">
|
||||
<div className="px-6 pt-4 pb-6">
|
||||
<div className="space-y-3">
|
||||
<Bar className="h-4 w-full" />
|
||||
<Bar className="h-4 w-5/6" />
|
||||
<Bar className="h-4 w-4/6" />
|
||||
<Bar className="h-4 w-full" />
|
||||
<Bar className="h-4 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Phones: the reply bar along the bottom (previous, reply, reply all, forward, next). */}
|
||||
<div className="sm:hidden flex items-center flex-shrink-0 bg-background border-t border-border pb-[calc(env(safe-area-inset-bottom)/2)] text-muted-foreground">
|
||||
{[ChevronLeft, Reply, ReplyAll, Forward, ChevronRight].map((Icon, i) => (
|
||||
<span key={i} className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]">
|
||||
<Icon className="w-5 h-5" />
|
||||
<Bar className="h-2 my-px" w="2.25rem" />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A conversation on a phone while it loads (ThreadConversationView): the
|
||||
* header with back, subject and count, then the message cards.
|
||||
*/
|
||||
export function MailConversationSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div aria-hidden="true" className={cn("flex-1 min-w-0 flex flex-col h-full bg-background overflow-hidden", className)}>
|
||||
<div
|
||||
className="flex items-center px-4 border-b border-border bg-background"
|
||||
style={{ gap: "var(--density-item-gap)", paddingBlock: "var(--density-header-py)" }}
|
||||
>
|
||||
<span className="flex items-center justify-center h-11 w-11 -ms-2 flex-shrink-0">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<TextLine className="font-semibold" w="80%" />
|
||||
<TextLine className="font-semibold" w="45%" />
|
||||
<TextLine className="text-sm" w="5rem" />
|
||||
</span>
|
||||
<span className="flex items-center gap-1 flex-shrink-0">
|
||||
{repeat(4, () => <Block className="h-11 w-11 rounded-md" />)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="space-y-3" style={{ padding: "var(--density-card-p)" }}>
|
||||
{repeat(3, (i) => (
|
||||
<div className="rounded-lg border border-border overflow-hidden bg-background shadow-sm">
|
||||
<div className="w-full flex items-start" style={{ gap: "var(--density-item-gap)", padding: "var(--density-card-p)" }}>
|
||||
<Circle className="w-10 h-10" />
|
||||
<span className="flex-1 min-w-0">
|
||||
<TextLine className="mb-0.5" w={textWidth(i, 35, 25)} />
|
||||
<TextLine className="text-sm" w="3rem" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="border-t border-border px-4 py-4 space-y-3">
|
||||
<Bar className="h-3.5 w-full" />
|
||||
<Bar className="h-3.5 w-5/6" />
|
||||
<Bar className="h-3.5" w={textWidth(i + 2, 40, 40)} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The empty reading pane: nothing open (EmailViewer without an email). */
|
||||
function EmptyReaderSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"flex-1 min-w-0 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center text-center p-8">
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-background shadow-lg flex items-center justify-center">
|
||||
<Mail className="w-10 h-10 text-muted-foreground" />
|
||||
</div>
|
||||
<TextLine className="text-xl font-semibold mb-2 w-56" w="100%" bar="h-4" />
|
||||
<TextLine className="w-72" w="100%" />
|
||||
<Block className="mt-6 h-10 w-28" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The reader's frame (EmailViewer's shell()). */
|
||||
function ReaderFrame({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full bg-background overflow-hidden relative">
|
||||
<div className="flex-1 min-h-0 flex flex-row overflow-hidden relative">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Mountain View's side panel rail (MvSidePanel): calendar and contacts. */
|
||||
function MvSidePanelSkeleton() {
|
||||
return (
|
||||
<div aria-hidden="true" className="hidden lg:mv:flex boot-no-mv-side:hidden w-14 shrink-0 flex-col items-center gap-2 pt-3">
|
||||
<span className="grid h-10 w-10 place-items-center rounded-full boot-no-calendar:hidden">
|
||||
<CalendarEvent className="h-5 w-5 text-[#1a73e8]" />
|
||||
</span>
|
||||
<span className="grid h-10 w-10 place-items-center rounded-full boot-no-contacts:hidden">
|
||||
<Users className="h-5 w-5 text-[#1a73e8]" />
|
||||
</span>
|
||||
<div className="my-1 h-px w-5 bg-border" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- The page ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The mail page's own content: everything in the row next to the rail.
|
||||
* `embedded` is the variant drawn inside a Pro pane (useIsEmbedded()), which
|
||||
* has no rail, no Mountain View shell and fills its pane (h-full).
|
||||
*/
|
||||
export function MailSkeletonBody({ embedded = false }: { embedded?: boolean }) {
|
||||
return (
|
||||
<>
|
||||
{/* Folders: in the flow on a desktop, a closed drawer below that. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="flex-shrink-0 h-full max-lg:hidden"
|
||||
style={{ width: "var(--boot-mail-sidebar-w, 256px)" }}
|
||||
>
|
||||
<SidebarSkeleton embedded={embedded} />
|
||||
</div>
|
||||
{/* AppMain. Under Mountain View the rounded card inset from the canvas. */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col flex-1 min-w-0 h-full",
|
||||
!embedded && "lg:mv:h-auto lg:mv:mb-4 lg:mv:me-4 lg:mv:rounded-[var(--mv-card-radius)] lg:mv:bg-background lg:mv:overflow-hidden",
|
||||
)}
|
||||
>
|
||||
<div className={cn("relative flex flex-1 min-h-0", !embedded && "lg:boot-horizontal:flex-col")}>
|
||||
{/* The message list. */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col bg-background min-w-0 h-full border-e border-border",
|
||||
// Phones and tablets give an open message the whole width.
|
||||
"max-md:flex-1 max-md:border-e-0 max-lg:boot-open:hidden",
|
||||
"md:flex-shrink-0 md:shadow-sm md:w-[var(--boot-mail-list-w,384px)]",
|
||||
embedded && "md:max-w-[max(220px,55%)]",
|
||||
// Focus: the list takes the width while nothing is open and makes way for the reader.
|
||||
"md:boot-focus:w-auto md:boot-focus:flex-1 md:boot-focus:border-e-0 md:boot-focus:boot-open:hidden",
|
||||
// Horizontal (desktop only): the list on top, full width.
|
||||
!embedded && [
|
||||
"lg:boot-horizontal:w-full lg:boot-horizontal:h-auto lg:boot-horizontal:border-e-0 lg:boot-horizontal:shadow-none",
|
||||
"lg:boot-horizontal:flex-1 lg:boot-horizontal:min-h-0",
|
||||
"lg:boot-horizontal:boot-open:flex-none lg:boot-horizontal:boot-open:h-[var(--boot-mail-list-h,320px)]",
|
||||
"lg:boot-horizontal:boot-open:shadow-[0_8px_12px_-6px_rgba(0,0,0,0.18)] dark:lg:boot-horizontal:boot-open:shadow-[0_8px_14px_-6px_rgba(0,0,0,0.55)]",
|
||||
],
|
||||
)}
|
||||
>
|
||||
{!embedded && <MobileHeaderSkeleton />}
|
||||
<ListToolbarSkeleton embedded={embedded} />
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div className={cn("flex-1 overflow-hidden bg-background relative pb-20", !embedded && "lg:mv:pb-0")}>
|
||||
<MailListRowsSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
{/* The round Compose button (Mountain View's sits in its navigation). */}
|
||||
<Circle
|
||||
className={cn(
|
||||
"absolute z-40 shadow-lg bottom-4 end-4 h-12 w-12 max-md:h-14 max-md:w-14",
|
||||
"max-md:mv:w-36 max-md:mv:rounded-2xl",
|
||||
!embedded && "lg:mv:hidden",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{/* The reading pane. */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col bg-background flex-1 min-w-0 h-full md:relative",
|
||||
"max-md:hidden max-md:boot-open:flex",
|
||||
"md:boot-focus:hidden md:boot-focus:boot-open:flex",
|
||||
!embedded && "lg:boot-horizontal:hidden lg:boot-horizontal:boot-open:flex lg:boot-horizontal:h-auto lg:boot-horizontal:min-h-0",
|
||||
)}
|
||||
>
|
||||
<ReaderFrame>
|
||||
<EmptyReaderSkeleton className="boot-open:hidden" />
|
||||
{/* A conversation link opens the conversation view on a phone, its newest message elsewhere. */}
|
||||
<MailMessageSkeleton className="hidden boot-open:flex max-md:boot-thread:hidden" />
|
||||
<MailConversationSkeleton className="hidden max-md:boot-thread:flex" />
|
||||
</ReaderFrame>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!embedded && <MvSidePanelSkeleton />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton of the mail page. `embedded`: the mail app inside a Pro pane, which
|
||||
* draws only its body at the pane's height.
|
||||
*/
|
||||
export function MailSkeleton({ label, labels, embedded = false }: MailSkeletonProps & { embedded?: boolean }) {
|
||||
if (embedded) {
|
||||
return (
|
||||
<SkeletonRegion surface="mail" label={label} className="flex h-full overflow-hidden bg-background">
|
||||
<MailSkeletonBody embedded />
|
||||
</SkeletonRegion>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<AppShellSkeleton
|
||||
surface="mail"
|
||||
label={label}
|
||||
labels={labels}
|
||||
railBelow="lg"
|
||||
active="mail"
|
||||
mvSearch
|
||||
bottomBarClassName="max-md:boot-open:hidden"
|
||||
>
|
||||
<MailSkeletonBody />
|
||||
</AppShellSkeleton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Bar } from "./kit";
|
||||
import { MV_CARD_ROW } from "./contacts-skeleton";
|
||||
import { AppShellSkeleton, type RailLabels } from "./shell";
|
||||
import { PhoneActionsSkeleton } from "./toolbar-skeleton";
|
||||
|
||||
export interface PluginPageSkeletonProps {
|
||||
/** common.loading */
|
||||
label?: string;
|
||||
/** Rail labels for the phone bottom bar (sidebar.*). */
|
||||
labels?: RailLabels;
|
||||
}
|
||||
|
||||
/**
|
||||
* A plugin page's own content (components/plugins/plugin-page-app.tsx).
|
||||
* Nothing about the page is known before its plugin starts, not even its
|
||||
* title, so this is the frame's column: the phone header (PhoneAppHeader,
|
||||
* below 48rem) and an empty content area, which is also what the page shows
|
||||
* while the plugins start. `embedded` is the Pro pane variant: no header.
|
||||
*/
|
||||
export function PluginPageSkeletonBody({ embedded = false }: { embedded?: boolean }) {
|
||||
return (
|
||||
<div aria-hidden="true" className={cn("flex flex-col flex-1 min-w-0", embedded && "h-full")} data-embedded={embedded ? "" : undefined}>
|
||||
{embedded ? null : (
|
||||
<div className="flex md:hidden items-center gap-2 h-14 shrink-0 border-b border-border bg-background px-4">
|
||||
<span className="flex items-center h-7 min-w-0">
|
||||
<Bar className="h-4" w="7rem" />
|
||||
</span>
|
||||
<PhoneActionsSkeleton className="flex" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col flex-1 min-h-0" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Skeleton of a plugin page (/apps/<plugin>/<page>). */
|
||||
export function PluginPageSkeleton({ label, labels }: PluginPageSkeletonProps) {
|
||||
return (
|
||||
<AppShellSkeleton surface="apps" label={label} labels={labels} railBelow="md" mvMenu={false} rowClassName={MV_CARD_ROW}>
|
||||
<PluginPageSkeletonBody />
|
||||
</AppShellSkeleton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* The Pro shell as a skeleton (app/(main)/[locale]/pro/page.tsx): the rail,
|
||||
* then one or two panes, each a tab strip (components/pro/pro-tab-bar.tsx)
|
||||
* over the active tab's page drawn embedded.
|
||||
*
|
||||
* The server can't know the stored tabs, so it draws every variant and CSS
|
||||
* picks one from what the boot script read out of `pro-tabs`
|
||||
* (data-boot-pro-*, see the "pro" section of app/boot-skeleton.css): how many
|
||||
* tabs each pane has, which is active, which pane has focus, whether the
|
||||
* split pane shows and on which side, and which page each pane shows.
|
||||
*/
|
||||
import type { CSSProperties } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import type { BootSurface } from "@/lib/boot-skeleton/surfaces";
|
||||
import { Bar, Block, SkeletonRegion, repeat, textWidth } from "./kit";
|
||||
import { SkeletonBody } from "./bodies";
|
||||
import { LoginSkeleton } from "./login-skeleton";
|
||||
import { RailSkeleton } from "./shell";
|
||||
|
||||
export interface ProSkeletonProps {
|
||||
/** common.loading */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const LINE = "1px solid rgba(128, 128, 128, 0.3)";
|
||||
const STRIP_BORDER: CSSProperties = { borderBottom: LINE };
|
||||
const DIVIDER: CSSProperties = { borderInlineStart: LINE };
|
||||
/** Most tabs a strip draws; the boot script caps its count to match. */
|
||||
const MAX_TABS = 8;
|
||||
const BODIES: BootSurface[] = ["mail", "calendar", "contacts", "files", "settings"];
|
||||
|
||||
/** ProTabBar: up to eight tabs, shown by CSS from the stored count. */
|
||||
function TabStripSkeleton() {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="relative flex items-stretch h-9 bg-secondary px-1 overflow-hidden flex-shrink-0"
|
||||
style={STRIP_BORDER}
|
||||
>
|
||||
{repeat(MAX_TABS, (i) => (
|
||||
<span
|
||||
data-sk-pro-tab=""
|
||||
className={cn(
|
||||
"relative flex items-center gap-1.5 px-3 h-9 text-sm",
|
||||
"min-w-0 flex-1 basis-0 max-w-[200px] [min-width:80px]",
|
||||
"border-e border-border first:border-s",
|
||||
)}
|
||||
>
|
||||
<Block className="w-4 h-4 flex-shrink-0 rounded-sm" />
|
||||
<span className="flex-1 min-w-0">
|
||||
<Bar className="h-3 max-w-full" w={textWidth(i, 50, 30)} />
|
||||
</span>
|
||||
{/* The active tab's bottom edge hides the strip's border; the
|
||||
focused pane's active tab gets the accent top edge. */}
|
||||
<span className="absolute inset-x-0 -bottom-px h-px" style={{ backgroundColor: "inherit" }} />
|
||||
<span
|
||||
className="absolute inset-x-0 top-0 h-0.5"
|
||||
style={{ backgroundColor: "var(--sk-pro-tab-accent, transparent)" }}
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
<span className="flex-1 min-w-[8px]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One pane: its tab strip over the active tab's page. */
|
||||
function PaneSkeleton({ pane, bodies, className }: { pane: "main" | "split"; bodies: BootSurface[]; className?: string }) {
|
||||
return (
|
||||
<div data-sk-pro-pane={pane} className={cn("flex flex-1 flex-col overflow-hidden min-w-0 min-h-0", className)}>
|
||||
<TabStripSkeleton />
|
||||
<div className="@container relative flex flex-1 flex-col overflow-hidden min-w-0 min-h-0">
|
||||
{bodies.map((surface) => (
|
||||
<div key={surface} data-sk-pro-body={surface} className="absolute inset-0 overflow-hidden">
|
||||
<div className="flex h-full min-h-0 overflow-hidden">
|
||||
<SkeletonBody surface={surface} embedded />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* A message, folder, search or compose tab: no page skeleton of its own. */}
|
||||
<div data-sk-pro-body="other" className="absolute inset-0 overflow-hidden bg-background" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Pro frame alone. AppShellSkeleton draws it in place of a standard
|
||||
* page's frame when Pro is about to take that page over; `data-pro-shell`
|
||||
* keeps a skeleton inside the real Pro shell from doing that again.
|
||||
*/
|
||||
export function ProFrameSkeleton({
|
||||
label,
|
||||
className,
|
||||
surface,
|
||||
}: ProSkeletonProps & {
|
||||
className?: string;
|
||||
/**
|
||||
* Set when this frame stands in for a standard page Pro is about to take
|
||||
* over. Its panes are then drawn empty (tab strips only): the frame doesn't
|
||||
* move on the hop to /pro, and the page's body, which would otherwise be in
|
||||
* every standard page's HTML a second and third time for the few who use
|
||||
* Pro, fills in once /pro has loaded. /pro's own HTML draws the bodies.
|
||||
*/
|
||||
surface?: BootSurface;
|
||||
}) {
|
||||
const bodies = surface ? [] : BODIES;
|
||||
return (
|
||||
<SkeletonRegion
|
||||
surface="pro"
|
||||
label={label}
|
||||
className={cn("flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]", className)}
|
||||
>
|
||||
<div data-pro-shell="" className="flex flex-1 overflow-hidden">
|
||||
<RailSkeleton search className="flex" />
|
||||
<div className="relative flex flex-row flex-1 overflow-hidden min-w-0">
|
||||
<PaneSkeleton pane="main" bodies={bodies} />
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="hidden boot-pro-split:block boot-pro-split-left:-order-1 flex-shrink-0 w-px"
|
||||
style={DIVIDER}
|
||||
/>
|
||||
<PaneSkeleton pane="split" bodies={bodies} className="hidden boot-pro-split:flex boot-pro-split-left:order-first" />
|
||||
</div>
|
||||
</div>
|
||||
</SkeletonRegion>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton of the Pro page, for its gate and the server HTML of /pro. A
|
||||
* visitor with nothing stored that could sign them back in gets the login
|
||||
* card's shape, as on every signed-in page.
|
||||
*/
|
||||
export function ProSkeleton({ label }: ProSkeletonProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="hidden boot-out:block">
|
||||
<LoginSkeleton label={label} />
|
||||
</div>
|
||||
<ProFrameSkeleton label={label} className="boot-out:hidden" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { BootSurface } from "@/lib/boot-skeleton/surfaces";
|
||||
import type { RailLabels } from "./shell";
|
||||
import { MailSkeleton } from "./mail-skeleton";
|
||||
import { CalendarSkeleton } from "./calendar-skeleton";
|
||||
import { ContactsSkeleton } from "./contacts-skeleton";
|
||||
import { FilesSkeleton } from "./files-skeleton";
|
||||
import { PluginPageSkeleton } from "./plugin-page-skeleton";
|
||||
import { SettingsSkeleton } from "./settings-skeleton";
|
||||
import { AdminSkeleton } from "./admin-skeleton";
|
||||
import { ProSkeleton } from "./pro-skeleton";
|
||||
import { AdminLoginSkeleton, LoginSkeleton } from "./login-skeleton";
|
||||
import { SetupSkeleton } from "./setup-skeleton";
|
||||
|
||||
interface RouteSkeletonProps {
|
||||
surface: BootSurface;
|
||||
label?: string;
|
||||
labels?: RailLabels;
|
||||
}
|
||||
|
||||
/**
|
||||
* The skeleton of a whole page, picked by surface. Used where the page is not
|
||||
* the one drawing it: a connector link (its destination), the protocol
|
||||
* handlers, a Lite page waiting for its message catalog, loading.tsx.
|
||||
*/
|
||||
export function RouteSkeleton({ surface, label, labels }: RouteSkeletonProps) {
|
||||
switch (surface) {
|
||||
case "mail":
|
||||
return <MailSkeleton label={label} labels={labels} />;
|
||||
case "calendar":
|
||||
return <CalendarSkeleton label={label} labels={labels} />;
|
||||
case "contacts":
|
||||
return <ContactsSkeleton label={label} labels={labels} />;
|
||||
case "files":
|
||||
return <FilesSkeleton label={label} labels={labels} />;
|
||||
case "apps":
|
||||
return <PluginPageSkeleton label={label} labels={labels} />;
|
||||
case "settings":
|
||||
return <SettingsSkeleton label={label} labels={labels} />;
|
||||
case "pro":
|
||||
return <ProSkeleton label={label} />;
|
||||
case "admin":
|
||||
return <AdminSkeleton label={label} labels={labels} />;
|
||||
case "admin-login":
|
||||
return <AdminLoginSkeleton label={label} />;
|
||||
case "login":
|
||||
return <LoginSkeleton label={label} />;
|
||||
case "setup":
|
||||
return <SetupSkeleton label={label} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import { ArrowLeft, ChevronRight, LogOut, MoreVertical, Search, Settings } from "@/components/icons";
|
||||
import { SETTINGS_NAV_GROUPS, SETTINGS_TAB_ICONS } from "@/components/settings/settings-tab-icons";
|
||||
import type { SettingsSearchTab } from "@/lib/settings-search";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Bar, Block, Circle, repeat, textWidth } from "./kit";
|
||||
import { AppShellSkeleton, type RailLabels } from "./shell";
|
||||
|
||||
export interface SettingsSkeletonProps {
|
||||
/** common.loading */
|
||||
label?: string;
|
||||
/** Rail labels for the phone bottom bar (sidebar.*). */
|
||||
labels?: RailLabels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows the nav may leave out, hidden when the last session's nav didn't have
|
||||
* them (`data-boot-settings-tabs`, app/boot-skeleton.css). Calendar and files
|
||||
* also follow the rail's hints. Recovery is an Enterprise feature, so it is
|
||||
* only drawn when the hints say it was there.
|
||||
*/
|
||||
const OPTIONAL: Partial<Record<SettingsSearchTab, string>> = {
|
||||
security: "boot-no-settings-security:hidden",
|
||||
sharing: "boot-no-settings-sharing:hidden",
|
||||
filters: "boot-no-settings-filters:hidden",
|
||||
vacation: "boot-no-settings-vacation:hidden",
|
||||
recovery: "hidden boot-settings-recovery:flex",
|
||||
calendar: "boot-no-calendar:hidden boot-no-settings-calendar:hidden",
|
||||
contacts: "boot-no-settings-contacts:hidden",
|
||||
files: "boot-no-files:hidden boot-no-settings-files:hidden",
|
||||
addons: "boot-no-settings-addons:hidden",
|
||||
};
|
||||
|
||||
/** A line of text as tall as its line box, so rows keep the real height. */
|
||||
function Line({ className, bar, w }: { className?: string; bar: string; w: string }) {
|
||||
return (
|
||||
<span className={cn("flex items-center h-[1lh]", className)}>
|
||||
<Bar className={bar} w={w} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The search field (ToolbarSearch): an h-8 input with the magnifier at its start. */
|
||||
function SearchField() {
|
||||
return (
|
||||
<span className="relative block">
|
||||
<Search className="absolute start-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<span className="block h-8 w-full rounded-md border border-input bg-background" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The Advanced switch row at the foot of the nav (AdvancedSettingsSwitch). */
|
||||
function AdvancedRow() {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2 text-sm">
|
||||
<Line bar="h-3" w="7rem" />
|
||||
<Block className="h-6 w-11 rounded-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The desktop nav (settings-app.tsx, "Desktop layout"). */
|
||||
function SettingsNav({ embedded }: { embedded: boolean }) {
|
||||
let row = 0;
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="border-e border-border bg-secondary flex flex-col shrink-0"
|
||||
style={{ width: "var(--boot-settings-nav-w, 256px)" }}
|
||||
>
|
||||
{embedded ? null : (
|
||||
// Back to mail; the Pro interface has none.
|
||||
<div className="border-b border-border boot-pro:hidden">
|
||||
<div className="flex items-center h-14 px-2">
|
||||
<span className="flex items-center w-full h-9 px-3 text-sm">
|
||||
<ArrowLeft className="w-4 h-4 me-2" />
|
||||
<Bar className="h-3" w="6rem" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-h-0 overflow-hidden py-2">
|
||||
<div className="px-3 pt-1 pb-1">
|
||||
<SearchField />
|
||||
</div>
|
||||
<div className="px-3 space-y-0.5">
|
||||
{SETTINGS_NAV_GROUPS.map(({ group, tabs }, groupIndex) => (
|
||||
<div key={group}>
|
||||
{groupIndex > 0 ? <div className="mx-1 my-2 border-t border-border" /> : null}
|
||||
{group === "mail" || group === "apps" ? (
|
||||
<div className="px-3 pt-2.5 pb-1">
|
||||
{/* An inline span in the real nav: the line box is the parent's. */}
|
||||
<Line bar="h-2" w="3rem" />
|
||||
</div>
|
||||
) : null}
|
||||
{tabs.map((tab) => {
|
||||
const Icon = SETTINGS_TAB_ICONS[tab];
|
||||
const i = row++;
|
||||
return (
|
||||
<div
|
||||
key={tab}
|
||||
data-sk-settings-tab={tab}
|
||||
className={cn(
|
||||
"flex w-full py-2 pe-3 ps-3 rounded-md text-sm items-center gap-2.5 text-muted-foreground",
|
||||
// The open page, from data-boot-settings-tab (app/boot-skeleton.css).
|
||||
"boot-settings-active:bg-accent boot-settings-active:text-accent-foreground",
|
||||
OPTIONAL[tab],
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4 shrink-0" />
|
||||
<Line className="flex-1 min-w-0" bar="h-3" w={textWidth(i, 35, 35)} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-border px-1 py-1">
|
||||
<AdvancedRow />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A settings page: the section jump row, then sections of rows (settings-section.tsx). */
|
||||
function SettingsPage({ sections }: { sections: number }) {
|
||||
return (
|
||||
<>
|
||||
{/* The section jump row; none on a page with one section, two lines on
|
||||
Accessibility (app/boot-skeleton.css). */}
|
||||
<div className="flex flex-wrap gap-1.5 mb-6 boot-settings-one-section:hidden">
|
||||
{repeat(4, (i) => (
|
||||
<Block className="h-8" w={`${4.5 + ((i * 3) % 4)}rem`} />
|
||||
))}
|
||||
<Block className="h-8 basis-full hidden boot-settings-two-rows:block" />
|
||||
</div>
|
||||
<div className="space-y-8">
|
||||
{repeat(sections, (s) => (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Line className="text-lg" bar="h-4" w={textWidth(s, 20, 20)} />
|
||||
<Line className="text-sm mt-1" bar="h-3" w={textWidth(s + 3, 45, 35)} />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{repeat(3, (r) => (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 py-3 border-b border-border last:border-0">
|
||||
<div className="flex-1 min-w-0 sm:pe-4">
|
||||
<Line className="text-sm" bar="h-3" w={textWidth(s * 3 + r, 25, 25)} />
|
||||
<Line className="text-xs mt-1" bar="h-2.5" w={textWidth(s * 3 + r + 5, 45, 40)} />
|
||||
</div>
|
||||
<Block className={r % 2 ? "h-6 w-11 rounded-full" : "h-8 w-28"} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** A phone header row: back arrow, title, then the ⋮ menu and the avatar (PhoneHeaderActions). */
|
||||
function PhoneHeader({ list }: { list: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
|
||||
<span className="grid place-items-center h-11 w-11 shrink-0">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</span>
|
||||
{list ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Settings className="w-5 h-5 text-muted-foreground" />
|
||||
<Line className="text-lg" bar="h-4" w="5rem" />
|
||||
</span>
|
||||
) : (
|
||||
<Line className="text-lg" bar="h-4" w="7rem" />
|
||||
)}
|
||||
<span className="flex items-center gap-1 flex-shrink-0 ms-auto">
|
||||
<span className="grid place-items-center h-11 w-11">
|
||||
<MoreVertical className="w-5 h-5" />
|
||||
</span>
|
||||
<span className="flex items-center justify-center w-10 h-10 pointer-coarse:w-11 pointer-coarse:h-11">
|
||||
<Circle className="w-8 h-8" />
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The phone and tablet list of pages (settings-app.tsx, "Mobile layout"). */
|
||||
function PhoneList() {
|
||||
let row = 0;
|
||||
return (
|
||||
<div aria-hidden="true" className="flex flex-col flex-1 min-h-0 boot-open:hidden">
|
||||
<PhoneHeader list />
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<div className="px-4 pt-3 pb-1">
|
||||
<SearchField />
|
||||
</div>
|
||||
<div className="py-2">
|
||||
{SETTINGS_NAV_GROUPS.map(({ group, tabs }, groupIndex) => (
|
||||
<div key={group}>
|
||||
{groupIndex > 0 ? <div className="mx-5 my-2 border-t border-border" /> : null}
|
||||
{group === "mail" || group === "apps" ? (
|
||||
<div className="px-5 pt-3 pb-1.5">
|
||||
{/* An inline span in the real nav: the line box is the parent's. */}
|
||||
<Line bar="h-2" w="3rem" />
|
||||
</div>
|
||||
) : null}
|
||||
{tabs.map((tab) => {
|
||||
const Icon = SETTINGS_TAB_ICONS[tab];
|
||||
const i = row++;
|
||||
return (
|
||||
<div
|
||||
key={tab}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between py-3.5 pe-5 ps-5 text-sm text-muted-foreground",
|
||||
OPTIONAL[tab],
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<Icon className="w-4 h-4 shrink-0" />
|
||||
<Line className="flex-1" bar="h-3" w={textWidth(i, 30, 30)} />
|
||||
</span>
|
||||
<ChevronRight className="w-4 h-4 shrink-0" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-border px-2 py-2">
|
||||
<AdvancedRow />
|
||||
</div>
|
||||
<div className="border-t border-border px-5 py-3">
|
||||
<span className="w-full flex items-center gap-3 py-2.5 text-sm text-destructive px-2">
|
||||
<LogOut className="w-4 h-4" />
|
||||
<Line bar="h-3" w="4rem" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One settings page on the phone or tablet, when the URL names one. */
|
||||
function PhonePage() {
|
||||
return (
|
||||
<div aria-hidden="true" className="hidden boot-open:flex flex-col flex-1 min-h-0">
|
||||
<PhoneHeader list={false} />
|
||||
<div className="flex-1 min-h-0 overflow-hidden p-4">
|
||||
<SettingsPage sections={2} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The settings page's own content: everything in the row next to the rail.
|
||||
* `embedded` is the variant drawn inside a Pro pane (useIsEmbedded()), which
|
||||
* has no rail, no Mountain View shell and fills its pane (h-full); it has no
|
||||
* back-to-mail box either, and the Pro shell is desktop only.
|
||||
*/
|
||||
export function SettingsSkeletonBody({ embedded = false }: { embedded?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex-1 min-w-0 min-h-0 flex", embedded ? "h-full" : "flex-col lg:flex-row")}
|
||||
data-embedded={embedded ? "" : undefined}
|
||||
>
|
||||
<div className={cn("flex-1 min-w-0 min-h-0", embedded ? "flex" : "hidden lg:flex")}>
|
||||
<SettingsNav embedded={embedded} />
|
||||
<div aria-hidden="true" className="flex-1 min-w-0 overflow-hidden">
|
||||
<div className="max-w-3xl mx-auto px-6 py-6">
|
||||
<SettingsPage sections={3} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{embedded ? null : (
|
||||
<div className="flex flex-col flex-1 min-h-0 lg:hidden">
|
||||
<PhoneList />
|
||||
<PhonePage />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Skeleton of the settings page. */
|
||||
export function SettingsSkeleton({ label, labels }: SettingsSkeletonProps) {
|
||||
return (
|
||||
<AppShellSkeleton
|
||||
surface="settings"
|
||||
label={label}
|
||||
labels={labels}
|
||||
railBelow="lg"
|
||||
active="settings"
|
||||
// Mountain View: the page sits on a card inset from the canvas.
|
||||
rowClassName="lg:mv:mb-4 lg:mv:mx-4 lg:mv:rounded-[var(--mv-card-radius)] lg:mv:bg-background"
|
||||
>
|
||||
<SettingsSkeletonBody />
|
||||
</AppShellSkeleton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* The setup wizard as it loads (app/(main)/setup/page.tsx): the header, the
|
||||
* seven-step progress bar and the card with the welcome step, which a fresh
|
||||
* install opens on. The wizard asks /api/setup/status first; a wizard
|
||||
* session that already passed the token goes on to a later step, whose card
|
||||
* is taller.
|
||||
*
|
||||
* The page passes its translated strings, so the frame is drawn for real;
|
||||
* without them (a connector link to /setup) it is drawn in bars.
|
||||
*/
|
||||
import { cn } from "@/lib/cn";
|
||||
import { SETUP_STEPS } from "@/lib/setup/steps";
|
||||
import { SkeletonRegion, repeat } from "./kit";
|
||||
import { TextLine } from "./login-skeleton";
|
||||
|
||||
export interface SetupSkeletonStrings {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
/** One label per step, in order. */
|
||||
steps: readonly string[];
|
||||
welcomeTitle: string;
|
||||
welcomeBody: string;
|
||||
tokenLabel: string;
|
||||
tokenPlaceholder: string;
|
||||
continueLabel: string;
|
||||
}
|
||||
|
||||
export interface SetupSkeletonProps {
|
||||
/** common.loading */
|
||||
label?: string;
|
||||
strings?: SetupSkeletonStrings;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const CENTERED = "inset-x-0 mx-auto";
|
||||
|
||||
export function SetupSkeleton({ label, strings, className }: SetupSkeletonProps) {
|
||||
return (
|
||||
<SkeletonRegion
|
||||
surface="setup"
|
||||
label={label}
|
||||
className={cn("min-h-screen flex items-center justify-center px-4 py-8", className)}
|
||||
>
|
||||
<div aria-hidden="true" className="w-full max-w-2xl">
|
||||
<div className="text-center mb-6">
|
||||
{strings ? (
|
||||
<>
|
||||
<span className="block text-2xl font-semibold">{strings.title}</span>
|
||||
<span className="block text-sm text-muted-foreground mt-1">{strings.subtitle}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TextLine className="text-2xl font-semibold" w="16rem" bar={cn("h-6", CENTERED)} />
|
||||
<TextLine className="text-sm mt-1" w="20rem" bar={CENTERED} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
{repeat(SETUP_STEPS.length, (i) => (
|
||||
<div className="flex-1">
|
||||
<div className={cn("h-1 rounded-full", i === 0 ? "bg-primary/60" : "bg-muted")} />
|
||||
{strings?.steps[i] ? (
|
||||
<div className={cn("mt-1.5 text-center", i === 0 ? "text-foreground font-medium" : "text-muted-foreground")}>
|
||||
{strings.steps[i]}
|
||||
</div>
|
||||
) : (
|
||||
<TextLine className="mt-1.5" w="60%" bar={CENTERED} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-card border border-border rounded-lg p-6 shadow-sm">
|
||||
<div className="space-y-4">
|
||||
{strings ? (
|
||||
<>
|
||||
<span className="block text-lg font-semibold">{strings.welcomeTitle}</span>
|
||||
<span className="block text-sm text-muted-foreground">{strings.welcomeBody}</span>
|
||||
<div>
|
||||
<span className="block text-sm font-medium mb-1">{strings.tokenLabel}</span>
|
||||
<span className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-muted-foreground">
|
||||
{strings.tokenPlaceholder}
|
||||
</span>
|
||||
</div>
|
||||
{/* Disabled until a token is typed in, as on the page. */}
|
||||
<span className="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium opacity-50">
|
||||
{strings.continueLabel}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TextLine className="text-lg font-semibold" w="6rem" />
|
||||
<TextLine className="text-sm" w="85%" />
|
||||
<div>
|
||||
<TextLine className="text-sm font-medium mb-1" w="6rem" />
|
||||
<span className="block h-10 w-full rounded-md border border-input bg-background" />
|
||||
</div>
|
||||
<TextLine className="text-sm w-24" w="100%" bar="h-9 rounded-md" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SkeletonRegion>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* The app frame every signed-in page shares, as a skeleton: the page root, the
|
||||
* Mountain View top bar, the vertical rail and the phone bottom bar. Class
|
||||
* lists mirror the real ones (components/layout/navigation-rail.tsx,
|
||||
* components/layout/mv-top-bar.tsx and the roots of mail-app, calendar-app,
|
||||
* contacts-app, files-app, settings-app), so the frame doesn't move when the
|
||||
* page takes over.
|
||||
*/
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
Accessibility,
|
||||
Bell,
|
||||
CalendarEvent,
|
||||
Folder,
|
||||
Grid3x3,
|
||||
Keyboard,
|
||||
Mail,
|
||||
Menu,
|
||||
Plus,
|
||||
Search,
|
||||
Settings,
|
||||
Shield,
|
||||
Users,
|
||||
} from "@/components/icons";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Bar, Circle, SkeletonRegion, type SkeletonSurface } from "./kit";
|
||||
import { LoginSkeleton } from "./login-skeleton";
|
||||
import { ProFrameSkeleton } from "./pro-skeleton";
|
||||
import { PRO_SURFACES, type BootSurface } from "@/lib/boot-skeleton/surfaces";
|
||||
|
||||
export type RailApp = "mail" | "calendar" | "contacts" | "files" | "settings";
|
||||
|
||||
/** A rail item drawn as current: an app, or the admin panel (NavigationRail adminActive). */
|
||||
export type RailActive = RailApp | "admin";
|
||||
|
||||
/** Translated rail labels for the phone bottom bar; bars are drawn where one is missing. */
|
||||
export type RailLabels = Partial<Record<RailApp | "admin", string>>;
|
||||
|
||||
const RAIL_BORDER = { borderRight: "1px solid rgba(128, 128, 128, 0.3)" } as const;
|
||||
const DIVIDER = { borderColor: "rgba(128, 128, 128, 0.3)" } as const;
|
||||
|
||||
/** Hidden when the last session's rail didn't show the app (bulwark-boot-hints). */
|
||||
const APP_HIDE: Record<"calendar" | "contacts" | "files", string> = {
|
||||
calendar: "boot-no-calendar:hidden",
|
||||
contacts: "boot-no-contacts:hidden",
|
||||
files: "boot-no-files:hidden",
|
||||
};
|
||||
|
||||
const APPS = [
|
||||
{ id: "mail", icon: Mail },
|
||||
{ id: "calendar", icon: CalendarEvent },
|
||||
{ id: "contacts", icon: Users },
|
||||
{ id: "files", icon: Folder },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* The 56px vertical rail (NavigationRail collapsed). `className` decides at
|
||||
* which widths it shows. `search` draws the global search button the Pro
|
||||
* shell's rail starts with. Items carry `data-sk-rail` so the Pro frame can
|
||||
* mark the focused tab's app from CSS.
|
||||
*/
|
||||
export function RailSkeleton({
|
||||
active,
|
||||
search = false,
|
||||
className,
|
||||
}: {
|
||||
active?: RailActive;
|
||||
search?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const item = (on: boolean) =>
|
||||
cn(
|
||||
"relative flex items-center justify-center w-10 h-10 rounded-md max-lg:min-h-[44px]",
|
||||
on ? "bg-primary/10 text-primary" : "text-muted-foreground",
|
||||
);
|
||||
const footerItem = "flex items-center justify-center w-10 h-10 rounded-md";
|
||||
return (
|
||||
<div aria-hidden="true" className={cn("w-14 bg-secondary flex-col flex-shrink-0", className)} style={RAIL_BORDER}>
|
||||
<div className="flex flex-col h-full items-center">
|
||||
<div className="flex flex-col flex-1 min-h-0 items-center gap-1 py-3 px-1">
|
||||
{search ? (
|
||||
<span className={item(false)}>
|
||||
<Search className="w-[18px] h-[18px] flex-shrink-0" />
|
||||
</span>
|
||||
) : null}
|
||||
{APPS.map(({ id, icon: Icon }) => (
|
||||
<span key={id} data-sk-rail={id} className={cn(item(active === id), id !== "mail" && APP_HIDE[id])}>
|
||||
<Icon className={cn("w-[18px] h-[18px] flex-shrink-0", active === id && "text-primary")} />
|
||||
</span>
|
||||
))}
|
||||
<span className={cn(item(false), "hidden boot-rail-add-app:flex")}>
|
||||
<Plus className="w-[18px] h-[18px] flex-shrink-0" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1 text-muted-foreground">
|
||||
<span className={cn(footerItem, active === "admin" ? "bg-primary/10 text-primary" : "hidden boot-rail-admin:flex")}>
|
||||
<Shield className="w-[18px] h-[18px]" />
|
||||
</span>
|
||||
<span className={footerItem}>
|
||||
<Bell className="w-[18px] h-[18px]" />
|
||||
</span>
|
||||
<span className={cn(footerItem, "hidden boot-rail-a11y:flex")}>
|
||||
<Accessibility className="w-[18px] h-[18px]" />
|
||||
</span>
|
||||
<span data-sk-rail="settings" className={cn(footerItem, active === "settings" && "bg-primary/10 text-primary")}>
|
||||
<Settings className="w-[18px] h-[18px]" />
|
||||
</span>
|
||||
<div className="w-8 border-t" style={DIVIDER} />
|
||||
<span className={footerItem}>
|
||||
<Keyboard className="w-[18px] h-[18px]" />
|
||||
</span>
|
||||
{/* The admin panel's rail has no mailbox: no quota, no account. */}
|
||||
{active === "admin" ? null : (
|
||||
<>
|
||||
<Circle className="hidden boot-rail-quota:block w-8 h-8" />
|
||||
<span className="flex items-center justify-center w-10 h-10 pointer-coarse:w-11 pointer-coarse:h-11">
|
||||
<Circle className="w-8 h-8" />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The phone/tablet bottom bar (NavigationRail horizontal). `className` decides
|
||||
* at which widths it shows.
|
||||
*/
|
||||
export function BottomBarSkeleton({
|
||||
active,
|
||||
labels,
|
||||
className,
|
||||
}: {
|
||||
active?: RailActive;
|
||||
labels?: RailLabels;
|
||||
className?: string;
|
||||
}) {
|
||||
// The admin panel's own bar has its entry before Settings.
|
||||
const items: { id: RailActive; icon: typeof Mail }[] = [
|
||||
...APPS,
|
||||
...(active === "admin" ? [{ id: "admin" as const, icon: Shield }] : []),
|
||||
{ id: "settings", icon: Settings },
|
||||
];
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"items-center bg-background border-t border-border shrink-0 overflow-hidden pb-[calc(env(safe-area-inset-bottom)/2)]",
|
||||
"mv:min-h-16 mv:border-t-0 mv:bg-secondary mv:shadow-[0_-1px_3px_rgb(0_0_0/0.08)]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{items.map(({ id, icon: Icon }) => (
|
||||
<span
|
||||
key={id}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
|
||||
active === id ? "text-primary" : "text-muted-foreground",
|
||||
(id === "calendar" || id === "contacts" || id === "files") && APP_HIDE[id],
|
||||
)}
|
||||
>
|
||||
<span className={cn("relative", active === id && "mv:min-w-14 mv:rounded-full mv:bg-primary/15 mv:px-4 mv:py-[3px]")}>
|
||||
<Icon className="w-5 h-5" />
|
||||
{active === id ? (
|
||||
<span className="mv:hidden absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
|
||||
) : null}
|
||||
</span>
|
||||
{labels?.[id] ? (
|
||||
<span className="text-[0.625rem] font-medium leading-tight truncate max-w-full">{labels[id]}</span>
|
||||
) : (
|
||||
<Bar className="h-2 my-px" w="2rem" />
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Mountain View's 64px top bar (components/layout/mv-top-bar.tsx), desktop only. */
|
||||
export function MvTopBarSkeleton({
|
||||
search = true,
|
||||
menu = true,
|
||||
className,
|
||||
}: {
|
||||
search?: boolean;
|
||||
/** The sidebar toggle before the brand (MvTopBar showMenuButton). */
|
||||
menu?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const round = "grid place-items-center w-12 h-12 rounded-full text-muted-foreground";
|
||||
return (
|
||||
<div aria-hidden="true" className={cn("items-center h-16 gap-2 ps-1 pe-4 shrink-0 bg-[var(--mv-canvas)]", className)}>
|
||||
<div className={cn("flex items-center gap-1 shrink-0", !menu && "ps-3")}>
|
||||
{menu ? (
|
||||
<span className={round}>
|
||||
<Menu className="w-5 h-5" />
|
||||
</span>
|
||||
) : null}
|
||||
<span className="flex items-center gap-2 pe-4 min-w-0">
|
||||
<Bar className="h-5" w="7rem" />
|
||||
</span>
|
||||
</div>
|
||||
{search ? (
|
||||
<div className="relative flex items-center flex-1 min-w-0 max-w-[720px]">
|
||||
<span className="block w-full h-[var(--mv-search-h,48px)] rounded-[var(--mv-search-radius,8px)] bg-[var(--mv-search-bg)]" />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex-1 min-w-0" />
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<span className={round}>
|
||||
<Bell className="w-5 h-5" />
|
||||
</span>
|
||||
<span className={round}>
|
||||
<Keyboard className="w-5 h-5" />
|
||||
</span>
|
||||
<span className={cn(round, "hidden boot-rail-admin:grid")}>
|
||||
<Shield className="w-5 h-5" />
|
||||
</span>
|
||||
<span className={round}>
|
||||
<Settings className="w-5 h-5" />
|
||||
</span>
|
||||
<span className={round}>
|
||||
<Grid3x3 className="w-5 h-5" />
|
||||
</span>
|
||||
{/* The account switcher (AccountSwitcher variant="header"). */}
|
||||
<span className="flex items-center justify-center w-10 h-10 pointer-coarse:w-11 pointer-coarse:h-11 ms-1">
|
||||
<Circle className="w-8 h-8" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AppShellSkeletonProps {
|
||||
surface: SkeletonSurface;
|
||||
/** The page's own "Loading" string (common.loading). */
|
||||
label?: string;
|
||||
/** Rail item drawn as current. */
|
||||
active?: RailApp;
|
||||
/**
|
||||
* Below which width the vertical rail gives way to the bottom bar: mail and
|
||||
* settings switch at 64rem, calendar, contacts, files and plugin pages at 48rem.
|
||||
*/
|
||||
railBelow: "md" | "lg";
|
||||
labels?: RailLabels;
|
||||
/** Whether the Mountain View top bar holds the search field (mail does). */
|
||||
mvSearch?: boolean;
|
||||
/** Whether the Mountain View top bar starts with the sidebar toggle (MvTopBar showMenuButton). */
|
||||
mvMenu?: boolean;
|
||||
/** Extra classes for the row under the top bar (Mountain View card margins etc.). */
|
||||
rowClassName?: string;
|
||||
/** Extra classes for the phone/tablet bottom bar, e.g. to hide it while a message is open. */
|
||||
bottomBarClassName?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Page root plus frame. `children` go in the row next to the rail: the page's
|
||||
* own sidebar, list and panes.
|
||||
*
|
||||
* When nothing is stored that could sign the visitor back in, the boot script
|
||||
* marks the page `data-boot-auth="out"` and the login card is drawn instead:
|
||||
* that is where the app is about to send them.
|
||||
*
|
||||
* When the Pro interface is about to take the page over (`data-boot-pro="on"`:
|
||||
* the setting is on and the screen is a desktop), the Pro frame is drawn
|
||||
* instead, so the hop to /pro moves nothing. The login card wins over it: a
|
||||
* signed-out visitor ends up on the login page either way.
|
||||
*/
|
||||
export function AppShellSkeleton({
|
||||
surface,
|
||||
label,
|
||||
active,
|
||||
railBelow,
|
||||
labels,
|
||||
mvSearch = false,
|
||||
mvMenu = true,
|
||||
rowClassName,
|
||||
bottomBarClassName,
|
||||
children,
|
||||
}: AppShellSkeletonProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="hidden boot-out:block">
|
||||
<LoginSkeleton label={label} accessibilityButton />
|
||||
</div>
|
||||
{(PRO_SURFACES as readonly string[]).includes(surface) ? (
|
||||
<div className="hidden boot-pro-frame:block">
|
||||
<ProFrameSkeleton label={label} surface={surface as BootSurface} />
|
||||
</div>
|
||||
) : null}
|
||||
<SkeletonRegion
|
||||
surface={surface}
|
||||
label={label}
|
||||
className="flex flex-col overflow-hidden pt-[env(safe-area-inset-top)] h-dvh bg-background lg:mv:bg-[var(--mv-canvas)] boot-out:hidden boot-pro-frame:hidden"
|
||||
>
|
||||
<MvTopBarSkeleton search={mvSearch} menu={mvMenu} className="hidden lg:mv:flex" />
|
||||
<div className={cn("flex flex-1 min-h-0 overflow-hidden", rowClassName)}>
|
||||
<RailSkeleton
|
||||
active={active}
|
||||
className={railBelow === "lg" ? "hidden lg:flex lg:mv:hidden" : "hidden md:flex lg:mv:hidden"}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
<BottomBarSkeleton
|
||||
active={active}
|
||||
labels={labels}
|
||||
className={cn(railBelow === "lg" ? "flex lg:hidden" : "flex md:hidden", bottomBarClassName)}
|
||||
/>
|
||||
</SkeletonRegion>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Header-row pieces shared by the contacts, files and plugin page skeletons,
|
||||
* drawn at the sizes of the real controls in components/ui/toolbar.tsx and
|
||||
* components/layout/phone-header-actions.tsx. Plain spans: nothing here is
|
||||
* focusable, and the caller's region is aria-hidden.
|
||||
*/
|
||||
import type { ComponentType } from "react";
|
||||
import { MoreVertical, Search } from "@/components/icons";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Bar, Circle } from "./kit";
|
||||
|
||||
type Icon = ComponentType<{ className?: string }>;
|
||||
|
||||
/** A 32px toolbar icon button (ToolbarButton, IconButton icon-sm). */
|
||||
export function ToolIcon({ icon: Icon, className }: { icon: Icon; className?: string }) {
|
||||
return (
|
||||
<span className={cn("grid place-items-center w-8 h-8 shrink-0 rounded-md text-muted-foreground", className)}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The 32px search field (ToolbarSearch): the magnifier, a placeholder bar,
|
||||
* the "/" key cap where there is a keyboard, and an optional trailing icon.
|
||||
*/
|
||||
export function SearchFieldSkeleton({ trailing, className }: { trailing?: Icon; className?: string }) {
|
||||
const Trailing = trailing;
|
||||
return (
|
||||
<span className={cn("relative flex items-center h-8 rounded-md border border-input bg-background ps-8", className)}>
|
||||
<Search className="absolute start-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Bar className="h-3" w="6rem" />
|
||||
<span className="absolute end-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5">
|
||||
<span className="mx-1 rounded border border-border px-1.5 font-sans text-[0.6875rem] leading-4 text-muted-foreground pointer-coarse:hidden">
|
||||
/
|
||||
</span>
|
||||
{Trailing ? (
|
||||
<span className="p-1 rounded-md text-muted-foreground">
|
||||
<Trailing className="w-4 h-4" />
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The end of a phone header (PhoneHeaderActions): the overflow menu and the
|
||||
* account avatar. `className` decides at which widths it shows.
|
||||
*/
|
||||
export function PhoneActionsSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<span className={cn("items-center gap-1 flex-shrink-0 ms-auto", className)}>
|
||||
<span className="grid place-items-center w-11 h-11 rounded-md text-muted-foreground">
|
||||
<MoreVertical className="w-5 h-5" />
|
||||
</span>
|
||||
<span className="flex items-center justify-center w-10 h-10 pointer-coarse:w-11 pointer-coarse:h-11">
|
||||
<Circle className="w-8 h-8" />
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { RailLabels } from "./shell";
|
||||
|
||||
/**
|
||||
* The few strings a page skeleton shows: the status message and the phone
|
||||
* bottom bar's labels. For the gates of client pages, which have the message
|
||||
* catalog; the skeleton components themselves stay free of next-intl.
|
||||
*/
|
||||
export function useSkeletonLabels(): { label: string; labels: RailLabels } {
|
||||
const t = useTranslations();
|
||||
return {
|
||||
label: t("common.loading"),
|
||||
labels: {
|
||||
mail: t("sidebar.mail"),
|
||||
calendar: t("sidebar.calendar"),
|
||||
contacts: t("sidebar.contacts"),
|
||||
files: t("sidebar.files"),
|
||||
settings: t("sidebar.settings"),
|
||||
admin: t("sidebar.admin"),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Calendar } from '@/lib/jmap/types';
|
||||
import { useCalendarStore } from '@/stores/calendar-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
// The calendar page before its data is there: the boot skeleton while the
|
||||
// session is restored, then the view's grid in the same shapes while the
|
||||
// calendars load (components/boot-skeleton/calendar-skeleton.tsx).
|
||||
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useIsMobile: () => false,
|
||||
useIsDesktop: () => true,
|
||||
useIsTablet: () => false,
|
||||
useMediaQuery: () => false,
|
||||
}));
|
||||
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
|
||||
vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null }));
|
||||
vi.mock('@/components/layout/inline-app-view', () => ({ InlineAppView: () => null }));
|
||||
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
|
||||
vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null }));
|
||||
vi.mock('@/hooks/use-refresh-gesture', () => ({ useRefreshGesture: () => ({ indicator: null }) }));
|
||||
vi.mock('@/hooks/use-calendar-push-refresh', () => ({ useCalendarPushRefresh: () => {} }));
|
||||
vi.mock('@/hooks/use-account-principal-emails', () => ({ useAccountPrincipalEmails: () => [] }));
|
||||
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ demoMode: false }) }));
|
||||
|
||||
import { CalendarApp } from '../calendar-app';
|
||||
|
||||
vi.setConfig({ testTimeout: 30000 });
|
||||
|
||||
const RIGHTS = {
|
||||
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
|
||||
mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true,
|
||||
};
|
||||
const calendars = [{
|
||||
id: 'cal', name: 'Personal', color: '#123456', isDefault: true, sortOrder: 0, description: null,
|
||||
isSubscribed: true, isVisible: true, includeInAvailability: 'all', defaultAlertsWithTime: null,
|
||||
defaultAlertsWithoutTime: null, timeZone: null, shareWith: null, myRights: RIGHTS,
|
||||
}] as unknown as Calendar[];
|
||||
|
||||
const client = {
|
||||
getAccountId: () => 'acc',
|
||||
getMaxParticipantsPerEvent: () => null,
|
||||
supportsCalendars: () => true,
|
||||
queryCalendarEvents: vi.fn().mockResolvedValue([]),
|
||||
getCalendarEvent: vi.fn().mockResolvedValue(null),
|
||||
getCalendarTasks: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
const noop = () => vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ enableCalendarTasks: true, proInterface: false } as never);
|
||||
useCalendarStore.setState({
|
||||
calendars: [], events: [], supportsCalendar: false, selectedCalendarIds: [], viewMode: 'week',
|
||||
selectedDate: new Date(2026, 10, 4), isLoading: false, isLoadingEvents: false, error: null,
|
||||
fetchCalendars: noop(), fetchEvents: noop(), refreshAllSubscriptions: noop(),
|
||||
} as never);
|
||||
});
|
||||
|
||||
const bootSkeleton = (root: HTMLElement) => root.querySelector('[data-boot-skeleton="calendar"]');
|
||||
const viewSkeleton = (root: HTMLElement) => root.querySelector('[data-calendar-view-skeleton]');
|
||||
|
||||
describe('CalendarApp before its data is there', () => {
|
||||
it('draws the calendar skeleton while the sign-in is restored', () => {
|
||||
// Still checking: no redirect to the login page yet.
|
||||
useAuthStore.setState({ isAuthenticated: false, client: null, isLoading: true, checkAuth: noop() } as never);
|
||||
const { container } = render(<CalendarApp />);
|
||||
const skeleton = bootSkeleton(container);
|
||||
expect(skeleton).not.toBeNull();
|
||||
expect(skeleton?.getAttribute('aria-busy')).toBe('true');
|
||||
expect(skeleton?.querySelector('[role="status"]')?.textContent).toBe('common.loading');
|
||||
// The toolbar's words, from the calendar's own messages.
|
||||
expect(skeleton?.textContent).toContain('views.today');
|
||||
});
|
||||
|
||||
it('draws the skeleton while the session has not said the account has calendars', () => {
|
||||
useAuthStore.setState({ isAuthenticated: true, client: client as never, isLoading: false, activeAccountId: 'acc' } as never);
|
||||
const { container } = render(<CalendarApp />);
|
||||
expect(bootSkeleton(container)).not.toBeNull();
|
||||
expect(container.querySelector('main')).toBeNull();
|
||||
});
|
||||
|
||||
it('draws the grid of the view in the skeleton shapes while the calendars load', () => {
|
||||
useAuthStore.setState({ isAuthenticated: true, client: client as never, isLoading: false, activeAccountId: 'acc' } as never);
|
||||
useCalendarStore.setState({ supportsCalendar: true, isLoading: true, viewMode: 'month' } as never);
|
||||
const { container } = render(<CalendarApp />);
|
||||
// The real chrome is up; only the grid waits.
|
||||
expect(bootSkeleton(container)).toBeNull();
|
||||
expect(container.querySelector('main')).not.toBeNull();
|
||||
const grid = viewSkeleton(container);
|
||||
expect(grid?.getAttribute('data-calendar-view-skeleton')).toBe('month');
|
||||
expect(grid?.querySelector('[role="status"]')?.textContent).toBe('status.loading_calendars');
|
||||
expect(container.querySelector('.animate-spin')).toBeNull();
|
||||
});
|
||||
|
||||
it('draws the real grid, without a spinner over it, while the first events load', () => {
|
||||
useAuthStore.setState({ isAuthenticated: true, client: client as never, isLoading: false, activeAccountId: 'acc' } as never);
|
||||
useCalendarStore.setState({
|
||||
supportsCalendar: true, calendars, selectedCalendarIds: ['cal'], isLoadingEvents: true,
|
||||
} as never);
|
||||
const { container } = render(<CalendarApp />);
|
||||
expect(viewSkeleton(container)).toBeNull();
|
||||
expect(container.querySelector('[role="grid"]')).not.toBeNull();
|
||||
expect(container.querySelector('.animate-spin')).toBeNull();
|
||||
expect(Array.from(container.querySelectorAll('[role="status"]')).some((s) => s.textContent === 'status.loading_events')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { EventDetailPopover } from '../event-detail-popover';
|
||||
import { EventModal } from '../event-modal';
|
||||
import { EventPrintSheet } from '../calendar-print';
|
||||
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
|
||||
|
||||
// Other JMAP clients, and ICS files with STYLED-DESCRIPTION, store an HTML
|
||||
// description with descriptionContentType text/html. The calendar showed its
|
||||
// markup as text; it now shows the text.
|
||||
|
||||
vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null }));
|
||||
|
||||
const calendar = {
|
||||
id: 'cal-1', name: 'Work', isDefault: true,
|
||||
myRights: { mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayDelete: true },
|
||||
} as unknown as Calendar;
|
||||
|
||||
const HTML = '<p>Agenda:<br>Budget & plans</p><p>Bring <b>notes</b></p>';
|
||||
|
||||
function htmlEvent(): CalendarEvent {
|
||||
return {
|
||||
id: 'evt-1', uid: 'u1', '@type': 'Event', calendarIds: { 'cal-1': true }, title: 'Review',
|
||||
description: HTML, descriptionContentType: 'text/html',
|
||||
start: '2026-11-20T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin', showWithoutTime: false,
|
||||
participants: null, alerts: null, useDefaultAlerts: false, recurrenceRules: null,
|
||||
} as unknown as CalendarEvent;
|
||||
}
|
||||
|
||||
const TEXT = 'Agenda:\nBudget & plans\n\nBring notes';
|
||||
|
||||
describe('HTML event descriptions', () => {
|
||||
it('shows the text in the detail popover', () => {
|
||||
render(
|
||||
<EventDetailPopover event={htmlEvent()} calendar={calendar} anchorRect={new DOMRect(100, 100, 50, 20)}
|
||||
onEdit={vi.fn()} onDelete={vi.fn()} onDuplicate={vi.fn()} onClose={vi.fn()} onSaveNote={vi.fn()} />,
|
||||
);
|
||||
// The popover renders in a portal.
|
||||
const text = document.body.textContent;
|
||||
expect(text).not.toContain('<p>');
|
||||
expect(text).not.toContain('&');
|
||||
expect(text).toContain('Budget & plans');
|
||||
});
|
||||
|
||||
it('shows the text in the editor, read and edit', () => {
|
||||
const { container } = render(
|
||||
<EventModal event={htmlEvent()} calendars={[calendar]} onSave={vi.fn()} onClose={vi.fn()} currentUserEmails={['me@example.com']} />,
|
||||
);
|
||||
expect(container.textContent).not.toContain('<p>');
|
||||
expect(container.textContent).toContain('Budget & plans');
|
||||
fireEvent.click(screen.getByText('events.edit'));
|
||||
expect((screen.getByPlaceholderText('form.description') as HTMLTextAreaElement).value).toBe(TEXT);
|
||||
});
|
||||
|
||||
it('prints the text', () => {
|
||||
const { container } = render(
|
||||
<EventPrintSheet event={htmlEvent()} calendar={calendar} timeFormat="24h" timeZone="Europe/Berlin" />,
|
||||
);
|
||||
const text = container.querySelector('.pr-text')?.textContent;
|
||||
expect(text).toBe(TEXT);
|
||||
});
|
||||
|
||||
it('leaves a plain-text description as it is', () => {
|
||||
const event = { ...htmlEvent(), description: 'a <b> c', descriptionContentType: 'text/plain' };
|
||||
const { container } = render(
|
||||
<EventPrintSheet event={event} calendar={calendar} timeFormat="24h" timeZone="Europe/Berlin" />,
|
||||
);
|
||||
expect(container.querySelector('.pr-text')?.textContent).toBe('a <b> c');
|
||||
});
|
||||
});
|
||||
|
||||
describe('saving an HTML event description from the editor', () => {
|
||||
async function save(event: CalendarEvent, newDescription?: string): Promise<Partial<CalendarEvent>> {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<EventModal event={event} calendars={[calendar]} onSave={onSave} onClose={vi.fn()} currentUserEmails={['me@example.com']} />);
|
||||
fireEvent.click(screen.getByText('events.edit'));
|
||||
if (newDescription !== undefined) {
|
||||
fireEvent.change(screen.getByPlaceholderText('form.description'), { target: { value: newDescription } });
|
||||
}
|
||||
fireEvent.click(screen.getByText('form.save'));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
|
||||
return onSave.mock.calls[0][0] as Partial<CalendarEvent>;
|
||||
}
|
||||
|
||||
// Other clients would read the text as HTML and run its lines together.
|
||||
it('labels an edited description as plain text', async () => {
|
||||
const data = await save(htmlEvent(), `${TEXT}\nMore`);
|
||||
expect(data.description).toBe(`${TEXT}\nMore`);
|
||||
expect(data.descriptionContentType).toBe('text/plain');
|
||||
});
|
||||
|
||||
it('leaves an untouched HTML description as it was', async () => {
|
||||
const data = await save(htmlEvent());
|
||||
expect(data).not.toHaveProperty('description');
|
||||
expect(data).not.toHaveProperty('descriptionContentType');
|
||||
});
|
||||
|
||||
// Stalwart writes a set type as STYLED-DESCRIPTION, which many clients skip.
|
||||
it('sends no type for an event that had none', async () => {
|
||||
const event = { ...htmlEvent(), description: 'old', descriptionContentType: undefined } as unknown as CalendarEvent;
|
||||
const data = await save(event, 'new');
|
||||
expect(data.description).toBe('new');
|
||||
expect(data).not.toHaveProperty('descriptionContentType');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { EventModal } from '../event-modal';
|
||||
import { useContactStore } from '@/stores/contact-store';
|
||||
import { createDemoContacts } from '@/lib/demo/fixtures/contacts';
|
||||
import { createDemoCalendars } from '@/lib/demo/fixtures/calendars';
|
||||
|
||||
vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null }));
|
||||
|
||||
beforeEach(() => useContactStore.setState({
|
||||
contacts: createDemoContacts(), contactsLoaded: true, directoryPrincipals: [], recentRecipients: [],
|
||||
}));
|
||||
|
||||
async function saveAfterSelecting(queries: string | string[], maxParticipants?: number) {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<EventModal calendars={createDemoCalendars()} onSave={onSave} onClose={vi.fn()} currentUserEmails={['nora@meridianlabs.example']} maxParticipants={maxParticipants} />);
|
||||
fireEvent.change(screen.getByPlaceholderText('form.title'), { target: { value: 'Issue 1505 synthetic reproduction' } });
|
||||
for (const query of typeof queries === 'string' ? [queries] : queries) {
|
||||
fireEvent.change(screen.getByPlaceholderText('email_placeholder'), { target: { value: query } });
|
||||
const option = await waitFor(() => within(screen.getByRole('listbox')).getAllByRole('option', { name: new RegExp(query) })[0]);
|
||||
fireEvent.mouseDown(option);
|
||||
}
|
||||
fireEvent.click(screen.getByText('form.save'));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
|
||||
return onSave.mock.calls[0];
|
||||
}
|
||||
|
||||
describe('Issue #1505: actual event editor and save payload', () => {
|
||||
it('control: individual contact survives selection and reaches saved attendees', async () => {
|
||||
const [, , metadata] = await saveAfterSelecting('Alice');
|
||||
expect(metadata.attendees).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('selecting Beacon team includes its five members in the saved attendees', async () => {
|
||||
const [data, , metadata] = await saveAfterSelecting('Beacon team');
|
||||
expect(metadata.attendees.map((a: { email: string }) => a.email)).toEqual([
|
||||
'alice.johnson@meridianlabs.example', 'bob.chen@meridianlabs.example',
|
||||
'priya.sharma@meridianlabs.example', 'ahmed.hassan@meridianlabs.example', 'lisa.tanaka@meridianlabs.example',
|
||||
]);
|
||||
expect(Object.values(data.participants)).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('keeps an existing attendee once when adding their group', async () => {
|
||||
const [, , metadata] = await saveAfterSelecting(['Alice', 'Beacon team']);
|
||||
expect(metadata.attendees).toHaveLength(5);
|
||||
expect(metadata.attendees.filter((a: { email: string }) => a.email === 'alice.johnson@meridianlabs.example')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('adds two overlapping groups without losing or duplicating attendees', async () => {
|
||||
const [, , metadata] = await saveAfterSelecting(['Beacon team', 'Leadership']);
|
||||
expect(metadata.attendees).toHaveLength(7);
|
||||
expect(new Set(metadata.attendees.map((a: { email: string }) => a.email)).size).toBe(7);
|
||||
});
|
||||
|
||||
it('warns when group members plus the organizer exceed the server participant limit', async () => {
|
||||
await saveAfterSelecting('Beacon team', 5);
|
||||
expect(screen.getByRole('status')).toHaveTextContent('participants.limit_warning');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ParticipantInput } from '../participant-input';
|
||||
import { useContactStore } from '@/stores/contact-store';
|
||||
import type { ContactCard } from '@/lib/jmap/types';
|
||||
import { createDemoContacts } from '@/lib/demo/fixtures/contacts';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
useContactStore.setState({ contacts: createDemoContacts(), contactsLoaded: true, directoryPrincipals: [], recentRecipients: [] });
|
||||
});
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
function search(query: string, participants: Array<{ name: string; email: string }> = []) {
|
||||
const onAdd = vi.fn();
|
||||
render(<ParticipantInput participants={participants} onAdd={onAdd} onRemove={vi.fn()} />);
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, { target: { value: query } });
|
||||
act(() => vi.advanceTimersByTime(200));
|
||||
return { input, onAdd };
|
||||
}
|
||||
|
||||
describe('Issue #1505: real contact store and real calendar participant input', () => {
|
||||
it('control: clicking an individual contact adds that attendee', () => {
|
||||
const { onAdd } = search('Alice');
|
||||
fireEvent.mouseDown(screen.getAllByRole('option')[0]);
|
||||
expect(onAdd).toHaveBeenCalledTimes(1);
|
||||
expect(onAdd.mock.calls[0][0].email).toContain('@');
|
||||
});
|
||||
|
||||
it('clicking Beacon team adds its five members as attendees', () => {
|
||||
const { onAdd } = search('Beacon team');
|
||||
fireEvent.mouseDown(screen.getByRole('option', { name: /Beacon team/ }));
|
||||
expect(onAdd).toHaveBeenCalledTimes(5);
|
||||
expect(screen.getByRole('combobox')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('selecting Beacon team with the keyboard adds its five members', () => {
|
||||
const { input, onAdd } = search('Beacon team');
|
||||
fireEvent.keyDown(input, { key: 'ArrowDown' });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
expect(onAdd).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('adds the only member of a one-person group', () => {
|
||||
const contacts = createDemoContacts();
|
||||
const member = contacts.find(c => c.id === 'demo-contact-alice')!;
|
||||
const group = contacts.find(c => c.id === 'demo-group-beacon')!;
|
||||
useContactStore.setState({ contacts: [member, { ...group, members: { [member.id]: true } }] });
|
||||
const { onAdd } = search('Beacon team');
|
||||
fireEvent.mouseDown(screen.getByRole('option', { name: /Beacon team/ }));
|
||||
expect(onAdd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('marks group suggestions with a translated group label and member count', () => {
|
||||
search('Beacon team');
|
||||
expect(screen.getByTitle('directory.type.group')).toBeInTheDocument();
|
||||
expect(screen.getByText('groups.member_count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds each usable address once, skipping existing attendees and missing or invalid emails', () => {
|
||||
const contacts: ContactCard[] = [
|
||||
{ addressBookIds: { book: true }, id: 'group', kind: 'group', name: { full: 'Test group' }, members: { a: true, b: true, c: true, d: true, e: true, f: true } },
|
||||
{ addressBookIds: { book: true }, id: 'a', kind: 'individual', name: { full: 'Existing' }, emails: { e: { address: 'EXISTING@example.com' } } },
|
||||
{ addressBookIds: { book: true }, id: 'b', kind: 'individual', name: { full: 'New' }, emails: { e: { address: ' new@example.com ' } } },
|
||||
{ addressBookIds: { book: true }, id: 'c', kind: 'individual', name: { full: 'Duplicate' }, emails: { e: { address: 'NEW@example.com' } } },
|
||||
{ addressBookIds: { book: true }, id: 'd', kind: 'individual', name: { full: 'Missing' } },
|
||||
{ addressBookIds: { book: true }, id: 'e', kind: 'individual', name: { full: 'Invalid' }, emails: { e: { address: 'not-an-email' } } },
|
||||
{ addressBookIds: { book: true }, id: 'f', kind: 'individual', name: { full: 'Another' }, emails: { e: { address: 'another@example.com' } } },
|
||||
];
|
||||
useContactStore.setState({ contacts });
|
||||
const { onAdd } = search('Test group', [{ name: 'Already invited', email: 'existing@example.com' }]);
|
||||
fireEvent.mouseDown(screen.getByRole('option', { name: /Test group/ }));
|
||||
expect(onAdd.mock.calls.map(([person]) => person)).toEqual([
|
||||
{ name: 'New', email: 'new@example.com' },
|
||||
{ name: 'Another', email: 'another@example.com' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps two matching groups selectable as separate suggestions', () => {
|
||||
const contacts = createDemoContacts();
|
||||
const group = contacts.find(c => c.id === 'demo-group-beacon')!;
|
||||
useContactStore.setState({ contacts: [...contacts, { ...group, id: 'other-group', name: { full: 'Beacon other team' } }] });
|
||||
const { onAdd } = search('Beacon');
|
||||
expect(screen.getAllByRole('option')).toHaveLength(2);
|
||||
fireEvent.mouseDown(screen.getByRole('option', { name: /Beacon other team/ }));
|
||||
expect(onAdd).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -21,7 +21,9 @@ import { toast } from "@/stores/toast-store";
|
||||
import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
|
||||
import { useAccountPrincipalEmails } from "@/hooks/use-account-principal-emails";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PaneSpinner, Spinner } from "@/components/ui/spinner";
|
||||
import { CalendarSkeleton, CalendarSkeletonBody, CalendarViewSkeleton, calendarSkeletonText } from "@/components/boot-skeleton/calendar-skeleton";
|
||||
import { SkeletonRegion } from "@/components/boot-skeleton/kit";
|
||||
import { useSkeletonLabels } from "@/components/boot-skeleton/use-skeleton-labels";
|
||||
import { CalendarToolbar } from "@/components/calendar/calendar-toolbar";
|
||||
import { CalendarMonthView } from "@/components/calendar/calendar-month-view";
|
||||
import { CalendarWeekView } from "@/components/calendar/calendar-week-view";
|
||||
@@ -30,6 +32,8 @@ import { CalendarAgendaView } from "@/components/calendar/calendar-agenda-view";
|
||||
import { TaskListView, filterTasksForList } from "@/components/calendar/task-list-view";
|
||||
import { CalendarLoadErrorNotice } from "@/components/calendar/calendar-load-error-notice";
|
||||
import { TaskToolbar } from "@/components/calendar/task-toolbar";
|
||||
import { PluginTaskView, usePluginTaskView } from "@/components/plugins/plugin-task-view";
|
||||
import { onOpenTask } from "@/lib/plugin-sandbox/open-task";
|
||||
import { TaskModal } from "@/components/calendar/task-modal";
|
||||
import { MiniCalendar } from "@/components/calendar/mini-calendar";
|
||||
import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel";
|
||||
@@ -56,6 +60,7 @@ import { useIsFocusedProTab, useIsPaneScoped } from "@/hooks/use-pane-context";
|
||||
import { useProMultiAccountCalendars } from "@/hooks/use-pro-multi-account-calendars";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { buildDuplicateEventData } from "@/lib/calendar-duplicate";
|
||||
import { descriptionWithNote } from "@/lib/calendar-description";
|
||||
import { filterTasksByCalendars } from "@/lib/calendar-tasks";
|
||||
import { baseEventStoreId, isServerRecurrenceInstance } from "@/lib/recurrence-instances";
|
||||
import { deleteOccurrence, saveOccurrenceEdit, type EventEditorState } from "@/lib/recurrence-scope-edit";
|
||||
@@ -151,6 +156,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
const describeError = useCalendarErrorMessage();
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
useDocumentTitle(tSidebar("calendar"));
|
||||
const skeletonLabels = useSkeletonLabels();
|
||||
const tWebcalAction = useTranslations("calendar.webcal_action");
|
||||
const { demoMode } = useConfig();
|
||||
// Subscriptions fetch the feed through /api/fetch-ical, which the demo has no
|
||||
@@ -759,6 +765,15 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
setShowTaskModal(true);
|
||||
}), [closeEventPanelForTask, whenEditorsDiscarded]);
|
||||
|
||||
// A plugin drawing the task view opens a task in the editor
|
||||
// (api.ui.openTask), as a click in Bulwark's list does.
|
||||
useEffect(() => onOpenTask((taskId) => {
|
||||
const task = useTaskStore.getState().tasks.find((t) => t.id === taskId);
|
||||
if (!task) return false;
|
||||
openEditTaskModal(task);
|
||||
return true;
|
||||
}), [openEditTaskModal]);
|
||||
|
||||
const handleToggleTaskComplete = useCallback((task: CalendarTask) => {
|
||||
if (!client) return;
|
||||
// The checkbox shows the result; only a failure needs saying.
|
||||
@@ -1519,13 +1534,10 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
const handleSaveNoteFromDetail = useCallback(async (note: string) => {
|
||||
if (!detailEvent || !client) return;
|
||||
const timestamp = format(displayNow(), "yyyy-MM-dd HH:mm");
|
||||
const separator = `\n\n--- ${timestamp} ---\n`;
|
||||
const newDescription = detailEvent.description
|
||||
? `${detailEvent.description}${separator}${note}`
|
||||
: `--- ${timestamp} ---\n${note}`;
|
||||
const fields = descriptionWithNote(detailEvent, `--- ${timestamp} ---`, note);
|
||||
try {
|
||||
if (!(await updateEvent(client, detailEvent.id, { description: newDescription }))) return;
|
||||
setDetailEvent({ ...detailEvent, description: newDescription });
|
||||
if (!(await updateEvent(client, detailEvent.id, fields))) return;
|
||||
setDetailEvent({ ...detailEvent, ...fields });
|
||||
toast.success(t("detail.note_saved"));
|
||||
} catch (error) {
|
||||
toast.error(describeError(error, t("notifications.event_error")));
|
||||
@@ -1770,6 +1782,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
return withCompany;
|
||||
}, [events, selectedCalendarIds, showBirthdayCalendar, birthdayEvents, companyEvents]);
|
||||
|
||||
// The plugin drawing the task view's list, if one offers it.
|
||||
const pluginTaskView = usePluginTaskView();
|
||||
// The tasks the task list shows; plugin buttons in its toolbar act on them.
|
||||
const listedTaskIds = useMemo(
|
||||
() => filterTasksForList(taskStore.tasks, selectedCalendarIds, taskStore.filter).map((task) => task.id),
|
||||
@@ -1851,13 +1865,27 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
</Dialog>
|
||||
) : null;
|
||||
|
||||
if (!isAuthenticated) return null;
|
||||
// Until the session says what the account has, the page is drawn as the
|
||||
// skeleton the server sent (components/boot-skeleton): the same markup, so
|
||||
// nothing moves while the sign-in is restored or the redirects decide.
|
||||
const skeletonText = calendarSkeletonText(t);
|
||||
const skeleton = isEmbedded ? (
|
||||
<SkeletonRegion surface="calendar" label={skeletonLabels.label} className="flex flex-col h-full">
|
||||
<CalendarSkeletonBody embedded text={skeletonText} />
|
||||
</SkeletonRegion>
|
||||
) : (
|
||||
<CalendarSkeleton label={skeletonLabels.label} labels={skeletonLabels.labels} text={skeletonText} />
|
||||
);
|
||||
if (!isAuthenticated) return skeleton;
|
||||
// Turned off by policy: nothing, while the effect above leaves for mail.
|
||||
if (!calendarEnabled) return null;
|
||||
if (!supportsCalendar) return renderWebcalAccountPicker();
|
||||
if (!supportsCalendar) return renderWebcalAccountPicker() ?? skeleton;
|
||||
|
||||
const renderView = () => {
|
||||
// The calendars are loading: the view's grid in the skeleton's shapes,
|
||||
// where the real one is about to be drawn.
|
||||
if (isLoading && calendars.length === 0) {
|
||||
return <PaneSpinner label={t("status.loading_calendars")} className="flex-1" />;
|
||||
return <CalendarViewSkeleton view={normalizedViewMode} label={t("status.loading_calendars")} text={skeletonText} />;
|
||||
}
|
||||
|
||||
const viewContent = (() => {
|
||||
@@ -1974,7 +2002,30 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
onCreateEvent={() => openCreateModal()}
|
||||
/>
|
||||
);
|
||||
case "tasks":
|
||||
case "tasks": {
|
||||
const taskList = (
|
||||
<TaskListView
|
||||
tasks={taskStore.tasks}
|
||||
calendars={displayCalendars}
|
||||
selectedCalendarIds={selectedCalendarIds}
|
||||
filter={taskStore.filter}
|
||||
onSelectTask={openEditTaskModal}
|
||||
onToggleComplete={handleToggleTaskComplete}
|
||||
selectedTaskId={taskStore.selectedTaskId}
|
||||
onQuickCreate={(title) => {
|
||||
// Into a calendar that takes it: the default one, else the
|
||||
// first the user may write to.
|
||||
const target = writableTaskCalendars.find((c) => c.isDefault) ?? writableTaskCalendars[0];
|
||||
if (!client || !target) {
|
||||
toast.error(t("notifications.task_save_error"));
|
||||
return;
|
||||
}
|
||||
taskStore.createTask(client, { "@type": "Task", title, progress: "needs-action", calendarIds: { [target.id]: true } })
|
||||
.then((created) => { if (created) toast.success(t("notifications.task_created")); })
|
||||
.catch((error: unknown) => toast.error(describeError(error, t("notifications.task_save_error"))));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{taskStore.error === TASKS_LOAD_ERROR && (
|
||||
@@ -1987,29 +2038,21 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
taskIds={listedTaskIds}
|
||||
compact={isMobile}
|
||||
/>
|
||||
<TaskListView
|
||||
tasks={taskStore.tasks}
|
||||
calendars={displayCalendars}
|
||||
selectedCalendarIds={selectedCalendarIds}
|
||||
filter={taskStore.filter}
|
||||
onSelectTask={openEditTaskModal}
|
||||
onToggleComplete={handleToggleTaskComplete}
|
||||
selectedTaskId={taskStore.selectedTaskId}
|
||||
onQuickCreate={(title) => {
|
||||
// Into a calendar that takes it: the default one, else the
|
||||
// first the user may write to.
|
||||
const target = writableTaskCalendars.find((c) => c.isDefault) ?? writableTaskCalendars[0];
|
||||
if (!client || !target) {
|
||||
toast.error(t("notifications.task_save_error"));
|
||||
return;
|
||||
}
|
||||
taskStore.createTask(client, { "@type": "Task", title, progress: "needs-action", calendarIds: { [target.id]: true } })
|
||||
.then((created) => { if (created) toast.success(t("notifications.task_created")); })
|
||||
.catch((error: unknown) => toast.error(describeError(error, t("notifications.task_save_error"))));
|
||||
}}
|
||||
/>
|
||||
{/* A plugin may draw the list itself (the calendar-task-view
|
||||
slot), with Bulwark's list back while its frame is not
|
||||
drawn; the toolbar above stays Bulwark's. */}
|
||||
{pluginTaskView ? (
|
||||
<PluginTaskView
|
||||
pluginId={pluginTaskView.pluginId}
|
||||
fill={pluginTaskView.fill}
|
||||
filter={taskStore.filter}
|
||||
calendarIds={selectedCalendarIds}
|
||||
fallback={taskList}
|
||||
/>
|
||||
) : taskList}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -2019,10 +2062,11 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
<CalendarLoadErrorNotice onRetry={retryEventsLoad} />
|
||||
)}
|
||||
{viewContent}
|
||||
{/* The first events: the grid is drawn already and they fill in on
|
||||
it, so this is said rather than shown (a dimmed grid with a
|
||||
spinner flashed between the skeleton and the events). */}
|
||||
{isLoadingEvents && calendars.length > 0 && events.length === 0 && (
|
||||
<div className="absolute inset-0 bg-background/50 flex items-center justify-center pointer-events-none">
|
||||
<Spinner label={t("status.loading_events")} />
|
||||
</div>
|
||||
<span role="status" className="sr-only">{t("status.loading_events")}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -33,6 +33,7 @@ import { formatDisplayHour, remapSegmentsToShownDays, resolveDisplayHours, resol
|
||||
import { groupTasksByDueDay } from "@/lib/calendar-tasks";
|
||||
import { eventOrganizer, getParticipantList, isDeclinedByUser } from "@/lib/calendar-participants";
|
||||
import { findMeetingLink, primaryLocationName } from "@/lib/event-links";
|
||||
import { descriptionAsText } from "@/lib/calendar-description";
|
||||
import {
|
||||
PRINT_LANE_MM,
|
||||
PRINT_PAGE_HEIGHT_MM,
|
||||
@@ -699,6 +700,7 @@ export function EventPrintSheet({
|
||||
|
||||
const location = primaryLocationName(event);
|
||||
const meeting = findMeetingLink(event);
|
||||
const description = descriptionAsText(event);
|
||||
const participants = getParticipantList(event, { resolveName: resolveContactName });
|
||||
const organizer = eventOrganizer(event);
|
||||
const organizerName = organizer ? (organizer.name || resolveContactName(organizer.email) || "") : "";
|
||||
@@ -754,10 +756,10 @@ export function EventPrintSheet({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{event.description && (
|
||||
{description && (
|
||||
<section className="pr-flow">
|
||||
<h2>{t("form.description")}</h2>
|
||||
<p className="pr-text" dir="auto">{event.description}</p>
|
||||
<p className="pr-text" dir="auto">{description}</p>
|
||||
</section>
|
||||
)}
|
||||
</article>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { canUserRsvp, getEventEditability, type EditabilityContext } from "@/lib
|
||||
import { useFormatEventDate } from "@/hooks/use-format-event-date";
|
||||
import { useContactNameResolver } from "@/hooks/use-contact-name-resolver";
|
||||
import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from "@/lib/event-links";
|
||||
import { descriptionAsText } from "@/lib/calendar-description";
|
||||
import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { eventToView } from "@/lib/plugin-projection";
|
||||
@@ -174,6 +175,7 @@ export function EventDetailPopover({
|
||||
// join URL in the description; surface it as the meeting link anyway.
|
||||
const meeting = useMemo(() => findMeetingLink(event), [event]);
|
||||
const pluginProps = useMemo(() => ({ event: eventToView(event) }), [event]);
|
||||
const description = useMemo(() => descriptionAsText(event), [event]);
|
||||
const virtualLocation = meeting?.uri ?? null;
|
||||
|
||||
// A URL opens as a link, a location that only names the service joins the
|
||||
@@ -630,11 +632,11 @@ export function EventDetailPopover({
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{event.description && (
|
||||
{description && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<AlignLeft className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<p dir="auto" className="text-sm text-muted-foreground whitespace-pre-line line-clamp-3 break-words">
|
||||
<LinkifiedText text={event.description} />
|
||||
<LinkifiedText text={description} />
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { LinkifiedText } from "@/components/ui/linkified-text";
|
||||
import { X, Trash2, Users, CalendarDays, CopyPlus, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, AlertTriangle, MoreVertical } from "@/components/icons";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from "@/lib/event-links";
|
||||
import { descriptionAsText, isHtmlDescription, plainDescriptionFields } from "@/lib/calendar-description";
|
||||
import { ParticipantStatusBadge } from "./rsvp-buttons";
|
||||
import { format, parseISO, addHours, isSameDay } from "date-fns";
|
||||
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types";
|
||||
@@ -371,7 +372,9 @@ export function EventModal({
|
||||
};
|
||||
|
||||
const [title, setTitle] = useState(event?.title || defaultTitle || "");
|
||||
const [description, setDescription] = useState(event?.description || "");
|
||||
// An HTML description is shown and edited as its text.
|
||||
const eventDescription = useMemo(() => (event ? descriptionAsText(event) : ""), [event]);
|
||||
const [description, setDescription] = useState(eventDescription);
|
||||
const [location, setLocation] = useState(
|
||||
event?.locations ? Object.values(event.locations)[0]?.name || "" : ""
|
||||
);
|
||||
@@ -788,8 +791,11 @@ export function EventModal({
|
||||
// On an existing event the empty string is how a description gets cleared, but
|
||||
// sending it on creation writes a `DESCRIPTION:` with nothing after it — which
|
||||
// invitation e-mails then render as a "Description" heading over blank space.
|
||||
if (trimmedDescription || event) {
|
||||
data.description = trimmedDescription;
|
||||
// An HTML description the user did not touch stays as it was; one they
|
||||
// edited is saved as the plain text it now is.
|
||||
const keepsHtml = !!event && isHtmlDescription(event) && trimmedDescription === eventDescription.trim();
|
||||
if ((trimmedDescription || event) && !keepsHtml) {
|
||||
Object.assign(data, plainDescriptionFields(event, trimmedDescription));
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
@@ -938,7 +944,7 @@ export function EventModal({
|
||||
savingRef.current = false;
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, calendars, recurrence, customRule, useDefaultAlerts, defaultAlerts, alertRows, preservedAlerts, attendees, initialAttendees, sendInvitations, currentUserEmails, userIsOrganizer, resolveContactName, event, onSave, isSaving, t]);
|
||||
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, calendars, recurrence, customRule, useDefaultAlerts, defaultAlerts, alertRows, preservedAlerts, attendees, initialAttendees, sendInvitations, currentUserEmails, userIsOrganizer, resolveContactName, event, eventDescription, onSave, isSaving, t]);
|
||||
|
||||
// The user's own answer, and the owner's in a calendar shared with them
|
||||
// (lib/calendar-delegate.ts).
|
||||
@@ -1189,11 +1195,11 @@ export function EventModal({
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{event.description && (
|
||||
{eventDescription && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<AlignLeft className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-line break-words">
|
||||
<LinkifiedText text={event.description} />
|
||||
<LinkifiedText text={eventDescription} />
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,16 +3,20 @@
|
||||
import { useState, useRef, useCallback, useEffect, useLayoutEffect, forwardRef, useImperativeHandle } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X } from "@/components/icons";
|
||||
import { X, Users } from "@/components/icons";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
|
||||
interface Participant {
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface ParticipantSuggestion extends Participant {
|
||||
group?: { id: string; memberCount: number };
|
||||
}
|
||||
|
||||
interface ParticipantInputProps {
|
||||
participants: Participant[];
|
||||
onAdd: (participant: Participant) => void;
|
||||
@@ -30,8 +34,9 @@ const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantInputProps>(function ParticipantInput({ participants, onAdd, onRemove, disabled, id }, ref) {
|
||||
const t = useTranslations("calendar.participants");
|
||||
const tContacts = useTranslations("contacts");
|
||||
const [query, setQuery] = useState("");
|
||||
const [suggestions, setSuggestions] = useState<Participant[]>([]);
|
||||
const [suggestions, setSuggestions] = useState<ParticipantSuggestion[]>([]);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -69,7 +74,7 @@ export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantIn
|
||||
}
|
||||
const results = useContactStore.getState().getAutocomplete(q);
|
||||
const existing = new Set(participants.map(p => p.email.toLowerCase()));
|
||||
const filtered = results.filter(r => !existing.has(r.email.toLowerCase()));
|
||||
const filtered = results.filter(r => r.group || !existing.has(r.email.toLowerCase()));
|
||||
setSuggestions(filtered.slice(0, 8));
|
||||
setShowSuggestions(filtered.length > 0);
|
||||
setActiveIndex(-1);
|
||||
@@ -87,10 +92,20 @@ export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantIn
|
||||
};
|
||||
}, []);
|
||||
|
||||
const addParticipant = useCallback((p: Participant) => {
|
||||
if (!p.email || !EMAIL_REGEX.test(p.email)) return;
|
||||
if (participants.some(e => e.email.toLowerCase() === p.email.toLowerCase())) return;
|
||||
onAdd(p);
|
||||
const addParticipant = useCallback((p: ParticipantSuggestion) => {
|
||||
if (!p.group && (!EMAIL_REGEX.test(p.email) || participants.some(e => e.email.toLowerCase() === p.email.toLowerCase()))) return;
|
||||
const candidates = p.group
|
||||
? useContactStore.getState().getGroupMembers(p.group.id).map(contact => ({
|
||||
name: getContactDisplayName(contact),
|
||||
email: getContactPrimaryEmail(contact).trim(),
|
||||
}))
|
||||
: [p];
|
||||
const existing = new Set(participants.map(participant => participant.email.toLowerCase()));
|
||||
for (const candidate of candidates) {
|
||||
if (!EMAIL_REGEX.test(candidate.email) || existing.has(candidate.email.toLowerCase())) continue;
|
||||
existing.add(candidate.email.toLowerCase());
|
||||
onAdd({ name: candidate.name, email: candidate.email });
|
||||
}
|
||||
setQuery("");
|
||||
setSuggestions([]);
|
||||
setShowSuggestions(false);
|
||||
@@ -174,7 +189,7 @@ export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantIn
|
||||
>
|
||||
{suggestions.map((s, i) => (
|
||||
<li
|
||||
key={s.email}
|
||||
key={s.group ? `group-${s.group.id}` : s.email}
|
||||
id={`suggestion-${i}`}
|
||||
role="option"
|
||||
aria-selected={i === activeIndex}
|
||||
@@ -183,10 +198,20 @@ export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantIn
|
||||
i === activeIndex ? "bg-accent text-accent-foreground" : "hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<Avatar name={s.name} email={s.email} size="sm" className="shrink-0 w-7 h-7 text-[0.625rem]" />
|
||||
{s.group ? (
|
||||
<span title={tContacts("directory.type.group")} className="shrink-0 w-7 h-7 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Users className="w-4 h-4 text-primary" aria-hidden />
|
||||
</span>
|
||||
) : (
|
||||
<Avatar name={s.name} email={s.email} size="sm" className="shrink-0 w-7 h-7 text-[0.625rem]" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate" title={s.name || s.email}>{s.name || s.email}</div>
|
||||
{s.name && (
|
||||
{s.group ? (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{tContacts("groups.member_count", { count: s.group.memberCount })}
|
||||
</div>
|
||||
) : s.name && (
|
||||
<div className="text-xs text-muted-foreground truncate" title={s.email}>{s.email}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import { TARGET_NAMES } from '@/lib/connector/registry';
|
||||
import { CONNECTOR_SURFACES } from '@/lib/boot-skeleton/surfaces';
|
||||
|
||||
// A connector link that can't be opened explains why in the reader's
|
||||
// language (keys in tests), not in English only.
|
||||
@@ -27,3 +30,21 @@ describe('connector link page', () => {
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('connector link page while it resolves', () => {
|
||||
// The server HTML is what shows until the redirect: the skeleton of the
|
||||
// page the link opens, with the opening text as its status message.
|
||||
it.each(TARGET_NAMES)('%s draws its destination skeleton', (target) => {
|
||||
const html = renderToString(<ConnectorClient target={target} />);
|
||||
const surface = CONNECTOR_SURFACES[target];
|
||||
expect(html).toContain(`data-boot-skeleton="${surface}"`);
|
||||
expect(html).toContain('role="status"');
|
||||
expect(html).toContain('opening');
|
||||
});
|
||||
|
||||
it('keeps the plain text for a target it does not know', () => {
|
||||
const html = renderToString(<ConnectorClient target="constructor" />);
|
||||
expect(html).not.toContain('data-boot-skeleton');
|
||||
expect(html).toContain('opening');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@ import { resolveTarget, type ResolveResult } from '@/lib/connector/registry';
|
||||
import { appPath } from '@/lib/deep-links';
|
||||
import { replaceWindowLocation } from '@/lib/browser-navigation';
|
||||
import { productName } from '@/lib/product-name';
|
||||
import { CONNECTOR_SURFACES } from '@/lib/boot-skeleton/surfaces';
|
||||
import { RouteSkeleton } from '@/components/boot-skeleton/route-skeleton';
|
||||
|
||||
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '';
|
||||
|
||||
@@ -49,6 +51,12 @@ export function ConnectorClient({ target }: { target: string }) {
|
||||
}, [destination]);
|
||||
|
||||
if (result === null || result.kind === 'ok') {
|
||||
// The page the link opens, as its skeleton: the boot script has already
|
||||
// set that page's variant attributes from the link's parameters, so the
|
||||
// hard redirect lands on the same shapes. No bottom bar labels: this page
|
||||
// is English only and the destination may not be.
|
||||
const surface = Object.hasOwn(CONNECTOR_SURFACES, target) ? CONNECTOR_SURFACES[target] : null;
|
||||
if (surface) return <RouteSkeleton surface={surface} label={t('opening')} />;
|
||||
return <Shell>{t('opening')}</Shell>;
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,19 @@ describe('ContactList', () => {
|
||||
expect(screen.getByTestId('contact-list-skeleton')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('draws the loading rows at the list density, as the page skeleton does', () => {
|
||||
const avatars = () => [...screen.getByTestId('contact-list-skeleton').querySelectorAll('.rounded-full')];
|
||||
act(() => { useSettingsStore.setState({ density: 'extra-compact', groupContactsByLetter: false }); });
|
||||
const { unmount } = render(<ContactList {...defaultProps} contacts={[]} isLoading />);
|
||||
expect(avatars().length).toBeGreaterThan(0);
|
||||
expect(avatars().every((el) => el.classList.contains('hidden'))).toBe(true);
|
||||
unmount();
|
||||
|
||||
act(() => { useSettingsStore.setState({ density: 'regular', groupContactsByLetter: true }); });
|
||||
render(<ContactList {...defaultProps} contacts={[]} isLoading />);
|
||||
expect(avatars().some((el) => el.classList.contains('hidden'))).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the contacts as soon as they are there, even while loading', () => {
|
||||
render(<ContactList {...defaultProps} isLoading />);
|
||||
expect(screen.queryByTestId('contact-list-skeleton')).toBeNull();
|
||||
|
||||
@@ -21,6 +21,7 @@ import { getContactDisplayName, getContactSortName, hasContactPhoto } from "@/st
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { isAppShortcutBlocked } from "@/lib/keyboard";
|
||||
import { isSingleKeyShortcutOff } from "@/lib/single-key-shortcuts";
|
||||
import { ContactRowsSkeleton } from "@/components/boot-skeleton/contacts-skeleton";
|
||||
|
||||
type TriState = boolean | null;
|
||||
|
||||
@@ -651,17 +652,8 @@ export function ContactList({
|
||||
className="flex-1 overflow-y-auto"
|
||||
>
|
||||
{sorted.length === 0 && isLoading && !searchQuery && activeFilters === 0 ? (
|
||||
<div aria-hidden className="py-1" data-testid="contact-list-skeleton">
|
||||
{Array.from({ length: 8 }, (_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-4 py-2.5">
|
||||
<div className="w-9 h-9 rounded-full bg-muted animate-pulse shrink-0" />
|
||||
<div className="flex-1 min-w-0 space-y-1.5">
|
||||
<div className="h-3.5 rounded bg-muted animate-pulse" style={{ width: `${45 + ((i * 29) % 35)}%` }} />
|
||||
<div className="h-3 rounded bg-muted/70 animate-pulse" style={{ width: `${30 + ((i * 17) % 30)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
// The rows the page skeleton drew, at the same density.
|
||||
<ContactRowsSkeleton density={density} letters={groupByLetter} />
|
||||
) : sorted.length === 0 ? (
|
||||
searchQuery || activeFilters > 0 ? (
|
||||
<EmptyState
|
||||
|
||||
@@ -55,6 +55,8 @@ import { useDeepLinkUrl } from "@/hooks/use-deep-link-url";
|
||||
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
|
||||
import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
import { ContactsSkeleton, ContactsSkeletonBody } from "@/components/boot-skeleton/contacts-skeleton";
|
||||
import { useSkeletonLabels } from "@/components/boot-skeleton/use-skeleton-labels";
|
||||
import { useRegisterCommands } from "@/lib/command-registry";
|
||||
import { useCloseOnBack } from "@/hooks/use-close-on-back";
|
||||
import { useLeaveGuard } from "@/lib/leave-guard";
|
||||
@@ -92,6 +94,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
// Static Lite build: the route params are empty, read the link from the URL.
|
||||
const linkSegments = useLiteLinkSegments('contacts', routeSegments);
|
||||
const t = useTranslations("contacts");
|
||||
const skeleton = useSkeletonLabels();
|
||||
// Organisation > Devices > No printing: no Print buttons (Ctrl+P prints a note).
|
||||
const printOff = useStrictestDevices().noPrint;
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
@@ -972,7 +975,12 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
}
|
||||
};
|
||||
|
||||
if (!isAuthenticated) return null;
|
||||
// Until the session is restored: the page skeleton the server drew.
|
||||
if (!isAuthenticated) {
|
||||
return isEmbedded
|
||||
? <ContactsSkeletonBody embedded />
|
||||
: <ContactsSkeleton label={skeleton.label} labels={skeleton.labels} />;
|
||||
}
|
||||
|
||||
const renderRightPanel = () => {
|
||||
// The directory is read-only: its panel shows the open entry whatever the view.
|
||||
@@ -1408,7 +1416,9 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
{!printOff && <ContextMenuItem icon={Printer} label={tCommon("print")} onClick={() => { close(); void requestPrint({ kind: "contact-list" }); }} />}
|
||||
</>
|
||||
)}
|
||||
isLoading={supportsSync && !contactsLoaded}
|
||||
// Until the session is back, nobody knows yet whether the server
|
||||
// keeps contacts: show the rows loading, not an empty book.
|
||||
isLoading={!contactsLoaded && (supportsSync || !client)}
|
||||
onPrint={printOff ? undefined : () => { void requestPrint({ kind: "contact-list" }); }}
|
||||
printTitle={printContent?.kind === "list" ? listTitle : null}
|
||||
/>
|
||||
@@ -1596,8 +1606,14 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
// prerender (static Lite build); at runtime it never suspends.
|
||||
export function ContactsApp(props: ContactsAppProps = {}) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
// The static Lite export writes this fallback into the page's HTML.
|
||||
<Suspense fallback={<ContactsFallback />}>
|
||||
<ContactsAppContent {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function ContactsFallback() {
|
||||
const { label, labels } = useSkeletonLabels();
|
||||
return <ContactsSkeleton label={label} labels={labels} />;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
} from '../compose-format';
|
||||
import { SIGNATURE_BLOCK_MARKER, SIGNATURE_RANGE_MARKER, containsEmbeddedSignature } from '../signature-block';
|
||||
import { plainTextBodyHasSignature } from '@/lib/signature-utils';
|
||||
import { extractUserAuthoredText } from '@/lib/email-composer-utils';
|
||||
import { buildQuotedHtmlBlock } from '../quoted-html';
|
||||
|
||||
const identity = { htmlSignature: '<b>Alice</b><br>Acme', textSignature: 'Alice\nAcme' };
|
||||
const count = (haystack: string, needle: string) => haystack.split(needle).length - 1;
|
||||
@@ -46,10 +48,24 @@ describe('compose-format (#1022): HTML <-> plain text switch', () => {
|
||||
|
||||
it('does not invent a signature for a body that had none (below-quote reply)', () => {
|
||||
const text = htmlComposeBodyToPlainText('<p>Thanks</p><blockquote>original</blockquote>', identity, { separator: true });
|
||||
expect(text).toBe('Thanks\n\noriginal');
|
||||
expect(text).toBe('Thanks\n\n> original');
|
||||
expect(plainTextBodyHasSignature(text, identity)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the quoted original of a rich reply marked with "> "', () => {
|
||||
const html = `<p>Thanks</p><p>On Monday Bob wrote:</p>${buildQuotedHtmlBlock('<p>Line one<br>Line two</p><p>Para two</p>')}`;
|
||||
const text = htmlComposeBodyToPlainText(html, identity, { separator: true });
|
||||
expect(text).toBe('Thanks\n\nOn Monday Bob wrote:\n\n> Line one\n> Line two\n>\n> Para two');
|
||||
// The attachment reminder and plugin edits only see the user's text.
|
||||
expect(extractUserAuthoredText(text, { plainTextMode: true })).not.toContain('Line one');
|
||||
});
|
||||
|
||||
it('keeps list markers and image alt text', () => {
|
||||
const html = '<ul><li>apples</li><li>pears</li></ul><ol><li>first</li><li>second</li></ol><p><img src="x.png" alt="a bike"></p>';
|
||||
const text = htmlComposeBodyToPlainText(html, identity, { separator: true, imageAlt: (alt) => `[Image: ${alt}]` });
|
||||
expect(text).toBe('- apples\n- pears\n\n1. first\n2. second\n\n[Image: a bike]');
|
||||
});
|
||||
|
||||
it('honours the separator setting', () => {
|
||||
const html = `<p>Hi</p>${buildEmbeddedSignatureHtml(identity, { embed: true, separator: false })}`;
|
||||
expect(htmlComposeBodyToPlainText(html, identity, { separator: false })).toBe('Hi\n\nAlice\nAcme');
|
||||
@@ -69,10 +85,27 @@ describe('compose-format (#1022): HTML <-> plain text switch', () => {
|
||||
|
||||
it('keeps a body without signature signature-free', () => {
|
||||
const html = plainComposeBodyToHtml('Thanks\n\n> original', identity, { separator: true });
|
||||
expect(html).toBe('<p>Thanks</p><p>> original</p>');
|
||||
expect(html).toBe('<p>Thanks</p><blockquote><p>original</p></blockquote>');
|
||||
expect(containsEmbeddedSignature(html)).toBe(false);
|
||||
});
|
||||
|
||||
it('turns "> " lines back into a quote, one level per marker', () => {
|
||||
const text = 'Thanks\n\nOn Monday Bob wrote:\n> Line one\n> Line two\n>\n> > older\nnot quoted';
|
||||
expect(plainComposeBodyToHtml(text, identity, { separator: true })).toBe(
|
||||
'<p>Thanks</p><p>On Monday Bob wrote:</p>'
|
||||
+ '<blockquote><p>Line one<br>Line two</p><blockquote><p>older</p></blockquote></blockquote>'
|
||||
+ '<p>not quoted</p>',
|
||||
);
|
||||
});
|
||||
|
||||
it('round-trips a quoted reply through plain text and back', () => {
|
||||
const start = `<p>Thanks</p><p>On Monday Bob wrote:</p>${buildQuotedHtmlBlock('<p>Line one</p><blockquote><p>older</p></blockquote>')}`;
|
||||
const text = htmlComposeBodyToPlainText(start, identity, { separator: true });
|
||||
const back = plainComposeBodyToHtml(text, identity, { separator: true });
|
||||
expect(back).toBe('<p>Thanks</p><p>On Monday Bob wrote:</p><blockquote><p>Line one</p><blockquote><p>older</p></blockquote></blockquote>');
|
||||
expect(htmlComposeBodyToPlainText(back, identity, { separator: true })).toBe(text);
|
||||
});
|
||||
|
||||
it('gives an untouched signature-only body an empty paragraph to type into', () => {
|
||||
const html = plainComposeBodyToHtml('\n\n-- \nAlice\nAcme', identity, { separator: true });
|
||||
expect(html.startsWith('<p></p>')).toBe(true);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { EmailComposer } from '../email-composer';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
// A plugin can offload a large attachment (onBeforeBlobUpload) and hand back
|
||||
// a link for the body. While it works, the user can switch the message
|
||||
// between rich text and plain text. The link must go in in the format the
|
||||
// body has when it arrives, not the one it had when the file was added.
|
||||
|
||||
vi.mock('@/components/email/rich-text-editor', () => ({
|
||||
RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
|
||||
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
|
||||
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
|
||||
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
|
||||
vi.mock('@/hooks/use-focus-trap', () => ({
|
||||
useFocusTrap: () => ({ current: null }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
|
||||
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
|
||||
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/auth-store', () => {
|
||||
const state = {
|
||||
client: null,
|
||||
identities: [],
|
||||
primaryIdentity: null,
|
||||
isAuthenticated: false,
|
||||
isDemoMode: false,
|
||||
activeAccountId: null,
|
||||
connectionLost: false,
|
||||
getClientForAccount: () => undefined,
|
||||
getAllConnectedClients: () => new Map(),
|
||||
syncIdentities: () => {},
|
||||
refreshIdentities: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAuthStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/identity-store', () => {
|
||||
const state = {
|
||||
identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }],
|
||||
defaultIdentityId: 'id-me',
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useIdentityStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/account-store', () => {
|
||||
const state = { accounts: [], getAccountById: () => undefined };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAccountStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/email-store', () => {
|
||||
const state = {
|
||||
draftSaveEnabled: false,
|
||||
sendRawEmail: async () => ({ sent: true }),
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useEmailStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/settings-store', () => {
|
||||
const state = {
|
||||
timeFormat: '24h',
|
||||
plainTextMode: false,
|
||||
subAddressDelimiter: '+',
|
||||
autoSelectReplyIdentity: true,
|
||||
attachmentReminderEnabled: false,
|
||||
attachmentReminderKeywords: [],
|
||||
emptySubjectWarningEnabled: false,
|
||||
sendDelaySeconds: 0,
|
||||
signaturePosition: 'above_quote',
|
||||
signatureSeparatorEnabled: false,
|
||||
requestReadReceiptDefault: false,
|
||||
addTrustedSender: () => {},
|
||||
trustedSendersAddressBook: null,
|
||||
updateSetting: () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useSettingsStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/contact-store', () => {
|
||||
const state = {
|
||||
contacts: [],
|
||||
getAutocomplete: async () => [],
|
||||
addToTrustedSendersBook: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useContactStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/template-store', () => {
|
||||
const state = { templates: [], addTemplate: async () => {} };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useTemplateStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/toast-store', () => ({
|
||||
toast: { info: () => {}, error: () => {}, success: () => {} },
|
||||
}));
|
||||
|
||||
// The offloading plugin: resolves when the test says so.
|
||||
const { offload } = vi.hoisted(() => {
|
||||
const offload: { finish: (value: unknown) => void } = { finish: () => {} };
|
||||
return { offload };
|
||||
});
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
getRecipientSuggestions: { call: async () => [] },
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
onBeforeAttachmentUpload: { size: 0, intercept: async () => true },
|
||||
onBeforeBlobUpload: {
|
||||
transform: () => new Promise((resolve) => { offload.finish = resolve; }),
|
||||
},
|
||||
onAfterAttachmentUpload: { emit: () => {} },
|
||||
},
|
||||
contactHooks: {
|
||||
search: { call: async () => [] },
|
||||
onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
|
||||
},
|
||||
isExternalAttachmentResult: (value: unknown) =>
|
||||
typeof value === 'object' && value !== null && (value as { externalAttachment?: boolean }).externalAttachment === true,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-storage', () => ({
|
||||
fileStorage: {
|
||||
saveFile: async () => {},
|
||||
getFile: async () => null,
|
||||
deleteFile: async () => {},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/upload-progress', () => ({
|
||||
onUploadProgress: () => () => {},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-sanitization', () => ({
|
||||
sanitizeSignatureHtml: (v: string) => v,
|
||||
sanitizeSignatureHtmlForDisplay: (v: string) => v,
|
||||
sanitizeEmailHtml: (v: string) => v,
|
||||
sanitizePluginBodyHtml: (v: string) => v,
|
||||
escapeHtml: (v: string) => v,
|
||||
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-threading', () => ({
|
||||
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||
}));
|
||||
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
|
||||
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
|
||||
vi.mock('@/components/email/quoted-html', () => ({
|
||||
buildQuotedHtmlBlock: () => '',
|
||||
serializeEditorContent: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
|
||||
|
||||
const LINK = {
|
||||
externalAttachment: true,
|
||||
html: '<p><a href="https://cloud.example/s/abc">report.pdf</a></p>',
|
||||
text: 'report.pdf: https://cloud.example/s/abc',
|
||||
};
|
||||
|
||||
const DRAFT = {
|
||||
to: 'bob@example.com',
|
||||
cc: '',
|
||||
bcc: '',
|
||||
subject: 'Report',
|
||||
body: '<p>See the file</p>',
|
||||
showCc: false,
|
||||
showBcc: false,
|
||||
selectedIdentityId: 'id-me',
|
||||
subAddressTag: '',
|
||||
mode: 'compose' as const,
|
||||
draftId: null,
|
||||
};
|
||||
|
||||
const toggle = () => {
|
||||
if (!screen.queryByTestId('composer-format-toggle')) fireEvent.click(screen.getByTestId('composer-options-menu'));
|
||||
return screen.getByTestId('composer-format-toggle') as HTMLButtonElement;
|
||||
};
|
||||
const textarea = () => document.querySelector('textarea') as HTMLTextAreaElement | null;
|
||||
|
||||
async function attachAndWaitForPlugin() {
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const file = new File(['x'], 'report.pdf', { type: 'application/pdf' });
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
}
|
||||
|
||||
async function finishOffload() {
|
||||
await act(async () => {
|
||||
offload.finish(LINK);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
}
|
||||
|
||||
describe('composer: cloud attachment link after a format switch', () => {
|
||||
afterEach(() => {
|
||||
useAuthStore.setState({ client: null });
|
||||
useSettingsStore.setState({ plainTextMode: false });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function mockClient() {
|
||||
useAuthStore.setState({
|
||||
client: {
|
||||
uploadBlob: vi.fn().mockResolvedValue({ blobId: 'blob-1' }),
|
||||
hasDelayedSend: () => false,
|
||||
getMaxDelayedSend: () => 0,
|
||||
} as never,
|
||||
});
|
||||
}
|
||||
|
||||
it('puts the plain-text link in when switched to plain text meanwhile', async () => {
|
||||
mockClient();
|
||||
render(<EmailComposer initialData={DRAFT} />);
|
||||
await attachAndWaitForPlugin();
|
||||
|
||||
fireEvent.click(toggle());
|
||||
expect(textarea()).not.toBeNull();
|
||||
await finishOffload();
|
||||
|
||||
expect(textarea()!.value).toBe(`See the file\n${LINK.text}`);
|
||||
});
|
||||
|
||||
it('puts the HTML link in when switched to rich text meanwhile', async () => {
|
||||
mockClient();
|
||||
useSettingsStore.setState({ plainTextMode: true });
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer initialData={{ ...DRAFT, body: 'See the file' }} onSend={onSend} />);
|
||||
await attachAndWaitForPlugin();
|
||||
|
||||
fireEvent.click(toggle());
|
||||
expect(screen.getByTestId('rich-text-editor')).toBeInTheDocument();
|
||||
await finishOffload();
|
||||
|
||||
fireEvent.click(screen.getAllByTestId('composer-send')[0]);
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
|
||||
const sent = onSend.mock.calls[0][0] as { htmlBody?: string };
|
||||
expect(sent.htmlBody).toContain('<a href="https://cloud.example/s/abc">report.pdf</a>');
|
||||
expect(sent.htmlBody).not.toContain(LINK.text);
|
||||
});
|
||||
});
|
||||
@@ -95,6 +95,8 @@ vi.mock('@/stores/settings-store', () => {
|
||||
// The default position: replies/forwards get the signature appended at
|
||||
// send time rather than embedded above the quote.
|
||||
signaturePosition: 'below_quote',
|
||||
signatureNewMailEnabled: true,
|
||||
signatureReplyForwardEnabled: true,
|
||||
signatureSeparatorEnabled: true,
|
||||
requestReadReceiptDefault: false,
|
||||
addTrustedSender: () => {},
|
||||
|
||||
@@ -95,6 +95,8 @@ vi.mock('@/stores/settings-store', () => {
|
||||
// The default position: replies/forwards get the signature appended at
|
||||
// send time rather than embedded above the quote.
|
||||
signaturePosition: 'below_quote',
|
||||
signatureNewMailEnabled: true,
|
||||
signatureReplyForwardEnabled: true,
|
||||
signatureSeparatorEnabled: true,
|
||||
requestReadReceiptDefault: false,
|
||||
addTrustedSender: () => {},
|
||||
|
||||
@@ -95,6 +95,8 @@ vi.mock('@/stores/settings-store', () => {
|
||||
// The default position: replies/forwards get the signature appended at
|
||||
// send time rather than embedded above the quote.
|
||||
signaturePosition: 'below_quote',
|
||||
signatureNewMailEnabled: true,
|
||||
signatureReplyForwardEnabled: true,
|
||||
signatureSeparatorEnabled: true,
|
||||
requestReadReceiptDefault: false,
|
||||
addTrustedSender: () => {},
|
||||
@@ -257,3 +259,40 @@ describe('composer signature follows the picked identity', () => {
|
||||
expect(sent.body).toBe('Plain draft');
|
||||
});
|
||||
});
|
||||
|
||||
describe('signature auto-insert toggles', () => {
|
||||
afterEach(() => {
|
||||
useSettingsStore.setState({
|
||||
plainTextMode: false,
|
||||
signatureNewMailEnabled: true,
|
||||
signatureReplyForwardEnabled: true,
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('keeps a new message free of the signature when the new-mail toggle is off', async () => {
|
||||
useSettingsStore.setState({ plainTextMode: true, signatureNewMailEnabled: false });
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer initialData={{ ...DRAFT, body: 'Draft text' }} onSend={onSend} />);
|
||||
|
||||
// Not embedded into the editable body…
|
||||
await waitFor(() => expect(textarea().value).toBe('Draft text'));
|
||||
// …and not appended at send time either.
|
||||
const sent = await send(onSend);
|
||||
expect(sent.body).toBe('Draft text');
|
||||
});
|
||||
|
||||
it('sends a reply without appending the signature when the reply toggle is off', async () => {
|
||||
useSettingsStore.setState({ signatureReplyForwardEnabled: false });
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer mode="reply" initialData={{ ...DRAFT, mode: 'reply' }} onSend={onSend} />);
|
||||
|
||||
// No read-only signature preview under the editor: it would promise a
|
||||
// signature that never goes out.
|
||||
expect(document.body.textContent).not.toContain('Alice');
|
||||
|
||||
const sent = await send(onSend);
|
||||
expect(sent.htmlBody).not.toContain('Alice');
|
||||
expect(sent.body).not.toContain('Alice');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -136,6 +136,8 @@ vi.mock('@/stores/settings-store', () => {
|
||||
// The default position: replies/forwards get the signature appended at
|
||||
// send time rather than embedded above the quote.
|
||||
signaturePosition: 'below_quote',
|
||||
signatureNewMailEnabled: true,
|
||||
signatureReplyForwardEnabled: true,
|
||||
signatureSeparatorEnabled: true,
|
||||
requestReadReceiptDefault: false,
|
||||
addTrustedSender: () => {},
|
||||
|
||||
@@ -96,6 +96,8 @@ vi.mock('@/stores/settings-store', () => {
|
||||
// The default position: replies/forwards get the signature appended at
|
||||
// send time rather than embedded above the quote.
|
||||
signaturePosition: 'below_quote',
|
||||
signatureNewMailEnabled: true,
|
||||
signatureReplyForwardEnabled: true,
|
||||
signatureSeparatorEnabled: true,
|
||||
requestReadReceiptDefault: false,
|
||||
addTrustedSender: () => {},
|
||||
|
||||
@@ -123,6 +123,8 @@ vi.mock('@/stores/settings-store', () => {
|
||||
// The default position: replies/forwards get the signature appended at
|
||||
// send time rather than embedded above the quote.
|
||||
signaturePosition: 'below_quote',
|
||||
signatureNewMailEnabled: true,
|
||||
signatureReplyForwardEnabled: true,
|
||||
signatureSeparatorEnabled: true,
|
||||
requestReadReceiptDefault: false,
|
||||
addTrustedSender: () => {},
|
||||
@@ -304,6 +306,33 @@ describe('reply signature above the quote (#842)', () => {
|
||||
expect(html).toContain('<i>Sales team</i>');
|
||||
expect(html.indexOf('<i>Sales team</i>')).toBeLessThan(html.indexOf('reply_line'));
|
||||
});
|
||||
|
||||
// #1503: with the delimiter on, a reply started with the "-- " line, so
|
||||
// there was no line above the signature to type the reply into.
|
||||
it('leaves an empty line above the delimiter', () => {
|
||||
useSettingsStore.setState({ signaturePosition: 'above_quote' });
|
||||
setIdentities([ME]);
|
||||
render(<EmailComposer mode="reply" replyTo={TO_ME} />);
|
||||
|
||||
const html = screen.getByTestId('rich-text-editor').getAttribute('data-html')!;
|
||||
expect(html.startsWith('<p></p><p data-signature-block="separator">-- </p>')).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves an empty line above a delimiter that arrives with late identities', async () => {
|
||||
useSettingsStore.setState({ signaturePosition: 'above_quote' });
|
||||
setIdentities([]);
|
||||
const props = { mode: 'reply' as const, replyTo: TO_ME };
|
||||
const { rerender } = render(<EmailComposer {...props} />);
|
||||
setIdentities([ME]);
|
||||
rerender(<EmailComposer {...props} />);
|
||||
|
||||
await waitFor(() => expect(editorState.html).toContain('<b>Alice</b>'));
|
||||
const lines = new DOMParser().parseFromString(editorState.html, 'text/html').body.children;
|
||||
expect(lines[0].tagName).toBe('P');
|
||||
expect(lines[0].textContent).toBe('');
|
||||
expect(lines[0].hasAttribute('data-signature-block')).toBe(false);
|
||||
expect(lines[1].getAttribute('data-signature-block')).toBe('separator');
|
||||
});
|
||||
});
|
||||
|
||||
it('still signs a reply below the quote at send time by default', async () => {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { act, fireEvent, render } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { useComposerSelectionMenu, COMPOSER_SELECTION_SURFACE } from '../composer-selection-menu';
|
||||
import { clearPluginActions, setPluginActions } from '@/lib/plugin-sandbox/actions';
|
||||
|
||||
// The plugins' right-click menu opens only for text the user selected
|
||||
// before the right-click. A word the browser selects as the button goes
|
||||
// down (Chrome on a misspelled word, Safari, Chrome on a Mac) belongs to the
|
||||
// browser's menu with its spelling suggestions.
|
||||
|
||||
let menu: ReturnType<typeof useComposerSelectionMenu>;
|
||||
|
||||
function Body() {
|
||||
menu = useComposerSelectionMenu();
|
||||
const read = () => {
|
||||
const ta = document.querySelector('textarea')!;
|
||||
return ta.value.slice(ta.selectionStart, ta.selectionEnd);
|
||||
};
|
||||
return <textarea defaultValue="Hello teh world" onMouseDown={menu.onMouseDown} onContextMenu={(e) => menu.onContextMenu(e, read)} />;
|
||||
}
|
||||
|
||||
function setup() {
|
||||
render(<Body />);
|
||||
const ta = document.querySelector('textarea')!;
|
||||
ta.focus();
|
||||
return ta;
|
||||
}
|
||||
|
||||
const rightClick = (ta: HTMLTextAreaElement, between?: () => void) => {
|
||||
fireEvent.mouseDown(ta, { button: 2 });
|
||||
between?.();
|
||||
const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 20, clientY: 20 });
|
||||
ta.dispatchEvent(event);
|
||||
return event;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// jsdom has no layout; the keyboard path places the menu under the selection.
|
||||
Range.prototype.getBoundingClientRect = () => new DOMRect(10, 10, 20, 10);
|
||||
setPluginActions('ai', 'AI', COMPOSER_SELECTION_SURFACE, [{ id: 'rewrite', label: 'Rewrite' }]);
|
||||
});
|
||||
afterEach(() => clearPluginActions('ai'));
|
||||
|
||||
describe('composer selection menu', () => {
|
||||
it('opens for text that was selected before the right-click', () => {
|
||||
const ta = setup();
|
||||
ta.setSelectionRange(0, 5);
|
||||
let event!: MouseEvent;
|
||||
act(() => { event = rightClick(ta); });
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(menu.contextMenu.isOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves the browser\'s menu for a word the browser selected on the press', () => {
|
||||
const ta = setup();
|
||||
ta.setSelectionRange(6, 6);
|
||||
const event = rightClick(ta, () => ta.setSelectionRange(6, 9));
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
expect(menu.contextMenu.isOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves it too when the press moved an existing selection to another word', () => {
|
||||
const ta = setup();
|
||||
ta.setSelectionRange(0, 5);
|
||||
expect(rightClick(ta, () => ta.setSelectionRange(6, 9)).defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it('opens from the keyboard (Shift+F10, the Menu key) on the current selection', () => {
|
||||
const ta = setup();
|
||||
ta.setSelectionRange(0, 5);
|
||||
const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true });
|
||||
ta.dispatchEvent(event);
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it('stays out of the way when a proofreading underline already took the click', () => {
|
||||
const ta = setup();
|
||||
ta.setSelectionRange(0, 5);
|
||||
fireEvent.mouseDown(ta, { button: 2 });
|
||||
const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 20, clientY: 20 });
|
||||
event.preventDefault();
|
||||
ta.dispatchEvent(event);
|
||||
expect(menu.contextMenu.isOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import { EmailComposer } from '../email-composer';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
// The real composer around the real editor: a template's data: image goes
|
||||
// out as an inline cid: part, not as a data: URI (#1490).
|
||||
|
||||
// A 1×1 PNG.
|
||||
const PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||
|
||||
const TEMPLATE = vi.hoisted(() => ({
|
||||
id: 'tpl-1',
|
||||
name: 'Offer',
|
||||
category: '',
|
||||
subject: 'Your offer',
|
||||
body: '<p>Your apartment</p><p><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" alt="Floor plan"></p>',
|
||||
isHTML: true,
|
||||
isFavorite: false,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null }));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
|
||||
// The picker hands the composer a template, as choosing one does. It takes
|
||||
// the focus away from the editor first, as the real dialog does.
|
||||
vi.mock('@/components/templates/template-picker', () => ({
|
||||
TemplatePicker: ({ onSelect }: { onSelect: (t: unknown, v: Record<string, string>) => void }) =>
|
||||
React.createElement('button', { 'data-testid': 'pick-template', onClick: () => onSelect(TEMPLATE, {}) }, 'pick'),
|
||||
}));
|
||||
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
|
||||
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
|
||||
vi.mock('@/hooks/use-focus-trap', () => ({
|
||||
useFocusTrap: () => ({ current: null }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
|
||||
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
|
||||
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
|
||||
}));
|
||||
|
||||
// ─── Store mocks ──────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/auth-store', () => {
|
||||
const state = {
|
||||
client: null,
|
||||
identities: [],
|
||||
primaryIdentity: null,
|
||||
isAuthenticated: false,
|
||||
isDemoMode: false,
|
||||
activeAccountId: null,
|
||||
connectionLost: false,
|
||||
getClientForAccount: () => undefined,
|
||||
getAllConnectedClients: () => new Map(),
|
||||
syncIdentities: () => {},
|
||||
refreshIdentities: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAuthStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/identity-store', () => {
|
||||
const state = {
|
||||
identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me', htmlSignature: '<p>Cheers, Me</p>' }],
|
||||
defaultIdentityId: 'id-me',
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useIdentityStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/account-store', () => {
|
||||
const state = { accounts: [], getAccountById: () => undefined };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAccountStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/email-store', () => {
|
||||
const state = {
|
||||
draftSaveEnabled: false,
|
||||
sendRawEmail: async () => ({ sent: true }),
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useEmailStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/settings-store', () => {
|
||||
const state = {
|
||||
timeFormat: '24h',
|
||||
plainTextMode: false,
|
||||
subAddressDelimiter: '+',
|
||||
autoSelectReplyIdentity: true,
|
||||
attachmentReminderEnabled: false,
|
||||
attachmentReminderKeywords: [],
|
||||
emptySubjectWarningEnabled: true,
|
||||
recipientMentionsEnabled: false,
|
||||
sendDelaySeconds: 0,
|
||||
signaturePosition: 'above_quote',
|
||||
signatureSeparatorEnabled: false,
|
||||
requestReadReceiptDefault: false,
|
||||
externalContentPolicy: 'block',
|
||||
isSenderTrusted: () => false,
|
||||
addTrustedSender: () => {},
|
||||
trustedSendersAddressBook: null,
|
||||
updateSetting: () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useSettingsStore: hook, registerTemplateSyncBridge: () => {} };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/contact-store', () => {
|
||||
const state = {
|
||||
contacts: [],
|
||||
getAutocomplete: async () => [],
|
||||
addToTrustedSendersBook: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useContactStore: hook };
|
||||
});
|
||||
|
||||
// ─── Misc dependency mocks ────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/toast-store', () => ({
|
||||
toast: { info: () => {}, error: () => {}, success: () => {} },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
getRecipientSuggestions: { call: async () => [] },
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
contactHooks: {
|
||||
search: { call: async () => [] },
|
||||
onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-threading', () => ({
|
||||
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||
}));
|
||||
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const DRAFT = {
|
||||
to: 'guest@example.com',
|
||||
cc: '',
|
||||
bcc: '',
|
||||
subject: '',
|
||||
body: '<p></p>',
|
||||
showCc: false,
|
||||
showBcc: false,
|
||||
selectedIdentityId: 'id-me',
|
||||
subAddressTag: '',
|
||||
mode: 'compose' as const,
|
||||
draftId: null,
|
||||
};
|
||||
|
||||
const nextFrame = () => act(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
|
||||
|
||||
describe('applying a template with a data: image (#1490)', () => {
|
||||
const range = Range.prototype as unknown as Record<string, unknown>;
|
||||
const doc = document as unknown as Record<string, unknown>;
|
||||
const client = {
|
||||
uploadBlob: vi.fn(async (_file: File) => ({ blobId: 'blob-plan' })),
|
||||
hasDelayedSend: () => false,
|
||||
getMaxDelayedSend: () => 0,
|
||||
};
|
||||
beforeAll(() => {
|
||||
range.getClientRects = () => [];
|
||||
range.getBoundingClientRect = () => ({ x: 0, y: 0, width: 0, height: 0, top: 0, right: 0, bottom: 0, left: 0 });
|
||||
doc.elementFromPoint = () => null;
|
||||
useAuthStore.setState({ client: client as never });
|
||||
});
|
||||
afterAll(() => {
|
||||
delete range.getClientRects;
|
||||
delete range.getBoundingClientRect;
|
||||
delete doc.elementFromPoint;
|
||||
useAuthStore.setState({ client: null });
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('uploads the image and sends it as an inline part shown by cid', async () => {
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer initialData={DRAFT} onSend={onSend} onClose={() => {}} />);
|
||||
await waitFor(() => {
|
||||
const el = document.querySelector('.ProseMirror') as (HTMLElement & { editor?: Editor }) | null;
|
||||
expect(el?.editor).toBeTruthy();
|
||||
});
|
||||
await nextFrame();
|
||||
|
||||
act(() => { fireEvent.keyDown(window, { key: 't' }); });
|
||||
fireEvent.click(await screen.findByTestId('pick-template'));
|
||||
await waitFor(() => expect(screen.queryByTestId('pick-template')).toBeNull());
|
||||
await waitFor(() => expect(document.querySelector('.ProseMirror img')).not.toBeNull());
|
||||
|
||||
expect(client.uploadBlob).toHaveBeenCalledTimes(1);
|
||||
const file = client.uploadBlob.mock.calls[0][0];
|
||||
expect(file.type).toBe('image/png');
|
||||
expect(new Uint8Array(await file.arrayBuffer())).toEqual(Uint8Array.from(atob(PNG), (c) => c.charCodeAt(0)));
|
||||
|
||||
fireEvent.click(screen.getAllByTestId('composer-send')[0]);
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
|
||||
const { htmlBody, attachments } = onSend.mock.calls[0][0];
|
||||
const inline = attachments?.find((a: { disposition?: string }) => a.disposition === 'inline');
|
||||
expect(inline).toMatchObject({ blobId: 'blob-plan', type: 'image/png' });
|
||||
expect(htmlBody).toContain(`src="cid:${inline.cid}"`);
|
||||
expect(htmlBody).not.toContain('data:image');
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,14 @@ vi.mock('@/components/templates/template-picker', () => ({
|
||||
TemplatePicker: ({ onSelect }: { onSelect: (t: unknown, v: Record<string, string>) => void }) =>
|
||||
React.createElement('button', { 'data-testid': 'pick-template', onClick: () => onSelect(TEMPLATE, {}) }, 'pick'),
|
||||
}));
|
||||
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
|
||||
// Save as template: what the composer hands the form.
|
||||
const savedForm = vi.hoisted(() => ({ initialData: null as null | { body?: string; isHTML?: boolean } }));
|
||||
vi.mock('@/components/templates/template-form', () => ({
|
||||
TemplateForm: (props: { initialData?: { body?: string; isHTML?: boolean } }) => {
|
||||
savedForm.initialData = props.initialData ?? null;
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
|
||||
vi.mock('@/hooks/use-focus-trap', () => ({
|
||||
useFocusTrap: () => ({ current: null }),
|
||||
@@ -107,6 +114,8 @@ vi.mock('@/stores/settings-store', () => {
|
||||
recipientMentionsEnabled: false,
|
||||
sendDelaySeconds: 0,
|
||||
signaturePosition: 'above_quote',
|
||||
signatureNewMailEnabled: true,
|
||||
signatureReplyForwardEnabled: true,
|
||||
signatureSeparatorEnabled: false,
|
||||
requestReadReceiptDefault: false,
|
||||
externalContentPolicy: 'block',
|
||||
@@ -272,4 +281,53 @@ describe('applying a template to a reply (#911)', () => {
|
||||
|
||||
expect(textarea.value).toBe('Hello Mr. XDear reader\n\nCheers, Me\n\nreply_line\n> Original message');
|
||||
});
|
||||
|
||||
// An HTML template goes into a plain text draft as its text (#1506).
|
||||
it('puts an HTML template into a plain text reply as text, not markup', async () => {
|
||||
useSettingsStore.setState({ plainTextMode: true });
|
||||
Object.assign(TEMPLATE, { body: '<p>Dear <strong>reader</strong></p>', isHTML: true });
|
||||
try {
|
||||
render(<EmailComposer mode="reply" replyTo={REPLY_TO} />);
|
||||
const textarea = await waitFor(() => {
|
||||
const el = document.querySelector('textarea');
|
||||
expect(el).toBeTruthy();
|
||||
return el as HTMLTextAreaElement;
|
||||
});
|
||||
|
||||
await applyTemplate();
|
||||
|
||||
expect(textarea.value).toContain('Dear reader');
|
||||
expect(textarea.value).not.toContain('<');
|
||||
} finally {
|
||||
Object.assign(TEMPLATE, { body: 'Dear reader', isHTML: false });
|
||||
}
|
||||
});
|
||||
|
||||
describe('saving the draft as a template (#1506)', () => {
|
||||
afterEach(() => {
|
||||
savedForm.initialData = null;
|
||||
});
|
||||
|
||||
async function openSaveAsTemplate() {
|
||||
fireEvent.click(await screen.findByTestId('composer-templates-menu'));
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'save_as_template' }));
|
||||
await waitFor(() => expect(savedForm.initialData).not.toBeNull());
|
||||
return savedForm.initialData!;
|
||||
}
|
||||
|
||||
it('marks a rich text draft as HTML', async () => {
|
||||
await openReply();
|
||||
const data = await openSaveAsTemplate();
|
||||
expect(data.body).toContain('<');
|
||||
expect(data.isHTML).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves a plain text draft as plain text', async () => {
|
||||
useSettingsStore.setState({ plainTextMode: true });
|
||||
render(<EmailComposer mode="reply" replyTo={REPLY_TO} />);
|
||||
await waitFor(() => expect(document.querySelector('textarea')).toBeTruthy());
|
||||
const data = await openSaveAsTemplate();
|
||||
expect(data.isHTML).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ProofreadingStatus } from '../proofreading/proofreading-status';
|
||||
import { ProofreadingNotice } from '../proofreading/proofreading-notice';
|
||||
import { IssuePopup } from '../proofreading/issue-popup';
|
||||
import type { Proofreading } from '../proofreading/use-proofreading';
|
||||
import type { PlacedIssue, ProofreaderStatus } from '@/lib/proofreading/composer-proofreader';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { editorA11yAttributes } from '../rich-text-editor';
|
||||
|
||||
// next-intl is mocked in vitest.setup.ts: t(key) gives the key back.
|
||||
|
||||
const announce = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@/lib/announce', () => ({ announce }));
|
||||
|
||||
const issue = (over: Partial<PlacedIssue> = {}, ann: Partial<PlacedIssue['annotation']> = {}): PlacedIssue => ({
|
||||
key: 'lt/c|s1|9|3|R1', checkerKey: 'lt/c', pluginId: 'lt', checkerId: 'c', checkerLabel: 'LanguageTool',
|
||||
kind: 'grammar', from: 10, to: 13, alternatives: [],
|
||||
annotation: { segmentId: 's1', offset: 9, length: 3, kind: 'grammar', message: 'Did you mean "den"?', replacements: ['den'], ruleId: 'R1', text: 'dem', ...ann },
|
||||
...over,
|
||||
});
|
||||
|
||||
const status = (over: Partial<ProofreaderStatus> = {}): ProofreaderStatus => ({
|
||||
available: true, on: true, userOn: true, forced: false, lockedOff: false, paused: null, checking: false, trouble: null,
|
||||
count: 1, activeLabels: ['LanguageTool'], activePlugins: [{ pluginId: 'lt', label: 'LanguageTool' }],
|
||||
availablePlugins: [{ pluginId: 'lt', label: 'LanguageTool' }], ...over,
|
||||
});
|
||||
|
||||
function proofreading(over: Partial<Proofreading> = {}): Proofreading {
|
||||
return {
|
||||
issues: [issue()], status: status(), composerOn: true, setComposerOn: vi.fn(), apply: vi.fn(() => true), dismiss: vi.fn(),
|
||||
browserSpellcheck: false, sendWarningIssues: () => [], popup: null, openPopup: vi.fn(), closePopup: vi.fn(),
|
||||
panel: null, setPanel: vi.fn(), navigate: vi.fn(() => true), jumpTo: vi.fn(), editorHandlers: { onOpen: vi.fn() },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
announce.mockReset();
|
||||
useSettingsStore.getState().resetToDefaults();
|
||||
});
|
||||
|
||||
describe('ProofreadingStatus', () => {
|
||||
it('shows nothing without a checker the user may use', () => {
|
||||
const { container } = render(<ProofreadingStatus proofreading={proofreading({ status: status({ available: false }) })} plainTextMode={false} />);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('shows the count and reads it out once', () => {
|
||||
render(<ProofreadingStatus proofreading={proofreading()} plainTextMode={false} />);
|
||||
expect(screen.getByTestId('proofreading-status').textContent).toContain('status.issues');
|
||||
expect(announce).toHaveBeenCalledWith('announce_count', expect.objectContaining({ key: 'proofreading-count' }));
|
||||
});
|
||||
|
||||
it('when off, explains what is sent before turning it on', () => {
|
||||
const p = proofreading({ status: status({ on: false, userOn: false, activePlugins: [] }), issues: [], panel: { focusKey: null } });
|
||||
render(<ProofreadingStatus proofreading={p} plainTextMode={false} />);
|
||||
expect(screen.getByTestId('proofreading-status').textContent).toContain('status.off');
|
||||
expect(screen.getByText('panel.notice')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId('proofreading-turn-on'));
|
||||
expect(useSettingsStore.getState().proofreading).toBe(true);
|
||||
expect(useSettingsStore.getState().proofreadingNoticeSeen).toEqual({ lt: true });
|
||||
});
|
||||
|
||||
it('offers this message\'s switch, but not when the organisation requires checking', () => {
|
||||
const p = proofreading({ panel: { focusKey: null } });
|
||||
const { rerender } = render(<ProofreadingStatus proofreading={p} plainTextMode={false} />);
|
||||
fireEvent.click(screen.getByTestId('proofreading-composer-switch'));
|
||||
expect(p.setComposerOn).toHaveBeenCalledWith(false);
|
||||
rerender(<ProofreadingStatus proofreading={proofreading({ panel: { focusKey: null }, status: status({ forced: true }) })} plainTextMode={false} />);
|
||||
expect(screen.queryByTestId('proofreading-composer-switch')).toBeNull();
|
||||
expect(screen.getByText('panel.required')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('says why nothing is checked for a protected message', () => {
|
||||
const p = proofreading({ panel: { focusKey: null }, status: status({ paused: { protected: true, reason: 'encrypt', pluginName: 'S/MIME' }, count: 0 }) });
|
||||
render(<ProofreadingStatus proofreading={p} plainTextMode={false} />);
|
||||
expect(screen.getByTestId('proofreading-status').textContent).toContain('status.paused');
|
||||
expect(screen.getByText('panel.paused_protected')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('proofreading-issue-list')).toBeNull();
|
||||
});
|
||||
|
||||
it('lists issues that jump to the text, each read out with its first suggestion', () => {
|
||||
const p = proofreading({ panel: { focusKey: null } });
|
||||
render(<ProofreadingStatus proofreading={p} plainTextMode={false} />);
|
||||
const item = screen.getByRole('button', { name: /^list_item/ });
|
||||
fireEvent.click(item);
|
||||
expect(p.jumpTo).toHaveBeenCalledWith(expect.objectContaining({ key: issue().key }));
|
||||
});
|
||||
|
||||
it('focuses the issue asked for when the list opens from the keyboard', () => {
|
||||
const p = proofreading({ panel: { focusKey: issue().key } });
|
||||
render(<ProofreadingStatus proofreading={p} plainTextMode={false} />);
|
||||
expect(document.activeElement?.getAttribute('data-issue-key')).toBe(issue().key);
|
||||
});
|
||||
|
||||
it('names the control with what it is about, and groups each plain-text issue', () => {
|
||||
render(<ProofreadingStatus proofreading={proofreading({ panel: { focusKey: null } })} plainTextMode />);
|
||||
expect(screen.getByTestId('proofreading-status').getAttribute('aria-label')).toBe('status.issues, status.title');
|
||||
expect(screen.getByRole('group', { name: /list_item/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not read out "no issues" when a message opens with none', () => {
|
||||
render(<ProofreadingStatus proofreading={proofreading({ status: status({ count: 0 }), issues: [] })} plainTextMode={false} />);
|
||||
expect(announce).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('in plain text, puts the suggestions in the list', () => {
|
||||
const p = proofreading({ panel: { focusKey: null } });
|
||||
render(<ProofreadingStatus proofreading={p} plainTextMode />);
|
||||
fireEvent.click(screen.getByTestId('proofreading-replacement'));
|
||||
expect(p.apply).toHaveBeenCalledWith(expect.objectContaining({ key: issue().key }), 'den');
|
||||
});
|
||||
});
|
||||
|
||||
describe('IssuePopup', () => {
|
||||
const props = (over: Partial<React.ComponentProps<typeof IssuePopup>> = {}) => ({
|
||||
state: { key: issue().key, anchor: new DOMRect(10, 10, 30, 16), via: 'click' as const },
|
||||
issue: issue(), anchor: () => null, onApply: vi.fn(() => true), onDismiss: vi.fn(), onClose: vi.fn(),
|
||||
onNavigate: vi.fn(), textRoot: () => null, container: document.body, ...over,
|
||||
});
|
||||
|
||||
it('puts in a suggestion, and says so when the text changed meanwhile', () => {
|
||||
const p = props({ onApply: vi.fn(() => false) });
|
||||
render(<IssuePopup {...p} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'popup.replace_with' }));
|
||||
expect(p.onApply).toHaveBeenCalledWith(expect.objectContaining({ key: issue().key }), 'den');
|
||||
expect(screen.getByRole('alert').textContent).toBe('popup.changed');
|
||||
});
|
||||
|
||||
it('offers the dictionary for spelling only, and the rule only when there is one', () => {
|
||||
const { rerender } = render(<IssuePopup {...props()} />);
|
||||
expect(screen.queryByText('popup.add_to_dictionary')).toBeNull();
|
||||
expect(screen.getByText('popup.disable_rule')).toBeInTheDocument();
|
||||
rerender(<IssuePopup {...props({ issue: issue({ kind: 'spelling' }, { kind: 'spelling', ruleId: undefined }) })} />);
|
||||
expect(screen.getByText('popup.add_to_dictionary')).toBeInTheDocument();
|
||||
expect(screen.queryByText('popup.disable_rule')).toBeNull();
|
||||
});
|
||||
|
||||
it('takes the focus when opened from the keyboard, moves with the arrows and closes on Escape', () => {
|
||||
const p = props({ state: { key: issue().key, anchor: null, via: 'keyboard' } });
|
||||
render(<IssuePopup {...p} />);
|
||||
const replacement = screen.getByRole('button', { name: 'popup.replace_with' });
|
||||
expect(document.activeElement).toBe(replacement);
|
||||
fireEvent.keyDown(replacement, { key: 'ArrowDown' });
|
||||
expect(document.activeElement?.textContent).toBe('popup.ignore');
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'Escape' });
|
||||
expect(p.onClose).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('goes back to the text on Tab past its last button or Shift+Tab before its first', () => {
|
||||
const p = props({ state: { key: issue().key, anchor: null, via: 'keyboard' } });
|
||||
render(<IssuePopup {...p} />);
|
||||
const items = screen.getByTestId('proofreading-popup').querySelectorAll<HTMLElement>('[data-popup-item]');
|
||||
fireEvent.keyDown(items[0], { key: 'Tab', shiftKey: true });
|
||||
expect(p.onClose).toHaveBeenCalledWith(true);
|
||||
vi.mocked(p.onClose).mockClear();
|
||||
items[items.length - 1].focus();
|
||||
fireEvent.keyDown(items[items.length - 1], { key: 'Tab' });
|
||||
expect(p.onClose).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('steps to the next issue with the shortcut and is described by its message', () => {
|
||||
const p = props({ state: { key: issue().key, anchor: null, via: 'keyboard' } });
|
||||
render(<IssuePopup {...p} />);
|
||||
fireEvent.keyDown(document.activeElement!, { key: "'", code: 'Quote', ctrlKey: true });
|
||||
expect(p.onNavigate).toHaveBeenCalledWith(1);
|
||||
const dialog = screen.getByTestId('proofreading-popup');
|
||||
expect(document.getElementById(dialog.getAttribute('aria-describedby')!)?.textContent).toContain('Did you mean');
|
||||
});
|
||||
|
||||
it('leaves the focus in the text when opened with the mouse', () => {
|
||||
render(<IssuePopup {...props()} />);
|
||||
expect(screen.getByTestId('proofreading-popup').contains(document.activeElement)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProofreadingNotice', () => {
|
||||
it('shows once per checker plugin, worded as the organisation\'s choice when required', () => {
|
||||
const { rerender, container } = render(<ProofreadingNotice status={status({ forced: true })} />);
|
||||
expect(screen.getByTestId('proofreading-notice').textContent).toContain('notice.forced');
|
||||
fireEvent.click(screen.getByText('notice.ok'));
|
||||
expect(useSettingsStore.getState().proofreadingNoticeSeen).toEqual({ lt: true });
|
||||
rerender(<ProofreadingNotice status={status({ forced: true })} />);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('editor attributes', () => {
|
||||
it('sets spellcheck and lang on the editing area', () => {
|
||||
expect(editorA11yAttributes({ spellcheck: false, lang: 'de-DE' })).toMatchObject({ spellcheck: 'false', lang: 'de-DE' });
|
||||
expect(editorA11yAttributes({})).toMatchObject({ spellcheck: 'true' });
|
||||
expect(editorA11yAttributes({})).not.toHaveProperty('lang');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Editor } from '@tiptap/core';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import { undo } from '@tiptap/pm/history';
|
||||
import {
|
||||
applyIssueReplacement, createTextCheckExtension, drawnIssues, selectNextIssue, setTextIssues, type TextIssueRange,
|
||||
} from '../proofreading/text-check-extension';
|
||||
|
||||
const editors: Editor[] = [];
|
||||
afterEach(() => { while (editors.length) editors.pop()!.destroy(); });
|
||||
|
||||
function setup(text: string, onOpen = vi.fn()) {
|
||||
const editor = new Editor({
|
||||
element: document.createElement('div'),
|
||||
extensions: [StarterKit, createTextCheckExtension(() => ({ onOpen }))],
|
||||
content: `<p>${text}</p>`,
|
||||
});
|
||||
editors.push(editor);
|
||||
return { editor, onOpen };
|
||||
}
|
||||
|
||||
/** The range of `word` (first occurrence) in a one-paragraph document. */
|
||||
function range(editor: Editor, word: string, key = word): TextIssueRange {
|
||||
const text = editor.state.doc.textContent;
|
||||
const from = 1 + text.indexOf(word);
|
||||
return { key, from, to: from + word.length, kind: 'grammar', text: word };
|
||||
}
|
||||
|
||||
const type = (editor: Editor, pos: number, text: string) => editor.view.dispatch(editor.state.tr.insertText(text, pos));
|
||||
|
||||
describe('text check underlines', () => {
|
||||
it('draws issues whose text matches and drops those that do not', () => {
|
||||
const { editor } = setup('Ich habe dem Brief geschrieben');
|
||||
setTextIssues(editor, [range(editor, 'dem'), { ...range(editor, 'Brief', 'stale'), text: 'Briefe' }]);
|
||||
expect(drawnIssues(editor.state).map((r) => r.key)).toEqual(['dem']);
|
||||
expect(editor.view.dom.querySelector('[data-text-issue="dem"]')?.className).toContain('text-issue-grammar');
|
||||
// Screen readers hear it as they do the browser's own markers.
|
||||
expect(editor.view.dom.querySelector('[data-text-issue="dem"]')?.getAttribute('aria-invalid')).toBe('grammar');
|
||||
});
|
||||
|
||||
it('moves with typing before it, keeps still for typing after it, and goes when its text is edited', () => {
|
||||
const { editor } = setup('Ich habe dem Brief geschrieben');
|
||||
const dem = range(editor, 'dem');
|
||||
setTextIssues(editor, [dem]);
|
||||
type(editor, 1, 'Ja, ');
|
||||
expect(drawnIssues(editor.state)[0]).toMatchObject({ from: dem.from + 4, to: dem.to + 4 });
|
||||
type(editor, editor.state.doc.content.size - 1, ' heute');
|
||||
expect(drawnIssues(editor.state)[0]).toMatchObject({ from: dem.from + 4 });
|
||||
// Typing right next to it leaves it.
|
||||
type(editor, dem.to + 4, '!');
|
||||
expect(drawnIssues(editor.state)).toHaveLength(1);
|
||||
// Typing inside it drops it.
|
||||
type(editor, dem.from + 5, 'x');
|
||||
expect(drawnIssues(editor.state)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('applies a replacement as one undo step', () => {
|
||||
const { editor } = setup('Ich habe dem Brief geschrieben');
|
||||
setTextIssues(editor, [range(editor, 'dem')]);
|
||||
expect(applyIssueReplacement(editor, 'dem', 'den')).toBe(true);
|
||||
expect(editor.state.doc.textContent).toBe('Ich habe den Brief geschrieben');
|
||||
// Typing right after the fix is a step of its own.
|
||||
type(editor, editor.state.selection.from, 'x');
|
||||
undo(editor.state, editor.view.dispatch);
|
||||
expect(editor.state.doc.textContent).toBe('Ich habe den Brief geschrieben');
|
||||
undo(editor.state, editor.view.dispatch);
|
||||
expect(editor.state.doc.textContent).toBe('Ich habe dem Brief geschrieben');
|
||||
});
|
||||
|
||||
it('keeps the replacement apart from typing just before it', () => {
|
||||
const { editor } = setup('Ich habe dem Brief');
|
||||
setTextIssues(editor, [range(editor, 'dem')]);
|
||||
type(editor, editor.state.doc.content.size - 1, ' heute');
|
||||
applyIssueReplacement(editor, 'dem', 'den');
|
||||
undo(editor.state, editor.view.dispatch);
|
||||
expect(editor.state.doc.textContent).toBe('Ich habe dem Brief heute');
|
||||
});
|
||||
|
||||
it('refuses a replacement when the text is not what the checker saw', () => {
|
||||
const { editor } = setup('Ich habe dem Brief');
|
||||
setTextIssues(editor, [range(editor, 'dem')]);
|
||||
expect(applyIssueReplacement(editor, 'unknown', 'x')).toBe(false);
|
||||
// The underlined word changed (here from another edit than typing).
|
||||
const dem = range(editor, 'dem');
|
||||
editor.view.dispatch(editor.state.tr.insertText('D', dem.from, dem.from + 1).setMeta('addToHistory', false));
|
||||
expect(editor.state.doc.textContent).toBe('Ich habe Dem Brief');
|
||||
expect(applyIssueReplacement(editor, 'dem', 'den')).toBe(false);
|
||||
});
|
||||
|
||||
it('steps through issues from the caret and selects them', () => {
|
||||
const { editor } = setup('teh cat and teh dog');
|
||||
const text = editor.state.doc.textContent;
|
||||
const first = { key: 'a', from: 1, to: 4, kind: 'spelling' as const, text: 'teh' };
|
||||
const second = { key: 'b', from: 1 + text.lastIndexOf('teh'), to: 4 + text.lastIndexOf('teh'), kind: 'spelling' as const, text: 'teh' };
|
||||
setTextIssues(editor, [first, second]);
|
||||
editor.commands.setTextSelection(1);
|
||||
expect(selectNextIssue(editor, 1)).toBe('a');
|
||||
expect(selectNextIssue(editor, 1)).toBe('b');
|
||||
expect(selectNextIssue(editor, 1)).toBe('a');
|
||||
expect(selectNextIssue(editor, -1)).toBe('b');
|
||||
expect(editor.state.selection).toMatchObject({ from: second.from, to: second.to });
|
||||
});
|
||||
|
||||
it('opens the popup on a right-click on an underline instead of the browser menu', () => {
|
||||
const { editor, onOpen } = setup('Ich habe dem Brief');
|
||||
setTextIssues(editor, [range(editor, 'dem')]);
|
||||
const el = editor.view.dom.querySelector('[data-text-issue="dem"]')!;
|
||||
const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 20, clientY: 20 });
|
||||
el.dispatchEvent(event);
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(onOpen).toHaveBeenCalledWith('dem', expect.anything(), 'contextmenu');
|
||||
const plain = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 5, clientY: 5 });
|
||||
editor.view.dom.querySelector('p')!.dispatchEvent(plain);
|
||||
expect(plain.defaultPrevented).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
// Printing a plain-text mail wrote its escaped text into the print window
|
||||
// with no white-space rule, so every line break collapsed into one run-on
|
||||
// paragraph, and a trailing ">" quote printed as the collapsed "•••" toggle
|
||||
// with the quoted text left out.
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
|
||||
useMediaQuery: () => false,
|
||||
useIsMobile: () => false,
|
||||
}));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({
|
||||
useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
|
||||
}));
|
||||
|
||||
class NoopResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', NoopResizeObserver);
|
||||
|
||||
const { EmailViewer } = await import('../email-viewer');
|
||||
|
||||
const mailboxes = [
|
||||
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
|
||||
] as unknown as Mailbox[];
|
||||
|
||||
const TEXT = 'Hi Bob,\n\nline one\nline two\n\nOn Monday you wrote:\n> the old question\n> second quoted line\n';
|
||||
|
||||
function plainMail(): Email {
|
||||
return {
|
||||
id: 'e1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
|
||||
from: [{ name: 'Alice', email: 'alice@example.com' }], to: [{ name: 'Bob', email: 'bob@example.com' }],
|
||||
subject: 'Hello there', receivedAt: '2026-01-01T00:00:00Z', preview: 'Hi', hasAttachment: false, size: 100,
|
||||
textBody: [{ partId: '1', type: 'text/plain' }], htmlBody: [{ partId: '1', type: 'text/plain' }], attachments: [],
|
||||
bodyValues: { '1': { value: TEXT, isTruncated: false } },
|
||||
} as unknown as Email;
|
||||
}
|
||||
|
||||
function printToDocument() {
|
||||
const popupDoc = document.implementation.createHTMLDocument('print');
|
||||
let written = '';
|
||||
const popup = {
|
||||
opener: window as Window | null,
|
||||
document: {
|
||||
write: (html: string) => { written += html; },
|
||||
close: () => { popupDoc.documentElement.innerHTML = written; },
|
||||
},
|
||||
focus: vi.fn(),
|
||||
print: vi.fn(),
|
||||
};
|
||||
vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window);
|
||||
render(<EmailViewer email={plainMail()} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
|
||||
fireEvent.click(screen.getAllByTitle('print')[0]);
|
||||
expect(popup.print).toHaveBeenCalled();
|
||||
return popupDoc;
|
||||
}
|
||||
|
||||
// The declarations of the print stylesheet's rules that apply to `el`.
|
||||
function appliedRules(doc: Document, el: Element): string {
|
||||
const css = Array.from(doc.querySelectorAll('style')).map((s) => s.textContent ?? '').join('\n');
|
||||
const rules = css.replace(/\/\*[\s\S]*?\*\//g, '').matchAll(/([^{}]+)\{([^{}]*)\}/g);
|
||||
return Array.from(rules)
|
||||
.filter(([, selector]) => {
|
||||
try { return el.matches(selector.trim()); } catch { return false; }
|
||||
})
|
||||
.map(([, , body]) => body)
|
||||
.join(';');
|
||||
}
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('printing a plain-text email', () => {
|
||||
it('keeps its line breaks', () => {
|
||||
const doc = printToDocument();
|
||||
const body = doc.querySelector('.body')!;
|
||||
|
||||
expect(body.textContent).toContain('line one\nline two');
|
||||
expect(appliedRules(doc, body)).toMatch(/white-space:\s*pre-wrap/);
|
||||
}, 30_000);
|
||||
|
||||
it('prints the trailing quote opened, without the toggle', () => {
|
||||
const doc = printToDocument();
|
||||
const details = doc.querySelector('.body details')!;
|
||||
|
||||
expect(details.hasAttribute('open')).toBe(true);
|
||||
expect(details.textContent).toContain('> the old question');
|
||||
expect(appliedRules(doc, details.querySelector('summary')!)).toMatch(/display:\s*none/);
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -194,6 +194,30 @@ describe('the reader toolbar and the plugin side panel', () => {
|
||||
expect(mounts['email-detail-sidebar']).toBe(1);
|
||||
});
|
||||
|
||||
it('loads a message with its toolbar where the loaded reader puts it', () => {
|
||||
// The loading state lifts the toolbar above the message row too, so the
|
||||
// row stays put when the message arrives: the same element at the same
|
||||
// place (a moved row counts as a layout shift).
|
||||
const { container, rerender } = render(view({ email: null, isLoading: true }));
|
||||
const root = container.firstElementChild as HTMLElement;
|
||||
expect(root.children).toHaveLength(2);
|
||||
const row = root.children[1];
|
||||
expect(row.className).toContain('flex-row');
|
||||
expect(root.children[0].querySelector('.sk-shape')).toBeTruthy();
|
||||
rerender(view({ email, isLoading: false }));
|
||||
expect(root.querySelector('[data-viewer-top-toolbar="full-width"]')).toBe(root.children[0]);
|
||||
expect(root.children[1]).toBe(row);
|
||||
});
|
||||
|
||||
it('loads a message on a phone with the toolbar in the message column', () => {
|
||||
device.isMobile = true;
|
||||
const { container } = render(
|
||||
<EmailViewer email={null} isLoading mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onDelete={() => {}} />,
|
||||
);
|
||||
const root = container.firstElementChild as HTMLElement;
|
||||
expect(root.children).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps the toolbar in the message column on a phone', () => {
|
||||
device.isMobile = true;
|
||||
show();
|
||||
|
||||
@@ -45,6 +45,7 @@ import { participantsAskedAgain } from '@/lib/calendar-participants';
|
||||
import { planCancellationRemoval, sameRecurrenceId, type CancellationRemoval } from '@/lib/calendar-cancellation';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getEventStartDate } from '@/lib/calendar-utils';
|
||||
import { descriptionAsText } from '@/lib/calendar-description';
|
||||
import { sanitizeColor } from '@/components/calendar/event-card';
|
||||
import { RecipientPopover } from './recipient-popover';
|
||||
import { useCalendarErrorMessage } from '@/hooks/use-calendar-error-message';
|
||||
@@ -332,11 +333,13 @@ function buildInvitationChangeItems(
|
||||
});
|
||||
}
|
||||
|
||||
if ((currentEvent.description ?? '') !== (proposedEvent.description ?? '') && proposedEvent.description) {
|
||||
const currentDescription = descriptionAsText(currentEvent);
|
||||
const proposedDescription = descriptionAsText(proposedEvent);
|
||||
if (currentDescription !== proposedDescription && proposedDescription) {
|
||||
changes.push({
|
||||
label: t('change_description'),
|
||||
before: currentEvent.description || t('change_empty'),
|
||||
after: proposedEvent.description,
|
||||
before: currentDescription || t('change_empty'),
|
||||
after: proposedDescription,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -139,7 +139,19 @@ export function replaceEmbeddedSignature(
|
||||
? children.find((node) => node.textContent?.includes(options.quoteHeader!))
|
||||
: undefined;
|
||||
const quote = header ?? children.find(isQuoteBoundary) ?? null;
|
||||
removed = { parent: doc.body, insertBefore: quote };
|
||||
// With no line above it, the signature would open the body and leave no
|
||||
// place to type (#1503). Put an empty one first, as getInitialBody does,
|
||||
// and keep the bare line breaks for the gap down to the quote.
|
||||
const above = quote ? children.slice(0, children.indexOf(quote)) : children;
|
||||
const hasLineAbove = above.some((node) => node.nodeType === 1
|
||||
? (node as Element).tagName !== 'BR'
|
||||
: !!node.textContent?.trim());
|
||||
if (!hasLineAbove) {
|
||||
removed = { parent: doc.body, insertBefore: above[0] ?? quote };
|
||||
signatureHtml = `<p></p>${signatureHtml}`;
|
||||
} else {
|
||||
removed = { parent: doc.body, insertBefore: quote };
|
||||
}
|
||||
}
|
||||
const host = doc.createElement('div');
|
||||
host.innerHTML = signatureHtml;
|
||||
@@ -190,23 +202,49 @@ export function replacePlainTextSignatureAboveQuote(
|
||||
* Convert the rich-text editor's body to a plain-text body. Formatting is
|
||||
* dropped (that is the point of plain text); an embedded signature comes back
|
||||
* as the identity's plain-text signature so the body still "carries" it.
|
||||
* The quote keeps its "> " lines and lists their markers, as in the text part
|
||||
* the send path builds: the attachment reminder and plugin edits find the
|
||||
* quote by them.
|
||||
*/
|
||||
export function htmlComposeBodyToPlainText(
|
||||
html: string,
|
||||
identity: SignatureIdentityLike,
|
||||
options: { separator: boolean },
|
||||
options: { separator: boolean; imageAlt?: (alt: string) => string },
|
||||
): string {
|
||||
const stripped = stripEmbeddedSignature(html);
|
||||
const text = htmlToPlainText(stripped.html, { paragraphSpacing: true });
|
||||
const text = htmlToPlainText(stripped.html, { paragraphSpacing: true, mailMarkup: true, imageAlt: options.imageAlt });
|
||||
if (!stripped.hadSignature) return text;
|
||||
return appendPlainTextSignature(text, identity, { separator: options.separator });
|
||||
}
|
||||
|
||||
const QUOTE_LINE = /^>/;
|
||||
|
||||
// Plain text to editor HTML, with runs of "> " lines as a <blockquote> (one
|
||||
// level per ">"), so a quote stays a quote in the rich editor.
|
||||
function plainTextWithQuotesToHtml(text: string): string {
|
||||
const lines = text.replace(/\r\n?/g, '\n').split('\n');
|
||||
if (!lines.some((line) => QUOTE_LINE.test(line))) return plainTextToComposerBody(text);
|
||||
const parts: string[] = [];
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const quoted = QUOTE_LINE.test(lines[i]);
|
||||
const run: string[] = [];
|
||||
while (i < lines.length && QUOTE_LINE.test(lines[i]) === quoted) run.push(lines[i++]);
|
||||
if (quoted) {
|
||||
parts.push(`<blockquote>${plainTextWithQuotesToHtml(run.map((line) => line.replace(/^> ?/, '')).join('\n')) || '<p></p>'}</blockquote>`);
|
||||
} else {
|
||||
parts.push(plainTextToComposerBody(run.join('\n').replace(/^\n+|\n+$/g, '')));
|
||||
}
|
||||
}
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a plain-text body to editor HTML: paragraphs per blank line, <br>
|
||||
* per newline. A trailing plain-text signature is swapped for the embedded
|
||||
* (marker-bracketed) HTML signature so identity switches and the send path
|
||||
* keep working as in a body that started out as HTML.
|
||||
* per newline, "> " lines as a quote. A trailing plain-text signature is
|
||||
* swapped for the embedded (marker-bracketed) HTML signature so identity
|
||||
* switches and the send path keep working as in a body that started out as
|
||||
* HTML.
|
||||
*/
|
||||
export function plainComposeBodyToHtml(
|
||||
text: string,
|
||||
@@ -215,7 +253,7 @@ export function plainComposeBodyToHtml(
|
||||
): string {
|
||||
const hadSignature = plainTextBodyHasSignature(text, identity);
|
||||
const userText = hadSignature ? plainTextBodyWithoutSignature(text, identity) : text;
|
||||
const html = plainTextToComposerBody(userText);
|
||||
const html = plainTextWithQuotesToHtml(userText);
|
||||
if (!hadSignature) return html;
|
||||
const embedded = buildEmbeddedSignatureHtml(identity, { embed: true, separator: options.separator });
|
||||
return `${html || '<p></p>'}${embedded}`;
|
||||
|
||||
@@ -6,8 +6,15 @@
|
||||
// its spelling suggestions. Cut and Copy are kept, since the menu replaces
|
||||
// the one that had them. A click on a plugin entry carries the composer id
|
||||
// and the selected text.
|
||||
//
|
||||
// It opens only for text the user had selected before the right-click.
|
||||
// Chrome (a misspelled word, any word on a Mac) and Safari select the word
|
||||
// under the pointer as the button goes down; that word belongs to the
|
||||
// browser's menu and its spelling suggestions, not to ours. A right-click on
|
||||
// a proofreading underline opens that issue's popup instead (the editor
|
||||
// handles it first).
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
||||
import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions";
|
||||
@@ -39,16 +46,40 @@ export function selectionMenuPoint(
|
||||
return rect ? { x: rect.left + 16, y: rect.top + 16 } : { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* The selected text as the browser has it right now: ProseMirror's own
|
||||
* selection catches up only on the next selectionchange, after the
|
||||
* contextmenu event.
|
||||
*/
|
||||
export function liveSelectionText(): string {
|
||||
const el = document.activeElement;
|
||||
if (el instanceof HTMLTextAreaElement) return el.value.slice(el.selectionStart ?? 0, el.selectionEnd ?? 0);
|
||||
return window.getSelection()?.toString() ?? "";
|
||||
}
|
||||
|
||||
/** The menu's state and the contextmenu handler for the message text. */
|
||||
export function useComposerSelectionMenu() {
|
||||
const actions = usePluginActions(COMPOSER_SELECTION_SURFACE);
|
||||
const menu = useContextMenu<SelectionMenuTarget>();
|
||||
const { openContextMenu } = menu;
|
||||
const enabled = actions.length > 0;
|
||||
// The selection when the right button went down, before the browser moved it.
|
||||
const beforeRef = useRef<{ text: string; at: number } | null>(null);
|
||||
const onMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if (e.button !== 2) return;
|
||||
beforeRef.current = { text: liveSelectionText(), at: Date.now() };
|
||||
}, []);
|
||||
const onContextMenu = useCallback((e: React.MouseEvent, read: () => string) => {
|
||||
if (!enabled) return;
|
||||
if (!enabled || e.defaultPrevented) return;
|
||||
const text = read();
|
||||
if (!text.trim()) return;
|
||||
const before = beforeRef.current;
|
||||
beforeRef.current = null;
|
||||
// From the mouse: only when that selection was there already and the
|
||||
// press didn't change it. From the keyboard (Shift+F10, the Menu key)
|
||||
// there is no press, and the selection is the user's.
|
||||
const fromMouse = !!before && Date.now() - before.at < 2000;
|
||||
if (fromMouse && (!before.text.trim() || before.text !== liveSelectionText())) return;
|
||||
const point = selectionMenuPoint(e);
|
||||
openContextMenu({
|
||||
preventDefault: () => e.preventDefault(),
|
||||
@@ -57,7 +88,7 @@ export function useComposerSelectionMenu() {
|
||||
clientY: point.y,
|
||||
} as React.MouseEvent, { text });
|
||||
}, [enabled, openContextMenu]);
|
||||
return { ...menu, onContextMenu, enabled };
|
||||
return { ...menu, onContextMenu, onMouseDown, enabled };
|
||||
}
|
||||
|
||||
export function ComposerSelectionMenu({
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { useMvComposeWindow } from "@/components/email/mv-compose-context";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { Button, TOUCH_HIT_AREA } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
@@ -103,7 +103,7 @@ import { OrgDisclaimerPreview } from "@/components/email/org-disclaimer-preview"
|
||||
import "@/lib/org/outgoing";
|
||||
import { SubAddressHelper } from "@/components/identity/sub-address-helper";
|
||||
import { generateSubAddress } from "@/lib/sub-addressing";
|
||||
import { substitutePlaceholders, spliceTemplateAboveSignature, composeBodyHasUserContent } from "@/lib/template-utils";
|
||||
import { substitutePlaceholders, spliceTemplateAboveSignature, composeBodyHasUserContent, embedTemplateImages } from "@/lib/template-utils";
|
||||
import { findUnfilledPlaceholders, stripEmbeddedSignatureHtml } from "@/lib/placeholder-check";
|
||||
import enMessages from "@/locales/en/common.json";
|
||||
import { TemplatePicker } from "@/components/templates/template-picker";
|
||||
@@ -155,6 +155,11 @@ import { announce as announceStatus } from "@/lib/announce";
|
||||
import { isReportPart, normalizeCid } from "@/lib/attachment-visibility";
|
||||
import { unnamedAttachmentName } from "@/lib/attachment-name";
|
||||
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
||||
import { useProofreading } from "@/components/email/proofreading/use-proofreading";
|
||||
import { IssuePopup } from "@/components/email/proofreading/issue-popup";
|
||||
import { ProofreadingStatus } from "@/components/email/proofreading/proofreading-status";
|
||||
import { ProofreadingNotice } from "@/components/email/proofreading/proofreading-notice";
|
||||
import { issueRect } from "@/components/email/proofreading/text-check-extension";
|
||||
import { isRecipientMentionActive } from "@/components/email/recipient-mention";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { htmlToPlainText as htmlToPlainTextShared } from "@/lib/html-to-text";
|
||||
@@ -499,6 +504,7 @@ export function EmailComposer({
|
||||
const tQuote = useTranslations('quote_header');
|
||||
const tViewer = useTranslations('email_viewer');
|
||||
const tClassification = useTranslations('classification');
|
||||
const tProofreading = useTranslations('proofreading');
|
||||
const tOffline = useTranslations('offline');
|
||||
const tSendGuards = useTranslations('send_guards');
|
||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
@@ -509,6 +515,10 @@ export function EmailComposer({
|
||||
const [plainTextMode, setPlainTextMode] = useState<boolean>(
|
||||
() => initialDataProp?.plainTextMode ?? plainTextModeDefault
|
||||
);
|
||||
// The format right now, for async work that writes into the body after an
|
||||
// await: the user may have switched it meanwhile.
|
||||
const plainTextModeRef = useRef(plainTextMode);
|
||||
plainTextModeRef.current = plainTextMode;
|
||||
// A plugin's onBeforeCompose prefill (plain text body) folded into what the
|
||||
// composer starts from: over `initialData` when the host hands one in (a
|
||||
// mailto:, a re-opened draft), otherwise over the fields built below.
|
||||
@@ -547,6 +557,13 @@ export function EmailComposer({
|
||||
const updateSetting = useSettingsStore((state) => state.updateSetting);
|
||||
const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds);
|
||||
const signaturePosition = useSettingsStore((state) => state.signaturePosition);
|
||||
const signatureNewMailEnabled = useSettingsStore((state) => state.signatureNewMailEnabled);
|
||||
const signatureReplyForwardEnabled = useSettingsStore((state) => state.signatureReplyForwardEnabled);
|
||||
// Whether automatic signature insertion is on for this composer mode: new
|
||||
// mail (and re-opened drafts, which open in compose mode) follow the
|
||||
// new-mail toggle, replies and forwards the reply/forward one. What the
|
||||
// body already carries is never stripped - only the insert/append is gated.
|
||||
const autoSignatureEnabled = mode === 'compose' ? signatureNewMailEnabled : signatureReplyForwardEnabled;
|
||||
const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled);
|
||||
const requestReadReceiptDefault = useSettingsStore((state) => state.requestReadReceiptDefault);
|
||||
// A draft belongs to the account the composer was opened in. Switching
|
||||
@@ -622,12 +639,13 @@ export function EmailComposer({
|
||||
const shouldEmbedSignatureAboveQuote =
|
||||
(mode === 'reply' || mode === 'replyAll' || mode === 'forward') &&
|
||||
signaturePosition === 'above_quote' &&
|
||||
signatureReplyForwardEnabled &&
|
||||
hasInitialSignature;
|
||||
// New-mail composes always embed the signature into the editor body so it's
|
||||
// editable/removable (the previous read-only preview below the editor was
|
||||
// never spec-correct - see #329). Compose mode also ignores any leftover
|
||||
// `replyTo` from a still-selected email; getInitialBody short-circuits below.
|
||||
const shouldEmbedSignatureInNewMail = mode === 'compose' && hasInitialSignature;
|
||||
const shouldEmbedSignatureInNewMail = mode === 'compose' && signatureNewMailEnabled && hasInitialSignature;
|
||||
|
||||
// Format a single EmailAddress for display in the composer input
|
||||
const toRecipient = (r: { name?: string; email?: string }): Recipient =>
|
||||
@@ -771,10 +789,13 @@ export function EmailComposer({
|
||||
: (from.email || from.name || tCommon('unknown')))
|
||||
: tCommon('unknown');
|
||||
|
||||
// Like a new mail, an empty line above the signature gives the cursor a
|
||||
// place for the reply. Without it the reply started on the "-- " line (#1503).
|
||||
const signatureBlock = buildEmbeddedSignatureHtml(initialSignatureIdentity, {
|
||||
embed: shouldEmbedSignatureAboveQuote,
|
||||
separator: signatureSeparatorEnabled,
|
||||
});
|
||||
const head = `${prefix || (signatureBlock ? '<p></p>' : '')}${signatureBlock}`;
|
||||
|
||||
// Whether the quoted original may show its remote images in the editor:
|
||||
// the viewer's rule, minus the per-message "load" click it doesn't know.
|
||||
@@ -798,7 +819,7 @@ export function EmailComposer({
|
||||
rewriteCidImagesForEditor(sanitizeEmailHtml(replyTo.htmlBody)),
|
||||
{ remoteAllowed: quoteRemoteAllowed, showQuoteBorder: replyTo.quoteWrapInBlockquote !== false },
|
||||
);
|
||||
return `${prefix}${signatureBlock}<br>${replyHeader(replyTo.quoteHeaderHtml)}${island}`;
|
||||
return `${head}<br>${replyHeader(replyTo.quoteHeaderHtml)}${island}`;
|
||||
}
|
||||
const escaped = replyTo.body
|
||||
? replyTo.body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')
|
||||
@@ -807,7 +828,7 @@ export function EmailComposer({
|
||||
const bodyHtml = wrap
|
||||
? `<blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escaped}</blockquote>`
|
||||
: escaped;
|
||||
return `${prefix}${signatureBlock}<br>${replyHeader(replyTo.quoteHeaderHtml)}${bodyHtml}`;
|
||||
return `${head}<br>${replyHeader(replyTo.quoteHeaderHtml)}${bodyHtml}`;
|
||||
}
|
||||
|
||||
// Build quoted content as HTML
|
||||
@@ -825,15 +846,15 @@ export function EmailComposer({
|
||||
rewriteCidImagesForEditor(sanitizeEmailHtml(replyTo.htmlBody)),
|
||||
{ remoteAllowed: quoteRemoteAllowed },
|
||||
);
|
||||
return `${prefix}${signatureBlock}<br>${replyHeader(`<div>${quoteHeader}</div>`)}${island}`;
|
||||
return `${head}<br>${replyHeader(`<div>${quoteHeader}</div>`)}${island}`;
|
||||
}
|
||||
|
||||
if (replyTo.body) {
|
||||
const escapedOriginal = replyTo.body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>');
|
||||
if (mode === 'forward') {
|
||||
return `${prefix}${signatureBlock}<br><br>${tQuote('forwarded_separator')}<br>${tQuote('from_label')}: ${escapeHtml(fromStrFull)}<br>${tQuote('date_label')}: ${escapeHtml(date)}<br>${tQuote('subject_label')}: ${escapeHtml(replyTo.subject || '')}<br><br>${escapedOriginal}`;
|
||||
return `${head}<br><br>${tQuote('forwarded_separator')}<br>${tQuote('from_label')}: ${escapeHtml(fromStrFull)}<br>${tQuote('date_label')}: ${escapeHtml(date)}<br>${tQuote('subject_label')}: ${escapeHtml(replyTo.subject || '')}<br><br>${escapedOriginal}`;
|
||||
} else if (mode === 'reply' || mode === 'replyAll') {
|
||||
return `${prefix}${signatureBlock}<br><br>${replyHeader(`<p>${tQuote('reply_line', { date: escapeHtml(date), from: escapeHtml(fromStrFull) })}<br></p>`)}<blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escapedOriginal}</blockquote>`;
|
||||
return `${head}<br><br>${replyHeader(`<p>${tQuote('reply_line', { date: escapeHtml(date), from: escapeHtml(fromStrFull) })}<br></p>`)}<blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escapedOriginal}</blockquote>`;
|
||||
}
|
||||
}
|
||||
return prefix;
|
||||
@@ -1087,6 +1108,9 @@ export function EmailComposer({
|
||||
const [emptySubjectDelayedUntil, setEmptySubjectDelayedUntil] = useState<string | undefined>();
|
||||
const [emptySubjectDontAskAgain, setEmptySubjectDontAskAgain] = useState(false);
|
||||
const [showPlaceholderWarning, setShowPlaceholderWarning] = useState(false);
|
||||
// A proofreading checker asked to warn before sending with open issues.
|
||||
const [proofreadingWarning, setProofreadingWarning] = useState<{ count: number; delayedUntil?: string } | null>(null);
|
||||
const proofreadingConfirmedRef = useRef(false);
|
||||
const [placeholderWarningMatches, setPlaceholderWarningMatches] = useState<string[]>([]);
|
||||
const [placeholderWarningDelayedUntil, setPlaceholderWarningDelayedUntil] = useState<string | undefined>();
|
||||
const [placeholderDontAskAgain, setPlaceholderDontAskAgain] = useState(false);
|
||||
@@ -1317,6 +1341,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
|
||||
@@ -1358,7 +1385,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);
|
||||
@@ -1928,12 +1955,12 @@ export function EmailComposer({
|
||||
const editor = editorRef.current;
|
||||
const html = editor && !editor.isDestroyed ? serializeEditorContent(editor) : body;
|
||||
editorRef.current = null;
|
||||
setBody(htmlComposeBodyToPlainText(html, signatureIdentity, { separator }));
|
||||
setBody(htmlComposeBodyToPlainText(html, signatureIdentity, { separator, imageAlt: imageAltText }));
|
||||
setPlainTextMode(true);
|
||||
// Keyed on the signature fields, not the identity object (see the
|
||||
// signature effects above for the same rationale).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [plainTextMode, body, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled]);
|
||||
}, [plainTextMode, body, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, imageAltText]);
|
||||
|
||||
// Move a chip from one recipient field to another. `toIndex`, when given,
|
||||
// inserts at that position in the destination (drag-and-drop reordering,
|
||||
@@ -2103,18 +2130,55 @@ export function EmailComposer({
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageUpload = useCallback(async (
|
||||
file: File,
|
||||
): Promise<{ src: string; cid: string } | null> => {
|
||||
if (!client) return null;
|
||||
try {
|
||||
const readAsDataUrl = new Promise<string | null>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => resolve((e.target?.result as string) ?? null);
|
||||
reader.onerror = () => resolve(null);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
const [{ blobId }, dataUrl] = await Promise.all([
|
||||
(composerClientRef.current ?? client).uploadBlob(file),
|
||||
readAsDataUrl,
|
||||
]);
|
||||
if (!dataUrl) throw new Error('Failed to read image as data URL');
|
||||
const cid = `${generateUUID()}@webmail`;
|
||||
inlineImagesRef.current.push({
|
||||
cid,
|
||||
blobId,
|
||||
type: file.type || 'application/octet-stream',
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
dataUrl,
|
||||
uploadedAt: Date.now(),
|
||||
});
|
||||
return { src: dataUrl, cid };
|
||||
} catch (error) {
|
||||
debug.error(`Failed to upload inline image ${file.name}:`, error);
|
||||
toast.error(t('upload_failed', { filename: file.name }));
|
||||
return null;
|
||||
}
|
||||
}, [client, t]);
|
||||
|
||||
const handleTemplateSelect = useCallback(async (template: EmailTemplate, filledValues: Record<string, string>) => {
|
||||
let filledSubject = Object.keys(filledValues).length > 0
|
||||
? substitutePlaceholders(template.subject, filledValues)
|
||||
: template.subject;
|
||||
const filledBody = Object.keys(filledValues).length > 0
|
||||
? substitutePlaceholders(template.body, filledValues)
|
||||
? substitutePlaceholders(template.body, filledValues, { html: !!template.isHTML })
|
||||
: template.body;
|
||||
|
||||
// In plain text mode, use template body as-is; otherwise convert to HTML
|
||||
let bodyContent = plainTextMode || template.isHTML
|
||||
? filledBody
|
||||
: `<p>${filledBody.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</p>`;
|
||||
// Match the template to the draft's format: an HTML template goes into a
|
||||
// plain-text draft as its text, not its markup (#1506).
|
||||
let bodyContent = plainTextMode
|
||||
? (template.isHTML ? htmlToPlainText(filledBody) : filledBody)
|
||||
: template.isHTML
|
||||
? filledBody
|
||||
: `<p>${filledBody.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</p>`;
|
||||
// Plugins may rewrite the template before it goes in (onTemplateRender).
|
||||
const rendered = await renderTemplateWithPlugins(template.id, {
|
||||
subject: filledSubject,
|
||||
@@ -2125,6 +2189,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).
|
||||
@@ -2210,7 +2276,7 @@ export function EmailComposer({
|
||||
|
||||
setShowTemplatePicker(false);
|
||||
notifyTemplateApplied(template);
|
||||
}, [mode, plainTextMode, signatureIdentity, signatureSeparatorEnabled]);
|
||||
}, [mode, plainTextMode, signatureIdentity, signatureSeparatorEnabled, handleImageUpload]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleTemplateKey = (e: KeyboardEvent) => {
|
||||
@@ -2320,7 +2386,9 @@ export function EmailComposer({
|
||||
// offloading it - don't drop a link for a file they cancelled.
|
||||
if (controller?.signal.aborted) continue;
|
||||
setAttachments(prev => prev.filter(att => att.file !== file));
|
||||
if (plainTextMode) {
|
||||
// The ref, not the closure: the user may have switched format
|
||||
// while the handler was offloading the file.
|
||||
if (plainTextModeRef.current) {
|
||||
setBody(previous =>
|
||||
previous && !previous.endsWith('\n') ? `${previous}\n${transformed.text}` : previous + transformed.text
|
||||
);
|
||||
@@ -2391,40 +2459,6 @@ export function EmailComposer({
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [client, t, plainTextMode]);
|
||||
|
||||
const handleImageUpload = useCallback(async (
|
||||
file: File,
|
||||
): Promise<{ src: string; cid: string } | null> => {
|
||||
if (!client) return null;
|
||||
try {
|
||||
const readAsDataUrl = new Promise<string | null>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => resolve((e.target?.result as string) ?? null);
|
||||
reader.onerror = () => resolve(null);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
const [{ blobId }, dataUrl] = await Promise.all([
|
||||
(composerClientRef.current ?? client).uploadBlob(file),
|
||||
readAsDataUrl,
|
||||
]);
|
||||
if (!dataUrl) throw new Error('Failed to read image as data URL');
|
||||
const cid = `${generateUUID()}@webmail`;
|
||||
inlineImagesRef.current.push({
|
||||
cid,
|
||||
blobId,
|
||||
type: file.type || 'application/octet-stream',
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
dataUrl,
|
||||
uploadedAt: Date.now(),
|
||||
});
|
||||
return { src: dataUrl, cid };
|
||||
} catch (error) {
|
||||
debug.error(`Failed to upload inline image ${file.name}:`, error);
|
||||
toast.error(t('upload_failed', { filename: file.name }));
|
||||
return null;
|
||||
}
|
||||
}, [client, t]);
|
||||
|
||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -2658,7 +2692,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,
|
||||
@@ -2666,7 +2700,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);
|
||||
@@ -2929,8 +2963,6 @@ export function EmailComposer({
|
||||
// catalogue): plain text at the caret, replacing the selection, so a
|
||||
// rewrite of a selected paragraph is one call and Ctrl+Z takes it back in
|
||||
// the rich editor. Stable, so the slot iframes don't get new props.
|
||||
const plainTextModeRef = useRef(plainTextMode);
|
||||
plainTextModeRef.current = plainTextMode;
|
||||
const pluginInsertText = useCallback((value: unknown) => {
|
||||
if (typeof value !== 'string' || value.length === 0 || value.length > MAX_PLUGIN_INSERT_LENGTH) return false;
|
||||
if (plainTextModeRef.current) {
|
||||
@@ -3114,6 +3146,21 @@ export function EmailComposer({
|
||||
};
|
||||
}, [wantsPluginSelection, pluginEditor, plainTextMode]);
|
||||
|
||||
// Proofreading: checker plugins underline mistakes while the user types
|
||||
// (components/email/proofreading). The browser's own spell check steps
|
||||
// back while one checks spelling, and the text carries its language.
|
||||
const uiLocale = useLocale();
|
||||
const writingLanguage = useSettingsStore((s) => s.writingLanguage);
|
||||
const textLang = writingLanguage && writingLanguage !== 'auto' ? writingLanguage : uiLocale;
|
||||
const proofreading = useProofreading({
|
||||
composerId: pluginComposerId,
|
||||
sessionKey: effectiveSessionKey,
|
||||
editor: pluginEditor,
|
||||
textareaRef: bodyRef,
|
||||
plainTextMode,
|
||||
ownOptions: pluginEditOptsRef,
|
||||
});
|
||||
|
||||
const pluginDraftProps = useMemo(
|
||||
() => ({ draft: pluginDraft, selection: pluginSelection, insertText: pluginInsertText, getSelectedText: pluginSelectedText, composerId: pluginComposerId }),
|
||||
[pluginDraft, pluginSelection, pluginInsertText, pluginSelectedText, pluginComposerId],
|
||||
@@ -3510,6 +3557,16 @@ export function EmailComposer({
|
||||
}
|
||||
}
|
||||
|
||||
// Open proofreading issues, when a checker asked for the warning (the
|
||||
// LanguageTool plugin's "warn before sending" option). Once per message.
|
||||
if (!proofreadingConfirmedRef.current) {
|
||||
const open = proofreading.sendWarningIssues().length;
|
||||
if (open > 0) {
|
||||
setProofreadingWarning({ count: open, delayedUntil });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Checks other features register (lib/outgoing/send-pipeline.ts), such
|
||||
// as the external-recipient confirm. They may ask the user, so a second
|
||||
// click meanwhile does nothing.
|
||||
@@ -3611,9 +3668,14 @@ export function EmailComposer({
|
||||
// above-quote replies, a re-opened draft) `signatureAlreadyInBody` is set
|
||||
// and the trailing append below is skipped so we don't duplicate it.
|
||||
|
||||
// Whether automatic signature insertion is on for this composer mode
|
||||
// (component-level `autoSignatureEnabled` - see signatureAlreadyInBody).
|
||||
// What the body already carries is never stripped - only the append is
|
||||
// gated.
|
||||
|
||||
// Build HTML signature block (used only in rich text mode)
|
||||
const buildSignatureHtml = (): string => {
|
||||
if (signatureAlreadyInBody) return '';
|
||||
if (signatureAlreadyInBody || !autoSignatureEnabled) return '';
|
||||
const sep = wantsSignatureSeparator(signatureSeparatorEnabled, signatureIdentity) ? `<br><br>-- <br>` : `<br><br>`;
|
||||
if (signatureIdentity?.htmlSignature) {
|
||||
return `${sep}${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}`;
|
||||
@@ -3630,8 +3692,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.
|
||||
@@ -4172,6 +4234,8 @@ export function EmailComposer({
|
||||
// Same for the "@" recipient list in the body: this handler runs in the
|
||||
// capture phase, before the editor could close the list itself.
|
||||
if (isRecipientMentionActive(editorRef.current)) return;
|
||||
// And for proofreading's popup and panel: Escape closes only them.
|
||||
if (proofreading.popup || proofreading.panel) return;
|
||||
e.preventDefault();
|
||||
handleClose();
|
||||
return;
|
||||
@@ -4734,6 +4798,23 @@ export function EmailComposer({
|
||||
<PluginSlot name="composer-above-body" className="w-full" extraProps={pluginDraftProps} />
|
||||
)}
|
||||
{/* Body */}
|
||||
<ProofreadingNotice status={proofreading.status} />
|
||||
{proofreading.popup && !plainTextMode && pluginEditor && (() => {
|
||||
const issue = proofreading.issues.find((i) => i.key === proofreading.popup!.key);
|
||||
return issue ? (
|
||||
<IssuePopup
|
||||
state={proofreading.popup}
|
||||
issue={issue}
|
||||
anchor={() => issueRect(pluginEditor, issue.key)}
|
||||
onApply={proofreading.apply}
|
||||
onDismiss={proofreading.dismiss}
|
||||
onClose={proofreading.closePopup}
|
||||
onNavigate={proofreading.navigate}
|
||||
textRoot={() => (pluginEditor.isDestroyed ? null : pluginEditor.view.dom)}
|
||||
container={(pluginEditor.isDestroyed ? null : pluginEditor.view.dom.closest<HTMLElement>('[aria-modal="true"]')) ?? document.body}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
{plainTextMode ? (
|
||||
<>
|
||||
<label htmlFor={`${fieldIdBase}-body`} className="sr-only">{t('body_label')}</label>
|
||||
@@ -4754,10 +4835,13 @@ export function EmailComposer({
|
||||
aria-invalid={validationErrors.body || undefined}
|
||||
aria-describedby={validationErrors.body ? `${fieldIdBase}-body-error` : undefined}
|
||||
onContextMenu={onBodyContextMenu}
|
||||
onMouseDown={selectionMenu.onMouseDown}
|
||||
spellCheck={proofreading.browserSpellcheck}
|
||||
lang={textLang}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div ref={editorContainerRef} onContextMenu={onBodyContextMenu}>
|
||||
<div ref={editorContainerRef} onContextMenu={onBodyContextMenu} onMouseDownCapture={selectionMenu.onMouseDown}>
|
||||
<RichTextEditor
|
||||
content={body}
|
||||
onChange={(html) => {
|
||||
@@ -4778,6 +4862,9 @@ export function EmailComposer({
|
||||
findMentionContacts={recipientMentionsEnabled && mentionAddsRecipientTo !== 'off' ? findMentionContacts : undefined}
|
||||
mentionAddsTo={mentionAddsRecipientTo === 'cc' ? 'cc' : 'to'}
|
||||
onMentionAdd={addMentionedRecipient}
|
||||
spellcheck={proofreading.browserSpellcheck}
|
||||
lang={textLang}
|
||||
textCheck={proofreading.editorHandlers}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -4790,8 +4877,10 @@ export function EmailComposer({
|
||||
|
||||
{/* Hide the visual signature preview when the signature has already been
|
||||
embedded into the body (compose, above-quote replies, re-opened
|
||||
drafts) - it would otherwise read as a second signature. */}
|
||||
{signatureAlreadyInBody ? null
|
||||
drafts) - it would otherwise read as a second signature. Also when
|
||||
automatic insertion is off: nothing will be appended at send time,
|
||||
so a preview would promise a signature that never goes out. */}
|
||||
{signatureAlreadyInBody || !autoSignatureEnabled ? null
|
||||
: plainTextMode ? (
|
||||
getPlainTextSignature(signatureIdentity) ? (
|
||||
<div className="px-4 pb-3 text-sm leading-6 text-muted-foreground break-words whitespace-pre-wrap font-mono">
|
||||
@@ -5122,6 +5211,7 @@ export function EmailComposer({
|
||||
</>}
|
||||
</ComposerToolbarMenu>
|
||||
<ComposerPluginToolbarButtons surface="composer-toolbar-actions" context={composerActionContext} />
|
||||
<ProofreadingStatus proofreading={proofreading} plainTextMode={plainTextMode} />
|
||||
</div>
|
||||
|
||||
{/* Right side - Discard + Send (desktop) */}
|
||||
@@ -5231,6 +5321,7 @@ export function EmailComposer({
|
||||
initialData={{
|
||||
subject,
|
||||
body,
|
||||
isHTML: !plainTextMode,
|
||||
to: withInput(to, toInput).map(r => formatRecipient(r.name, r.email)),
|
||||
cc: withInput(cc, ccInput).map(r => formatRecipient(r.name, r.email)),
|
||||
bcc: withInput(bcc, bccInput).map(r => formatRecipient(r.name, r.email)),
|
||||
@@ -5394,6 +5485,40 @@ export function EmailComposer({
|
||||
</label>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
isOpen={proofreadingWarning !== null}
|
||||
onClose={() => setProofreadingWarning(null)}
|
||||
role="alertdialog"
|
||||
showClose={false}
|
||||
title={tProofreading('send_warning.title')}
|
||||
description={tProofreading('send_warning.body', { count: proofreadingWarning?.count ?? 0 })}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setProofreadingWarning(null);
|
||||
// After the focus trap hands focus back, go to the first issue.
|
||||
setTimeout(() => proofreading.navigate(1), 0);
|
||||
}}
|
||||
>
|
||||
{tProofreading('send_warning.review')}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="proofreading-send-anyway"
|
||||
onClick={() => {
|
||||
const delayed = proofreadingWarning?.delayedUntil;
|
||||
proofreadingConfirmedRef.current = true;
|
||||
setProofreadingWarning(null);
|
||||
handleSend({ skipSubjectCheck: true, skipAttachmentCheck: true, delayedUntil: delayed });
|
||||
}}
|
||||
>
|
||||
{tProofreading('send_warning.send')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
isOpen={showCloseDialog}
|
||||
onClose={() => dismissCloseDialog()}
|
||||
|
||||
@@ -29,6 +29,8 @@ import { useShallow } from "zustand/react/shallow";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useListRowShape } from "@/hooks/use-list-row-shape";
|
||||
import { MailListRowsSkeleton, type MailRowShape } from "@/components/boot-skeleton/mail-skeleton";
|
||||
import type { Density } from "@/lib/density";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useMessageListTabsStore } from "@/stores/message-list-tabs-store";
|
||||
import { emailKeyFor, groupEmailsByThread, sortThreadGroups, threadKeyFor, getEmailTagIds } from "@/lib/thread-utils";
|
||||
@@ -124,27 +126,11 @@ const BATCH_TOOLBAR_WIDE = 480;
|
||||
|
||||
// Declared out here so re-renders of the list keep the same element. Inside
|
||||
// EmailList every render made a new component, which remounted the skeleton
|
||||
// and replayed its fade-in each time the store changed while a list loaded.
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="animate-in fade-in duration-200" data-testid="email-list-skeleton">
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<div key={i} className="border-b border-border px-4 py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-muted/50 rounded-full" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="h-4 bg-muted/50 rounded w-32" />
|
||||
<div className="h-3 bg-muted/50 rounded w-16" />
|
||||
</div>
|
||||
<div className="h-4 bg-muted/50 rounded w-3/4 mb-2" />
|
||||
<div className="h-3 bg-muted/50 rounded w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
// each time the store changed while a list loaded.
|
||||
function LoadingSkeleton({ shape, density, preview }: { shape: MailRowShape; density: Density; preview: boolean }) {
|
||||
// The page skeleton's rows (components/boot-skeleton), so the list the app
|
||||
// draws while it loads is the one the server painted before it started.
|
||||
return <MailListRowsSkeleton shape={shape} density={density} preview={preview} className="contents" />;
|
||||
}
|
||||
|
||||
/** Rows per page in simple mode's paged list. */
|
||||
@@ -1222,7 +1208,13 @@ export function EmailList({
|
||||
)}
|
||||
|
||||
{isLoading && emails.length === 0 ? (
|
||||
<LoadingSkeleton />
|
||||
<div data-testid="email-list-skeleton">
|
||||
<LoadingSkeleton
|
||||
shape={isFocusedMailLayout ? (mountainViewList ? 'mv' : 'one-line') : 'stacked'}
|
||||
density={density}
|
||||
preview={showPreview}
|
||||
/>
|
||||
</div>
|
||||
) : emails.length === 0 && !isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
{/* R9: an empty search offers Clear search; Drafts, which the
|
||||
|
||||
@@ -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';
|
||||
@@ -3665,9 +3666,12 @@ export function EmailViewer({
|
||||
// The body was sanitized with EMAIL_IFRAME_SANITIZE_CONFIG which permits
|
||||
// <style>. The print window has no iframe isolation, so re-sanitize with
|
||||
// the stricter config that forbids <style> before injecting into the DOM.
|
||||
const printableBody = effectiveEmailContent.isHtml
|
||||
? sanitizeEmailHtml(effectiveEmailContent.html)
|
||||
: effectiveEmailContent.html;
|
||||
// A plain-text body is escaped text with its own newlines: it prints its
|
||||
// trailing quote opened instead of behind the collapsed toggle.
|
||||
const plainBody = !effectiveEmailContent.isHtml;
|
||||
const printableBody = plainBody
|
||||
? effectiveEmailContent.html.replace(/<details>/g, '<details open>')
|
||||
: sanitizeEmailHtml(effectiveEmailContent.html);
|
||||
|
||||
const printWindow = openedWindow ?? window.open('', '_blank');
|
||||
if (!printWindow) return;
|
||||
@@ -3692,6 +3696,9 @@ export function EmailViewer({
|
||||
.meta { font-size: 13px; color: #555; line-height: 1.6; }
|
||||
.meta strong { color: #000; }
|
||||
.body { font-size: 14px; line-height: 1.6; }
|
||||
/* Plain text keeps its line breaks, as in the reader. */
|
||||
.body.plain { white-space: pre-wrap; word-break: break-word; }
|
||||
.body.plain summary { display: none !important; }
|
||||
/* Only force the cap on images that set no width of their own: an
|
||||
!important 100% also overrides a sender's inline max-width, and an
|
||||
image sized by max-width + max-height + width:100% then grows to
|
||||
@@ -3713,7 +3720,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
${date ? `<div><strong>${escapeHtml(t('date'))}:</strong> ${escapeHtml(date)}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="body">${printableBody}</div>
|
||||
<div class="body${plainBody ? ' plain' : ''}">${printableBody}</div>
|
||||
</body></html>`);
|
||||
printWindow.document.close();
|
||||
printWindow.focus();
|
||||
@@ -3928,12 +3935,14 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
)}
|
||||
</>
|
||||
);
|
||||
const shell = (content: React.ReactNode) => (
|
||||
// `top` is the row above the message row; by default the one holding only
|
||||
// the panel button (none without a panel or on a phone).
|
||||
const shell = (content: React.ReactNode, top: React.ReactNode = toggleOnlyToolbar) => (
|
||||
<div
|
||||
ref={setViewerRootEl}
|
||||
className={cn("flex-1 flex flex-col h-full bg-background overflow-hidden relative", className)}
|
||||
>
|
||||
{toggleOnlyToolbar}
|
||||
{top}
|
||||
<div className="flex-1 min-h-0 flex flex-row overflow-hidden relative">
|
||||
{content}
|
||||
{sidePanel}
|
||||
@@ -3942,53 +3951,21 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Show loading skeleton while email is being fetched
|
||||
// Show loading skeleton while email is being fetched: the page skeleton's
|
||||
// message pane, so a linked message keeps the shape it had before the app
|
||||
// started.
|
||||
// Its toolbar sits where the loaded reader puts its own: above the message
|
||||
// row when lifted, so the row doesn't move when the message arrives. The
|
||||
// panel button is real in it, at the end of the row as in the loaded one.
|
||||
if (isLoading && !email) {
|
||||
const lifted = toolbarPosition === 'top' && liftToolbar;
|
||||
return shell(
|
||||
<div className="flex-1 min-w-0 flex flex-col h-full bg-background overflow-hidden animate-in fade-in duration-200">
|
||||
{/* Loading Header Skeleton - gentler animation */}
|
||||
<div className="bg-background border-b border-border">
|
||||
<div className="px-4 lg:px-6 py-3 lg:py-4">
|
||||
<div className="flex items-start justify-between gap-2 lg:gap-4">
|
||||
<div className="flex-1 min-w-0 space-y-2 lg:space-y-3">
|
||||
<div className="h-6 lg:h-8 bg-muted/60 rounded-md w-3/4"></div>
|
||||
<div className="flex items-center gap-2 lg:gap-3">
|
||||
<div className="h-3 lg:h-4 bg-muted/60 rounded w-24 lg:w-32"></div>
|
||||
<div className="h-3 lg:h-4 bg-muted/60 rounded w-16 lg:w-24"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 lg:gap-2">
|
||||
<div className="h-8 w-8 lg:w-20 bg-muted/60 rounded"></div>
|
||||
<div className="h-8 w-8 bg-muted/60 rounded hidden lg:block"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading Sender Info Skeleton */}
|
||||
<div className="px-4 lg:px-6 pb-3 lg:pb-4">
|
||||
<div className="flex items-start gap-3 lg:gap-4">
|
||||
<div className="w-10 h-10 lg:w-12 lg:h-12 bg-muted/60 rounded-full"></div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 bg-muted/60 rounded w-48"></div>
|
||||
<div className="h-3 bg-muted/60 rounded w-64"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MailMessageSkeleton toolbar={!lifted} />,
|
||||
lifted ? (
|
||||
<div data-viewer-top-toolbar="full-width" className="flex-shrink-0">
|
||||
<MailMessageToolbarSkeleton end={panelToggle} />
|
||||
</div>
|
||||
|
||||
{/* Loading Content Skeleton */}
|
||||
<div className="flex-1 overflow-auto bg-muted/20">
|
||||
<div className="px-6 pt-4 pb-6">
|
||||
<div className="space-y-3">
|
||||
<div className="h-4 bg-muted/60 rounded w-full"></div>
|
||||
<div className="h-4 bg-muted/60 rounded w-5/6"></div>
|
||||
<div className="h-4 bg-muted/60 rounded w-4/6"></div>
|
||||
<div className="h-4 bg-muted/60 rounded w-full"></div>
|
||||
<div className="h-4 bg-muted/60 rounded w-3/4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : toggleOnlyToolbar,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
// What one issue says and what can be done about it: the message, up to five
|
||||
// suggestions, Ignore, Add to dictionary (spelling) and Turn off this rule.
|
||||
// Used in the popup on an underline and, in the plain-text composer, in the
|
||||
// issue list.
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DismissAction, PlacedIssue } from "@/lib/proofreading/composer-proofreader";
|
||||
|
||||
export const KIND_DOT: Record<PlacedIssue["kind"], string> = {
|
||||
spelling: "bg-[var(--text-issue-spelling)]",
|
||||
grammar: "bg-[var(--text-issue-grammar)]",
|
||||
style: "bg-[var(--text-issue-style)]",
|
||||
other: "bg-[var(--text-issue-other)]",
|
||||
};
|
||||
|
||||
const itemClass =
|
||||
"inline-flex min-h-8 items-center rounded-md px-2.5 text-sm outline-none transition-colors " +
|
||||
"hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring max-md:min-h-10";
|
||||
|
||||
interface Props {
|
||||
issue: PlacedIssue;
|
||||
onApply: (issue: PlacedIssue, replacement: string) => void;
|
||||
onDismiss: (issue: PlacedIssue, action: DismissAction) => void;
|
||||
/** The issue list puts the word first; the popup has it in the text. */
|
||||
showWord?: boolean;
|
||||
/** Shown when a suggestion could not be put in (the text changed). */
|
||||
error?: string | null;
|
||||
/** id for the message, which the popup is described by. */
|
||||
messageId?: string;
|
||||
}
|
||||
|
||||
export function IssueHeading({ issue }: { issue: PlacedIssue }) {
|
||||
const t = useTranslations("proofreading");
|
||||
return (
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className={cn("h-2 w-2 shrink-0 rounded-full", KIND_DOT[issue.kind])} aria-hidden="true" />
|
||||
<span>{t(`kind.${issue.kind}`)}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span className="truncate">{issue.checkerLabel}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Suggestions({ issue, onApply }: Pick<Props, "issue" | "onApply">) {
|
||||
const t = useTranslations("proofreading");
|
||||
if (!issue.annotation.replacements.length) {
|
||||
return <p className="text-sm text-muted-foreground">{t("popup.no_suggestions")}</p>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1" role="group" aria-label={t("popup.suggestions")}>
|
||||
{issue.annotation.replacements.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
data-popup-item=""
|
||||
data-testid="proofreading-replacement"
|
||||
aria-label={t("popup.replace_with", { text: r })}
|
||||
className={cn(itemClass, "border border-border bg-background font-medium")}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onApply(issue, r)}
|
||||
>
|
||||
<bdi dir="auto">{r}</bdi>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function IssueActions({ issue, onApply, onDismiss, showWord = false, error, messageId }: Props) {
|
||||
const t = useTranslations("proofreading");
|
||||
const a = issue.annotation;
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<IssueHeading issue={issue} />
|
||||
<p id={messageId} className="text-sm text-foreground" dir="auto">
|
||||
{showWord && <><bdi className="font-semibold">{a.text}</bdi>{": "}</>}
|
||||
{a.message}
|
||||
</p>
|
||||
<Suggestions issue={issue} onApply={onApply} />
|
||||
{issue.alternatives.map((alt) => (
|
||||
<div key={alt.key} className="flex flex-col gap-1.5 border-t border-border pt-2">
|
||||
<span className="text-xs text-muted-foreground">{t("popup.also", { checker: alt.checkerLabel })}</span>
|
||||
<p className="text-sm" dir="auto">{alt.annotation.message}</p>
|
||||
<Suggestions issue={alt} onApply={onApply} />
|
||||
</div>
|
||||
))}
|
||||
{error && <p className="text-xs text-destructive" role="alert">{error}</p>}
|
||||
<div className="flex flex-wrap gap-1 border-t border-border pt-2">
|
||||
<button type="button" data-popup-item="" className={itemClass} onMouseDown={(e) => e.preventDefault()} onClick={() => onDismiss(issue, "ignore")}>
|
||||
{t("popup.ignore")}
|
||||
</button>
|
||||
{issue.kind === "spelling" && (
|
||||
<button type="button" data-popup-item="" className={itemClass} onMouseDown={(e) => e.preventDefault()} onClick={() => onDismiss(issue, "dictionary")}>
|
||||
{t("popup.add_to_dictionary")}
|
||||
</button>
|
||||
)}
|
||||
{a.ruleId && (
|
||||
<button
|
||||
type="button"
|
||||
data-popup-item=""
|
||||
className={itemClass}
|
||||
title={a.ruleLabel}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onDismiss(issue, "disable-rule")}
|
||||
>
|
||||
{t("popup.disable_rule")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** What a screen reader reads for an issue in the list: the word first, as it shows. */
|
||||
export function issueListLabel(t: ReturnType<typeof useTranslations>, issue: PlacedIssue): string {
|
||||
const a = issue.annotation;
|
||||
const text = a.replacements.length
|
||||
? t("list_item", { word: a.text, message: a.message, suggestion: a.replacements[0] })
|
||||
: t("list_item_no_suggestion", { word: a.text, message: a.message });
|
||||
return `${text} (${t(`kind.${issue.kind}`)})`;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user