mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 08:15:38 +00:00
Merge branch 'main' into feat/proofreading
# Conflicts: # components/admin/admin-shell.tsx
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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// The admin sign-in page renders in full on the server, which can't know the
|
||||
// theme: it sends both logos and CSS shows the one for the theme, instead of
|
||||
// the light logo turning white after the first paint in dark mode.
|
||||
|
||||
vi.mock('@/hooks/use-config', () => ({
|
||||
useConfig: () => ({
|
||||
appName: 'Webmail',
|
||||
loginLogoLightUrl: '/branding/light.svg',
|
||||
loginLogoDarkUrl: '/branding/dark.svg',
|
||||
}),
|
||||
}));
|
||||
|
||||
const { default: AdminLoginPage } = await import('../login/page');
|
||||
|
||||
describe('admin sign-in logo', () => {
|
||||
it('sends the light and the dark logo, each for its theme', () => {
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = renderToString(<AdminLoginPage />);
|
||||
const logos = [...container.querySelectorAll('img')].map((img) => [img.getAttribute('src'), img.className]);
|
||||
expect(logos).toEqual([
|
||||
['/branding/light.svg', expect.stringContaining('dark:hidden')],
|
||||
['/branding/dark.svg', expect.stringContaining('hidden dark:block')],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// While the admin session is checked, the content column shows the page's
|
||||
// header and section placeholders where the boot skeleton drew them, not a
|
||||
// line of text at a different height.
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ replace: vi.fn(), push: vi.fn() }),
|
||||
usePathname: () => '/admin/settings',
|
||||
}));
|
||||
vi.mock('@/lib/browser-navigation', () => ({
|
||||
// The session check never answers.
|
||||
apiFetch: vi.fn(() => new Promise(() => {})),
|
||||
getPathPrefix: () => '',
|
||||
withBasePath: (href: string) => href,
|
||||
}));
|
||||
vi.mock('@/components/admin/admin-shell', () => ({
|
||||
AdminShell: ({ children }: { children: ReactNode }) => <main>{children}</main>,
|
||||
}));
|
||||
vi.mock('../_unsaved-changes-guard', () => ({ UnsavedChangesGuard: () => null }));
|
||||
|
||||
const { AdminLayoutClient } = await import('../_layout-client');
|
||||
|
||||
describe('admin layout while the session is checked', () => {
|
||||
it('draws the page header and section placeholders in the content slot', () => {
|
||||
render(<AdminLayoutClient><p>page</p></AdminLayoutClient>);
|
||||
const main = screen.getByRole('main');
|
||||
expect(main.querySelector('[data-testid="admin-body-skeleton"]')).not.toBeNull();
|
||||
// The open page's own title and description, as AdminPageHeader shows them.
|
||||
expect(main.textContent).toContain('How this Bulwark connects and what the login page offers');
|
||||
expect(screen.getByRole('status').textContent).toBe('Loading admin panel…');
|
||||
expect(screen.getByRole('status').className).toContain('sr-only');
|
||||
expect(screen.queryByText('page')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -6,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,37 +3,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowUpCircle,
|
||||
Activity,
|
||||
Bell,
|
||||
HeartRateMonitor,
|
||||
Building2,
|
||||
CalendarDays,
|
||||
ChevronRight,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
LayoutTemplate,
|
||||
LogOut,
|
||||
Palette,
|
||||
PenLine,
|
||||
Puzzle,
|
||||
Scale,
|
||||
ScrollText,
|
||||
Search,
|
||||
Server,
|
||||
Share2,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
Smartphone,
|
||||
Store,
|
||||
SwatchBook,
|
||||
Tag,
|
||||
SpellCheck,
|
||||
X,
|
||||
type AppIcon,
|
||||
} from '@/components/icons';
|
||||
import { ArrowLeft, ChevronRight, KeyRound, LogOut, Search, Shield, X } from '@/components/icons';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { NavigationRail } from '@/components/layout/navigation-rail';
|
||||
import { AboutFooter } from '@/components/settings/about-data-settings';
|
||||
@@ -45,42 +15,13 @@ import { useUpdateStore, selectHasUpdate } from '@/stores/update-store';
|
||||
import { useAdminUnsavedStore } from '@/stores/admin-unsaved-store';
|
||||
import { apiFetch, getPathPrefix } from '@/lib/browser-navigation';
|
||||
import { ADMIN_PAGES, adminPath, type AdminPageDef, type AdminPageId, type AdminSectionDef } from '@/lib/admin/admin-nav';
|
||||
import { AdminSessionLinksSkeleton, AdminSkeleton } from '@/components/boot-skeleton/admin-skeleton';
|
||||
import { useSkeletonLabels } from '@/components/boot-skeleton/use-skeleton-labels';
|
||||
import { ADMIN_GROUPS, ADMIN_GROUP_LABELS, ADMIN_PAGE_ICONS as PAGE_ICONS } from './admin-page-icons';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AdminLink } from './admin-link';
|
||||
import { AdminShellContext } from './shell-context';
|
||||
|
||||
const PAGE_ICONS: Record<AdminPageId, AppIcon> = {
|
||||
dashboard: LayoutDashboard,
|
||||
settings: Server,
|
||||
auth: Shield,
|
||||
branding: Palette,
|
||||
policy: Scale,
|
||||
organisation: Building2,
|
||||
'org-signatures': PenLine,
|
||||
'org-templates': LayoutTemplate,
|
||||
'org-security': ShieldCheck,
|
||||
'org-sharing': Share2,
|
||||
'org-labels': Tag,
|
||||
'org-proofreading': SpellCheck,
|
||||
'org-calendars': CalendarDays,
|
||||
'org-notices': Bell,
|
||||
'org-devices': Smartphone,
|
||||
plugins: Puzzle,
|
||||
themes: SwatchBook,
|
||||
marketplace: Store,
|
||||
version: ArrowUpCircle,
|
||||
telemetry: Activity,
|
||||
monitoring: HeartRateMonitor,
|
||||
logs: ScrollText,
|
||||
};
|
||||
|
||||
const GROUP_LABELS: Record<AdminPageDef['group'], string | null> = {
|
||||
top: null,
|
||||
organisation: 'Organisation',
|
||||
extensions: 'Extensions',
|
||||
system: 'System',
|
||||
};
|
||||
|
||||
interface AdminShellProps {
|
||||
/** Whether the admin session is in place; the sidebar counts load then. */
|
||||
authenticated: boolean;
|
||||
@@ -109,6 +50,7 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
|
||||
const tSettings = useTranslations('settings');
|
||||
const tSidebar = useTranslations('sidebar');
|
||||
const location = useAdminLocation();
|
||||
const { label: loadingLabel, labels: railLabels } = useSkeletonLabels();
|
||||
const isDesktop = useIsDesktop();
|
||||
// The server can't know the window width, so its HTML would be the phone
|
||||
// layout, shown on desktop until hydration swaps it (the rail's Admin link
|
||||
@@ -188,8 +130,8 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
|
||||
}, [trimmed]);
|
||||
|
||||
const groups = useMemo(() => (
|
||||
(['top', 'organisation', 'extensions', 'system'] as const)
|
||||
.map((group) => ({ group, label: GROUP_LABELS[group], items: matches.filter((m) => m.page.group === group) }))
|
||||
ADMIN_GROUPS
|
||||
.map((group) => ({ group, label: ADMIN_GROUP_LABELS[group], items: matches.filter((m) => m.page.group === group) }))
|
||||
.filter((g) => g.items.length > 0)
|
||||
), [matches]);
|
||||
|
||||
@@ -301,7 +243,19 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!hydrated) return <div className="h-dvh bg-background" />;
|
||||
// The server HTML and the hydration pass: the panel's skeleton, both
|
||||
// layouts drawn and picked by CSS until the width is known.
|
||||
if (!hydrated) {
|
||||
return (
|
||||
<AdminSkeleton
|
||||
page={location.page}
|
||||
label={loadingLabel}
|
||||
backLabel={tSettings('back_to_mail')}
|
||||
searchLabel={tShell('admin_search')}
|
||||
labels={railLabels}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isDesktop) {
|
||||
const showIndex = location.page === null && !onChangePassword;
|
||||
@@ -382,7 +336,11 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{showSessionLinks && <div className="border-t border-border px-2 py-2">{sessionLinks(true)}</div>}
|
||||
{(showSessionLinks || !authenticated) && (
|
||||
<div className="border-t border-border px-2 py-2">
|
||||
{showSessionLinks ? sessionLinks(true) : <AdminSessionLinksSkeleton mobile />}
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t border-border px-2 py-2"><AboutFooter /></div>
|
||||
</>
|
||||
) : (
|
||||
@@ -473,7 +431,12 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border px-1 py-1">
|
||||
{showSessionLinks && <div className="space-y-0.5 px-1 py-1">{sessionLinks(false)}</div>}
|
||||
{/* Held in place while the session is checked, as the boot skeleton draws them. */}
|
||||
{(showSessionLinks || !authenticated) && (
|
||||
<div className="space-y-0.5 px-1 py-1">
|
||||
{showSessionLinks ? sessionLinks(false) : <AdminSessionLinksSkeleton mobile={false} />}
|
||||
</div>
|
||||
)}
|
||||
<AboutFooter />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { RouteSkeleton } from '../route-skeleton';
|
||||
import { BottomBarSkeleton, RailSkeleton } from '../shell';
|
||||
import { AdminSkeleton } from '../admin-skeleton';
|
||||
import type { BootSurface } from '@/lib/boot-skeleton/surfaces';
|
||||
|
||||
function dom(html: string) {
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = html;
|
||||
return container;
|
||||
}
|
||||
|
||||
const css = readFileSync(join(process.cwd(), 'app/boot-skeleton.css'), 'utf8');
|
||||
|
||||
// A signed-in page drawn for a visitor with nothing stored: the boot script
|
||||
// sets data-boot-auth="out", and the CSS swaps the app's frame for the
|
||||
// login card, the page the app is about to send them to.
|
||||
describe('signed-in pages for a signed-out visitor', () => {
|
||||
it('boot-out matches data-boot-auth="out"', () => {
|
||||
expect(css).toMatch(/@custom-variant boot-out \(&:where\(\[data-boot-auth="out"\] \*\)\);/);
|
||||
});
|
||||
|
||||
it.each<BootSurface>(['mail', 'calendar', 'contacts', 'files', 'apps', 'settings'])('%s carries the login card, shown only with boot-out', (surface) => {
|
||||
const container = dom(renderToString(<RouteSkeleton surface={surface} label="Loading" />));
|
||||
const login = container.querySelector('[data-boot-skeleton="login"]');
|
||||
expect(login).not.toBeNull();
|
||||
// Hidden unless the page is marked signed out.
|
||||
expect(login!.parentElement!.className.split(' ')).toEqual(expect.arrayContaining(['hidden', 'boot-out:block']));
|
||||
// The card itself: logo, fields, button.
|
||||
expect(login!.querySelectorAll('.h-11').length).toBeGreaterThanOrEqual(3);
|
||||
// And the accessibility button the login page will have in its corner.
|
||||
expect(login!.querySelector('.fixed.bottom-4.end-4')).not.toBeNull();
|
||||
// And the app's own frame goes away then.
|
||||
const app = container.querySelector(`[data-boot-skeleton="${surface}"]`);
|
||||
expect(app!.className.split(' ')).toContain('boot-out:hidden');
|
||||
});
|
||||
});
|
||||
|
||||
describe('login skeleton', () => {
|
||||
// The login page renders the real accessibility button itself.
|
||||
it('leaves the accessibility button to the login page', () => {
|
||||
const container = dom(renderToString(<RouteSkeleton surface="login" label="Loading" />));
|
||||
expect(container.querySelector('.fixed')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin skeleton', () => {
|
||||
it('draws the page list with its real names and the open page current', () => {
|
||||
const container = dom(renderToString(<AdminSkeleton page="branding" label="Loading" />));
|
||||
const text = container.textContent ?? '';
|
||||
for (const name of ['Overview', 'Server', 'Branding', 'Organisation', 'Extensions', 'System', 'Audit log']) {
|
||||
expect(text).toContain(name);
|
||||
}
|
||||
const current = [...container.querySelectorAll('.bg-accent')].map((el) => el.textContent);
|
||||
expect(current).toEqual(['Branding']);
|
||||
});
|
||||
|
||||
it('shows the phone list at /admin and the page below it', () => {
|
||||
const index = dom(renderToString(<AdminSkeleton page={null} />));
|
||||
const detail = dom(renderToString(<AdminSkeleton page="logs" />));
|
||||
const phone = (c: HTMLElement) => c.querySelector('.lg\\:hidden')!;
|
||||
// Index: the list (with its chevrons) is shown, the page column hidden.
|
||||
expect(phone(index).querySelector('.p-4')!.className).toContain('hidden');
|
||||
expect(phone(detail).querySelector('.p-4')!.className).not.toContain('hidden');
|
||||
// Unknown page (a connector link): the boot script's pane attribute decides.
|
||||
const unknown = dom(renderToString(<AdminSkeleton />));
|
||||
expect(phone(unknown).querySelector('.p-4')!.className).toContain('boot-open:block');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rail with the admin panel current', () => {
|
||||
it('always draws the admin entry, as current', () => {
|
||||
const rail = dom(renderToString(<RailSkeleton active="admin" />));
|
||||
const shield = rail.querySelector('.bg-primary\\/10')!;
|
||||
expect(shield).not.toBeNull();
|
||||
expect(shield.className).not.toContain('hidden');
|
||||
// Without it, only the last session's hint shows the entry.
|
||||
const plain = dom(renderToString(<RailSkeleton active="mail" />));
|
||||
expect(plain.innerHTML).toContain('boot-rail-admin:flex');
|
||||
});
|
||||
|
||||
it('has no account or quota circle: the admin panel has no mailbox', () => {
|
||||
const circles = (html: string) => dom(html).querySelectorAll('.rounded-full.w-8.h-8').length;
|
||||
expect(circles(renderToString(<RailSkeleton active="admin" />))).toBe(0);
|
||||
expect(circles(renderToString(<RailSkeleton active="mail" />))).toBe(2);
|
||||
});
|
||||
|
||||
it('adds Admin to the bottom bar before Settings', () => {
|
||||
const bar = dom(renderToString(<BottomBarSkeleton active="admin" labels={{ mail: 'Mail', admin: 'Admin', settings: 'Settings' }} />));
|
||||
const labels = [...bar.querySelectorAll('span.truncate')].map((el) => el.textContent);
|
||||
expect(labels.slice(-2)).toEqual(['Admin', 'Settings']);
|
||||
const plain = dom(renderToString(<BottomBarSkeleton active="mail" labels={{ admin: 'Admin' }} />));
|
||||
expect(plain.textContent).not.toContain('Admin');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
// Contacts, Files and plugin pages until the session is restored: each draws
|
||||
// the page skeleton the server drew (not a blank page or a spinner), and the
|
||||
// first screens after it keep that shape.
|
||||
|
||||
const nav = vi.hoisted(() => ({ suspend: false }));
|
||||
const never = new Promise<never>(() => {});
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn(), back: vi.fn(), replace: vi.fn() }),
|
||||
useParams: () => ({ locale: 'en' }),
|
||||
usePathname: () => '/en/contacts',
|
||||
useSearchParams: () => {
|
||||
// The static Lite export prerenders without search params: the hook
|
||||
// suspends there and the Suspense fallback is what lands in the HTML.
|
||||
if (nav.suspend) throw never;
|
||||
return new URLSearchParams();
|
||||
},
|
||||
}));
|
||||
vi.mock('@/i18n/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn(), back: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => '/',
|
||||
Link: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a>,
|
||||
}));
|
||||
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
|
||||
vi.mock('@/components/contacts/contact-activity', () => ({ ContactActivity: () => null }));
|
||||
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => <nav data-testid="rail" /> }));
|
||||
vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null }));
|
||||
vi.mock('@/components/layout/inline-app-view', () => ({ InlineAppView: () => null }));
|
||||
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
|
||||
vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null }));
|
||||
vi.mock('@/hooks/use-sidebar-apps', () => ({
|
||||
useSidebarApps: () => ({
|
||||
showAppsModal: false, inlineApp: null, loadedApps: [], handleManageApps: vi.fn(),
|
||||
handleInlineApp: vi.fn(), closeInlineApp: vi.fn(), closeAppsModal: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { ContactsApp } = await import('@/components/contacts/contacts-app');
|
||||
const { FilesApp } = await import('@/components/files/files-app');
|
||||
const { PluginPageApp } = await import('@/components/plugins/plugin-page-app');
|
||||
const { useAuthStore } = await import('@/stores/auth-store');
|
||||
const { useContactStore } = await import('@/stores/contact-store');
|
||||
const { usePluginStore } = await import('@/stores/plugin-store');
|
||||
|
||||
/** Signed out as far as the page knows, with checkAuth still running. */
|
||||
function restoring() {
|
||||
act(() => {
|
||||
useAuthStore.setState({ isAuthenticated: false, client: null, isLoading: true, checkAuth: () => never });
|
||||
});
|
||||
}
|
||||
|
||||
const skeleton = (container: HTMLElement, surface: string) => container.querySelector(`[data-boot-skeleton="${surface}"]`);
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
nav.suspend = false;
|
||||
});
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
useAuthStore.setState({ isAuthenticated: false, client: null, isLoading: false });
|
||||
useContactStore.setState({ contacts: [], supportsSync: false, contactsLoaded: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('gates draw the page skeleton while the session is restored', () => {
|
||||
it('contacts', () => {
|
||||
restoring();
|
||||
const { container } = render(<ContactsApp />);
|
||||
expect(skeleton(container, 'contacts')).not.toBeNull();
|
||||
expect(container.querySelector('.animate-spin')).toBeNull();
|
||||
});
|
||||
|
||||
it('files', () => {
|
||||
restoring();
|
||||
const { container } = render(<FilesApp />);
|
||||
expect(skeleton(container, 'files')).not.toBeNull();
|
||||
expect(container.querySelector('.animate-spin')).toBeNull();
|
||||
});
|
||||
|
||||
it('plugin pages', () => {
|
||||
restoring();
|
||||
const { container } = render(<PluginPageApp linkSegments={['kanban', 'board']} />);
|
||||
expect(skeleton(container, 'apps')).not.toBeNull();
|
||||
expect(container.querySelector('.animate-spin')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('contacts', () => {
|
||||
it('puts the skeleton, not nothing, into a prerendered page whose search params suspend', () => {
|
||||
nav.suspend = true;
|
||||
restoring();
|
||||
const { container } = render(<ContactsApp />);
|
||||
expect(skeleton(container, 'contacts')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows the rows loading, not an empty address book, before the session is back', () => {
|
||||
// A remembered session: signed in from storage, no client yet, and on a
|
||||
// first visit nothing stored about the server's contacts support.
|
||||
act(() => {
|
||||
useAuthStore.setState({ isAuthenticated: true, client: null, isLoading: true, checkAuth: () => never });
|
||||
useContactStore.setState({ contacts: [], supportsSync: false, contactsLoaded: false });
|
||||
});
|
||||
render(<ContactsApp />);
|
||||
expect(screen.getByTestId('contact-list-skeleton')).toBeInTheDocument();
|
||||
expect(screen.queryByText('empty_state_title')).toBeNull();
|
||||
});
|
||||
|
||||
it('still says the book is empty once a server without contacts is known', () => {
|
||||
const client = { supportsPrincipals: () => false } as unknown as IJMAPClient;
|
||||
act(() => {
|
||||
useAuthStore.setState({ isAuthenticated: true, client, isLoading: false });
|
||||
useContactStore.setState({ contacts: [], supportsSync: false, contactsLoaded: false });
|
||||
});
|
||||
render(<ContactsApp />);
|
||||
expect(screen.queryByTestId('contact-list-skeleton')).toBeNull();
|
||||
expect(screen.getByText('empty_state_title')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('plugin pages', () => {
|
||||
it('keeps the empty content area, not a spinner, while the plugins start', () => {
|
||||
act(() => {
|
||||
useAuthStore.setState({ isAuthenticated: true, client: {} as never, isLoading: false });
|
||||
usePluginStore.setState({ initialized: false, initializePlugins: vi.fn().mockResolvedValue(undefined) });
|
||||
});
|
||||
const { container } = render(<PluginPageApp linkSegments={['kanban', 'board']} />);
|
||||
expect(container.querySelector('.animate-spin')).toBeNull();
|
||||
expect(screen.getByRole('status')).toHaveTextContent('loading');
|
||||
expect(screen.queryByText('not_available_title')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { act } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import { hydrateRoot } from 'react-dom/client';
|
||||
import { RouteSkeleton } from '../route-skeleton';
|
||||
import { CalendarSkeleton, CalendarViewSkeleton } from '../calendar-skeleton';
|
||||
import type { BootSurface } from '@/lib/boot-skeleton/surfaces';
|
||||
|
||||
/**
|
||||
* Every page skeleton is rendered by the server and then hydrated by the same
|
||||
* component on the client. They must produce identical markup, or React throws
|
||||
* the server HTML away and the page flashes.
|
||||
*/
|
||||
|
||||
const SURFACES: BootSurface[] = [
|
||||
'mail', 'calendar', 'contacts', 'files', 'apps', 'settings', 'pro', 'admin', 'admin-login', 'login', 'setup',
|
||||
];
|
||||
|
||||
const LABELS = { mail: 'Mail', calendar: 'Calendar', contacts: 'Contacts', files: 'Files', settings: 'Settings' };
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('page skeletons hydrate without a mismatch', () => {
|
||||
it.each(SURFACES)('%s', async (surface) => {
|
||||
const element = <RouteSkeleton surface={surface} label="Loading..." labels={LABELS} />;
|
||||
const html = renderToString(element);
|
||||
expect(html).toContain(`data-boot-skeleton="${surface}"`);
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = html;
|
||||
document.body.appendChild(container);
|
||||
const errors: unknown[] = [];
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation((...args) => { errors.push(args); });
|
||||
await act(async () => {
|
||||
hydrateRoot(container, element, { onRecoverableError: (e) => { errors.push(e); } });
|
||||
});
|
||||
consoleError.mockRestore();
|
||||
expect(errors).toEqual([]);
|
||||
expect(container.innerHTML).toBe(html);
|
||||
});
|
||||
|
||||
it.each(SURFACES)('%s: one status message, busy, and nothing focusable', (surface) => {
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = renderToString(<RouteSkeleton surface={surface} label="Loading..." labels={LABELS} />);
|
||||
// A signed-in page also carries the login card for signed-out visitors,
|
||||
// hidden by CSS; count per region.
|
||||
for (const region of container.querySelectorAll('[data-boot-skeleton]')) {
|
||||
expect(region.getAttribute('aria-busy')).toBe('true');
|
||||
expect(region.querySelectorAll('[role="status"]').length).toBeLessThanOrEqual(1);
|
||||
}
|
||||
expect(container.querySelectorAll('[role="status"]').length).toBeGreaterThan(0);
|
||||
expect(container.querySelectorAll('a[href], button, input, select, textarea, [tabindex]')).toHaveLength(0);
|
||||
for (const shape of container.querySelectorAll('.sk-shape')) {
|
||||
expect(shape.closest('[aria-hidden="true"]'), shape.outerHTML).not.toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the calendar skeleton with its words', () => {
|
||||
const text = {
|
||||
title: 'Calendar', today: 'Today', search: 'Search events', tasks: 'Tasks',
|
||||
views: { month: 'Month', week: 'Week', day: 'Day', agenda: 'Agenda', tasks: 'Tasks' },
|
||||
taskFilters: ['All', 'Pending', 'Completed', 'Overdue'] as [string, string, string, string],
|
||||
newTask: 'New task', quickAdd: 'Add a task',
|
||||
};
|
||||
|
||||
it('hydrates without a mismatch and reads out only its status', async () => {
|
||||
const element = <CalendarSkeleton label="Loading..." labels={LABELS} text={text} />;
|
||||
const html = renderToString(element);
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = html;
|
||||
document.body.appendChild(container);
|
||||
const errors: unknown[] = [];
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation((...args) => { errors.push(args); });
|
||||
await act(async () => {
|
||||
hydrateRoot(container, element, { onRecoverableError: (e) => { errors.push(e); } });
|
||||
});
|
||||
consoleError.mockRestore();
|
||||
expect(errors).toEqual([]);
|
||||
expect(container.innerHTML).toBe(html);
|
||||
|
||||
const region = container.querySelector('[data-boot-skeleton="calendar"]')!;
|
||||
expect(region.textContent).toContain('Search events');
|
||||
const walker = document.createTreeWalker(region, NodeFilter.SHOW_TEXT);
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
if (!node.textContent?.trim()) continue;
|
||||
const el = node.parentElement!;
|
||||
expect(el.closest('[aria-hidden="true"]') || el.closest('[role="status"]'), el.outerHTML).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('draws one view, with its status, for the loaded page', () => {
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = renderToString(<CalendarViewSkeleton view="week" label="Loading calendars" text={text} />);
|
||||
expect(container.querySelectorAll('[role="status"]')).toHaveLength(1);
|
||||
expect(container.querySelector('.sk-cal-week')).not.toBeNull();
|
||||
expect(container.querySelector('.sk-cal-month')).toBeNull();
|
||||
expect(container.querySelectorAll('button, [tabindex]')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MailListRowsSkeleton, MailSkeleton } from '../mail-skeleton';
|
||||
|
||||
function html(element: React.ReactElement): HTMLElement {
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = renderToString(element);
|
||||
return container;
|
||||
}
|
||||
|
||||
describe('mail skeleton', () => {
|
||||
// The app hides its bottom bar while a message is open on a phone; the
|
||||
// skeleton of /mail/message/<id> must not draw one either.
|
||||
it('hides the phone bottom bar when a message or conversation is open', () => {
|
||||
const page = html(<MailSkeleton label="Loading" />);
|
||||
// The bar's items are its 64px-basis tabs; it is the last such row on the page.
|
||||
const tabs = page.querySelectorAll('.basis-\\[64px\\]');
|
||||
const bar = tabs[tabs.length - 1]?.parentElement as HTMLElement;
|
||||
expect(bar.querySelector('svg')).not.toBeNull();
|
||||
expect(bar).toBeTruthy();
|
||||
expect(bar.className.split(' ')).toContain('max-md:boot-open:hidden');
|
||||
});
|
||||
|
||||
it('draws only the body inside a Pro pane: no frame, no sidebar header', () => {
|
||||
const pane = html(<MailSkeleton label="Loading" embedded />);
|
||||
const region = pane.querySelector('[data-boot-skeleton="mail"]')!;
|
||||
expect(region.className).toContain('h-full');
|
||||
expect(region.className).not.toContain('h-dvh');
|
||||
expect(pane.querySelector('.boot-out\\:block')).toBeNull();
|
||||
expect(pane.querySelector('.lg\\:mv\\:hidden.border-b')).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['extra-compact', 0, 0],
|
||||
['compact', 16, 0],
|
||||
['regular', 16, 16],
|
||||
] as const)('draws %s stacked rows with %i avatars and %i previews', (density, avatars, previews) => {
|
||||
const rows = html(<MailListRowsSkeleton shape="stacked" density={density} preview />);
|
||||
expect(rows.querySelectorAll('.w-10.h-10').length).toBe(avatars);
|
||||
expect(rows.querySelectorAll('.leading-relaxed').length).toBe(previews * 2);
|
||||
});
|
||||
|
||||
it('leaves the preview out when previews are turned off', () => {
|
||||
const rows = html(<MailListRowsSkeleton shape="stacked" density="comfortable" preview={false} />);
|
||||
expect(rows.querySelectorAll('.leading-relaxed').length).toBe(0);
|
||||
});
|
||||
|
||||
it('draws every shape for the boot page and lets the boot attributes pick', () => {
|
||||
const rows = html(<MailListRowsSkeleton />);
|
||||
expect(rows.querySelector('.boot-density-xc\\:hidden')).not.toBeNull();
|
||||
expect(rows.querySelector('.boot-dense\\:hidden')).not.toBeNull();
|
||||
expect(rows.querySelector('.md\\:mv\\:block')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MvTopBarSkeleton } from '../shell';
|
||||
|
||||
// The Mountain View top bar as the pages draw it (components/layout/mv-top-bar.tsx).
|
||||
|
||||
describe('MvTopBarSkeleton', () => {
|
||||
it('starts with the sidebar toggle unless the page leaves it out, as contacts, files and plugin pages do', () => {
|
||||
const withMenu = render(<MvTopBarSkeleton />).container;
|
||||
expect(withMenu.querySelector('.tabler-icon-menu-2, .tabler-icon-menu')).not.toBeNull();
|
||||
|
||||
const without = render(<MvTopBarSkeleton menu={false} />).container;
|
||||
expect(without.querySelector('.tabler-icon-menu-2, .tabler-icon-menu')).toBeNull();
|
||||
// MvTopBar showMenuButton={false} pads the brand instead.
|
||||
expect(without.firstElementChild?.firstElementChild).toHaveClass('ps-3');
|
||||
});
|
||||
|
||||
it('ends with the account avatar, as the real bar does', () => {
|
||||
const bar = render(<MvTopBarSkeleton />).container.firstElementChild!;
|
||||
const cluster = bar.lastElementChild!;
|
||||
expect(cluster.lastElementChild?.querySelector('.rounded-full.w-8.h-8')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import { AppShellSkeleton } from '../shell';
|
||||
import { ProSkeleton } from '../pro-skeleton';
|
||||
|
||||
// The Pro interface takes a standard page over on a desktop (redirect to
|
||||
// /pro after mount). The page's skeleton has to be the Pro frame from the
|
||||
// first paint, or the hop moves everything. The swap is CSS on attributes the
|
||||
// boot script sets; these tests pin the markup the CSS works on.
|
||||
|
||||
function html(element: React.ReactElement) {
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = renderToString(element);
|
||||
return container;
|
||||
}
|
||||
|
||||
const classes = (el: Element | null | undefined) => (el?.getAttribute('class') ?? '').split(/\s+/);
|
||||
|
||||
describe('AppShellSkeleton under the Pro interface', () => {
|
||||
const page = () =>
|
||||
html(
|
||||
<AppShellSkeleton surface="calendar" label="Loading" railBelow="md" active="calendar">
|
||||
<div data-testid="calendar-body" />
|
||||
</AppShellSkeleton>,
|
||||
);
|
||||
|
||||
it('draws the Pro frame next to the standard one, swapped by boot-pro-frame', () => {
|
||||
const container = page();
|
||||
const pro = container.querySelector('[data-boot-skeleton="pro"]');
|
||||
const standard = container.querySelector('[data-boot-skeleton="calendar"]');
|
||||
expect(pro).not.toBeNull();
|
||||
expect(standard).not.toBeNull();
|
||||
// Hidden unless Pro takes over; the standard frame hides when it does.
|
||||
expect(classes(pro?.parentElement)).toEqual(expect.arrayContaining(['hidden', 'boot-pro-frame:block']));
|
||||
expect(classes(standard)).toEqual(expect.arrayContaining(['boot-pro-frame:hidden', 'boot-out:hidden']));
|
||||
// The page's own body stays in the standard frame only.
|
||||
expect(pro?.querySelector('[data-testid="calendar-body"]')).toBeNull();
|
||||
expect(standard?.querySelector('[data-testid="calendar-body"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('gives the Pro frame the shell root, the rail with search, and two panes', () => {
|
||||
const pro = page().querySelector('[data-boot-skeleton="pro"]');
|
||||
expect(classes(pro)).toEqual(expect.arrayContaining(['flex', 'flex-col', 'h-dvh']));
|
||||
const shell = pro?.querySelector('[data-pro-shell]');
|
||||
const rail = shell?.firstElementChild;
|
||||
expect(classes(rail)).toEqual(expect.arrayContaining(['w-14', 'flex']));
|
||||
expect(rail?.querySelectorAll('[data-sk-rail]')).toHaveLength(5);
|
||||
const panes = pro?.querySelectorAll('[data-sk-pro-pane]') ?? [];
|
||||
expect([...panes].map((p) => p.getAttribute('data-sk-pro-pane'))).toEqual(['main', 'split']);
|
||||
for (const pane of panes) {
|
||||
// The tab strip, with eight tab slots for the CSS to pick from, right
|
||||
// above the pane body.
|
||||
const [strip, body] = [...pane.children];
|
||||
expect(strip.querySelectorAll('[data-sk-pro-tab]')).toHaveLength(8);
|
||||
expect(classes(strip)).toEqual(expect.arrayContaining(['h-9', 'bg-secondary']));
|
||||
// A page Pro takes over draws its panes empty, to keep its HTML small.
|
||||
// (The /pro page draws every body, see ProSkeleton below.)
|
||||
expect([...body.children].map((b) => b.getAttribute('data-sk-pro-body'))).toEqual(['other']);
|
||||
}
|
||||
// The split pane only shows with a split.
|
||||
expect(classes(panes[1])).toEqual(expect.arrayContaining(['hidden', 'boot-pro-split:flex']));
|
||||
});
|
||||
|
||||
it('lets the login card win for a signed-out visitor', () => {
|
||||
const container = page();
|
||||
const login = container.querySelector('[data-boot-skeleton="login"]');
|
||||
expect(classes(login?.parentElement)).toEqual(expect.arrayContaining(['hidden', 'boot-out:block']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProSkeleton', () => {
|
||||
it('is the Pro frame, or the login card for a signed-out visitor', () => {
|
||||
const container = html(<ProSkeleton label="Loading" />);
|
||||
const pro = container.querySelector('[data-boot-skeleton="pro"]');
|
||||
expect(classes(pro)).toContain('boot-out:hidden');
|
||||
expect(pro?.querySelector('[data-pro-shell]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-boot-skeleton="login"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('draws every page body in both panes on /pro, where the stored tabs decide', () => {
|
||||
const container = html(<ProSkeleton label="Loading" />);
|
||||
for (const pane of container.querySelectorAll('[data-sk-pro-pane]')) {
|
||||
const body = pane.children[1];
|
||||
expect([...body.children].map((b) => b.getAttribute('data-sk-pro-body'))).toEqual([
|
||||
'mail', 'calendar', 'contacts', 'files', 'settings', 'other',
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Pro CSS picks the stored tabs and pages', () => {
|
||||
// The "pro" section of app/boot-skeleton.css, minus the Tailwind variants,
|
||||
// applied to the real markup with the attributes the boot script sets.
|
||||
const css = readFileSync(path.resolve(__dirname, '../../../app/boot-skeleton.css'), 'utf8')
|
||||
.split('/* pro */')[1]
|
||||
.replace(/@custom-variant[^;]*;/g, '');
|
||||
|
||||
let container: HTMLElement;
|
||||
beforeEach(() => {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'pro-css';
|
||||
style.textContent = css;
|
||||
document.head.appendChild(style);
|
||||
container = document.createElement('div');
|
||||
container.innerHTML = renderToString(<ProSkeleton label="Loading" />);
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
afterEach(() => {
|
||||
document.getElementById('pro-css')?.remove();
|
||||
container.remove();
|
||||
for (const a of [...document.documentElement.attributes]) {
|
||||
if (a.name.startsWith('data-boot-')) document.documentElement.removeAttribute(a.name);
|
||||
}
|
||||
});
|
||||
|
||||
const boot = (attrs: Record<string, string>) => {
|
||||
for (const [k, v] of Object.entries(attrs)) document.documentElement.setAttribute(`data-boot-${k}`, v);
|
||||
};
|
||||
const pane = (which: string) => container.querySelector(`[data-sk-pro-pane="${which}"]`)!;
|
||||
const shown = (els: Iterable<Element>) => [...els].filter((el) => getComputedStyle(el).display !== 'none');
|
||||
const tabs = (which: string) => shown(pane(which).querySelectorAll('[data-sk-pro-tab]'));
|
||||
const active = (which: string) =>
|
||||
[...pane(which).querySelectorAll('[data-sk-pro-tab]')].findIndex(
|
||||
(el) => !['', 'transparent', 'rgba(0, 0, 0, 0)'].includes(getComputedStyle(el).backgroundColor),
|
||||
);
|
||||
const body = (which: string) =>
|
||||
shown(pane(which).querySelectorAll('[data-sk-pro-body]')).map((el) => el.getAttribute('data-sk-pro-body'));
|
||||
|
||||
it('draws one active mail tab without attributes', () => {
|
||||
expect(tabs('main')).toHaveLength(1);
|
||||
expect(active('main')).toBe(0);
|
||||
expect(body('main')).toEqual(['mail']);
|
||||
});
|
||||
|
||||
it('draws the stored count, the active tab and its page in each pane', () => {
|
||||
boot({ pro: 'on', 'pro-main-tabs': '3', 'pro-main-active': '2', 'pro-main-kind': 'calendar', 'pro-split-tabs': '2', 'pro-split-active': '0', 'pro-split-kind': 'other', 'pro-split': 'right' });
|
||||
expect(tabs('main')).toHaveLength(3);
|
||||
expect(active('main')).toBe(2);
|
||||
expect(body('main')).toEqual(['calendar']);
|
||||
expect(tabs('split')).toHaveLength(2);
|
||||
expect(active('split')).toBe(0);
|
||||
expect(body('split')).toEqual(['other']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import { SettingsSkeleton, SettingsSkeletonBody } from '../settings-skeleton';
|
||||
import { SETTINGS_TAB_IDS } from '@/lib/settings-tabs';
|
||||
|
||||
/**
|
||||
* The settings skeleton marks the open page and hides the pages the last
|
||||
* session's nav didn't have, both from attributes the boot script sets on
|
||||
* <html>. The selectors live in app/boot-skeleton.css as Tailwind variants;
|
||||
* read them from there and run them against the rendered rows.
|
||||
*/
|
||||
const css = readFileSync(join(process.cwd(), 'app/boot-skeleton.css'), 'utf8');
|
||||
|
||||
/** The selector inside `@custom-variant <name> (&:where(<selector>))`. */
|
||||
function variantSelector(name: string): string {
|
||||
const match = css.match(new RegExp(`@custom-variant ${name} \\(&:where\\(([\\s\\S]*?)\\)\\);`));
|
||||
if (!match) throw new Error(`no variant ${name}`);
|
||||
return match[1].trim();
|
||||
}
|
||||
|
||||
/** The standard settings frame (the hidden Pro frame carries its own copy of the body). */
|
||||
function mount(html: string) {
|
||||
document.body.innerHTML = html;
|
||||
return document.body.querySelector<HTMLElement>('[data-boot-skeleton="settings"]') ?? document.body;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
document.documentElement.removeAttribute('data-boot-settings-tab');
|
||||
document.documentElement.removeAttribute('data-boot-settings-tabs');
|
||||
});
|
||||
|
||||
describe('settings skeleton nav', () => {
|
||||
it('draws a row for every settings page', () => {
|
||||
const root = mount(renderToString(<SettingsSkeleton label="Loading" />));
|
||||
const ids = [...root.querySelectorAll('[data-sk-settings-tab]')].map((el) => el.getAttribute('data-sk-settings-tab'));
|
||||
expect([...ids].sort()).toEqual([...SETTINGS_TAB_IDS].sort());
|
||||
});
|
||||
|
||||
it.each(SETTINGS_TAB_IDS)('highlights only the %s row when that page is open', (tab) => {
|
||||
const root = mount(renderToString(<SettingsSkeleton label="Loading" />));
|
||||
const active = variantSelector('boot-settings-active');
|
||||
document.documentElement.setAttribute('data-boot-settings-tab', tab);
|
||||
const rows = [...root.querySelectorAll<HTMLElement>('[data-sk-settings-tab]')];
|
||||
const matching = rows.filter((row) => row.matches(active)).map((row) => row.getAttribute('data-sk-settings-tab'));
|
||||
expect(matching).toEqual([tab]);
|
||||
const row = rows.find((r) => r.getAttribute('data-sk-settings-tab') === tab)!;
|
||||
expect(row.className).toContain('boot-settings-active:bg-accent');
|
||||
});
|
||||
|
||||
it('hides the optional pages the last nav did not list, and shows recovery only when it did', () => {
|
||||
const root = mount(renderToString(<SettingsSkeletonBody />));
|
||||
const row = (tab: string) => root.querySelector<HTMLElement>(`[data-sk-settings-tab="${tab}"]`)!;
|
||||
const hidesFilters = variantSelector('boot-no-settings-filters');
|
||||
const showsRecovery = variantSelector('boot-settings-recovery');
|
||||
expect(row('filters').className).toContain('boot-no-settings-filters:hidden');
|
||||
expect(row('recovery').className).toContain('boot-settings-recovery:flex');
|
||||
|
||||
// No hints: everything but recovery.
|
||||
expect(row('filters').matches(hidesFilters)).toBe(false);
|
||||
expect(row('recovery').matches(showsRecovery)).toBe(false);
|
||||
|
||||
document.documentElement.setAttribute('data-boot-settings-tabs', 'account appearance recovery');
|
||||
expect(row('filters').matches(hidesFilters)).toBe(true);
|
||||
expect(row('recovery').matches(showsRecovery)).toBe(true);
|
||||
|
||||
document.documentElement.setAttribute('data-boot-settings-tabs', 'account filters');
|
||||
expect(row('filters').matches(hidesFilters)).toBe(false);
|
||||
expect(row('recovery').matches(showsRecovery)).toBe(false);
|
||||
});
|
||||
|
||||
it('drops the section jump row on pages with one section', () => {
|
||||
const root = mount(renderToString(<SettingsSkeleton label="Loading" />));
|
||||
const row = root.querySelector<HTMLElement>('.boot-settings-one-section\\:hidden')!;
|
||||
expect(row).not.toBeNull();
|
||||
const oneSection = variantSelector('boot-settings-one-section');
|
||||
document.documentElement.setAttribute('data-boot-settings-tab', 'filters');
|
||||
expect(row.matches(oneSection)).toBe(true);
|
||||
document.documentElement.setAttribute('data-boot-settings-tab', 'appearance');
|
||||
expect(row.matches(oneSection)).toBe(false);
|
||||
});
|
||||
|
||||
it('has the back-to-mail box only outside a Pro pane', () => {
|
||||
const page = mount(renderToString(<SettingsSkeleton label="Loading" />));
|
||||
expect(page.querySelector('.boot-pro\\:hidden')).not.toBeNull();
|
||||
const pane = mount(renderToString(<SettingsSkeletonBody embedded />));
|
||||
expect(pane.querySelector('.boot-pro\\:hidden')).toBeNull();
|
||||
// A Pro pane is desktop only: no phone list.
|
||||
expect(pane.querySelector('.boot-open\\:hidden')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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,700 @@
|
||||
/**
|
||||
* The mail page as a skeleton: folder sidebar, message list and reading pane,
|
||||
* in each layout the app has (split, horizontal, focus, Mountain View, tablet,
|
||||
* phone). Class lists follow the real components (components/mail/mail-app.tsx,
|
||||
* components/layout/sidebar.tsx, components/layout/mobile-header.tsx,
|
||||
* components/email/thread-list-item.tsx, components/email/email-viewer.tsx),
|
||||
* so the page fills in over it without anything moving.
|
||||
*
|
||||
* The pieces with a `density`/`shape` prop are shared with the loaded app's
|
||||
* own loading states (the list's skeleton rows, the sidebar before the
|
||||
* folders arrive, the viewer while a message loads). Without those props they
|
||||
* pick their variant by CSS from the boot attributes, which is what the page
|
||||
* skeleton needs; with them they draw exactly the one the app asks for.
|
||||
*/
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
CalendarEvent,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Forward,
|
||||
Reply,
|
||||
ReplyAll,
|
||||
ChevronDown,
|
||||
ChevronsLeft,
|
||||
Filter,
|
||||
Mail,
|
||||
Menu,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings,
|
||||
Square,
|
||||
Users,
|
||||
} from "@/components/icons";
|
||||
import { cn } from "@/lib/cn";
|
||||
import type { Density } from "@/lib/density";
|
||||
import { Bar, Block, Circle, SkeletonRegion, repeat, textWidth } from "./kit";
|
||||
import { AppShellSkeleton, type RailLabels } from "./shell";
|
||||
|
||||
export interface MailSkeletonProps {
|
||||
/** common.loading */
|
||||
label?: string;
|
||||
/** Rail labels for the phone bottom bar (sidebar.*). */
|
||||
labels?: RailLabels;
|
||||
}
|
||||
|
||||
/** Folder row indent: the row's own start padding and the expand chevron's slot (sidebar.tsx). */
|
||||
export const FOLDER_ROW_PX_BASE = 8;
|
||||
export const FOLDER_CHEVRON_SLOT = 20;
|
||||
|
||||
/**
|
||||
* One line of placeholder text that is exactly as tall as a line of the real
|
||||
* text (h-lh: one line-height of its own font size and leading), with the bar
|
||||
* centred on it. `className` carries the font size and leading.
|
||||
*/
|
||||
function TextLine({ className, w, bar = "h-3" }: { className?: string; w: string; bar?: string }) {
|
||||
return (
|
||||
<span aria-hidden="true" className={cn("relative block h-lh", className)}>
|
||||
<Bar className={cn("absolute inset-y-0 start-0 my-auto", bar)} w={w} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Sidebar -----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Folder rows as SidebarRow draws them (sidebar.tsx): text-sm, the density's
|
||||
* block padding, a 2px leading stripe, the chevron slot and a 16px icon.
|
||||
* Under Mountain View the rows are 32px pills with 20px icons, as
|
||||
* globals.css makes the real ones.
|
||||
*/
|
||||
export function FolderRowsSkeleton({ count = 7 }: { count?: number }) {
|
||||
return (
|
||||
// On the sidebar's panel colour, which dark themes share with the shapes.
|
||||
<div aria-hidden="true" className="sk-lift-dark">
|
||||
{repeat(count, (i) => (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full flex items-center max-lg:min-h-[44px] text-sm pe-2 border-s-2 border-transparent",
|
||||
"mv:min-h-[var(--mv-nav-row-h)] mv:me-3 mv:pe-4 mv:border-s-0",
|
||||
)}
|
||||
style={{ paddingBlock: "var(--density-sidebar-py)" }}
|
||||
>
|
||||
<span className="flex-shrink-0" style={{ width: FOLDER_ROW_PX_BASE + FOLDER_CHEVRON_SLOT }} />
|
||||
<span className="flex items-center gap-2 mv:gap-[18px] min-w-0 flex-1">
|
||||
<span className="flex items-center justify-center flex-shrink-0 w-4 h-4 mv:w-5 mv:h-5">
|
||||
<Block className="w-full h-full rounded" />
|
||||
</span>
|
||||
<TextLine className="flex-1" w={textWidth(i, 35, 30)} />
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The "Folders" section header (SidebarSectionHeader, first). */
|
||||
function FolderSectionHeaderSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div aria-hidden="true" className={cn("w-full flex items-center pb-1 px-3 pt-3", className)}>
|
||||
<span className="flex flex-1 min-w-0 items-center">
|
||||
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||
<TextLine className="ms-1.5 text-sm font-semibold w-16" w="100%" />
|
||||
</span>
|
||||
<span className="-my-0.5 h-6 w-6 grid place-items-center text-muted-foreground/70">
|
||||
<Settings className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarSkeleton({ embedded }: { embedded: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col h-full border-e overflow-hidden bg-secondary border-border",
|
||||
// Dark themes draw the panel in the shapes' own colour: lift them off it.
|
||||
"sk-lift-dark",
|
||||
!embedded && "lg:mv:bg-[var(--mv-canvas)] lg:mv:border-transparent",
|
||||
)}
|
||||
>
|
||||
{/* Header: collapse button and account switcher. Hidden in a Pro pane and under Mountain View. */}
|
||||
{!embedded && (
|
||||
<div className="border-b border-border lg:mv:hidden">
|
||||
<div className="flex items-center h-14 gap-1 px-2">
|
||||
<span className="hidden lg:flex items-center justify-center h-8 w-8 flex-shrink-0 text-muted-foreground">
|
||||
<ChevronsLeft className="w-4 h-4" />
|
||||
</span>
|
||||
<span className="flex flex-1 min-w-0 items-center gap-2 px-2 py-1.5">
|
||||
<Circle className="w-9 h-9" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<TextLine className="text-sm" w="70%" />
|
||||
<TextLine className="text-xs" w="85%" bar="h-2.5" />
|
||||
</span>
|
||||
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Mountain View: the Compose button heads the navigation. */}
|
||||
{!embedded && (
|
||||
<div className="hidden lg:mv:block pt-2 pb-3 px-3">
|
||||
<span className="flex w-fit items-center gap-3 ps-4 pe-6 h-[var(--mv-compose-h,56px)] rounded-2xl bg-[var(--mv-compose-bg)] text-[var(--mv-compose-fg)]">
|
||||
<Pencil className="w-6 h-6 flex-shrink-0" />
|
||||
<TextLine className="text-sm w-14" w="100%" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{/* Mountain View lists the folders without a heading. */}
|
||||
<FolderSectionHeaderSkeleton className={embedded ? undefined : "lg:mv:hidden"} />
|
||||
<FolderRowsSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Message list ------------------------------------------------------------
|
||||
|
||||
/** How the list draws its rows (useListRowShape, plus Mountain View's own rows). */
|
||||
export type MailRowShape = "stacked" | "one-line" | "mv";
|
||||
|
||||
interface RowOptions {
|
||||
/** Live: the density setting. Left out, the boot attributes decide by CSS. */
|
||||
density?: Density;
|
||||
/** Live: whether rows show a preview (showPreview). */
|
||||
preview?: boolean;
|
||||
}
|
||||
|
||||
/** Classes for the avatar, or null where the density has none. */
|
||||
function avatarClass(o: RowOptions): string | null {
|
||||
if (o.density === undefined) return "boot-density-xc:hidden";
|
||||
return o.density === "extra-compact" ? null : "";
|
||||
}
|
||||
|
||||
/** Classes for the two preview lines, or null where the row has none. */
|
||||
function previewClass(o: RowOptions): string | null {
|
||||
if (o.density === undefined) return "boot-dense:hidden boot-no-preview:hidden";
|
||||
return o.preview === false || o.density === "compact" || o.density === "extra-compact" ? null : "";
|
||||
}
|
||||
|
||||
/*
|
||||
* The rows below are drawn dozens of times, so they use the short classes in
|
||||
* app/boot-skeleton.css (mail section) for what every row repeats: sk-mrow
|
||||
* (the row and its rule), sk-mcell (the cell: the density's padding and gap,
|
||||
* and its minimum height), sk-mav (the avatar's column) and sk-ml (a line of
|
||||
* text, with its bar).
|
||||
*/
|
||||
|
||||
/** One line of row text, as tall as the real line; `w` is the bar's width. */
|
||||
function L({ className, w }: { className?: string; w: string }) {
|
||||
return (
|
||||
<span className={cn("sk-ml", className)}>
|
||||
<span className="sk-shape" style={{ width: w }} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** A stacked row (ThreadListItem on phones and in the split list). */
|
||||
function StackedRow({ i, o }: { i: number; o: RowOptions }) {
|
||||
const avatar = avatarClass(o);
|
||||
const preview = previewClass(o);
|
||||
return (
|
||||
<div className="sk-mrow max-md:mv:border-b-transparent">
|
||||
<div className="sk-mcell sk-mcell-min">
|
||||
{avatar !== null && (
|
||||
<span className={cn("sk-mav", avatar)}>
|
||||
<span className="sk-shape block rounded-full w-10 h-10" />
|
||||
</span>
|
||||
)}
|
||||
<span className="block flex-1 min-w-0">
|
||||
<span className="flex items-center justify-between gap-2 mb-1">
|
||||
<L className="text-sm flex-1" w={textWidth(i, 30, 30)} />
|
||||
<L className="text-xs w-10" w="100%" />
|
||||
</span>
|
||||
<L className="mb-1 text-sm" w={textWidth(i + 3, 55, 35)} />
|
||||
{preview !== null && (
|
||||
<span className={cn("block", preview)}>
|
||||
{/* Mountain View's phone rows keep the preview to one line, beside a 32px star. */}
|
||||
<L className="text-sm leading-relaxed max-md:mv:h-8" w="95%" />
|
||||
<L className="text-sm leading-relaxed max-md:mv:hidden" w={textWidth(i + 5, 45, 40)} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A one-line row (the focused list and the bottom reading pane on a desktop). */
|
||||
function OneLineRow({ i, o }: { i: number; o: RowOptions }) {
|
||||
const avatar = avatarClass(o);
|
||||
return (
|
||||
<div className="sk-mrow">
|
||||
<div className="sk-mcell items-center">
|
||||
{avatar !== null && (
|
||||
<span className={cn("sk-mav", avatar)}>
|
||||
<span className="sk-shape block rounded-full w-8 h-8" />
|
||||
</span>
|
||||
)}
|
||||
<span className="flex flex-1 min-w-0 gap-3">
|
||||
<L className="w-32 lg:w-40 text-sm" w={textWidth(i, 50, 40)} />
|
||||
<L className="flex-1 text-sm" w={textWidth(i + 2, 35, 45)} />
|
||||
<L className="w-10 text-xs self-center" w="100%" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Mountain View's row on a tablet or desktop: check box and star, then one 40px line. */
|
||||
function MvRow({ i }: { i: number }) {
|
||||
return (
|
||||
<div className="sk-mrow">
|
||||
<div className="sk-mcell items-center py-0 min-h-10">
|
||||
{/* The check box and the star, 26px each (an 18px icon in p-1). */}
|
||||
<span className="flex flex-shrink-0 items-center gap-[2px]">
|
||||
<span className="block w-3.5 h-3.5 m-[6px] rounded-[3px] border-2 border-muted-foreground/70" />
|
||||
<span className="sk-shape block w-3.5 h-3.5 m-[6px] rounded-full" />
|
||||
</span>
|
||||
<span className="flex flex-1 min-w-0 gap-3">
|
||||
<L className="w-32 lg:w-40 text-sm" w={textWidth(i, 45, 40)} />
|
||||
<L className="flex-1 text-sm" w={textWidth(i + 2, 35, 45)} />
|
||||
<L className="w-10 text-xs self-center" w="100%" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Enough rows of each shape to fill a tall viewport. */
|
||||
const ROWS: Record<MailRowShape, number> = { stacked: 16, "one-line": 32, mv: 22 };
|
||||
|
||||
/**
|
||||
* Message list rows. With `shape`, the rows the loaded list is about to draw
|
||||
* (EmailList's loading state); without, every shape, shown by CSS from the
|
||||
* boot attributes (one-line where useListRowShape says so, Mountain View's
|
||||
* rows from a tablet up).
|
||||
*/
|
||||
export function MailListRowsSkeleton({
|
||||
shape,
|
||||
density,
|
||||
preview,
|
||||
className,
|
||||
}: { shape?: MailRowShape; className?: string } & RowOptions) {
|
||||
const o: RowOptions = { density, preview };
|
||||
if (shape) {
|
||||
return (
|
||||
<div aria-hidden="true" className={className} data-mail-row-skeleton={shape}>
|
||||
{repeat(ROWS[shape], (i) =>
|
||||
shape === "stacked" ? <StackedRow i={i} o={o} /> : shape === "one-line" ? <OneLineRow i={i} o={o} /> : <MvRow i={i} />,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div aria-hidden="true" className={className}>
|
||||
<div className="md:boot-focus:hidden lg:boot-horizontal:hidden md:mv:hidden">
|
||||
{repeat(ROWS.stacked, (i) => <StackedRow i={i} o={o} />)}
|
||||
</div>
|
||||
<div className="hidden md:boot-std:boot-focus:block lg:boot-std:boot-horizontal:block">
|
||||
{repeat(ROWS["one-line"], (i) => <OneLineRow i={i} o={o} />)}
|
||||
</div>
|
||||
<div className="hidden md:mv:block">{repeat(ROWS.mv, (i) => <MvRow i={i} />)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The list header row: select toggle, search field, folder menu (mail-app.tsx). */
|
||||
function ListToolbarSkeleton({ embedded }: { embedded: boolean }) {
|
||||
const icon = "flex items-center justify-center h-8 w-8 shrink-0 text-muted-foreground";
|
||||
const round = "grid place-items-center w-10 h-10 rounded-full shrink-0 text-muted-foreground";
|
||||
return (
|
||||
<div className="border-b border-border bg-background max-md:hidden">
|
||||
<div className={cn("px-3 h-14 flex items-center", !embedded && "lg:mv:hidden")}>
|
||||
<div className="flex items-center gap-1 w-full">
|
||||
<span className={icon}>
|
||||
<Square className="w-4 h-4" />
|
||||
</span>
|
||||
<span className="relative flex-1 flex items-center h-8 rounded-md border border-input bg-background ps-8 pe-16">
|
||||
<Search className="absolute start-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<TextLine className="flex-1 text-sm" w="6rem" />
|
||||
<span className="absolute end-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 text-muted-foreground">
|
||||
<span className="pointer-coarse:hidden mx-1 rounded border border-border px-1.5 font-sans text-[0.6875rem] leading-4">
|
||||
/
|
||||
</span>
|
||||
<span className="p-1">
|
||||
<Filter className="w-4 h-4" />
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className={icon}>
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Mountain View's list toolbar (MvListToolbar). */}
|
||||
{!embedded && (
|
||||
<div className="hidden lg:mv:flex items-center gap-1 h-12 ps-[5px] pe-2 shrink-0">
|
||||
<span className={round}>
|
||||
<Square className="w-[18px] h-[18px]" />
|
||||
</span>
|
||||
<span className={round}>
|
||||
<RefreshCw className="w-[18px] h-[18px]" />
|
||||
</span>
|
||||
<span className={round}>
|
||||
<MoreVertical className="w-[18px] h-[18px]" />
|
||||
</span>
|
||||
<span className="flex-1 min-w-0" />
|
||||
<TextLine className="w-24 text-xs pe-2 shrink-0" w="100%" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The phone and tablet header (MobileHeader): menu, the search pill on
|
||||
* phones or the folder name on tablets, the ⋮ menu and the account.
|
||||
*/
|
||||
function MobileHeaderSkeleton() {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"lg:hidden flex items-center justify-between h-14 shrink-0",
|
||||
"border-b border-border bg-background px-4",
|
||||
"mv:mx-2 mv:mt-2 mv:mb-1 mv:rounded-full mv:border-0 mv:bg-muted mv:px-2 mv:shadow-sm",
|
||||
// Mountain View's pill is the shapes' own colour: lift them off it.
|
||||
"sk-lift-mv",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0 flex-shrink-0">
|
||||
<span className="flex items-center justify-center h-11 w-11">
|
||||
<Menu className="h-5 w-5" />
|
||||
</span>
|
||||
<TextLine className="max-md:hidden w-28 font-semibold text-lg" w="100%" bar="h-4" />
|
||||
</span>
|
||||
<span className="md:hidden flex-1 min-w-0 h-10 flex items-center gap-2 rounded-full pe-1 mx-1 bg-muted ps-3 mv:mx-0 mv:bg-transparent mv:ps-2 text-muted-foreground sk-lift">
|
||||
<Search className="h-4 w-4 flex-shrink-0" />
|
||||
<TextLine className="flex-1 text-sm" w="7rem" />
|
||||
</span>
|
||||
<span className="flex items-center gap-1 flex-shrink-0">
|
||||
<span className="flex items-center justify-center h-11 w-11">
|
||||
<MoreVertical className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="flex items-center justify-center w-10 h-10 pointer-coarse:w-11 pointer-coarse:h-11">
|
||||
<Circle className="w-8 h-8" />
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Reading pane --------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The viewer while a message loads (EmailViewer's loading state draws this
|
||||
* same component): the toolbar, the subject block and the sender block of the
|
||||
* default reader, then the message body's tinted sheet.
|
||||
*/
|
||||
/**
|
||||
* The reader's top toolbar while a message loads. The loaded reader lifts its
|
||||
* toolbar above the message row on a tablet or desktop (EmailViewer's
|
||||
* `liftToolbar`); its loading state draws this there too, so the row below
|
||||
* doesn't move when the message arrives.
|
||||
*/
|
||||
export function MailMessageToolbarSkeleton() {
|
||||
const button = "h-8 w-20 rounded-md";
|
||||
return (
|
||||
<div className="bg-background flex-shrink-0 border-b border-border">
|
||||
<div className="px-2 sm:px-4 lg:px-6 flex items-center h-14">
|
||||
{/* Phones: back, then icon-over-label actions. */}
|
||||
<div className="sm:hidden flex items-center justify-between gap-0.5 w-full">
|
||||
<span className="flex items-center justify-center h-9 w-9 -ms-1 flex-shrink-0">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</span>
|
||||
{repeat(6, (i) => (
|
||||
<span className="flex flex-col items-center gap-1 px-1 flex-1">
|
||||
<Block className="w-5 h-5 rounded" />
|
||||
<Bar className="h-2" w={i === 5 ? "3.5rem" : "2.25rem"} />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="max-sm:hidden flex items-center justify-between gap-2 w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Back to the list, where the reader has the list's place (tablet, focus). */}
|
||||
<span className="hidden md:max-lg:flex md:boot-focus:flex items-center justify-center h-9 w-9 -ms-1 flex-shrink-0">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</span>
|
||||
<Block className={button} />
|
||||
<Block className={button} />
|
||||
<Block className={button} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Block className={button} />
|
||||
<Block className={button} />
|
||||
<Block className={cn(button, "max-md:hidden")} />
|
||||
<Block className="h-8 w-8 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MailMessageSkeleton({ className, toolbar = true }: { className?: string; toolbar?: boolean }) {
|
||||
return (
|
||||
<div aria-hidden="true" className={cn("flex-1 min-w-0 flex flex-col h-full bg-background overflow-hidden", className)}>
|
||||
{toolbar ? <MailMessageToolbarSkeleton /> : null}
|
||||
<div className="bg-background border-b border-border">
|
||||
<div className="px-4 lg:px-6" style={{ paddingBlock: "var(--density-header-py)" }}>
|
||||
<div className="flex items-start justify-between gap-2 lg:gap-4">
|
||||
<TextLine className="flex-1 min-w-0 text-lg lg:text-xl font-bold" w="70%" bar="h-4 lg:h-5" />
|
||||
{/* Date and size. */}
|
||||
<span className="w-28 max-sm:hidden flex flex-col items-end">
|
||||
<TextLine className="w-full text-sm" w="100%" />
|
||||
<TextLine className="w-8 text-xs" w="100%" bar="h-2.5" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-background px-4 lg:px-6 border-b border-border" style={{ paddingBlock: "var(--density-header-py)" }}>
|
||||
<div className="flex items-start" style={{ gap: "var(--density-item-gap)" }}>
|
||||
<Circle className="w-10 h-10" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<TextLine className="font-semibold" w="10rem" />
|
||||
<TextLine className="text-sm leading-6" w="13rem" />
|
||||
<TextLine className="text-sm leading-6" w="16rem" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden bg-muted/30">
|
||||
<div className="px-6 pt-4 pb-6">
|
||||
<div className="space-y-3">
|
||||
<Bar className="h-4 w-full" />
|
||||
<Bar className="h-4 w-5/6" />
|
||||
<Bar className="h-4 w-4/6" />
|
||||
<Bar className="h-4 w-full" />
|
||||
<Bar className="h-4 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Phones: the reply bar along the bottom (previous, reply, reply all, forward, next). */}
|
||||
<div className="sm:hidden flex items-center flex-shrink-0 bg-background border-t border-border pb-[calc(env(safe-area-inset-bottom)/2)] text-muted-foreground">
|
||||
{[ChevronLeft, Reply, ReplyAll, Forward, ChevronRight].map((Icon, i) => (
|
||||
<span key={i} className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]">
|
||||
<Icon className="w-5 h-5" />
|
||||
<Bar className="h-2 my-px" w="2.25rem" />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A conversation on a phone while it loads (ThreadConversationView): the
|
||||
* header with back, subject and count, then the message cards.
|
||||
*/
|
||||
export function MailConversationSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div aria-hidden="true" className={cn("flex-1 min-w-0 flex flex-col h-full bg-background overflow-hidden", className)}>
|
||||
<div
|
||||
className="flex items-center px-4 border-b border-border bg-background"
|
||||
style={{ gap: "var(--density-item-gap)", paddingBlock: "var(--density-header-py)" }}
|
||||
>
|
||||
<span className="flex items-center justify-center h-11 w-11 -ms-2 flex-shrink-0">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<TextLine className="font-semibold" w="80%" />
|
||||
<TextLine className="font-semibold" w="45%" />
|
||||
<TextLine className="text-sm" w="5rem" />
|
||||
</span>
|
||||
<span className="flex items-center gap-1 flex-shrink-0">
|
||||
{repeat(4, () => <Block className="h-11 w-11 rounded-md" />)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="space-y-3" style={{ padding: "var(--density-card-p)" }}>
|
||||
{repeat(3, (i) => (
|
||||
<div className="rounded-lg border border-border overflow-hidden bg-background shadow-sm">
|
||||
<div className="w-full flex items-start" style={{ gap: "var(--density-item-gap)", padding: "var(--density-card-p)" }}>
|
||||
<Circle className="w-10 h-10" />
|
||||
<span className="flex-1 min-w-0">
|
||||
<TextLine className="mb-0.5" w={textWidth(i, 35, 25)} />
|
||||
<TextLine className="text-sm" w="3rem" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="border-t border-border px-4 py-4 space-y-3">
|
||||
<Bar className="h-3.5 w-full" />
|
||||
<Bar className="h-3.5 w-5/6" />
|
||||
<Bar className="h-3.5" w={textWidth(i + 2, 40, 40)} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The empty reading pane: nothing open (EmailViewer without an email). */
|
||||
function EmptyReaderSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"flex-1 min-w-0 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center text-center p-8">
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-background shadow-lg flex items-center justify-center">
|
||||
<Mail className="w-10 h-10 text-muted-foreground" />
|
||||
</div>
|
||||
<TextLine className="text-xl font-semibold mb-2 w-56" w="100%" bar="h-4" />
|
||||
<TextLine className="w-72" w="100%" />
|
||||
<Block className="mt-6 h-10 w-28" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The reader's frame (EmailViewer's shell()). */
|
||||
function ReaderFrame({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full bg-background overflow-hidden relative">
|
||||
<div className="flex-1 min-h-0 flex flex-row overflow-hidden relative">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Mountain View's side panel rail (MvSidePanel): calendar and contacts. */
|
||||
function MvSidePanelSkeleton() {
|
||||
return (
|
||||
<div aria-hidden="true" className="hidden lg:mv:flex boot-no-mv-side:hidden w-14 shrink-0 flex-col items-center gap-2 pt-3">
|
||||
<span className="grid h-10 w-10 place-items-center rounded-full boot-no-calendar:hidden">
|
||||
<CalendarEvent className="h-5 w-5 text-[#1a73e8]" />
|
||||
</span>
|
||||
<span className="grid h-10 w-10 place-items-center rounded-full boot-no-contacts:hidden">
|
||||
<Users className="h-5 w-5 text-[#1a73e8]" />
|
||||
</span>
|
||||
<div className="my-1 h-px w-5 bg-border" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- The page ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The mail page's own content: everything in the row next to the rail.
|
||||
* `embedded` is the variant drawn inside a Pro pane (useIsEmbedded()), which
|
||||
* has no rail, no Mountain View shell and fills its pane (h-full).
|
||||
*/
|
||||
export function MailSkeletonBody({ embedded = false }: { embedded?: boolean }) {
|
||||
return (
|
||||
<>
|
||||
{/* Folders: in the flow on a desktop, a closed drawer below that. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="flex-shrink-0 h-full max-lg:hidden"
|
||||
style={{ width: "var(--boot-mail-sidebar-w, 256px)" }}
|
||||
>
|
||||
<SidebarSkeleton embedded={embedded} />
|
||||
</div>
|
||||
{/* AppMain. Under Mountain View the rounded card inset from the canvas. */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col flex-1 min-w-0 h-full",
|
||||
!embedded && "lg:mv:h-auto lg:mv:mb-4 lg:mv:me-4 lg:mv:rounded-[var(--mv-card-radius)] lg:mv:bg-background lg:mv:overflow-hidden",
|
||||
)}
|
||||
>
|
||||
<div className={cn("relative flex flex-1 min-h-0", !embedded && "lg:boot-horizontal:flex-col")}>
|
||||
{/* The message list. */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col bg-background min-w-0 h-full border-e border-border",
|
||||
// Phones and tablets give an open message the whole width.
|
||||
"max-md:flex-1 max-md:border-e-0 max-lg:boot-open:hidden",
|
||||
"md:flex-shrink-0 md:shadow-sm md:w-[var(--boot-mail-list-w,384px)]",
|
||||
embedded && "md:max-w-[max(220px,55%)]",
|
||||
// Focus: the list takes the width while nothing is open and makes way for the reader.
|
||||
"md:boot-focus:w-auto md:boot-focus:flex-1 md:boot-focus:border-e-0 md:boot-focus:boot-open:hidden",
|
||||
// Horizontal (desktop only): the list on top, full width.
|
||||
!embedded && [
|
||||
"lg:boot-horizontal:w-full lg:boot-horizontal:h-auto lg:boot-horizontal:border-e-0 lg:boot-horizontal:shadow-none",
|
||||
"lg:boot-horizontal:flex-1 lg:boot-horizontal:min-h-0",
|
||||
"lg:boot-horizontal:boot-open:flex-none lg:boot-horizontal:boot-open:h-[var(--boot-mail-list-h,320px)]",
|
||||
"lg:boot-horizontal:boot-open:shadow-[0_8px_12px_-6px_rgba(0,0,0,0.18)] dark:lg:boot-horizontal:boot-open:shadow-[0_8px_14px_-6px_rgba(0,0,0,0.55)]",
|
||||
],
|
||||
)}
|
||||
>
|
||||
{!embedded && <MobileHeaderSkeleton />}
|
||||
<ListToolbarSkeleton embedded={embedded} />
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div className={cn("flex-1 overflow-hidden bg-background relative pb-20", !embedded && "lg:mv:pb-0")}>
|
||||
<MailListRowsSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
{/* The round Compose button (Mountain View's sits in its navigation). */}
|
||||
<Circle
|
||||
className={cn(
|
||||
"absolute z-40 shadow-lg bottom-4 end-4 h-12 w-12 max-md:h-14 max-md:w-14",
|
||||
"max-md:mv:w-36 max-md:mv:rounded-2xl",
|
||||
!embedded && "lg:mv:hidden",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{/* The reading pane. */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col bg-background flex-1 min-w-0 h-full md:relative",
|
||||
"max-md:hidden max-md:boot-open:flex",
|
||||
"md:boot-focus:hidden md:boot-focus:boot-open:flex",
|
||||
!embedded && "lg:boot-horizontal:hidden lg:boot-horizontal:boot-open:flex lg:boot-horizontal:h-auto lg:boot-horizontal:min-h-0",
|
||||
)}
|
||||
>
|
||||
<ReaderFrame>
|
||||
<EmptyReaderSkeleton className="boot-open:hidden" />
|
||||
{/* A conversation link opens the conversation view on a phone, its newest message elsewhere. */}
|
||||
<MailMessageSkeleton className="hidden boot-open:flex max-md:boot-thread:hidden" />
|
||||
<MailConversationSkeleton className="hidden max-md:boot-thread:flex" />
|
||||
</ReaderFrame>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!embedded && <MvSidePanelSkeleton />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton of the mail page. `embedded`: the mail app inside a Pro pane, which
|
||||
* draws only its body at the pane's height.
|
||||
*/
|
||||
export function MailSkeleton({ label, labels, embedded = false }: MailSkeletonProps & { embedded?: boolean }) {
|
||||
if (embedded) {
|
||||
return (
|
||||
<SkeletonRegion surface="mail" label={label} className="flex h-full overflow-hidden bg-background">
|
||||
<MailSkeletonBody embedded />
|
||||
</SkeletonRegion>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<AppShellSkeleton
|
||||
surface="mail"
|
||||
label={label}
|
||||
labels={labels}
|
||||
railBelow="lg"
|
||||
active="mail"
|
||||
mvSearch
|
||||
bottomBarClassName="max-md:boot-open:hidden"
|
||||
>
|
||||
<MailSkeletonBody />
|
||||
</AppShellSkeleton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Bar } from "./kit";
|
||||
import { MV_CARD_ROW } from "./contacts-skeleton";
|
||||
import { AppShellSkeleton, type RailLabels } from "./shell";
|
||||
import { PhoneActionsSkeleton } from "./toolbar-skeleton";
|
||||
|
||||
export interface PluginPageSkeletonProps {
|
||||
/** common.loading */
|
||||
label?: string;
|
||||
/** Rail labels for the phone bottom bar (sidebar.*). */
|
||||
labels?: RailLabels;
|
||||
}
|
||||
|
||||
/**
|
||||
* A plugin page's own content (components/plugins/plugin-page-app.tsx).
|
||||
* Nothing about the page is known before its plugin starts, not even its
|
||||
* title, so this is the frame's column: the phone header (PhoneAppHeader,
|
||||
* below 48rem) and an empty content area, which is also what the page shows
|
||||
* while the plugins start. `embedded` is the Pro pane variant: no header.
|
||||
*/
|
||||
export function PluginPageSkeletonBody({ embedded = false }: { embedded?: boolean }) {
|
||||
return (
|
||||
<div aria-hidden="true" className={cn("flex flex-col flex-1 min-w-0", embedded && "h-full")} data-embedded={embedded ? "" : undefined}>
|
||||
{embedded ? null : (
|
||||
<div className="flex md:hidden items-center gap-2 h-14 shrink-0 border-b border-border bg-background px-4">
|
||||
<span className="flex items-center h-7 min-w-0">
|
||||
<Bar className="h-4" w="7rem" />
|
||||
</span>
|
||||
<PhoneActionsSkeleton className="flex" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col flex-1 min-h-0" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Skeleton of a plugin page (/apps/<plugin>/<page>). */
|
||||
export function PluginPageSkeleton({ label, labels }: PluginPageSkeletonProps) {
|
||||
return (
|
||||
<AppShellSkeleton surface="apps" label={label} labels={labels} railBelow="md" mvMenu={false} rowClassName={MV_CARD_ROW}>
|
||||
<PluginPageSkeletonBody />
|
||||
</AppShellSkeleton>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,9 @@ import { toast } from "@/stores/toast-store";
|
||||
import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
|
||||
import { useAccountPrincipalEmails } from "@/hooks/use-account-principal-emails";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PaneSpinner, Spinner } from "@/components/ui/spinner";
|
||||
import { CalendarSkeleton, CalendarSkeletonBody, CalendarViewSkeleton, calendarSkeletonText } from "@/components/boot-skeleton/calendar-skeleton";
|
||||
import { SkeletonRegion } from "@/components/boot-skeleton/kit";
|
||||
import { useSkeletonLabels } from "@/components/boot-skeleton/use-skeleton-labels";
|
||||
import { CalendarToolbar } from "@/components/calendar/calendar-toolbar";
|
||||
import { CalendarMonthView } from "@/components/calendar/calendar-month-view";
|
||||
import { CalendarWeekView } from "@/components/calendar/calendar-week-view";
|
||||
@@ -30,6 +32,8 @@ import { CalendarAgendaView } from "@/components/calendar/calendar-agenda-view";
|
||||
import { TaskListView, filterTasksForList } from "@/components/calendar/task-list-view";
|
||||
import { CalendarLoadErrorNotice } from "@/components/calendar/calendar-load-error-notice";
|
||||
import { TaskToolbar } from "@/components/calendar/task-toolbar";
|
||||
import { PluginTaskView, usePluginTaskView } from "@/components/plugins/plugin-task-view";
|
||||
import { onOpenTask } from "@/lib/plugin-sandbox/open-task";
|
||||
import { TaskModal } from "@/components/calendar/task-modal";
|
||||
import { MiniCalendar } from "@/components/calendar/mini-calendar";
|
||||
import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel";
|
||||
@@ -150,6 +154,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
const describeError = useCalendarErrorMessage();
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
useDocumentTitle(tSidebar("calendar"));
|
||||
const skeletonLabels = useSkeletonLabels();
|
||||
const tWebcalAction = useTranslations("calendar.webcal_action");
|
||||
const { demoMode } = useConfig();
|
||||
// Subscriptions fetch the feed through /api/fetch-ical, which the demo has no
|
||||
@@ -758,6 +763,15 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
setShowTaskModal(true);
|
||||
}), [closeEventPanelForTask, whenEditorsDiscarded]);
|
||||
|
||||
// A plugin drawing the task view opens a task in the editor
|
||||
// (api.ui.openTask), as a click in Bulwark's list does.
|
||||
useEffect(() => onOpenTask((taskId) => {
|
||||
const task = useTaskStore.getState().tasks.find((t) => t.id === taskId);
|
||||
if (!task) return false;
|
||||
openEditTaskModal(task);
|
||||
return true;
|
||||
}), [openEditTaskModal]);
|
||||
|
||||
const handleToggleTaskComplete = useCallback((task: CalendarTask) => {
|
||||
if (!client) return;
|
||||
// The checkbox shows the result; only a failure needs saying.
|
||||
@@ -1764,6 +1778,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
return withCompany;
|
||||
}, [events, selectedCalendarIds, showBirthdayCalendar, birthdayEvents, companyEvents]);
|
||||
|
||||
// The plugin drawing the task view's list, if one offers it.
|
||||
const pluginTaskView = usePluginTaskView();
|
||||
// The tasks the task list shows; plugin buttons in its toolbar act on them.
|
||||
const listedTaskIds = useMemo(
|
||||
() => filterTasksForList(taskStore.tasks, selectedCalendarIds, taskStore.filter).map((task) => task.id),
|
||||
@@ -1845,13 +1861,27 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
</Dialog>
|
||||
) : null;
|
||||
|
||||
if (!isAuthenticated) return null;
|
||||
// Until the session says what the account has, the page is drawn as the
|
||||
// skeleton the server sent (components/boot-skeleton): the same markup, so
|
||||
// nothing moves while the sign-in is restored or the redirects decide.
|
||||
const skeletonText = calendarSkeletonText(t);
|
||||
const skeleton = isEmbedded ? (
|
||||
<SkeletonRegion surface="calendar" label={skeletonLabels.label} className="flex flex-col h-full">
|
||||
<CalendarSkeletonBody embedded text={skeletonText} />
|
||||
</SkeletonRegion>
|
||||
) : (
|
||||
<CalendarSkeleton label={skeletonLabels.label} labels={skeletonLabels.labels} text={skeletonText} />
|
||||
);
|
||||
if (!isAuthenticated) return skeleton;
|
||||
// Turned off by policy: nothing, while the effect above leaves for mail.
|
||||
if (!calendarEnabled) return null;
|
||||
if (!supportsCalendar) return renderWebcalAccountPicker();
|
||||
if (!supportsCalendar) return renderWebcalAccountPicker() ?? skeleton;
|
||||
|
||||
const renderView = () => {
|
||||
// The calendars are loading: the view's grid in the skeleton's shapes,
|
||||
// where the real one is about to be drawn.
|
||||
if (isLoading && calendars.length === 0) {
|
||||
return <PaneSpinner label={t("status.loading_calendars")} className="flex-1" />;
|
||||
return <CalendarViewSkeleton view={normalizedViewMode} label={t("status.loading_calendars")} text={skeletonText} />;
|
||||
}
|
||||
|
||||
const viewContent = (() => {
|
||||
@@ -1968,7 +1998,30 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
onCreateEvent={() => openCreateModal()}
|
||||
/>
|
||||
);
|
||||
case "tasks":
|
||||
case "tasks": {
|
||||
const taskList = (
|
||||
<TaskListView
|
||||
tasks={taskStore.tasks}
|
||||
calendars={displayCalendars}
|
||||
selectedCalendarIds={selectedCalendarIds}
|
||||
filter={taskStore.filter}
|
||||
onSelectTask={openEditTaskModal}
|
||||
onToggleComplete={handleToggleTaskComplete}
|
||||
selectedTaskId={taskStore.selectedTaskId}
|
||||
onQuickCreate={(title) => {
|
||||
// Into a calendar that takes it: the default one, else the
|
||||
// first the user may write to.
|
||||
const target = writableTaskCalendars.find((c) => c.isDefault) ?? writableTaskCalendars[0];
|
||||
if (!client || !target) {
|
||||
toast.error(t("notifications.task_save_error"));
|
||||
return;
|
||||
}
|
||||
taskStore.createTask(client, { "@type": "Task", title, progress: "needs-action", calendarIds: { [target.id]: true } })
|
||||
.then((created) => { if (created) toast.success(t("notifications.task_created")); })
|
||||
.catch((error: unknown) => toast.error(describeError(error, t("notifications.task_save_error"))));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{taskStore.error === TASKS_LOAD_ERROR && (
|
||||
@@ -1981,29 +2034,21 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
taskIds={listedTaskIds}
|
||||
compact={isMobile}
|
||||
/>
|
||||
<TaskListView
|
||||
tasks={taskStore.tasks}
|
||||
calendars={displayCalendars}
|
||||
selectedCalendarIds={selectedCalendarIds}
|
||||
filter={taskStore.filter}
|
||||
onSelectTask={openEditTaskModal}
|
||||
onToggleComplete={handleToggleTaskComplete}
|
||||
selectedTaskId={taskStore.selectedTaskId}
|
||||
onQuickCreate={(title) => {
|
||||
// Into a calendar that takes it: the default one, else the
|
||||
// first the user may write to.
|
||||
const target = writableTaskCalendars.find((c) => c.isDefault) ?? writableTaskCalendars[0];
|
||||
if (!client || !target) {
|
||||
toast.error(t("notifications.task_save_error"));
|
||||
return;
|
||||
}
|
||||
taskStore.createTask(client, { "@type": "Task", title, progress: "needs-action", calendarIds: { [target.id]: true } })
|
||||
.then((created) => { if (created) toast.success(t("notifications.task_created")); })
|
||||
.catch((error: unknown) => toast.error(describeError(error, t("notifications.task_save_error"))));
|
||||
}}
|
||||
/>
|
||||
{/* A plugin may draw the list itself (the calendar-task-view
|
||||
slot), with Bulwark's list back while its frame is not
|
||||
drawn; the toolbar above stays Bulwark's. */}
|
||||
{pluginTaskView ? (
|
||||
<PluginTaskView
|
||||
pluginId={pluginTaskView.pluginId}
|
||||
fill={pluginTaskView.fill}
|
||||
filter={taskStore.filter}
|
||||
calendarIds={selectedCalendarIds}
|
||||
fallback={taskList}
|
||||
/>
|
||||
) : taskList}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -2013,10 +2058,11 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
<CalendarLoadErrorNotice onRetry={retryEventsLoad} />
|
||||
)}
|
||||
{viewContent}
|
||||
{/* The first events: the grid is drawn already and they fill in on
|
||||
it, so this is said rather than shown (a dimmed grid with a
|
||||
spinner flashed between the skeleton and the events). */}
|
||||
{isLoadingEvents && calendars.length > 0 && events.length === 0 && (
|
||||
<div className="absolute inset-0 bg-background/50 flex items-center justify-center pointer-events-none">
|
||||
<Spinner label={t("status.loading_events")} />
|
||||
</div>
|
||||
<span role="status" className="sr-only">{t("status.loading_events")}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import { TARGET_NAMES } from '@/lib/connector/registry';
|
||||
import { CONNECTOR_SURFACES } from '@/lib/boot-skeleton/surfaces';
|
||||
|
||||
// A connector link that can't be opened explains why in the reader's
|
||||
// language (keys in tests), not in English only.
|
||||
@@ -27,3 +30,21 @@ describe('connector link page', () => {
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('connector link page while it resolves', () => {
|
||||
// The server HTML is what shows until the redirect: the skeleton of the
|
||||
// page the link opens, with the opening text as its status message.
|
||||
it.each(TARGET_NAMES)('%s draws its destination skeleton', (target) => {
|
||||
const html = renderToString(<ConnectorClient target={target} />);
|
||||
const surface = CONNECTOR_SURFACES[target];
|
||||
expect(html).toContain(`data-boot-skeleton="${surface}"`);
|
||||
expect(html).toContain('role="status"');
|
||||
expect(html).toContain('opening');
|
||||
});
|
||||
|
||||
it('keeps the plain text for a target it does not know', () => {
|
||||
const html = renderToString(<ConnectorClient target="constructor" />);
|
||||
expect(html).not.toContain('data-boot-skeleton');
|
||||
expect(html).toContain('opening');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@ import { resolveTarget, type ResolveResult } from '@/lib/connector/registry';
|
||||
import { appPath } from '@/lib/deep-links';
|
||||
import { replaceWindowLocation } from '@/lib/browser-navigation';
|
||||
import { productName } from '@/lib/product-name';
|
||||
import { CONNECTOR_SURFACES } from '@/lib/boot-skeleton/surfaces';
|
||||
import { RouteSkeleton } from '@/components/boot-skeleton/route-skeleton';
|
||||
|
||||
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '';
|
||||
|
||||
@@ -49,6 +51,12 @@ export function ConnectorClient({ target }: { target: string }) {
|
||||
}, [destination]);
|
||||
|
||||
if (result === null || result.kind === 'ok') {
|
||||
// The page the link opens, as its skeleton: the boot script has already
|
||||
// set that page's variant attributes from the link's parameters, so the
|
||||
// hard redirect lands on the same shapes. No bottom bar labels: this page
|
||||
// is English only and the destination may not be.
|
||||
const surface = Object.hasOwn(CONNECTOR_SURFACES, target) ? CONNECTOR_SURFACES[target] : null;
|
||||
if (surface) return <RouteSkeleton surface={surface} label={t('opening')} />;
|
||||
return <Shell>{t('opening')}</Shell>;
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,19 @@ describe('ContactList', () => {
|
||||
expect(screen.getByTestId('contact-list-skeleton')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('draws the loading rows at the list density, as the page skeleton does', () => {
|
||||
const avatars = () => [...screen.getByTestId('contact-list-skeleton').querySelectorAll('.rounded-full')];
|
||||
act(() => { useSettingsStore.setState({ density: 'extra-compact', groupContactsByLetter: false }); });
|
||||
const { unmount } = render(<ContactList {...defaultProps} contacts={[]} isLoading />);
|
||||
expect(avatars().length).toBeGreaterThan(0);
|
||||
expect(avatars().every((el) => el.classList.contains('hidden'))).toBe(true);
|
||||
unmount();
|
||||
|
||||
act(() => { useSettingsStore.setState({ density: 'regular', groupContactsByLetter: true }); });
|
||||
render(<ContactList {...defaultProps} contacts={[]} isLoading />);
|
||||
expect(avatars().some((el) => el.classList.contains('hidden'))).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the contacts as soon as they are there, even while loading', () => {
|
||||
render(<ContactList {...defaultProps} isLoading />);
|
||||
expect(screen.queryByTestId('contact-list-skeleton')).toBeNull();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -53,6 +53,8 @@ import { useDeepLinkUrl } from "@/hooks/use-deep-link-url";
|
||||
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
|
||||
import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
import { ContactsSkeleton, ContactsSkeletonBody } from "@/components/boot-skeleton/contacts-skeleton";
|
||||
import { useSkeletonLabels } from "@/components/boot-skeleton/use-skeleton-labels";
|
||||
import { useRegisterCommands } from "@/lib/command-registry";
|
||||
import { useCloseOnBack } from "@/hooks/use-close-on-back";
|
||||
import { useLeaveGuard } from "@/lib/leave-guard";
|
||||
@@ -90,6 +92,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
// Static Lite build: the route params are empty, read the link from the URL.
|
||||
const linkSegments = useLiteLinkSegments('contacts', routeSegments);
|
||||
const t = useTranslations("contacts");
|
||||
const skeleton = useSkeletonLabels();
|
||||
// Organisation > Devices > No printing: no Print buttons (Ctrl+P prints a note).
|
||||
const printOff = useStrictestDevices().noPrint;
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
@@ -956,7 +959,12 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
}
|
||||
};
|
||||
|
||||
if (!isAuthenticated) return null;
|
||||
// Until the session is restored: the page skeleton the server drew.
|
||||
if (!isAuthenticated) {
|
||||
return isEmbedded
|
||||
? <ContactsSkeletonBody embedded />
|
||||
: <ContactsSkeleton label={skeleton.label} labels={skeleton.labels} />;
|
||||
}
|
||||
|
||||
const renderRightPanel = () => {
|
||||
// The directory is read-only: its panel shows the open entry whatever the view.
|
||||
@@ -1392,7 +1400,9 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
{!printOff && <ContextMenuItem icon={Printer} label={tCommon("print")} onClick={() => { close(); void requestPrint({ kind: "contact-list" }); }} />}
|
||||
</>
|
||||
)}
|
||||
isLoading={supportsSync && !contactsLoaded}
|
||||
// Until the session is back, nobody knows yet whether the server
|
||||
// keeps contacts: show the rows loading, not an empty book.
|
||||
isLoading={!contactsLoaded && (supportsSync || !client)}
|
||||
onPrint={printOff ? undefined : () => { void requestPrint({ kind: "contact-list" }); }}
|
||||
printTitle={printContent?.kind === "list" ? listTitle : null}
|
||||
/>
|
||||
@@ -1566,8 +1576,14 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
// prerender (static Lite build); at runtime it never suspends.
|
||||
export function ContactsApp(props: ContactsAppProps = {}) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
// The static Lite export writes this fallback into the page's HTML.
|
||||
<Suspense fallback={<ContactsFallback />}>
|
||||
<ContactsAppContent {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function ContactsFallback() {
|
||||
const { label, labels } = useSkeletonLabels();
|
||||
return <ContactsSkeleton label={label} labels={labels} />;
|
||||
}
|
||||
|
||||
@@ -49,6 +49,8 @@ const settings = vi.hoisted(() => ({
|
||||
emptySubjectWarningEnabled: false,
|
||||
sendDelaySeconds: 0,
|
||||
signaturePosition: 'below_quote',
|
||||
signatureNewMailEnabled: true,
|
||||
signatureReplyForwardEnabled: true,
|
||||
signatureSeparatorEnabled: false,
|
||||
requestReadReceiptDefault: false,
|
||||
sendConfirmation: false,
|
||||
|
||||
@@ -95,6 +95,8 @@ vi.mock('@/stores/settings-store', () => {
|
||||
// The default position: replies/forwards get the signature appended at
|
||||
// send time rather than embedded above the quote.
|
||||
signaturePosition: 'below_quote',
|
||||
signatureNewMailEnabled: true,
|
||||
signatureReplyForwardEnabled: true,
|
||||
signatureSeparatorEnabled: true,
|
||||
requestReadReceiptDefault: false,
|
||||
addTrustedSender: () => {},
|
||||
|
||||
@@ -95,6 +95,8 @@ vi.mock('@/stores/settings-store', () => {
|
||||
// The default position: replies/forwards get the signature appended at
|
||||
// send time rather than embedded above the quote.
|
||||
signaturePosition: 'below_quote',
|
||||
signatureNewMailEnabled: true,
|
||||
signatureReplyForwardEnabled: true,
|
||||
signatureSeparatorEnabled: true,
|
||||
requestReadReceiptDefault: false,
|
||||
addTrustedSender: () => {},
|
||||
|
||||
@@ -95,6 +95,8 @@ vi.mock('@/stores/settings-store', () => {
|
||||
// The default position: replies/forwards get the signature appended at
|
||||
// send time rather than embedded above the quote.
|
||||
signaturePosition: 'below_quote',
|
||||
signatureNewMailEnabled: true,
|
||||
signatureReplyForwardEnabled: true,
|
||||
signatureSeparatorEnabled: true,
|
||||
requestReadReceiptDefault: false,
|
||||
addTrustedSender: () => {},
|
||||
@@ -257,3 +259,40 @@ describe('composer signature follows the picked identity', () => {
|
||||
expect(sent.body).toBe('Plain draft');
|
||||
});
|
||||
});
|
||||
|
||||
describe('signature auto-insert toggles', () => {
|
||||
afterEach(() => {
|
||||
useSettingsStore.setState({
|
||||
plainTextMode: false,
|
||||
signatureNewMailEnabled: true,
|
||||
signatureReplyForwardEnabled: true,
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('keeps a new message free of the signature when the new-mail toggle is off', async () => {
|
||||
useSettingsStore.setState({ plainTextMode: true, signatureNewMailEnabled: false });
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer initialData={{ ...DRAFT, body: 'Draft text' }} onSend={onSend} />);
|
||||
|
||||
// Not embedded into the editable body…
|
||||
await waitFor(() => expect(textarea().value).toBe('Draft text'));
|
||||
// …and not appended at send time either.
|
||||
const sent = await send(onSend);
|
||||
expect(sent.body).toBe('Draft text');
|
||||
});
|
||||
|
||||
it('sends a reply without appending the signature when the reply toggle is off', async () => {
|
||||
useSettingsStore.setState({ signatureReplyForwardEnabled: false });
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer mode="reply" initialData={{ ...DRAFT, mode: 'reply' }} onSend={onSend} />);
|
||||
|
||||
// No read-only signature preview under the editor: it would promise a
|
||||
// signature that never goes out.
|
||||
expect(document.body.textContent).not.toContain('Alice');
|
||||
|
||||
const sent = await send(onSend);
|
||||
expect(sent.htmlBody).not.toContain('Alice');
|
||||
expect(sent.body).not.toContain('Alice');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -136,6 +136,8 @@ vi.mock('@/stores/settings-store', () => {
|
||||
// The default position: replies/forwards get the signature appended at
|
||||
// send time rather than embedded above the quote.
|
||||
signaturePosition: 'below_quote',
|
||||
signatureNewMailEnabled: true,
|
||||
signatureReplyForwardEnabled: true,
|
||||
signatureSeparatorEnabled: true,
|
||||
requestReadReceiptDefault: false,
|
||||
addTrustedSender: () => {},
|
||||
|
||||
@@ -96,6 +96,8 @@ vi.mock('@/stores/settings-store', () => {
|
||||
// The default position: replies/forwards get the signature appended at
|
||||
// send time rather than embedded above the quote.
|
||||
signaturePosition: 'below_quote',
|
||||
signatureNewMailEnabled: true,
|
||||
signatureReplyForwardEnabled: true,
|
||||
signatureSeparatorEnabled: true,
|
||||
requestReadReceiptDefault: false,
|
||||
addTrustedSender: () => {},
|
||||
|
||||
@@ -123,6 +123,8 @@ vi.mock('@/stores/settings-store', () => {
|
||||
// The default position: replies/forwards get the signature appended at
|
||||
// send time rather than embedded above the quote.
|
||||
signaturePosition: 'below_quote',
|
||||
signatureNewMailEnabled: true,
|
||||
signatureReplyForwardEnabled: true,
|
||||
signatureSeparatorEnabled: true,
|
||||
requestReadReceiptDefault: false,
|
||||
addTrustedSender: () => {},
|
||||
@@ -304,6 +306,33 @@ describe('reply signature above the quote (#842)', () => {
|
||||
expect(html).toContain('<i>Sales team</i>');
|
||||
expect(html.indexOf('<i>Sales team</i>')).toBeLessThan(html.indexOf('reply_line'));
|
||||
});
|
||||
|
||||
// #1503: with the delimiter on, a reply started with the "-- " line, so
|
||||
// there was no line above the signature to type the reply into.
|
||||
it('leaves an empty line above the delimiter', () => {
|
||||
useSettingsStore.setState({ signaturePosition: 'above_quote' });
|
||||
setIdentities([ME]);
|
||||
render(<EmailComposer mode="reply" replyTo={TO_ME} />);
|
||||
|
||||
const html = screen.getByTestId('rich-text-editor').getAttribute('data-html')!;
|
||||
expect(html.startsWith('<p></p><p data-signature-block="separator">-- </p>')).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves an empty line above a delimiter that arrives with late identities', async () => {
|
||||
useSettingsStore.setState({ signaturePosition: 'above_quote' });
|
||||
setIdentities([]);
|
||||
const props = { mode: 'reply' as const, replyTo: TO_ME };
|
||||
const { rerender } = render(<EmailComposer {...props} />);
|
||||
setIdentities([ME]);
|
||||
rerender(<EmailComposer {...props} />);
|
||||
|
||||
await waitFor(() => expect(editorState.html).toContain('<b>Alice</b>'));
|
||||
const lines = new DOMParser().parseFromString(editorState.html, 'text/html').body.children;
|
||||
expect(lines[0].tagName).toBe('P');
|
||||
expect(lines[0].textContent).toBe('');
|
||||
expect(lines[0].hasAttribute('data-signature-block')).toBe(false);
|
||||
expect(lines[1].getAttribute('data-signature-block')).toBe('separator');
|
||||
});
|
||||
});
|
||||
|
||||
it('still signs a reply below the quote at send time by default', async () => {
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import { EmailComposer } from '../email-composer';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
// The real composer around the real editor: a template's data: image goes
|
||||
// out as an inline cid: part, not as a data: URI (#1490).
|
||||
|
||||
// A 1×1 PNG.
|
||||
const PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||
|
||||
const TEMPLATE = vi.hoisted(() => ({
|
||||
id: 'tpl-1',
|
||||
name: 'Offer',
|
||||
category: '',
|
||||
subject: 'Your offer',
|
||||
body: '<p>Your apartment</p><p><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" alt="Floor plan"></p>',
|
||||
isHTML: true,
|
||||
isFavorite: false,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null }));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
|
||||
// The picker hands the composer a template, as choosing one does. It takes
|
||||
// the focus away from the editor first, as the real dialog does.
|
||||
vi.mock('@/components/templates/template-picker', () => ({
|
||||
TemplatePicker: ({ onSelect }: { onSelect: (t: unknown, v: Record<string, string>) => void }) =>
|
||||
React.createElement('button', { 'data-testid': 'pick-template', onClick: () => onSelect(TEMPLATE, {}) }, 'pick'),
|
||||
}));
|
||||
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
|
||||
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
|
||||
vi.mock('@/hooks/use-focus-trap', () => ({
|
||||
useFocusTrap: () => ({ current: null }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
|
||||
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
|
||||
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
|
||||
}));
|
||||
|
||||
// ─── Store mocks ──────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/auth-store', () => {
|
||||
const state = {
|
||||
client: null,
|
||||
identities: [],
|
||||
primaryIdentity: null,
|
||||
isAuthenticated: false,
|
||||
isDemoMode: false,
|
||||
activeAccountId: null,
|
||||
connectionLost: false,
|
||||
getClientForAccount: () => undefined,
|
||||
getAllConnectedClients: () => new Map(),
|
||||
syncIdentities: () => {},
|
||||
refreshIdentities: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAuthStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/identity-store', () => {
|
||||
const state = {
|
||||
identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me', htmlSignature: '<p>Cheers, Me</p>' }],
|
||||
defaultIdentityId: 'id-me',
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useIdentityStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/account-store', () => {
|
||||
const state = { accounts: [], getAccountById: () => undefined };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAccountStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/email-store', () => {
|
||||
const state = {
|
||||
draftSaveEnabled: false,
|
||||
sendRawEmail: async () => ({ sent: true }),
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useEmailStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/settings-store', () => {
|
||||
const state = {
|
||||
timeFormat: '24h',
|
||||
plainTextMode: false,
|
||||
subAddressDelimiter: '+',
|
||||
autoSelectReplyIdentity: true,
|
||||
attachmentReminderEnabled: false,
|
||||
attachmentReminderKeywords: [],
|
||||
emptySubjectWarningEnabled: true,
|
||||
recipientMentionsEnabled: false,
|
||||
sendDelaySeconds: 0,
|
||||
signaturePosition: 'above_quote',
|
||||
signatureSeparatorEnabled: false,
|
||||
requestReadReceiptDefault: false,
|
||||
externalContentPolicy: 'block',
|
||||
isSenderTrusted: () => false,
|
||||
addTrustedSender: () => {},
|
||||
trustedSendersAddressBook: null,
|
||||
updateSetting: () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useSettingsStore: hook, registerTemplateSyncBridge: () => {} };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/contact-store', () => {
|
||||
const state = {
|
||||
contacts: [],
|
||||
getAutocomplete: async () => [],
|
||||
addToTrustedSendersBook: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useContactStore: hook };
|
||||
});
|
||||
|
||||
// ─── Misc dependency mocks ────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/toast-store', () => ({
|
||||
toast: { info: () => {}, error: () => {}, success: () => {} },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
getRecipientSuggestions: { call: async () => [] },
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
contactHooks: {
|
||||
search: { call: async () => [] },
|
||||
onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-threading', () => ({
|
||||
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||
}));
|
||||
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const DRAFT = {
|
||||
to: 'guest@example.com',
|
||||
cc: '',
|
||||
bcc: '',
|
||||
subject: '',
|
||||
body: '<p></p>',
|
||||
showCc: false,
|
||||
showBcc: false,
|
||||
selectedIdentityId: 'id-me',
|
||||
subAddressTag: '',
|
||||
mode: 'compose' as const,
|
||||
draftId: null,
|
||||
};
|
||||
|
||||
const nextFrame = () => act(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
|
||||
|
||||
describe('applying a template with a data: image (#1490)', () => {
|
||||
const range = Range.prototype as unknown as Record<string, unknown>;
|
||||
const doc = document as unknown as Record<string, unknown>;
|
||||
const client = {
|
||||
uploadBlob: vi.fn(async (_file: File) => ({ blobId: 'blob-plan' })),
|
||||
hasDelayedSend: () => false,
|
||||
getMaxDelayedSend: () => 0,
|
||||
};
|
||||
beforeAll(() => {
|
||||
range.getClientRects = () => [];
|
||||
range.getBoundingClientRect = () => ({ x: 0, y: 0, width: 0, height: 0, top: 0, right: 0, bottom: 0, left: 0 });
|
||||
doc.elementFromPoint = () => null;
|
||||
useAuthStore.setState({ client: client as never });
|
||||
});
|
||||
afterAll(() => {
|
||||
delete range.getClientRects;
|
||||
delete range.getBoundingClientRect;
|
||||
delete doc.elementFromPoint;
|
||||
useAuthStore.setState({ client: null });
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('uploads the image and sends it as an inline part shown by cid', async () => {
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer initialData={DRAFT} onSend={onSend} onClose={() => {}} />);
|
||||
await waitFor(() => {
|
||||
const el = document.querySelector('.ProseMirror') as (HTMLElement & { editor?: Editor }) | null;
|
||||
expect(el?.editor).toBeTruthy();
|
||||
});
|
||||
await nextFrame();
|
||||
|
||||
act(() => { fireEvent.keyDown(window, { key: 't' }); });
|
||||
fireEvent.click(await screen.findByTestId('pick-template'));
|
||||
await waitFor(() => expect(screen.queryByTestId('pick-template')).toBeNull());
|
||||
await waitFor(() => expect(document.querySelector('.ProseMirror img')).not.toBeNull());
|
||||
|
||||
expect(client.uploadBlob).toHaveBeenCalledTimes(1);
|
||||
const file = client.uploadBlob.mock.calls[0][0];
|
||||
expect(file.type).toBe('image/png');
|
||||
expect(new Uint8Array(await file.arrayBuffer())).toEqual(Uint8Array.from(atob(PNG), (c) => c.charCodeAt(0)));
|
||||
|
||||
fireEvent.click(screen.getAllByTestId('composer-send')[0]);
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
|
||||
const { htmlBody, attachments } = onSend.mock.calls[0][0];
|
||||
const inline = attachments?.find((a: { disposition?: string }) => a.disposition === 'inline');
|
||||
expect(inline).toMatchObject({ blobId: 'blob-plan', type: 'image/png' });
|
||||
expect(htmlBody).toContain(`src="cid:${inline.cid}"`);
|
||||
expect(htmlBody).not.toContain('data:image');
|
||||
});
|
||||
});
|
||||
@@ -107,6 +107,8 @@ vi.mock('@/stores/settings-store', () => {
|
||||
recipientMentionsEnabled: false,
|
||||
sendDelaySeconds: 0,
|
||||
signaturePosition: 'above_quote',
|
||||
signatureNewMailEnabled: true,
|
||||
signatureReplyForwardEnabled: true,
|
||||
signatureSeparatorEnabled: false,
|
||||
requestReadReceiptDefault: false,
|
||||
externalContentPolicy: 'block',
|
||||
|
||||
@@ -131,6 +131,33 @@ describe('the reader toolbar and the plugin side panel', () => {
|
||||
expect(document.querySelector('[data-plugin-detail-panel]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('loads a message with its toolbar where the loaded reader puts it', () => {
|
||||
// The loading state lifts the toolbar above the message row too, so the
|
||||
// row stays put when the message arrives: the same element at the same
|
||||
// place (a moved row counts as a layout shift).
|
||||
const view = (props: { email: Email | null; isLoading: boolean }) => (
|
||||
<EmailViewer mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onDelete={() => {}} {...props} />
|
||||
);
|
||||
const { container, rerender } = render(view({ email: null, isLoading: true }));
|
||||
const root = container.firstElementChild as HTMLElement;
|
||||
expect(root.children).toHaveLength(2);
|
||||
const row = root.children[1];
|
||||
expect(row.className).toContain('flex-row');
|
||||
expect(root.children[0].querySelector('.sk-shape')).toBeTruthy();
|
||||
rerender(view({ email, isLoading: false }));
|
||||
expect(root.querySelector('[data-viewer-top-toolbar="full-width"]')).toBe(root.children[0]);
|
||||
expect(root.children[1]).toBe(row);
|
||||
});
|
||||
|
||||
it('loads a message on a phone with the toolbar in the message column', () => {
|
||||
device.isMobile = true;
|
||||
const { container } = render(
|
||||
<EmailViewer email={null} isLoading mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onDelete={() => {}} />,
|
||||
);
|
||||
const root = container.firstElementChild as HTMLElement;
|
||||
expect(root.children).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps the toolbar in the message column on a phone', () => {
|
||||
device.isMobile = true;
|
||||
show();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -103,7 +103,7 @@ import { OrgDisclaimerPreview } from "@/components/email/org-disclaimer-preview"
|
||||
import "@/lib/org/outgoing";
|
||||
import { SubAddressHelper } from "@/components/identity/sub-address-helper";
|
||||
import { generateSubAddress } from "@/lib/sub-addressing";
|
||||
import { substitutePlaceholders, spliceTemplateAboveSignature, composeBodyHasUserContent } from "@/lib/template-utils";
|
||||
import { substitutePlaceholders, spliceTemplateAboveSignature, composeBodyHasUserContent, embedTemplateImages } from "@/lib/template-utils";
|
||||
import { findUnfilledPlaceholders, stripEmbeddedSignatureHtml } from "@/lib/placeholder-check";
|
||||
import enMessages from "@/locales/en/common.json";
|
||||
import { TemplatePicker } from "@/components/templates/template-picker";
|
||||
@@ -553,6 +553,13 @@ export function EmailComposer({
|
||||
const updateSetting = useSettingsStore((state) => state.updateSetting);
|
||||
const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds);
|
||||
const signaturePosition = useSettingsStore((state) => state.signaturePosition);
|
||||
const signatureNewMailEnabled = useSettingsStore((state) => state.signatureNewMailEnabled);
|
||||
const signatureReplyForwardEnabled = useSettingsStore((state) => state.signatureReplyForwardEnabled);
|
||||
// Whether automatic signature insertion is on for this composer mode: new
|
||||
// mail (and re-opened drafts, which open in compose mode) follow the
|
||||
// new-mail toggle, replies and forwards the reply/forward one. What the
|
||||
// body already carries is never stripped - only the insert/append is gated.
|
||||
const autoSignatureEnabled = mode === 'compose' ? signatureNewMailEnabled : signatureReplyForwardEnabled;
|
||||
const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled);
|
||||
const requestReadReceiptDefault = useSettingsStore((state) => state.requestReadReceiptDefault);
|
||||
// A draft belongs to the account the composer was opened in. Switching
|
||||
@@ -628,12 +635,13 @@ export function EmailComposer({
|
||||
const shouldEmbedSignatureAboveQuote =
|
||||
(mode === 'reply' || mode === 'replyAll' || mode === 'forward') &&
|
||||
signaturePosition === 'above_quote' &&
|
||||
signatureReplyForwardEnabled &&
|
||||
hasInitialSignature;
|
||||
// New-mail composes always embed the signature into the editor body so it's
|
||||
// editable/removable (the previous read-only preview below the editor was
|
||||
// never spec-correct - see #329). Compose mode also ignores any leftover
|
||||
// `replyTo` from a still-selected email; getInitialBody short-circuits below.
|
||||
const shouldEmbedSignatureInNewMail = mode === 'compose' && hasInitialSignature;
|
||||
const shouldEmbedSignatureInNewMail = mode === 'compose' && signatureNewMailEnabled && hasInitialSignature;
|
||||
|
||||
// Format a single EmailAddress for display in the composer input
|
||||
const toRecipient = (r: { name?: string; email?: string }): Recipient =>
|
||||
@@ -777,10 +785,13 @@ export function EmailComposer({
|
||||
: (from.email || from.name || tCommon('unknown')))
|
||||
: tCommon('unknown');
|
||||
|
||||
// Like a new mail, an empty line above the signature gives the cursor a
|
||||
// place for the reply. Without it the reply started on the "-- " line (#1503).
|
||||
const signatureBlock = buildEmbeddedSignatureHtml(initialSignatureIdentity, {
|
||||
embed: shouldEmbedSignatureAboveQuote,
|
||||
separator: signatureSeparatorEnabled,
|
||||
});
|
||||
const head = `${prefix || (signatureBlock ? '<p></p>' : '')}${signatureBlock}`;
|
||||
|
||||
// Whether the quoted original may show its remote images in the editor:
|
||||
// the viewer's rule, minus the per-message "load" click it doesn't know.
|
||||
@@ -804,7 +815,7 @@ export function EmailComposer({
|
||||
rewriteCidImagesForEditor(sanitizeEmailHtml(replyTo.htmlBody)),
|
||||
{ remoteAllowed: quoteRemoteAllowed, showQuoteBorder: replyTo.quoteWrapInBlockquote !== false },
|
||||
);
|
||||
return `${prefix}${signatureBlock}<br>${replyHeader(replyTo.quoteHeaderHtml)}${island}`;
|
||||
return `${head}<br>${replyHeader(replyTo.quoteHeaderHtml)}${island}`;
|
||||
}
|
||||
const escaped = replyTo.body
|
||||
? replyTo.body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')
|
||||
@@ -813,7 +824,7 @@ export function EmailComposer({
|
||||
const bodyHtml = wrap
|
||||
? `<blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escaped}</blockquote>`
|
||||
: escaped;
|
||||
return `${prefix}${signatureBlock}<br>${replyHeader(replyTo.quoteHeaderHtml)}${bodyHtml}`;
|
||||
return `${head}<br>${replyHeader(replyTo.quoteHeaderHtml)}${bodyHtml}`;
|
||||
}
|
||||
|
||||
// Build quoted content as HTML
|
||||
@@ -831,15 +842,15 @@ export function EmailComposer({
|
||||
rewriteCidImagesForEditor(sanitizeEmailHtml(replyTo.htmlBody)),
|
||||
{ remoteAllowed: quoteRemoteAllowed },
|
||||
);
|
||||
return `${prefix}${signatureBlock}<br>${replyHeader(`<div>${quoteHeader}</div>`)}${island}`;
|
||||
return `${head}<br>${replyHeader(`<div>${quoteHeader}</div>`)}${island}`;
|
||||
}
|
||||
|
||||
if (replyTo.body) {
|
||||
const escapedOriginal = replyTo.body.replace(/&/g, '&').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;
|
||||
@@ -1326,6 +1337,9 @@ export function EmailComposer({
|
||||
const isReplyLike = mode === 'reply' || mode === 'replyAll' || mode === 'forward';
|
||||
if (!isReplyLike && mode !== 'compose') return;
|
||||
if (isReplyLike && signaturePosition !== 'above_quote') return;
|
||||
// With automatic insertion off the body never got a signature spliced
|
||||
// in, so there is nothing to swap.
|
||||
if (!autoSignatureEnabled) return;
|
||||
|
||||
if (plainTextMode) {
|
||||
// A new plain-text message carries the signature at the end of the
|
||||
@@ -1367,7 +1381,7 @@ export function EmailComposer({
|
||||
// would re-run on unrelated identity-field changes and re-splice the
|
||||
// signature into the live editor.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [signatureIdentity?.id, signatureContent, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode, pluginSignatureRevision]);
|
||||
}, [signatureIdentity?.id, signatureContent, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode, pluginSignatureRevision, autoSignatureEnabled]);
|
||||
|
||||
const openScheduleDialog = useCallback(() => {
|
||||
setShowScheduleDialog(true);
|
||||
@@ -2112,6 +2126,40 @@ export function EmailComposer({
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageUpload = useCallback(async (
|
||||
file: File,
|
||||
): Promise<{ src: string; cid: string } | null> => {
|
||||
if (!client) return null;
|
||||
try {
|
||||
const readAsDataUrl = new Promise<string | null>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => resolve((e.target?.result as string) ?? null);
|
||||
reader.onerror = () => resolve(null);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
const [{ blobId }, dataUrl] = await Promise.all([
|
||||
(composerClientRef.current ?? client).uploadBlob(file),
|
||||
readAsDataUrl,
|
||||
]);
|
||||
if (!dataUrl) throw new Error('Failed to read image as data URL');
|
||||
const cid = `${generateUUID()}@webmail`;
|
||||
inlineImagesRef.current.push({
|
||||
cid,
|
||||
blobId,
|
||||
type: file.type || 'application/octet-stream',
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
dataUrl,
|
||||
uploadedAt: Date.now(),
|
||||
});
|
||||
return { src: dataUrl, cid };
|
||||
} catch (error) {
|
||||
debug.error(`Failed to upload inline image ${file.name}:`, error);
|
||||
toast.error(t('upload_failed', { filename: file.name }));
|
||||
return null;
|
||||
}
|
||||
}, [client, t]);
|
||||
|
||||
const handleTemplateSelect = useCallback(async (template: EmailTemplate, filledValues: Record<string, string>) => {
|
||||
let filledSubject = Object.keys(filledValues).length > 0
|
||||
? substitutePlaceholders(template.subject, filledValues)
|
||||
@@ -2134,6 +2182,8 @@ export function EmailComposer({
|
||||
filledSubject = rendered.subject;
|
||||
bodyContent = plainTextMode ? rendered.text : sanitizePluginBodyHtml(rendered.html);
|
||||
}
|
||||
// Its data: images go out as cid: parts, like pasted ones (#1490).
|
||||
if (!plainTextMode) bodyContent = await embedTemplateImages(bodyContent, handleImageUpload);
|
||||
|
||||
if (mode === 'compose') {
|
||||
// An empty template subject must not wipe one the user typed (#540).
|
||||
@@ -2219,7 +2269,7 @@ export function EmailComposer({
|
||||
|
||||
setShowTemplatePicker(false);
|
||||
notifyTemplateApplied(template);
|
||||
}, [mode, plainTextMode, signatureIdentity, signatureSeparatorEnabled]);
|
||||
}, [mode, plainTextMode, signatureIdentity, signatureSeparatorEnabled, handleImageUpload]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleTemplateKey = (e: KeyboardEvent) => {
|
||||
@@ -2402,40 +2452,6 @@ export function EmailComposer({
|
||||
}
|
||||
}, [client, t, plainTextMode]);
|
||||
|
||||
const handleImageUpload = useCallback(async (
|
||||
file: File,
|
||||
): Promise<{ src: string; cid: string } | null> => {
|
||||
if (!client) return null;
|
||||
try {
|
||||
const readAsDataUrl = new Promise<string | null>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => resolve((e.target?.result as string) ?? null);
|
||||
reader.onerror = () => resolve(null);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
const [{ blobId }, dataUrl] = await Promise.all([
|
||||
(composerClientRef.current ?? client).uploadBlob(file),
|
||||
readAsDataUrl,
|
||||
]);
|
||||
if (!dataUrl) throw new Error('Failed to read image as data URL');
|
||||
const cid = `${generateUUID()}@webmail`;
|
||||
inlineImagesRef.current.push({
|
||||
cid,
|
||||
blobId,
|
||||
type: file.type || 'application/octet-stream',
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
dataUrl,
|
||||
uploadedAt: Date.now(),
|
||||
});
|
||||
return { src: dataUrl, cid };
|
||||
} catch (error) {
|
||||
debug.error(`Failed to upload inline image ${file.name}:`, error);
|
||||
toast.error(t('upload_failed', { filename: file.name }));
|
||||
return null;
|
||||
}
|
||||
}, [client, t]);
|
||||
|
||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!event.target.files) return;
|
||||
await addFiles(Array.from(event.target.files));
|
||||
@@ -2667,7 +2683,7 @@ export function EmailComposer({
|
||||
// below the editor - was saved without it (#823). Embed the *marked-up*
|
||||
// form so re-opening the draft round-trips the signature as one block and
|
||||
// signatureAlreadyInBody sees it instead of appending a second copy.
|
||||
const draftSignatureHtml = signatureAlreadyInBody
|
||||
const draftSignatureHtml = signatureAlreadyInBody || !autoSignatureEnabled
|
||||
? ''
|
||||
: buildEmbeddedSignatureHtml(signatureIdentity, {
|
||||
embed: true,
|
||||
@@ -2675,7 +2691,7 @@ export function EmailComposer({
|
||||
});
|
||||
const draftHtmlBody = plainTextMode ? undefined : `${body}${draftSignatureHtml}`;
|
||||
const draftTextBody = plainTextMode
|
||||
? (signatureAlreadyInBody
|
||||
? (signatureAlreadyInBody || !autoSignatureEnabled
|
||||
? body
|
||||
: appendPlainTextSignature(body, signatureIdentity, { separator: signatureSeparatorEnabled }))
|
||||
: htmlToPlainText(draftHtmlBody!, imageAltText);
|
||||
@@ -3637,9 +3653,14 @@ export function EmailComposer({
|
||||
// above-quote replies, a re-opened draft) `signatureAlreadyInBody` is set
|
||||
// and the trailing append below is skipped so we don't duplicate it.
|
||||
|
||||
// Whether automatic signature insertion is on for this composer mode
|
||||
// (component-level `autoSignatureEnabled` - see signatureAlreadyInBody).
|
||||
// What the body already carries is never stripped - only the append is
|
||||
// gated.
|
||||
|
||||
// Build HTML signature block (used only in rich text mode)
|
||||
const buildSignatureHtml = (): string => {
|
||||
if (signatureAlreadyInBody) return '';
|
||||
if (signatureAlreadyInBody || !autoSignatureEnabled) return '';
|
||||
const sep = wantsSignatureSeparator(signatureSeparatorEnabled, signatureIdentity) ? `<br><br>-- <br>` : `<br><br>`;
|
||||
if (signatureIdentity?.htmlSignature) {
|
||||
return `${sep}${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}`;
|
||||
@@ -3656,8 +3677,8 @@ export function EmailComposer({
|
||||
// In plain text mode, send text/plain only (no HTML body)
|
||||
const signatureOpts = { separator: signatureSeparatorEnabled };
|
||||
const builtTextBody = plainTextMode
|
||||
? (signatureAlreadyInBody ? body : appendPlainTextSignature(body, signatureIdentity, signatureOpts))
|
||||
: (signatureAlreadyInBody ? htmlToPlainText(body, imageAltText) : appendPlainTextSignature(htmlToPlainText(body, imageAltText), signatureIdentity, signatureOpts));
|
||||
? (signatureAlreadyInBody || !autoSignatureEnabled ? body : appendPlainTextSignature(body, signatureIdentity, signatureOpts))
|
||||
: (signatureAlreadyInBody || !autoSignatureEnabled ? htmlToPlainText(body, imageAltText) : appendPlainTextSignature(htmlToPlainText(body, imageAltText), signatureIdentity, signatureOpts));
|
||||
|
||||
// Before any blobId is read: forwarded parts may still sit on the account
|
||||
// that holds the original message.
|
||||
@@ -4834,8 +4855,10 @@ export function EmailComposer({
|
||||
|
||||
{/* Hide the visual signature preview when the signature has already been
|
||||
embedded into the body (compose, above-quote replies, re-opened
|
||||
drafts) - it would otherwise read as a second signature. */}
|
||||
{signatureAlreadyInBody ? null
|
||||
drafts) - it would otherwise read as a second signature. Also when
|
||||
automatic insertion is off: nothing will be appended at send time,
|
||||
so a preview would promise a signature that never goes out. */}
|
||||
{signatureAlreadyInBody || !autoSignatureEnabled ? null
|
||||
: plainTextMode ? (
|
||||
getPlainTextSignature(signatureIdentity) ? (
|
||||
<div className="px-4 pb-3 text-sm leading-6 text-muted-foreground break-words whitespace-pre-wrap font-mono">
|
||||
|
||||
@@ -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';
|
||||
@@ -3929,12 +3930,12 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
)}
|
||||
</>
|
||||
);
|
||||
const shell = (content: React.ReactNode) => (
|
||||
const shell = (content: React.ReactNode, top: React.ReactNode = null) => (
|
||||
<div
|
||||
ref={setViewerRootEl}
|
||||
className={cn("flex-1 flex flex-col h-full bg-background overflow-hidden relative", className)}
|
||||
>
|
||||
{null}
|
||||
{top}
|
||||
<div className="flex-1 min-h-0 flex flex-row overflow-hidden relative">
|
||||
{content}
|
||||
{sidePanel}
|
||||
@@ -3943,54 +3944,14 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Show loading skeleton while email is being fetched
|
||||
// Show loading skeleton while email is being fetched: the page skeleton's
|
||||
// message pane, so a linked message keeps the shape it had before the app
|
||||
// started.
|
||||
// Its toolbar sits where the loaded reader puts its own: above the message
|
||||
// row when lifted, so the row doesn't move when the message arrives.
|
||||
if (isLoading && !email) {
|
||||
return shell(
|
||||
<div className="flex-1 min-w-0 flex flex-col h-full bg-background overflow-hidden animate-in fade-in duration-200">
|
||||
{/* Loading Header Skeleton - gentler animation */}
|
||||
<div className="bg-background border-b border-border">
|
||||
<div className="px-4 lg:px-6 py-3 lg:py-4">
|
||||
<div className="flex items-start justify-between gap-2 lg:gap-4">
|
||||
<div className="flex-1 min-w-0 space-y-2 lg:space-y-3">
|
||||
<div className="h-6 lg:h-8 bg-muted/60 rounded-md w-3/4"></div>
|
||||
<div className="flex items-center gap-2 lg:gap-3">
|
||||
<div className="h-3 lg:h-4 bg-muted/60 rounded w-24 lg:w-32"></div>
|
||||
<div className="h-3 lg:h-4 bg-muted/60 rounded w-16 lg:w-24"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 lg:gap-2">
|
||||
<div className="h-8 w-8 lg:w-20 bg-muted/60 rounded"></div>
|
||||
<div className="h-8 w-8 bg-muted/60 rounded hidden lg:block"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading Sender Info Skeleton */}
|
||||
<div className="px-4 lg:px-6 pb-3 lg:pb-4">
|
||||
<div className="flex items-start gap-3 lg:gap-4">
|
||||
<div className="w-10 h-10 lg:w-12 lg:h-12 bg-muted/60 rounded-full"></div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 bg-muted/60 rounded w-48"></div>
|
||||
<div className="h-3 bg-muted/60 rounded w-64"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading Content Skeleton */}
|
||||
<div className="flex-1 overflow-auto bg-muted/20">
|
||||
<div className="px-6 pt-4 pb-6">
|
||||
<div className="space-y-3">
|
||||
<div className="h-4 bg-muted/60 rounded w-full"></div>
|
||||
<div className="h-4 bg-muted/60 rounded w-5/6"></div>
|
||||
<div className="h-4 bg-muted/60 rounded w-4/6"></div>
|
||||
<div className="h-4 bg-muted/60 rounded w-full"></div>
|
||||
<div className="h-4 bg-muted/60 rounded w-3/4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const lifted = toolbarPosition === 'top' && liftToolbar;
|
||||
return shell(<MailMessageSkeleton toolbar={!lifted} />, lifted ? <MailMessageToolbarSkeleton /> : null);
|
||||
}
|
||||
|
||||
if (!email) {
|
||||
|
||||
@@ -35,7 +35,7 @@ import { brandIndicatorDomain } from "@/lib/email-headers";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatDate, formatFileSize, cn, cleanPreview } from "@/lib/utils";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { PaneSpinner } from "@/components/ui/spinner";
|
||||
import { MailConversationSkeleton } from "@/components/boot-skeleton/mail-skeleton";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronDown,
|
||||
@@ -245,8 +245,13 @@ export function ThreadConversationView({
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
// One centred spinner for a whole pane (R9).
|
||||
return <PaneSpinner label={t("threads.loading")} className="bg-background" />;
|
||||
// The conversation's shape (the page skeleton's), with one status message.
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background" aria-busy="true">
|
||||
<span role="status" className="sr-only">{t("threads.loading")}</span>
|
||||
<MailConversationSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -143,6 +143,19 @@ describe('FileBrowser', () => {
|
||||
act(() => { useFileStore.setState({ client: null, sharedRoots: [], allFolders: [] }); });
|
||||
});
|
||||
|
||||
it('draws the loading listing in the list view, as the page skeleton did', () => {
|
||||
renderBrowser({ resources: [], hasListed: false });
|
||||
expect(screen.getByTestId('files-list-skeleton')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('files-grid-skeleton')).toBeNull();
|
||||
});
|
||||
|
||||
it('draws the loading listing as tiles when the grid view is picked', () => {
|
||||
act(() => { useSettingsStore.setState({ filesSettings: { ...DEFAULT_FILES_SETTINGS, defaultViewMode: 'grid' } }); });
|
||||
renderBrowser({ resources: [], hasListed: false });
|
||||
expect(screen.getByTestId('files-grid-skeleton')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('files-list-skeleton')).toBeNull();
|
||||
});
|
||||
|
||||
it('lists folders shared with the user at the root and in the sidebar (#1181)', async () => {
|
||||
const { onNavigate } = renderBrowser();
|
||||
await screen.findAllByText('Team');
|
||||
|
||||
@@ -42,6 +42,7 @@ import type { FileNodeRights } from "@/lib/jmap/types";
|
||||
import type { FileResourceView } from "@/lib/plugin-types";
|
||||
import { isAppShortcutBlocked, isArrowKeyWidgetTarget } from "@/lib/keyboard";
|
||||
import { isSingleKeyShortcutOff } from "@/lib/single-key-shortcuts";
|
||||
import { FilesListingSkeleton } from "@/components/boot-skeleton/files-skeleton";
|
||||
|
||||
const NO_FORMATS: string[] = [];
|
||||
|
||||
@@ -1079,7 +1080,7 @@ export function FileBrowser({
|
||||
);
|
||||
}
|
||||
// Until the first listing is in, an empty list means "not loaded yet".
|
||||
if ((isLoading || !hasListed) && resources.length === 0) return <ListSkeleton />;
|
||||
if ((isLoading || !hasListed) && resources.length === 0) return <FilesListingSkeleton view={viewMode} />;
|
||||
if (showEmpty && searchQuery) return <NoSearchResults query={searchQuery} onClear={() => setSearchQuery("")} />;
|
||||
if (showEmpty && inTrash) {
|
||||
return <EmptyState icon={Trash2} title={t("trash_empty_title")} description={t("trash_empty_hint")} />;
|
||||
@@ -1522,21 +1523,6 @@ function SharedWithMe({ folders, pages, onOpen, iconOptions }: {
|
||||
);
|
||||
}
|
||||
|
||||
function ListSkeleton() {
|
||||
return (
|
||||
<div className="px-4" aria-hidden>
|
||||
<div className="h-9 border-b border-border" />
|
||||
{Array.from({ length: 7 }, (_, i) => (
|
||||
<div key={i} className="h-10 flex items-center gap-3 border-b border-border/60">
|
||||
<div className="w-4 h-4 rounded bg-muted animate-pulse" />
|
||||
<div className="w-5 h-5 rounded bg-muted animate-pulse" />
|
||||
<div className="h-3.5 rounded bg-muted animate-pulse" style={{ width: `${30 + ((i * 37) % 35)}%` }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Plugin entries at the end of a file's menu, after a separator when there are any. */
|
||||
function FilePluginMenuEntries({ files, mimeType, onRan }: { files: FileResourceView[]; mimeType?: string; onRan: () => void }) {
|
||||
const hasActions = usePluginActions("file-actions", { mimeType }).length > 0;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* The file list's columns: select, name, then modified (from md) and size
|
||||
* (from sm), then the row actions. A plain module, so the page skeleton
|
||||
* (components/boot-skeleton/files-skeleton.tsx) lays its rows out on the
|
||||
* same grid as FileListView without importing a client component.
|
||||
*/
|
||||
export const FILE_LIST_COLUMNS =
|
||||
"grid grid-cols-[auto_minmax(0,1fr)_auto] sm:grid-cols-[auto_minmax(0,1fr)_5.5rem_auto] md:grid-cols-[auto_minmax(0,1fr)_9rem_5.5rem_auto] items-center gap-x-3";
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@/components/files/file-item-parts";
|
||||
import type { SortDir, SortKey } from "@/components/files/files-header";
|
||||
import { TOUCH_HIT_AREA } from "@/components/ui/button";
|
||||
import { FILE_LIST_COLUMNS } from "./file-list-layout";
|
||||
|
||||
export interface FileItemHandlers {
|
||||
/** Selection click (plain, Ctrl/Cmd, Shift) on the item body. */
|
||||
@@ -147,7 +148,7 @@ function SortHeader({ label, column, sortKey, sortDir, onSort, className }: {
|
||||
);
|
||||
}
|
||||
|
||||
const LIST_COLUMNS = "grid grid-cols-[auto_minmax(0,1fr)_auto] sm:grid-cols-[auto_minmax(0,1fr)_5.5rem_auto] md:grid-cols-[auto_minmax(0,1fr)_9rem_5.5rem_auto] items-center gap-x-3";
|
||||
const LIST_COLUMNS = FILE_LIST_COLUMNS;
|
||||
|
||||
export function FileListView({
|
||||
resources, selected, focusedName, tabStopName, dropTarget, cutNames, starredPaths, pathOf, touch, iconOptions, getImageUrl, handlers,
|
||||
|
||||
@@ -43,6 +43,8 @@ import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
import { MvTopBar } from "@/components/layout/mv-top-bar";
|
||||
import { useMountainViewShell } from "@/hooks/use-mountain-view-shell";
|
||||
import { FilesSkeleton, FilesSkeletonBody } from "@/components/boot-skeleton/files-skeleton";
|
||||
import { useSkeletonLabels } from "@/components/boot-skeleton/use-skeleton-labels";
|
||||
|
||||
export interface FilesAppProps {
|
||||
/** Path segments after `/files` - the folder path, one segment per level. */
|
||||
@@ -53,6 +55,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
// Static Lite build: the route params are empty, read the link from the URL.
|
||||
const linkSegments = useLiteLinkSegments('files', routeSegments);
|
||||
const t = useTranslations("files");
|
||||
const skeleton = useSkeletonLabels();
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
const tShell = useTranslations("shell");
|
||||
useDocumentTitle(tSidebar("files"));
|
||||
@@ -802,7 +805,12 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
useFileStore.setState({ supportsFiles: null });
|
||||
}, [getClientForAccount]);
|
||||
|
||||
if (!isAuthenticated) return null;
|
||||
// Until the session is restored: the page skeleton the server drew.
|
||||
if (!isAuthenticated) {
|
||||
return isEmbedded
|
||||
? <FilesSkeletonBody embedded />
|
||||
: <FilesSkeleton label={skeleton.label} labels={skeleton.labels} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col overflow-hidden pt-[env(safe-area-inset-top)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import type { Mailbox } from '@/lib/jmap/types';
|
||||
|
||||
// A folder row opens its folder wherever it is clicked, not only on the
|
||||
// button inside it. Mountain View draws a 32px pill around a ~20px button,
|
||||
// and clicks on the pill's padding, the chevron slot or the count did
|
||||
// nothing (#1501). The row's own controls keep their click.
|
||||
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({
|
||||
useTour: () => ({ startTour: vi.fn(), resetTourCompletion: vi.fn(), activeTour: null, endTour: vi.fn(), registerStep: vi.fn() }),
|
||||
}));
|
||||
vi.mock('@/contexts/drag-drop-context', () => ({
|
||||
useDragDropContext: () => ({ isDragging: false }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-mailbox-drop', () => ({
|
||||
useMailboxDrop: () => ({ dropHandlers: {}, isValidDropTarget: false, isInvalidDropTarget: false }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-tag-drop', () => ({
|
||||
useTagDrop: () => ({ dropHandlers: {}, isValidDropTarget: false }),
|
||||
}));
|
||||
vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null }));
|
||||
|
||||
const { Sidebar } = await import('../sidebar');
|
||||
const { useSettingsStore } = await import('@/stores/settings-store');
|
||||
const { useUIStore } = await import('@/stores/ui-store');
|
||||
|
||||
const mailboxes = [
|
||||
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, totalEmails: 9, unreadEmails: 4, sortOrder: 0, myRights: { mayAddItems: true } },
|
||||
{ id: 'receipts', name: 'Receipts', role: null, parentId: null, totalEmails: 5, unreadEmails: 3, sortOrder: 1, myRights: { mayAddItems: true } },
|
||||
{ id: 'shops', name: 'Shops', role: null, parentId: 'receipts', totalEmails: 0, unreadEmails: 0, sortOrder: 0, myRights: { mayAddItems: true } },
|
||||
] as unknown as Mailbox[];
|
||||
|
||||
const row = (id: string) => document.querySelector<HTMLElement>(`[data-testid="folder-row"][data-mailbox-id="${id}"]`)!;
|
||||
|
||||
describe.each([
|
||||
['Mountain View', 'mountain-view', true],
|
||||
['the default layout', 'default', false],
|
||||
] as const)('folder row click area in %s', (_name, interfaceLayout, mvShell) => {
|
||||
beforeEach(() => {
|
||||
useUIStore.setState({ sidebarCollapsed: false } as never);
|
||||
useSettingsStore.setState({ interfaceLayout, hideAccountSwitcher: true, enableUnifiedMailbox: false } as never);
|
||||
});
|
||||
|
||||
it('opens the folder from a click on the row outside its button', () => {
|
||||
const onMailboxSelect = vi.fn();
|
||||
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" onMailboxSelect={onMailboxSelect} mvShell={mvShell} />);
|
||||
fireEvent.click(row('receipts'));
|
||||
expect(onMailboxSelect).toHaveBeenCalledWith('receipts');
|
||||
});
|
||||
|
||||
it('opens the folder from a click on its count', () => {
|
||||
const onMailboxSelect = vi.fn();
|
||||
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" onMailboxSelect={onMailboxSelect} mvShell={mvShell} />);
|
||||
fireEvent.click(row('receipts').querySelector('[data-testid="folder-counts"]')!);
|
||||
expect(onMailboxSelect).toHaveBeenCalledWith('receipts');
|
||||
});
|
||||
|
||||
it('shows the pointer over the whole row', () => {
|
||||
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" onMailboxSelect={() => {}} mvShell={mvShell} />);
|
||||
expect(row('receipts').className).toContain('cursor-pointer');
|
||||
});
|
||||
|
||||
it('only expands or collapses the folder from its chevron', () => {
|
||||
const onMailboxSelect = vi.fn();
|
||||
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" onMailboxSelect={onMailboxSelect} mvShell={mvShell} />);
|
||||
const shownBefore = row('shops') !== null;
|
||||
fireEvent.click(row('receipts').querySelector('[data-testid="folder-expand-toggle"]')!);
|
||||
expect(row('shops') !== null).toBe(!shownBefore);
|
||||
expect(onMailboxSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('folder row click area with the unread filter', () => {
|
||||
it('filters to unread from the unread count without also opening the folder', () => {
|
||||
useSettingsStore.setState({ interfaceLayout: 'default', hideAccountSwitcher: true, enableUnifiedMailbox: false } as never);
|
||||
const onMailboxSelect = vi.fn();
|
||||
const onUnreadFilterClick = vi.fn();
|
||||
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" onMailboxSelect={onMailboxSelect} onUnreadFilterClick={onUnreadFilterClick} />);
|
||||
fireEvent.click(row('receipts').querySelector('[data-testid="folder-counts"] button')!);
|
||||
expect(onUnreadFilterClick).toHaveBeenCalledWith('receipts');
|
||||
expect(onMailboxSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ import { readableTextOn } from "@/lib/color-transform";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import type { CalendarEvent } from "@/lib/jmap/types";
|
||||
import { useCvdColor } from "@/hooks/use-cvd-color";
|
||||
import { PluginSidePanelBody, SidePanelButton, usePluginSidePanel } from "@/components/plugins/plugin-side-panels";
|
||||
|
||||
export type MvSidePanelId = "calendar" | "tasks" | "contacts";
|
||||
|
||||
@@ -46,6 +47,18 @@ export function MvSidePanel() {
|
||||
const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled("contactsEnabled"));
|
||||
const tasksEnabled = useSettingsStore((s) => s.enableCalendarTasks);
|
||||
const [open, setOpen] = useState<MvSidePanelId | null>(null);
|
||||
// Plugins' panels (the side-panel slot), their buttons below the apps'.
|
||||
// A plugin's panel opening (a click, or the plugin's openPanel) shuts the app's, and the other way round.
|
||||
const plugins = usePluginSidePanel(() => setOpen(null));
|
||||
const pluginOpen = plugins.entries.find((e) => e.pluginId === plugins.open) ?? null;
|
||||
const openApp = (id: MvSidePanelId | null) => {
|
||||
if (id) plugins.choose(null);
|
||||
setOpen(id);
|
||||
};
|
||||
const openPlugin = (pluginId: string | null) => {
|
||||
if (pluginId) setOpen(null);
|
||||
plugins.choose(pluginId);
|
||||
};
|
||||
|
||||
const showCalendar = supportsCalendar && calendarEnabled;
|
||||
const showContacts = (client?.supportsContacts() ?? false) && contactsEnabled;
|
||||
@@ -57,9 +70,11 @@ export function MvSidePanel() {
|
||||
...(showContacts ? [{ id: "contacts" as const, label: t("contacts"), icon: Users, color: "text-[#1a73e8]" }] : []),
|
||||
];
|
||||
|
||||
if (buttons.length === 0) return null;
|
||||
if (buttons.length === 0 && plugins.entries.length === 0) return null;
|
||||
// A plugin's panel without a rail button (setSidePanel's rail: false) opens on its request only.
|
||||
const pluginButtons = plugins.entries.filter((e) => e.inRail);
|
||||
// A panel whose app went away (policy change, account switch) stays shut.
|
||||
const active = open && buttons.some((b) => b.id === open) ? open : null;
|
||||
const active = open && !pluginOpen && buttons.some((b) => b.id === open) ? open : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -75,7 +90,17 @@ export function MvSidePanel() {
|
||||
{active === "contacts" && <ContactsPanel onClose={() => setOpen(null)} />}
|
||||
</aside>
|
||||
)}
|
||||
<nav
|
||||
{pluginOpen && (
|
||||
<aside
|
||||
data-simple-hide=""
|
||||
data-plugin-side-panel={pluginOpen.pluginId}
|
||||
className="mb-4 me-2 flex w-[300px] shrink-0 flex-col overflow-hidden rounded-2xl bg-background"
|
||||
aria-label={pluginOpen.title}
|
||||
>
|
||||
<PluginSidePanelBody entry={pluginOpen} focusRequest={plugins.focusRequest} onClose={() => plugins.choose(null)} />
|
||||
</aside>
|
||||
)}
|
||||
{(buttons.length > 0 || pluginButtons.length > 0) && <nav
|
||||
data-mv-side-rail=""
|
||||
className="flex w-14 shrink-0 flex-col items-center gap-2 pt-3"
|
||||
aria-label={t("mv_side_panel")}
|
||||
@@ -84,7 +109,7 @@ export function MvSidePanel() {
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setOpen((cur) => (cur === id ? null : id))}
|
||||
onClick={() => openApp(active === id ? null : id)}
|
||||
className={cn(
|
||||
"relative grid h-10 w-10 place-items-center rounded-full transition-colors hover:bg-[var(--mv-nav-hover)]",
|
||||
active === id && "bg-[var(--mv-nav-selected)]",
|
||||
@@ -96,8 +121,18 @@ export function MvSidePanel() {
|
||||
<Icon className={cn("h-5 w-5", color)} />
|
||||
</button>
|
||||
))}
|
||||
{pluginButtons.map((entry) => (
|
||||
<SidePanelButton
|
||||
key={entry.pluginId}
|
||||
entry={entry}
|
||||
pressed={pluginOpen?.pluginId === entry.pluginId}
|
||||
onClick={() => openPlugin(pluginOpen?.pluginId === entry.pluginId ? null : entry.pluginId)}
|
||||
className="h-10 w-10 rounded-full hover:bg-[var(--mv-nav-hover)]"
|
||||
pressedClassName="bg-[var(--mv-nav-selected)] text-foreground"
|
||||
/>
|
||||
))}
|
||||
<div className="my-1 h-px w-5 bg-border" aria-hidden />
|
||||
</nav>
|
||||
</nav>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import { toUnicodeEmail } from "@/lib/idn";
|
||||
import { usePluginPages, pluginPageHref, isPluginPageActive } from "@/hooks/use-plugin-pages";
|
||||
import { NotificationBell } from "@/components/notifications/notification-center";
|
||||
import { useBottomControls } from "@/hooks/use-bottom-controls";
|
||||
import { writeRailHints, type RailHint } from "@/lib/boot-skeleton/hints";
|
||||
|
||||
interface NavItem {
|
||||
id: string;
|
||||
@@ -348,6 +349,23 @@ export function NavigationRail({
|
||||
|
||||
const visibleItems = navItems.filter((item) => !item.hidden);
|
||||
|
||||
// Leave the next page load's skeleton a note of what this rail shows
|
||||
// (lib/boot-skeleton/hints.ts). Only once a session is up: before that the
|
||||
// rail is still missing the apps the server offers.
|
||||
const quotaShown = !!quota && quota.total > 0;
|
||||
useEffect(() => {
|
||||
if (!client) return;
|
||||
const items: RailHint[] = [];
|
||||
for (const item of navItems) if (item.id !== 'mail' && !item.hidden) items.push(item.id as RailHint);
|
||||
if (sidebarAppsEnabled && onManageApps) items.push('add-app');
|
||||
if (isStalwartAdmin) items.push('admin');
|
||||
if (showAccessibilityButton) items.push('a11y');
|
||||
if (quotaShown) items.push('quota');
|
||||
writeRailHints(items);
|
||||
// navItems is rebuilt every render; its inputs are listed instead.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [client, supportsCalendar, calendarEnabled, supportsContacts, contactsEnabled, supportsFiles, filesEnabled, sidebarAppsEnabled, !!onManageApps, isStalwartAdmin, showAccessibilityButton, quotaShown]);
|
||||
|
||||
const getIsActive = (href: string, itemId: string) => {
|
||||
if (activeAppId) return false;
|
||||
if (onNavigate) {
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
MoreVertical,
|
||||
} from "@/components/icons";
|
||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||
import { FOLDER_CHEVRON_SLOT, FOLDER_ROW_PX_BASE, FolderRowsSkeleton } from "@/components/boot-skeleton/mail-skeleton";
|
||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||
import {
|
||||
buildKeywordTree,
|
||||
@@ -168,8 +169,8 @@ interface SidebarProps {
|
||||
onLoginSharedMailboxSelect?: (loginId: string, mailboxId: string) => void;
|
||||
}
|
||||
|
||||
const ROW_PX_BASE = 8;
|
||||
const CHEVRON_SLOT = 20;
|
||||
const ROW_PX_BASE = FOLDER_ROW_PX_BASE;
|
||||
const CHEVRON_SLOT = FOLDER_CHEVRON_SLOT;
|
||||
const INDENT_STEP = 12;
|
||||
|
||||
// Icons and colours follow the JMAP role only.
|
||||
@@ -430,11 +431,20 @@ function SidebarRow({
|
||||
const longPress = useLongPress((point) => {
|
||||
if (rowRef.current && onContextMenu) onContextMenu(menuEventAt(rowRef.current, point));
|
||||
}, hasMenu);
|
||||
// The whole row opens the folder, not just its button: the row's padding,
|
||||
// the chevron slot and the space around the count were dead (#1501). The
|
||||
// button stays the keyboard target; controls of their own keep their click.
|
||||
const openFromRow = !isVirtual && onClick
|
||||
? (e: React.MouseEvent) => {
|
||||
if (!(e.target as Element).closest('button, a, input, [role="button"]')) onClick();
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rowRef}
|
||||
{...(dropHandlers || {})}
|
||||
onClick={openFromRow}
|
||||
onContextMenu={onContextMenu}
|
||||
onTouchStart={hasMenu ? longPress.onTouchStart : undefined}
|
||||
onTouchMove={hasMenu ? longPress.onTouchMove : undefined}
|
||||
@@ -452,6 +462,7 @@ function SidebarRow({
|
||||
className={cn(
|
||||
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-colors duration-150",
|
||||
isCollapsed ? "justify-center px-1" : "pe-2",
|
||||
openFromRow && "cursor-pointer select-none",
|
||||
isVirtual
|
||||
? "text-muted-foreground"
|
||||
: isSelected
|
||||
@@ -2054,8 +2065,11 @@ function SidebarImpl({
|
||||
{((expanded && !isCollapsed) || isCollapsed) && (
|
||||
<>
|
||||
{tree.length === 0 ? (
|
||||
<div className="px-4 py-2 text-sm text-muted-foreground">
|
||||
{!isCollapsed && t("loading_mailboxes")}
|
||||
// The page skeleton's folder rows, so nothing moves when the
|
||||
// folders arrive (components/boot-skeleton).
|
||||
<div data-testid="folders-loading">
|
||||
<span className="sr-only">{t("loading_mailboxes")}</span>
|
||||
{!isCollapsed && <FolderRowsSkeleton />}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -2120,8 +2134,11 @@ function SidebarImpl({
|
||||
{(mvFoldersHeadless || (foldersExpanded && !isCollapsed) || isCollapsed) && (
|
||||
<>
|
||||
{mailboxes.length === 0 ? (
|
||||
<div className="px-4 py-2 text-sm text-muted-foreground">
|
||||
{!isCollapsed && t("loading_mailboxes")}
|
||||
// The page skeleton's folder rows, so nothing moves when the
|
||||
// folders arrive (components/boot-skeleton).
|
||||
<div data-testid="folders-loading">
|
||||
<span className="sr-only">{t("loading_mailboxes")}</span>
|
||||
{!isCollapsed && <FolderRowsSkeleton />}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import React, { Suspense, lazy } from 'react';
|
||||
import { act, render, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Mailbox } from '@/lib/jmap/types';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { usePluginStore } from '@/stores/plugin-store';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { useUIStore } from '@/stores/ui-store';
|
||||
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
|
||||
|
||||
// The mail page hands over from the page skeleton the server drew without
|
||||
// anything jumping: the auth gate draws that same skeleton, the list keeps
|
||||
// its skeleton rows until the folder's first page is in, and a linked message
|
||||
// keeps the reader open while it loads.
|
||||
|
||||
const nav = vi.hoisted(() => ({ replace: vi.fn(), push: vi.fn(), back: vi.fn(), prefetch: vi.fn() }));
|
||||
vi.mock('@/i18n/navigation', () => ({
|
||||
useRouter: () => nav,
|
||||
usePathname: () => '/en',
|
||||
Link: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
vi.mock('next/dynamic', () => ({ default: (load: () => Promise<React.ComponentType<Record<string, unknown>>>) => {
|
||||
const Component = lazy(async () => ({ default: await load() }));
|
||||
return function Dynamic(props: Record<string, unknown>) {
|
||||
return <Suspense fallback={null}><Component {...props} /></Suspense>;
|
||||
};
|
||||
} }));
|
||||
const viewer = vi.hoisted(() => ({ isLoading: [] as boolean[] }));
|
||||
vi.mock('@/components/email/email-viewer', () => ({
|
||||
EmailViewer: ({ isLoading }: { isLoading?: boolean }) => {
|
||||
viewer.isLoading.push(!!isLoading);
|
||||
return <div data-testid="viewer" data-loading={isLoading ? '' : undefined} />;
|
||||
},
|
||||
}));
|
||||
const list = vi.hoisted(() => ({ isLoading: [] as boolean[] }));
|
||||
vi.mock('@/components/email/email-list', () => ({
|
||||
EmailList: ({ isLoading }: { isLoading?: boolean }) => {
|
||||
list.isLoading.push(!!isLoading);
|
||||
return <div data-testid="list" data-loading={isLoading ? '' : undefined} />;
|
||||
},
|
||||
}));
|
||||
vi.mock('@/components/email/email-composer', () => ({ EmailComposer: () => null }));
|
||||
vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null }));
|
||||
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => <div data-testid="bottom-nav" /> }));
|
||||
vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null }));
|
||||
vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null }));
|
||||
vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null }));
|
||||
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
|
||||
vi.mock('@/components/layout/mv-side-panel', () => ({ MvSidePanel: () => null }));
|
||||
vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), MessageListTabs: () => null }));
|
||||
vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) }));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
|
||||
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
|
||||
vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} }));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] }));
|
||||
vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} }));
|
||||
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
|
||||
vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
|
||||
const device = vi.hoisted(() => ({ isMobile: false }));
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useDeviceDetection: () => ({ isMobile: device.isMobile, isTablet: false, isDesktop: !device.isMobile }),
|
||||
useMediaQuery: () => false,
|
||||
useIsDesktop: () => !device.isMobile,
|
||||
}));
|
||||
vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
|
||||
|
||||
const { MailApp } = await import('../mail-app');
|
||||
|
||||
const inbox = {
|
||||
id: 'inbox', name: 'Inbox', role: 'inbox', sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true,
|
||||
} as Mailbox;
|
||||
|
||||
/** A promise the test settles by hand. */
|
||||
function deferred<T = void>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((r) => { resolve = r; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
let fetchEmails: ReturnType<typeof vi.fn>;
|
||||
let fetchEmailContent: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
list.isLoading = [];
|
||||
viewer.isLoading = [];
|
||||
device.isMobile = false;
|
||||
nav.replace.mockReset();
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
|
||||
usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
|
||||
useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) });
|
||||
useUIStore.setState({ activeView: 'list' });
|
||||
const client = {
|
||||
getAccountId: () => 'acc', getUsername: () => 'a@example.org',
|
||||
getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null,
|
||||
getMailboxes: vi.fn().mockResolvedValue([inbox]),
|
||||
};
|
||||
useAuthStore.setState({
|
||||
isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [],
|
||||
getClientForAccount: () => client as never,
|
||||
getAllConnectedClients: () => new Map([['login-a', client]]) as never,
|
||||
});
|
||||
useSettingsStore.setState({ enableUnifiedMailbox: false, interfaceLayout: 'default', mailLayout: 'focus' } as never);
|
||||
fetchEmails = vi.fn().mockResolvedValue(undefined);
|
||||
fetchEmailContent = vi.fn().mockResolvedValue(null);
|
||||
useEmailStore.setState({
|
||||
emails: [], selectedEmail: null, selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: [inbox],
|
||||
viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
|
||||
isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '',
|
||||
searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '',
|
||||
selectedEmailKeys: new Set(), unreadSection: null,
|
||||
fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails,
|
||||
fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined),
|
||||
refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined),
|
||||
fetchEmailContent,
|
||||
} as never);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('mail page hand-off from the boot skeleton', () => {
|
||||
it('draws the mail skeleton, not a spinner, while the session is restored', async () => {
|
||||
useAuthStore.setState({ isAuthenticated: false, client: null, checkAuth: vi.fn(() => new Promise(() => {})) } as never);
|
||||
const { container } = render(<MailApp />);
|
||||
await act(async () => {});
|
||||
expect(container.querySelector('[data-boot-skeleton="mail"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-boot-skeleton="mail"] [role="status"]')?.textContent).toBeTruthy();
|
||||
expect(container.querySelector('.animate-spin')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the list loading until the folder\'s first page is in, then shows an empty folder as empty', async () => {
|
||||
const page = deferred();
|
||||
fetchEmails.mockReturnValue(page.promise);
|
||||
const { getByTestId } = render(<MailApp />);
|
||||
await act(async () => {});
|
||||
// Nothing fetched yet: the store is not loading, but the list must not say "no emails".
|
||||
expect(useEmailStore.getState().isLoading).toBe(false);
|
||||
expect(getByTestId('list').hasAttribute('data-loading')).toBe(true);
|
||||
await act(async () => { page.resolve(); });
|
||||
await waitFor(() => expect(getByTestId('list').hasAttribute('data-loading')).toBe(false));
|
||||
});
|
||||
|
||||
it('keeps the reader open in its loading state while a linked message loads (focus layout)', async () => {
|
||||
const content = deferred<null>();
|
||||
fetchEmailContent.mockReturnValue(content.promise);
|
||||
const { getByTestId, container } = render(<MailApp linkSegments={['message', 'm9']} />);
|
||||
await act(async () => {});
|
||||
const reader = container.querySelector('[data-pane="reader"]')!;
|
||||
expect(reader.className).not.toMatch(/(^|\s)md:hidden(\s|$)/);
|
||||
expect(getByTestId('viewer').hasAttribute('data-loading')).toBe(true);
|
||||
// Not found: the reader goes away again and the list takes the page.
|
||||
await act(async () => { content.resolve(null); });
|
||||
await waitFor(() => expect(container.querySelector('[data-pane="reader"]')!.className).toMatch(/(^|\s)md:hidden(\s|$)/));
|
||||
});
|
||||
|
||||
it('opens a linked message over the list on a phone and hides the bottom bar meanwhile', async () => {
|
||||
device.isMobile = true;
|
||||
fetchEmailContent.mockReturnValue(new Promise(() => {}));
|
||||
const { container, queryByTestId } = render(<MailApp linkSegments={['message', 'm9']} />);
|
||||
await act(async () => {});
|
||||
const reader = container.querySelector('[data-pane="reader"]')!;
|
||||
expect(reader.className).not.toMatch(/(^|\s)max-md:hidden(\s|$)/);
|
||||
expect(queryByTestId('bottom-nav')).toBeNull();
|
||||
});
|
||||
|
||||
it('draws a linked conversation on a phone as the conversation view, not the message viewer, while it loads', async () => {
|
||||
device.isMobile = true;
|
||||
const client = useAuthStore.getState().client as unknown as Record<string, unknown>;
|
||||
client.getThreadEmails = vi.fn(() => new Promise(() => {}));
|
||||
const { container, queryByTestId } = render(<MailApp linkSegments={['thread', 't9']} />);
|
||||
await act(async () => {});
|
||||
const reader = container.querySelector('[data-pane="reader"]')!;
|
||||
expect(reader.querySelector('[aria-busy="true"] [role="status"]')).not.toBeNull();
|
||||
expect(queryByTestId('viewer')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -256,3 +256,41 @@ describe('archiving a group message by month', () => {
|
||||
expect(client.createMailbox).toHaveBeenCalledWith('2026', 'k', 'ce', undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shared message deep link from the personal Inbox (#1491)', () => {
|
||||
it('selects the shared folder for a freshly fetched message without source stamps', async () => {
|
||||
window.history.replaceState(null, '', '/mail/message/eaaaaab?owner=ce');
|
||||
const email = { ...groupMessage, mailboxIds: { 'ce:a': true }, sourceClientAccountId: undefined, sourceAccountId: undefined } as Email;
|
||||
const fetchContent = vi.fn().mockImplementation(async () => {
|
||||
useEmailStore.setState({ selectedEmail: email });
|
||||
return email;
|
||||
});
|
||||
const fetchEmails = vi.fn().mockResolvedValue(undefined);
|
||||
useEmailStore.setState({
|
||||
emails: [], selectedEmail: null, selectedMailbox: 'a', selectedKeyword: null,
|
||||
fetchEmailContent: fetchContent, fetchEmails,
|
||||
});
|
||||
const mounted = render(<MailApp linkSegments={['message', 'eaaaaab']} />);
|
||||
try {
|
||||
await waitFor(() => expect(fetchContent).toHaveBeenCalledWith(client, 'eaaaaab', 'ce'));
|
||||
await waitFor(() => expect(useEmailStore.getState().selectedMailbox).toBe('ce:a'));
|
||||
expect(fetchEmails).toHaveBeenCalledWith(client, 'ce:a', { background: true });
|
||||
expect(useEmailStore.getState().selectedEmail?.subject).toBe('GRP one');
|
||||
} finally {
|
||||
mounted.unmount();
|
||||
window.history.replaceState(null, '', '/');
|
||||
}
|
||||
});
|
||||
|
||||
it('opens the shared Inbox for a grouped notification of the shared account', async () => {
|
||||
window.history.replaceState(null, '', '/mail/folder/inbox?owner=ce');
|
||||
useEmailStore.setState({ emails: [], selectedEmail: null, selectedMailbox: 'a', selectedKeyword: null } as never);
|
||||
const mounted = render(<MailApp linkSegments={['folder', 'inbox']} />);
|
||||
try {
|
||||
await waitFor(() => expect(useEmailStore.getState().selectedMailbox).toBe('ce:a'));
|
||||
} finally {
|
||||
mounted.unmount();
|
||||
window.history.replaceState(null, '', '/');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,6 +107,7 @@ import { PluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { useAppCommands } from "@/lib/app-commands";
|
||||
import { MvSearchForm } from "@/components/search/mv-search-form";
|
||||
import { MvSidePanel } from "@/components/layout/mv-side-panel";
|
||||
import { PluginSidePanel } from "@/components/plugins/plugin-side-panels";
|
||||
import { MvListToolbar } from "@/components/email/mv-list-toolbar";
|
||||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
@@ -176,6 +177,8 @@ import type { QuoteHeader } from "@/lib/plugin-types";
|
||||
import { initialLinkSearch, useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
|
||||
import { useMinimisedComposerFlush } from "@/hooks/use-minimised-composer-flush";
|
||||
import { quickReplyEnvelope, saveQuickReplyDraft } from "@/lib/outgoing/quick-reply-envelope";
|
||||
import { MailConversationSkeleton, MailSkeleton } from "@/components/boot-skeleton/mail-skeleton";
|
||||
import { useSkeletonLabels } from "@/components/boot-skeleton/use-skeleton-labels";
|
||||
|
||||
export interface MailAppProps {
|
||||
/**
|
||||
@@ -231,6 +234,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// Static Lite build: the route params are empty, read the link from the URL.
|
||||
const linkSegments = useLiteLinkSegments('mail', routeSegments);
|
||||
const t = useTranslations();
|
||||
// The page skeleton the gates below draw: the same one the server sent.
|
||||
const skeletonLabels = useSkeletonLabels();
|
||||
const tNotifications = useTranslations('notifications');
|
||||
// The toasts' Undo after a delete to Trash, archive, move or spam report.
|
||||
const undo = undoLabels(tNotifications);
|
||||
@@ -367,6 +372,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// Mobile/tablet responsive hooks
|
||||
const { isMobile, isTablet } = useDeviceDetection();
|
||||
const isEmbedded = useIsEmbedded();
|
||||
// A message or conversation link shows the reader from the first frame, as
|
||||
// the page skeleton draws it, in its loading state until the link has been
|
||||
// applied. Otherwise the focused layout and phones showed the list alone
|
||||
// while the session came up and only then opened the reader.
|
||||
const [deepLinkPane, setDeepLinkPane] = useState<'message' | 'thread' | null>(() => {
|
||||
if (isEmbedded || typeof window === 'undefined') return null;
|
||||
const link = parseMailPath(linkSegments ?? [], new URLSearchParams(initialLinkSearch('mail')));
|
||||
return link?.kind === 'message' || link?.kind === 'thread' ? link.kind : null;
|
||||
});
|
||||
/**
|
||||
* Mountain View's list cursor. `j` / `k` there move a highlight without
|
||||
* opening the conversation, marking it read or changing the URL - only
|
||||
@@ -1796,7 +1810,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
? await freshMailboxes(switchedFrom)
|
||||
: useEmailStore.getState().mailboxes;
|
||||
const state = useEmailStore.getState();
|
||||
const mailboxId = resolveFolderRef(link.ref, mailboxes);
|
||||
const mailboxId = resolveFolderRef(
|
||||
link.ref,
|
||||
mailboxes,
|
||||
link.ownerAccountId === activeClient.getAccountId() ? undefined : link.ownerAccountId,
|
||||
);
|
||||
if (!mailboxId) {
|
||||
toast.error(t('deep_link.folder_not_found'));
|
||||
return;
|
||||
@@ -1948,7 +1966,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
useEffect(() => {
|
||||
if (deepLinkHandledRef.current) return;
|
||||
if (!isAuthenticated || !client) return;
|
||||
if (mailboxes.length === 0) return;
|
||||
if (mailboxes.length === 0) {
|
||||
// The account has no folders to open the link in: stop holding the reader open for it.
|
||||
if (initialMailLoadDone) setDeepLinkPane(null);
|
||||
return;
|
||||
}
|
||||
if (!initialMailLoadDone) return;
|
||||
|
||||
const params = new URLSearchParams(initialSearchRef.current ?? window.location.search);
|
||||
@@ -1960,6 +1982,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
|
||||
if (!link && !openLatestUnread) {
|
||||
deepLinkHandledRef.current = true;
|
||||
setDeepLinkPane(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1971,7 +1994,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
deepLinkHandledRef.current = true;
|
||||
|
||||
if (link) {
|
||||
void applyMailDeepLinkRef.current(link, { onLoad: true });
|
||||
void applyMailDeepLinkRef.current(link, { onLoad: true }).finally(() => setDeepLinkPane(null));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4118,6 +4141,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
signatureIdentity,
|
||||
sendingIdentity,
|
||||
separator: settings.signatureSeparatorEnabled,
|
||||
// The user's reply/forward signature toggle: off sends the bare text.
|
||||
insertSignature: settings.signatureReplyForwardEnabled,
|
||||
to: recipients,
|
||||
fromEmail: headerFromEmail,
|
||||
identityId: sendingIdentity?.id,
|
||||
@@ -4472,14 +4497,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
], !isEmbedded);
|
||||
|
||||
if (!initialCheckDone || authLoading || (!isAuthenticated || !client)) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-foreground mx-auto"></div>
|
||||
<p className="mt-4 text-sm text-muted-foreground">{t("common.loading")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <MailSkeleton label={skeletonLabels.label} labels={skeletonLabels.labels} embedded={isEmbedded} />;
|
||||
}
|
||||
|
||||
// Get current mailbox name for mobile header
|
||||
@@ -4499,8 +4517,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
&& (pendingDraft?.mode ?? composerMode) !== 'compose' && Boolean(selectedEmail);
|
||||
const floatingComposer = mvShell && showComposer && !isEmbedded && !mvInlineReply;
|
||||
const hasViewerContent = (showComposer && !floatingComposer) || Boolean(conversationThread)
|
||||
|| Boolean(selectedEmail && !(mvShell && cursorOnly));
|
||||
const shouldCollapseListPane = (isTablet && !tabletListVisible && !readingPaneEmpty) || (!isMobile && isFocusedMailLayout && hasViewerContent);
|
||||
|| Boolean(selectedEmail && !(mvShell && cursorOnly)) || deepLinkPane !== null;
|
||||
// A linked message on a phone opens over the list, as one picked there does.
|
||||
const mobileView = deepLinkPane !== null && isMobile ? 'viewer' : activeView;
|
||||
const shouldCollapseListPane = (isTablet && ((!tabletListVisible && !readingPaneEmpty) || deepLinkPane !== null)) || (!isMobile && isFocusedMailLayout && hasViewerContent);
|
||||
const shouldHideViewerPane = !isMobile && !hasViewerContent && isFocusedMailLayout;
|
||||
const shouldHideHorizontalViewerPane = isHorizontalMailLayout && !hasViewerContent;
|
||||
|
||||
@@ -4887,7 +4907,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
return <MailSkeleton label={skeletonLabels.label} labels={skeletonLabels.labels} embedded={isEmbedded} />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -5087,7 +5107,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
: "h-full border-e border-border",
|
||||
// Mobile: full width, hidden when viewing email
|
||||
"max-md:flex-1 max-md:border-e-0 max-md:border-b-0",
|
||||
isMobile && activeView !== "list" && "max-md:hidden",
|
||||
isMobile && mobileView !== "list" && "max-md:hidden",
|
||||
// Pane-mobile (narrow Pro pane on a wide viewport): the
|
||||
// `max-md:` viewport classes above never apply, so mirror
|
||||
// them with pane-scoped equivalents.
|
||||
@@ -5328,7 +5348,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
hideBatchToolbar={mvShell}
|
||||
emails={activeEmails}
|
||||
selectedEmailKey={selectedEmail ? emailKeyFor(selectedEmail) : undefined}
|
||||
isLoading={activeIsLoading}
|
||||
// Until the first page of the folder has arrived an empty list
|
||||
// is not an empty folder: it draws its skeleton rows, not "no
|
||||
// emails".
|
||||
isLoading={activeIsLoading || (!initialMailLoadDone && activeEmails.length === 0)}
|
||||
hasMore={activeHasMore}
|
||||
isLoadingMoreItems={isScheduledView ? isLoadingScheduled && activeEmails.length > 0 : undefined}
|
||||
isScheduledView={isScheduledView}
|
||||
@@ -5518,7 +5541,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// Mobile: full screen overlay when active
|
||||
"max-md:fixed max-md:inset-0 max-md:z-30",
|
||||
"max-md:h-full max-md:pt-[env(safe-area-inset-top)]",
|
||||
isMobile && activeView !== "viewer" && "max-md:hidden",
|
||||
isMobile && mobileView !== "viewer" && "max-md:hidden",
|
||||
// Tablet/Desktop: relative. Pane-mobile instead overlays the
|
||||
// pane (absolute within the relative content row above) - the
|
||||
// viewport `max-md:` classes never fire there, and `fixed`
|
||||
@@ -5568,7 +5591,14 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
</button>
|
||||
)}
|
||||
{/* Mobile Conversation View - shown when thread is selected on mobile */}
|
||||
{isMobile && conversationThread ? (
|
||||
{isMobile && !conversationThread && deepLinkPane === 'thread' ? (
|
||||
// A linked conversation on a phone, still loading: the shape the
|
||||
// page skeleton gave it.
|
||||
<div className="flex flex-col h-full bg-background" aria-busy="true">
|
||||
<span role="status" className="sr-only">{t('threads.loading')}</span>
|
||||
<MailConversationSkeleton />
|
||||
</div>
|
||||
) : isMobile && conversationThread ? (
|
||||
<ThreadConversationView
|
||||
thread={conversationThread}
|
||||
emails={conversationEmails}
|
||||
@@ -5623,7 +5653,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
<EmailViewer
|
||||
email={selectedEmail}
|
||||
blobSource={resolveEmailBlobContext(selectedEmail, client)}
|
||||
isLoading={isLoadingEmail}
|
||||
isLoading={isLoadingEmail || deepLinkPane !== null}
|
||||
onReply={handleReply}
|
||||
onReplyAll={handleReplyAll}
|
||||
onForward={handleForward}
|
||||
@@ -5749,7 +5779,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
</div>
|
||||
|
||||
{/* Bottom Navigation - mobile and tablet (hidden when embedded) */}
|
||||
{(isMobile || isTablet) && activeView !== "viewer" && !isEmbedded && (
|
||||
{(isMobile || isTablet) && mobileView !== "viewer" && !isEmbedded && (
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
@@ -5762,6 +5792,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
{/* Mountain View: Gmail's side panel - calendar, tasks and contacts
|
||||
next to the mail, with their icons down the right edge. */}
|
||||
{mvShell && !inlineApp && !fullscreenReading && <MvSidePanel />}
|
||||
{/* The default layout: plugins' panels beside the mail (the
|
||||
side-panel slot), with their buttons in a rail at the right edge.
|
||||
Desktop only; Mountain View has them in its side panel. */}
|
||||
{!mvShell && !isMobile && !isTablet && !isEmbedded && !inlineApp && !fullscreenReading && <PluginSidePanel />}
|
||||
</div>
|
||||
|
||||
{/* Keyboard Shortcuts Modal */}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
vi.mock('next-intl', () => ({ useTranslations: () => (key: string, values?: Record<string, string>) => (values?.name ? `${key}:${values.name}` : key) }));
|
||||
vi.mock('../plugin-iframe-slot', () => ({ PluginIframeSlot: () => <div data-testid="custom-frame" /> }));
|
||||
|
||||
import { PluginDialogHost } from '../plugin-dialog-host';
|
||||
import { awaitCustomDialog, head, resetDialogLimits, resolveHead } from '@/lib/plugin-sandbox/host-dialog';
|
||||
import { POPOVER_GAP } from '@/lib/plugin-sandbox/dialog-anchor';
|
||||
|
||||
// A custom dialog with an anchor: a popover beside the control the user
|
||||
// clicked, naming the plugin in one line, closed by Escape or a click outside.
|
||||
|
||||
beforeEach(() => {
|
||||
while (head()) resolveHead(null);
|
||||
resetDialogLimits();
|
||||
});
|
||||
|
||||
const anchor = { left: 100, top: 50, right: 124, bottom: 74 };
|
||||
|
||||
describe('a plugin dialog beside its trigger', () => {
|
||||
it('stands below the trigger, names the plugin, and has no centred backdrop', () => {
|
||||
render(<PluginDialogHost />);
|
||||
act(() => { void awaitCustomDialog({ pluginId: 'follow-ups', title: 'Follow up', message: '', slot: 'plugin-dialog', width: 320, anchor }); });
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: 'Follow up' });
|
||||
expect(dialog).toHaveAttribute('data-plugin-dialog-popover');
|
||||
expect(dialog.className).not.toMatch(/bg-black/);
|
||||
expect(screen.getByTestId('custom-frame')).toBeInTheDocument();
|
||||
expect(dialog.querySelector('[data-plugin-dialog-source]')).toHaveTextContent('from_plugin:follow-ups');
|
||||
expect(screen.getByRole('button', { name: 'plugin_menu:follow-ups' })).toBeInTheDocument();
|
||||
const panel = dialog.querySelector<HTMLElement>('[data-plugin-dialog="follow-ups"]')!;
|
||||
expect(panel.style.left).toBe('100px');
|
||||
expect(panel.style.top).toBe(`${74 + POPOVER_GAP}px`);
|
||||
expect(panel.style.width).toContain('320px');
|
||||
});
|
||||
|
||||
it('closes with null on a click outside', async () => {
|
||||
render(<PluginDialogHost />);
|
||||
let result: Promise<unknown>;
|
||||
act(() => { result = awaitCustomDialog({ pluginId: 'follow-ups', title: 'Follow up', message: '', slot: 'plugin-dialog', anchor }); });
|
||||
await act(async () => { fireEvent.mouseDown(screen.getByRole('dialog')); });
|
||||
await expect(result!).resolves.toBeNull();
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
});
|
||||
|
||||
it('closes with null on Escape', async () => {
|
||||
render(<PluginDialogHost />);
|
||||
let result: Promise<unknown>;
|
||||
act(() => { result = awaitCustomDialog({ pluginId: 'follow-ups', title: 'Follow up', message: '', slot: 'plugin-dialog', anchor }); });
|
||||
await act(async () => { fireEvent.keyDown(document, { key: 'Escape' }); });
|
||||
await expect(result!).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('is the centred dialog without an anchor', () => {
|
||||
render(<PluginDialogHost />);
|
||||
act(() => { void awaitCustomDialog({ pluginId: 'follow-ups', title: 'Follow up', message: '', slot: 'plugin-dialog' }); });
|
||||
const dialog = screen.getByRole('dialog', { name: 'Follow up' });
|
||||
expect(dialog).not.toHaveAttribute('data-plugin-dialog-popover');
|
||||
expect(screen.getByRole('button', { name: 'close' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -107,7 +107,7 @@ describe('plugin pages', () => {
|
||||
render(<PluginPageApp linkSegments={['kanban', 'board']} />);
|
||||
|
||||
expect(screen.queryByText('not_available_title')).toBeNull();
|
||||
expect(screen.getByLabelText('loading')).toBeInTheDocument();
|
||||
expect(screen.getByRole('status')).toHaveTextContent('loading');
|
||||
});
|
||||
|
||||
it('does not mount another plugin page under a wrong page id', () => {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import type { InstalledPlugin } from '@/lib/plugin-types';
|
||||
|
||||
const createSlotInstance = vi.fn((..._args: unknown[]) => ({ destroy: vi.fn(), updateProps: vi.fn(), setTheme: vi.fn() }));
|
||||
vi.mock('@/lib/plugin-sandbox/host-bridge', () => ({
|
||||
createSlotInstance: (...args: unknown[]) => createSlotInstance(...args),
|
||||
}));
|
||||
vi.mock('@/lib/plugin-sandbox/host-theme', () => ({ snapshotHostTheme: () => ({}) }));
|
||||
|
||||
const { PluginSidePanel } = await import('../plugin-side-panels');
|
||||
const { MvSidePanel } = await import('@/components/layout/mv-side-panel');
|
||||
const registry = await import('@/lib/plugin-sandbox/registry');
|
||||
const { dispatchApiCall } = await import('@/lib/plugin-sandbox/host-api');
|
||||
const { clearSidePanel } = await import('@/lib/plugin-sandbox/side-panels');
|
||||
const { useCalendarStore } = await import('@/stores/calendar-store');
|
||||
const { useSettingsStore } = await import('@/stores/settings-store');
|
||||
|
||||
function offer(id: string, name: string, opts: { slot?: string; fill?: boolean } = {}) {
|
||||
const plugin: InstalledPlugin = {
|
||||
id, name, version: '1.0.0', author: '', description: '', type: 'ui-extension',
|
||||
permissions: ['ui:sidebar-widget'], grantedPermissions: ['ui:sidebar-widget'], entrypoint: 'index.js',
|
||||
enabled: true, status: 'running', settings: {},
|
||||
};
|
||||
registry.register({
|
||||
plugin,
|
||||
code: '',
|
||||
tier: 'untrusted',
|
||||
background: { evaluateShouldShow: vi.fn(async () => true) } as never,
|
||||
slotOffers: [{ name: (opts.slot ?? 'side-panel') as never, order: 100, hasShouldShow: false, ...(opts.fill ? { fill: true } : {}) }],
|
||||
hookDisposables: [],
|
||||
});
|
||||
return plugin;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
createSlotInstance.mockClear();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const p of registry.all()) {
|
||||
clearSidePanel(p.plugin.id);
|
||||
registry.deregister(p.plugin.id);
|
||||
}
|
||||
});
|
||||
|
||||
describe('PluginSidePanel (default layout)', () => {
|
||||
it('draws nothing when no plugin offers a side panel', () => {
|
||||
offer('w', 'Widget', { slot: 'sidebar-widget' });
|
||||
const { container } = render(<PluginSidePanel />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('gives each plugin a button in the rail that opens and closes its panel, filled by the frame', async () => {
|
||||
offer('fu', 'Follow-ups', { fill: true });
|
||||
const { container } = render(<PluginSidePanel />);
|
||||
const button = screen.getByRole('button', { name: 'Follow-ups' });
|
||||
expect(button).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(container.querySelector('[data-plugin-side-panel]')).toBeNull();
|
||||
|
||||
fireEvent.click(button);
|
||||
expect(button).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('complementary', { name: 'Follow-ups' })).toBeInTheDocument();
|
||||
await waitFor(() => expect(createSlotInstance).toHaveBeenCalled());
|
||||
const opts = createSlotInstance.mock.calls[0][0] as { slot: string; extraProps: Record<string, unknown>; fill?: boolean };
|
||||
expect(opts.slot).toBe('side-panel');
|
||||
expect(opts.extraProps).toEqual({ focusRequest: 0 });
|
||||
expect(container.querySelector('[data-plugin-iframe-fill]')).not.toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'close' }));
|
||||
expect(container.querySelector('[data-plugin-side-panel]')).toBeNull();
|
||||
});
|
||||
|
||||
it('remembers the open panel in this browser', () => {
|
||||
offer('fu', 'Follow-ups');
|
||||
const first = render(<PluginSidePanel />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Follow-ups' }));
|
||||
first.unmount();
|
||||
|
||||
const { container } = render(<PluginSidePanel />);
|
||||
expect(container.querySelector('[data-plugin-side-panel="fu"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('names the button and panel as the plugin set them, and by its name again after null', async () => {
|
||||
const plugin = offer('fu', 'Follow-ups');
|
||||
render(<PluginSidePanel />);
|
||||
await act(async () => { await dispatchApiCall(plugin, 'ui.setSidePanel', [{ title: 'Upcoming for me', icon: 'flag' }]); });
|
||||
expect(screen.getByRole('button', { name: 'Upcoming for me' })).toBeInTheDocument();
|
||||
|
||||
await expect(dispatchApiCall(plugin, 'ui.setSidePanel', [{ title: 'x'.repeat(41) }])).rejects.toThrow(/at most 40/);
|
||||
await expect(dispatchApiCall(plugin, 'ui.setSidePanel', [{ icon: 'no icon' }])).rejects.toThrow(/icon/);
|
||||
await act(async () => { await dispatchApiCall(plugin, 'ui.setSidePanel', [null]); });
|
||||
expect(screen.getByRole('button', { name: 'Follow-ups' })).toBeInTheDocument();
|
||||
|
||||
const noPerm = { ...plugin, id: 'other', permissions: [], grantedPermissions: [] };
|
||||
await expect(dispatchApiCall(noPerm, 'ui.setSidePanel', [{ title: 'Mine' }])).rejects.toThrow(/lacks permission "ui:sidebar-widget"/);
|
||||
});
|
||||
|
||||
it('leaves the button out with rail: false, the panel opened on the plugin\'s request only', async () => {
|
||||
const plugin = offer('fu', 'Follow-ups');
|
||||
const { container } = render(<PluginSidePanel />);
|
||||
await act(async () => { await dispatchApiCall(plugin, 'ui.setSidePanel', [{ title: 'Upcoming for me', icon: 'flag', rail: false }]); });
|
||||
expect(screen.queryByRole('button', { name: 'Upcoming for me' })).toBeNull();
|
||||
expect(container.querySelector('[data-plugin-side-rail]')).toBeNull();
|
||||
|
||||
await act(async () => { await dispatchApiCall(plugin, 'ui.setPanel', ['side-panel', 'toggle']); });
|
||||
expect(screen.getByRole('complementary', { name: 'Upcoming for me' })).toBeInTheDocument();
|
||||
|
||||
await expect(dispatchApiCall(plugin, 'ui.setSidePanel', [{ rail: 'no' }])).rejects.toThrow(/rail/);
|
||||
});
|
||||
|
||||
it('opens on the plugin\'s request with a new focusRequest, and answers null without a panel on screen', async () => {
|
||||
const plugin = offer('fu', 'Follow-ups');
|
||||
const { container, unmount } = render(<PluginSidePanel />);
|
||||
let answer: unknown;
|
||||
await act(async () => { answer = await dispatchApiCall(plugin, 'ui.setPanel', ['side-panel', 'open']); });
|
||||
expect(answer).toBe(true);
|
||||
expect(container.querySelector('[data-plugin-side-panel="fu"]')).not.toBeNull();
|
||||
await waitFor(() => expect(createSlotInstance).toHaveBeenCalled());
|
||||
expect((createSlotInstance.mock.calls[0][0] as { extraProps: unknown }).extraProps).toEqual({ focusRequest: 1 });
|
||||
|
||||
await act(async () => { answer = await dispatchApiCall(plugin, 'ui.setPanel', ['side-panel', 'toggle']); });
|
||||
expect(answer).toBe(false);
|
||||
expect(container.querySelector('[data-plugin-side-panel]')).toBeNull();
|
||||
|
||||
unmount();
|
||||
await expect(dispatchApiCall(plugin, 'ui.setPanel', ['side-panel', 'open'])).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MvSidePanel with plugin panels', () => {
|
||||
beforeEach(() => {
|
||||
useCalendarStore.setState({ supportsCalendar: true, calendars: [], events: [] } as never);
|
||||
useSettingsStore.setState({ enableCalendarTasks: false } as never);
|
||||
});
|
||||
|
||||
it('adds the plugins\' buttons to its rail, one panel open at a time', () => {
|
||||
offer('fu', 'Follow-ups');
|
||||
const { container } = render(<MvSidePanel />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'calendar' }));
|
||||
expect(container.querySelector('[data-mv-side-panel="calendar"]')).not.toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Follow-ups' }));
|
||||
expect(container.querySelector('[data-mv-side-panel]')).toBeNull();
|
||||
expect(container.querySelector('[data-plugin-side-panel="fu"]')).not.toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'calendar' }));
|
||||
expect(container.querySelector('[data-plugin-side-panel]')).toBeNull();
|
||||
expect(container.querySelector('[data-mv-side-panel="calendar"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('draws no rail for a plugin panel without a button alone, and opens it on request', async () => {
|
||||
useCalendarStore.setState({ supportsCalendar: false } as never);
|
||||
const plugin = offer('fu', 'Follow-ups');
|
||||
const { container } = render(<MvSidePanel />);
|
||||
await act(async () => { await dispatchApiCall(plugin, 'ui.setSidePanel', [{ rail: false }]); });
|
||||
expect(container.querySelector('[data-mv-side-rail]')).toBeNull();
|
||||
await act(async () => { await dispatchApiCall(plugin, 'ui.setPanel', ['side-panel', 'open']); });
|
||||
expect(container.querySelector('[data-plugin-side-panel="fu"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows the rail for a plugin panel alone', () => {
|
||||
useCalendarStore.setState({ supportsCalendar: false } as never);
|
||||
offer('fu', 'Follow-ups');
|
||||
render(<MvSidePanel />);
|
||||
expect(screen.getByRole('button', { name: 'Follow-ups' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { cleanup, render, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import type { InstalledPlugin } from '@/lib/plugin-types';
|
||||
|
||||
const slotInstance = (initPromise: Promise<unknown> = Promise.resolve({})) => ({ destroy: vi.fn(), updateProps: vi.fn(), setTheme: vi.fn(), initPromise });
|
||||
const createSlotInstance = vi.fn((..._args: unknown[]) => slotInstance());
|
||||
vi.mock('@/lib/plugin-sandbox/host-bridge', () => ({
|
||||
createSlotInstance: (...args: unknown[]) => createSlotInstance(...args),
|
||||
}));
|
||||
vi.mock('@/lib/plugin-sandbox/host-theme', () => ({ snapshotHostTheme: () => ({}) }));
|
||||
|
||||
const { PluginTaskView, usePluginTaskView } = await import('../plugin-task-view');
|
||||
const registry = await import('@/lib/plugin-sandbox/registry');
|
||||
|
||||
function offer(id: string, opts: { slot?: string; fill?: boolean; permissions?: InstalledPlugin['permissions']; shouldShow?: boolean } = {}) {
|
||||
const permissions = opts.permissions ?? ['ui:tasks', 'tasks:read'];
|
||||
const plugin: InstalledPlugin = {
|
||||
id, name: id, version: '1.0.0', author: '', description: '', type: 'ui-extension',
|
||||
permissions, grantedPermissions: permissions, entrypoint: 'index.js',
|
||||
enabled: true, status: 'running', settings: {},
|
||||
};
|
||||
registry.register({
|
||||
plugin,
|
||||
code: '',
|
||||
tier: 'untrusted',
|
||||
background: { evaluateShouldShow: vi.fn(async () => opts.shouldShow ?? true) } as never,
|
||||
slotOffers: [{ name: (opts.slot ?? 'calendar-task-view') as never, order: 100, hasShouldShow: opts.shouldShow !== undefined, ...(opts.fill ? { fill: true } : {}) }],
|
||||
hookDisposables: [],
|
||||
});
|
||||
}
|
||||
|
||||
/** The task view's list area as calendar-app draws it. */
|
||||
function TaskArea({ calendarIds = ['c1', 'c2'] }: { calendarIds?: string[] }) {
|
||||
const view = usePluginTaskView();
|
||||
const list = <div data-testid="bulwark-task-list" />;
|
||||
if (!view) return list;
|
||||
return <PluginTaskView pluginId={view.pluginId} fill={view.fill} filter="pending" calendarIds={calendarIds} fallback={list} />;
|
||||
}
|
||||
|
||||
beforeEach(() => { createSlotInstance.mockClear(); });
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
for (const p of registry.all()) registry.deregister(p.plugin.id);
|
||||
});
|
||||
|
||||
describe('calendar-task-view', () => {
|
||||
it('leaves Bulwark\'s list while no plugin offers the slot', () => {
|
||||
offer('other', { slot: 'task-detail-section' });
|
||||
const { getByTestId, container } = render(<TaskArea />);
|
||||
expect(createSlotInstance).not.toHaveBeenCalled();
|
||||
expect(getByTestId('bulwark-task-list')).toBeInTheDocument();
|
||||
expect(container.querySelector('[data-plugin-task-view]')).toBeNull();
|
||||
});
|
||||
|
||||
it('draws the first plugin offering it, filled, with the toolbar\'s filter and the shown calendars', async () => {
|
||||
offer('follow-ups', { fill: true });
|
||||
offer('second');
|
||||
const { container, queryByTestId } = render(<TaskArea />);
|
||||
expect(queryByTestId('bulwark-task-list')).toBeNull();
|
||||
expect(container.querySelectorAll('[data-plugin-task-view]')).toHaveLength(1);
|
||||
expect(container.querySelector('[data-plugin-task-view]')).toHaveAttribute('data-plugin-task-view', 'follow-ups');
|
||||
await waitFor(() => expect(createSlotInstance).toHaveBeenCalledTimes(1));
|
||||
const opts = createSlotInstance.mock.calls[0][0] as { slot: string; extraProps: Record<string, unknown>; fill?: boolean };
|
||||
expect(opts.slot).toBe('calendar-task-view');
|
||||
expect(opts.fill).toBe(true);
|
||||
expect(opts.extraProps).toEqual({ filter: 'pending', calendarIds: ['c1', 'c2'] });
|
||||
expect(container.querySelector('[data-plugin-iframe-fill]')).not.toBeNull();
|
||||
await Promise.resolve();
|
||||
expect(queryByTestId('bulwark-task-list')).toBeNull();
|
||||
});
|
||||
|
||||
it('hands the calendars only to a plugin that reads tasks', async () => {
|
||||
offer('looks', { permissions: ['ui:tasks'] });
|
||||
render(<TaskArea />);
|
||||
await waitFor(() => expect(createSlotInstance).toHaveBeenCalled());
|
||||
const opts = createSlotInstance.mock.calls[0][0] as { extraProps: Record<string, unknown> };
|
||||
expect(opts.extraProps).toEqual({ filter: 'pending' });
|
||||
});
|
||||
|
||||
it("gives Bulwark's list back when the plugin's shouldShow says no", async () => {
|
||||
offer('picky', { shouldShow: false });
|
||||
const { findByTestId, container } = render(<TaskArea />);
|
||||
expect(await findByTestId('bulwark-task-list')).toBeInTheDocument();
|
||||
expect(createSlotInstance).not.toHaveBeenCalled();
|
||||
expect(container.querySelector('[data-plugin-task-view]')).toHaveClass('hidden');
|
||||
});
|
||||
|
||||
it("gives Bulwark's list back when the plugin's frame fails to start", async () => {
|
||||
createSlotInstance.mockImplementationOnce(() => slotInstance(Promise.reject(new Error('boom'))));
|
||||
offer('broken');
|
||||
const { findByTestId, container } = render(<TaskArea />);
|
||||
expect(await findByTestId('bulwark-task-list')).toBeInTheDocument();
|
||||
expect(createSlotInstance).toHaveBeenCalledTimes(1);
|
||||
expect(container.querySelector('[data-plugin-task-view]')).toHaveClass('hidden');
|
||||
});
|
||||
|
||||
it('lets the area scroll for a plugin that does not fill it', async () => {
|
||||
offer('plain');
|
||||
const { container } = render(<TaskArea />);
|
||||
await waitFor(() => expect(createSlotInstance).toHaveBeenCalled());
|
||||
expect(container.querySelector('[data-plugin-task-view]')?.className).toContain('overflow-y-auto');
|
||||
});
|
||||
});
|
||||
@@ -9,11 +9,14 @@
|
||||
// They look like the app's own dialogs: a custom dialog (api.ui.openDialog)
|
||||
// is the filter editor's layout, with the plugin's buttons (kit
|
||||
// DialogButtons) drawn by the host in its footer; confirm, alert and prompt
|
||||
// are the app's ConfirmDialog and PromptDialog.
|
||||
// are the app's ConfirmDialog and PromptDialog. A custom dialog opened with
|
||||
// `anchor: 'trigger'` is a popover beside the control the user clicked
|
||||
// (dialog-anchor.ts), with a one-line header naming the plugin.
|
||||
|
||||
import React, { useCallback, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
||||
import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { cancelForPlugin, head, overlay, resolveDialog, subscribe, type DialogRequest } from '@/lib/plugin-sandbox/host-dialog';
|
||||
import { placePopover, POPOVER_MARGIN, trackTriggers, type AnchorRect } from '@/lib/plugin-sandbox/dialog-anchor';
|
||||
import type { DialogButtonSpec } from '@/lib/plugin-sandbox/ui-kit/dialog-buttons';
|
||||
import { usePluginStore } from '@/stores/plugin-store';
|
||||
import { useFocusTrap } from '@/hooks/use-focus-trap';
|
||||
@@ -43,6 +46,9 @@ export function PluginDialogHost(): React.JSX.Element | null {
|
||||
const top = useSyncExternalStore(subscribe, overlay, () => null);
|
||||
const t = useTranslations('plugins.dialog');
|
||||
|
||||
// What the user clicked last, for a dialog that opens beside it.
|
||||
useEffect(() => trackTriggers(), []);
|
||||
|
||||
// Say so when a plugin was turned off for failing; otherwise it just
|
||||
// stops working with no hint why.
|
||||
useEffect(() => {
|
||||
@@ -183,6 +189,9 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: '
|
||||
},
|
||||
});
|
||||
|
||||
const anchor = current?.kind === 'custom' ? current.anchor : undefined;
|
||||
const place = usePopoverPlace(anchor, panelRef);
|
||||
|
||||
if (!current) return null;
|
||||
|
||||
const name = pluginName ?? current.pluginId;
|
||||
@@ -204,6 +213,76 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: '
|
||||
</p>
|
||||
) : null;
|
||||
|
||||
const footer = buttons.length > 0 && (
|
||||
<div className={cn('flex flex-wrap items-center justify-end gap-2 border-t border-border', anchor ? 'px-3 py-2' : 'px-6 py-4')} data-plugin-dialog-buttons="">
|
||||
{buttons.map((b) => (
|
||||
<Button
|
||||
key={b.id}
|
||||
type="button"
|
||||
variant={DIALOG_BUTTON_VARIANT[b.variant ?? 'primary']}
|
||||
size={anchor ? 'sm' : undefined}
|
||||
disabled={b.disabled || b.busy}
|
||||
aria-busy={b.busy || undefined}
|
||||
className="gap-1.5"
|
||||
onClick={() => pressButton(b.id)}
|
||||
>
|
||||
{b.busy ? <Loader2 className="w-4 h-4 animate-spin" aria-hidden="true" /> : <FooterIcon name={b.icon} />}
|
||||
{b.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
const frame = (
|
||||
<PluginIframeSlot
|
||||
pluginId={current.pluginId}
|
||||
slot={(current.slot ?? 'plugin-dialog') as SlotName}
|
||||
extraProps={customProps}
|
||||
/>
|
||||
);
|
||||
|
||||
// Beside the control the user clicked: no backdrop, no big header. The
|
||||
// plugin is still named in the host's own line, with the way to turn it
|
||||
// off; a click outside or Escape closes it as the dialog's X does.
|
||||
if (anchor) {
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-hidden={layer === 'below' ? true : undefined}
|
||||
className="fixed inset-0 z-[100000]"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) cancel();
|
||||
}}
|
||||
data-plugin-dialog-popover=""
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="absolute flex flex-col overflow-hidden rounded-lg border border-border bg-background text-foreground shadow-xl animate-in fade-in zoom-in-95 duration-150"
|
||||
style={{
|
||||
left: place?.left ?? 0,
|
||||
top: place?.top ?? 0,
|
||||
visibility: place ? undefined : 'hidden',
|
||||
width: `min(${current.width ?? 560}px, calc(100vw - ${2 * POPOVER_MARGIN}px))`,
|
||||
maxHeight: `calc(100vh - ${2 * POPOVER_MARGIN}px)`,
|
||||
}}
|
||||
data-plugin-dialog={current.pluginId}
|
||||
>
|
||||
<div className="flex items-center gap-1 border-b border-border ps-3 pe-1 py-0.5">
|
||||
<h2 id={titleId} className="sr-only">{current.title || name}</h2>
|
||||
<p className="flex min-w-0 flex-1 items-center gap-1 text-xs text-muted-foreground" data-plugin-dialog-source="">
|
||||
<Puzzle className="h-3 w-3 flex-shrink-0" aria-hidden="true" />
|
||||
<span className="min-w-0 truncate">{t('from_plugin', { name })}</span>
|
||||
</p>
|
||||
<PluginDialogMenu name={name} onTurnOff={turnOff} compact />
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">{frame}</div>
|
||||
{footer}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
@@ -242,30 +321,9 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: '
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-4">
|
||||
{message}
|
||||
<PluginIframeSlot
|
||||
pluginId={current.pluginId}
|
||||
slot={(current.slot ?? 'plugin-dialog') as SlotName}
|
||||
extraProps={customProps}
|
||||
/>
|
||||
{frame}
|
||||
</div>
|
||||
{buttons.length > 0 && (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 px-6 py-4 border-t border-border" data-plugin-dialog-buttons="">
|
||||
{buttons.map((b) => (
|
||||
<Button
|
||||
key={b.id}
|
||||
type="button"
|
||||
variant={DIALOG_BUTTON_VARIANT[b.variant ?? 'primary']}
|
||||
disabled={b.disabled || b.busy}
|
||||
aria-busy={b.busy || undefined}
|
||||
className="gap-1.5"
|
||||
onClick={() => pressButton(b.id)}
|
||||
>
|
||||
{b.busy ? <Loader2 className="w-4 h-4 animate-spin" aria-hidden="true" /> : <FooterIcon name={b.icon} />}
|
||||
{b.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{footer}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -328,6 +386,37 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: '
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a popover dialog's panel stands: measured before it paints and again
|
||||
* when its content (the plugin's frame) or the window changes size.
|
||||
*/
|
||||
function usePopoverPlace(anchor: AnchorRect | undefined, panelRef: React.RefObject<HTMLElement | null>): { left: number; top: number } | null {
|
||||
const [place, setPlace] = useState<{ left: number; top: number } | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
const panel = panelRef.current;
|
||||
if (!anchor || !panel) return;
|
||||
const update = () => {
|
||||
const r = panel.getBoundingClientRect();
|
||||
const next = placePopover(
|
||||
anchor,
|
||||
{ width: r.width, height: r.height },
|
||||
{ width: window.innerWidth, height: window.innerHeight },
|
||||
document.documentElement.dir === 'rtl',
|
||||
);
|
||||
setPlace((p) => (p && p.left === next.left && p.top === next.top ? p : next));
|
||||
};
|
||||
update();
|
||||
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(update);
|
||||
observer?.observe(panel);
|
||||
window.addEventListener('resize', update);
|
||||
return () => {
|
||||
observer?.disconnect();
|
||||
window.removeEventListener('resize', update);
|
||||
};
|
||||
}, [anchor, panelRef]);
|
||||
return place;
|
||||
}
|
||||
|
||||
/** A footer button's icon by name; unknown names show none. */
|
||||
function FooterIcon({ name }: { name?: string }) {
|
||||
const Glyph = name ? iconForName(name) : null;
|
||||
@@ -335,7 +424,7 @@ function FooterIcon({ name }: { name?: string }) {
|
||||
}
|
||||
|
||||
/** The "⋯" menu in a plugin dialog's header: the way out of a plugin that keeps asking. */
|
||||
function PluginDialogMenu({ name, onTurnOff }: { name: string; onTurnOff: () => void }) {
|
||||
function PluginDialogMenu({ name, onTurnOff, compact }: { name: string; onTurnOff: () => void; compact?: boolean }) {
|
||||
const t = useTranslations('plugins.dialog');
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
@@ -356,9 +445,9 @@ function PluginDialogMenu({ name, onTurnOff }: { name: string; onTurnOff: () =>
|
||||
aria-expanded={open}
|
||||
aria-label={t('plugin_menu', { name })}
|
||||
title={t('plugin_menu', { name })}
|
||||
className={cn('p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground', open && 'bg-muted text-foreground')}
|
||||
className={cn(compact ? 'p-1' : 'p-1.5', 'rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground', open && 'bg-muted text-foreground')}
|
||||
>
|
||||
<MoreVertical className="w-5 h-5" />
|
||||
<MoreVertical className={compact ? 'w-4 h-4' : 'w-5 h-5'} />
|
||||
</button>
|
||||
{open && (
|
||||
<div role="menu" className="absolute end-0 top-full z-10 mt-1 min-w-[200px] rounded-md border border-border bg-background py-1 shadow-lg">
|
||||
|
||||
@@ -25,6 +25,13 @@ interface Props {
|
||||
extraProps?: Record<string, unknown>;
|
||||
/** The frame fills its panel and scrolls inside, instead of growing with its content. */
|
||||
fill?: boolean;
|
||||
/**
|
||||
* Told whether the frame is up: false when the plugin's shouldShow says no
|
||||
* or its frame fails to start, true once it has started. For a slot that
|
||||
* stands in for a part of Bulwark (calendar-task-view), so the caller can
|
||||
* draw its own part again.
|
||||
*/
|
||||
onShownChange?: (shown: boolean) => void;
|
||||
}
|
||||
|
||||
interface FloatingDropdownState {
|
||||
@@ -38,9 +45,11 @@ interface FloatingDropdownState {
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export function PluginIframeSlot({ pluginId, slot, extraProps, fill = false }: Props) {
|
||||
export function PluginIframeSlot({ pluginId, slot, extraProps, fill = false, onShownChange }: Props) {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const instanceRef = useRef<SandboxInstance | null>(null);
|
||||
const onShownChangeRef = useRef(onShownChange);
|
||||
useEffect(() => { onShownChangeRef.current = onShownChange; }, [onShownChange]);
|
||||
const [height, setHeight] = useState<number>(0);
|
||||
const [width, setWidth] = useState<number | undefined>(undefined);
|
||||
const [floatingDropdown, setFloatingDropdown] = useState<FloatingDropdownState | null>(null);
|
||||
@@ -80,6 +89,10 @@ export function PluginIframeSlot({ pluginId, slot, extraProps, fill = false }: P
|
||||
return () => { cancelled = true; };
|
||||
}, [pluginId, slot, props]);
|
||||
|
||||
useEffect(() => {
|
||||
if (show === false) onShownChangeRef.current?.(false);
|
||||
}, [show]);
|
||||
|
||||
// Spawn / tear down the slot iframe.
|
||||
useEffect(() => {
|
||||
if (show !== true) return;
|
||||
@@ -130,7 +143,17 @@ export function PluginIframeSlot({ pluginId, slot, extraProps, fill = false }: P
|
||||
},
|
||||
});
|
||||
instanceRef.current = inst;
|
||||
// A frame that fails to start stays blank; say so to a caller that can
|
||||
// draw something in its place.
|
||||
let live = true;
|
||||
if (onShownChangeRef.current) {
|
||||
inst.initPromise.then(
|
||||
() => { if (live) onShownChangeRef.current?.(true); },
|
||||
() => { if (live) onShownChangeRef.current?.(false); },
|
||||
);
|
||||
}
|
||||
return () => {
|
||||
live = false;
|
||||
try { inst.destroy(); } catch { /* ignore */ }
|
||||
instanceRef.current = null;
|
||||
setFloatingDropdown(null);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { Loader2, Puzzle } from "@/components/icons";
|
||||
import { Puzzle } from "@/components/icons";
|
||||
import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { usePluginStore } from "@/stores/plugin-store";
|
||||
@@ -22,6 +22,8 @@ import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
import { findPage, subscribePages } from "@/lib/plugin-sandbox/pages";
|
||||
import { offersForSlot, subscribe as subscribeSlots } from "@/lib/plugin-sandbox/registry";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PluginPageSkeleton, PluginPageSkeletonBody } from "@/components/boot-skeleton/plugin-page-skeleton";
|
||||
import { useSkeletonLabels } from "@/components/boot-skeleton/use-skeleton-labels";
|
||||
|
||||
export interface PluginPageAppProps {
|
||||
/** Path segments after `/apps`: `[pluginId, pageId, ...subPath]`. */
|
||||
@@ -36,6 +38,7 @@ export interface PluginPageAppProps {
|
||||
*/
|
||||
export function PluginPageApp({ linkSegments = [] }: PluginPageAppProps) {
|
||||
const t = useTranslations("plugin_pages");
|
||||
const skeleton = useSkeletonLabels();
|
||||
const [pluginId = "", pageId = "", ...rest] = linkSegments;
|
||||
const subPath = rest.join("/");
|
||||
|
||||
@@ -91,7 +94,12 @@ export function PluginPageApp({ linkSegments = [] }: PluginPageAppProps) {
|
||||
|
||||
const extraProps = useMemo(() => ({ pageId, path: subPath }), [pageId, subPath]);
|
||||
|
||||
if (!isAuthenticated) return null;
|
||||
// Until the session is restored: the page skeleton the server drew.
|
||||
if (!isAuthenticated) {
|
||||
return isEmbedded
|
||||
? <PluginPageSkeletonBody embedded />
|
||||
: <PluginPageSkeleton label={skeleton.label} labels={skeleton.labels} />;
|
||||
}
|
||||
|
||||
const available = !!page && offersPage;
|
||||
let content: React.ReactNode;
|
||||
@@ -102,9 +110,11 @@ export function PluginPageApp({ linkSegments = [] }: PluginPageAppProps) {
|
||||
</div>
|
||||
);
|
||||
} else if (!pluginsReady) {
|
||||
// The plugins are starting: the same empty area the page skeleton drew,
|
||||
// so nothing moves before the page (or the "not available" note) arrives.
|
||||
content = (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="w-6 h-6 text-muted-foreground animate-spin" aria-label={t("loading")} />
|
||||
<div className="flex flex-1 min-h-0" role="status" data-plugin-page-loading="">
|
||||
<span className="sr-only">{t("loading")}</span>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { iconForName, Puzzle, X, type AppIcon } from '@/components/icons';
|
||||
import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers';
|
||||
import { get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
|
||||
import { onPanelRequest } from '@/lib/plugin-sandbox/panel-requests';
|
||||
import { sidePanelLooks, subscribeSidePanels } from '@/lib/plugin-sandbox/side-panels';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PluginIframeSlot } from './plugin-iframe-slot';
|
||||
|
||||
// The `side-panel` slot: one panel per plugin beside the mail, opened from
|
||||
// its button in the rail at the right edge. Mountain View puts the buttons
|
||||
// in its own side panel's rail (mv-side-panel.tsx); the default layout
|
||||
// shows PluginSidePanel. At most one panel is open; which one is
|
||||
// remembered in this browser. A plugin that set `rail: false` has no
|
||||
// button: its panel opens only on its request; a rail without buttons is
|
||||
// not drawn.
|
||||
|
||||
const STORAGE_KEY = 'plugin-side-panel-open';
|
||||
|
||||
export interface SidePanelEntry {
|
||||
pluginId: string;
|
||||
title: string;
|
||||
Icon: AppIcon;
|
||||
fill: boolean;
|
||||
/** Whether the rail shows its button (api.ui.setSidePanel's `rail`). */
|
||||
inRail: boolean;
|
||||
}
|
||||
|
||||
function readStored(): string | null {
|
||||
try { return localStorage.getItem(STORAGE_KEY); } catch { return null; }
|
||||
}
|
||||
|
||||
function writeStored(pluginId: string | null): void {
|
||||
try {
|
||||
if (pluginId) localStorage.setItem(STORAGE_KEY, pluginId);
|
||||
else localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
// Storage may be blocked; the panel is just shut next time.
|
||||
}
|
||||
}
|
||||
|
||||
/** The plugins offering a side panel, each with the title and icon it set (api.ui.setSidePanel). */
|
||||
export function usePluginSidePanelEntries(): SidePanelEntry[] {
|
||||
const offers = usePluginSlotOffers('side-panel');
|
||||
const looks = useSyncExternalStore(subscribeSidePanels, sidePanelLooks, sidePanelLooks);
|
||||
return useMemo(() => {
|
||||
return offers.map((offer) => {
|
||||
const look = looks.get(offer.pluginId);
|
||||
return {
|
||||
pluginId: offer.pluginId,
|
||||
title: look?.title || getActivePlugin(offer.pluginId)?.plugin.name || offer.pluginId,
|
||||
Icon: iconForName(look?.icon) ?? Puzzle,
|
||||
fill: !!offer.fill,
|
||||
inRail: look?.rail !== false,
|
||||
};
|
||||
});
|
||||
}, [offers, looks]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which plugin's side panel is open (null: none), remembered in this
|
||||
* browser, and the plugins' api.ui.openPanel('side-panel') requests;
|
||||
* `onOpen` runs when a plugin's request opens its panel.
|
||||
*/
|
||||
export function usePluginSidePanel(onOpen?: () => void) {
|
||||
const entries = usePluginSidePanelEntries();
|
||||
const [chosen, setChosen] = useState<string | null>(readStored);
|
||||
const [focusRequest, setFocusRequest] = useState(0);
|
||||
// A plugin that went away (disabled, removed) leaves its panel shut.
|
||||
const open = chosen && entries.some((e) => e.pluginId === chosen) ? chosen : null;
|
||||
const choose = useCallback((pluginId: string | null) => {
|
||||
setChosen(pluginId);
|
||||
writeStored(pluginId);
|
||||
}, []);
|
||||
const state = useRef({ entries, open, onOpen });
|
||||
state.current = { entries, open, onOpen };
|
||||
useEffect(() => onPanelRequest((request) => {
|
||||
if (request.slot !== 'side-panel') return null;
|
||||
if (!state.current.entries.some((e) => e.pluginId === request.pluginId)) return null;
|
||||
const isOpen = state.current.open === request.pluginId;
|
||||
const wantOpen = request.mode === 'open' ? true : request.mode === 'close' ? false : !isOpen;
|
||||
if (wantOpen) {
|
||||
choose(request.pluginId);
|
||||
setFocusRequest((n) => n + 1);
|
||||
state.current.onOpen?.();
|
||||
} else if (isOpen) {
|
||||
choose(null);
|
||||
}
|
||||
return wantOpen;
|
||||
}), [choose]);
|
||||
return { entries, open, choose, focusRequest };
|
||||
}
|
||||
|
||||
/** A plugin's panel: the host's header (title, close) over the plugin's frame, which fills the rest. */
|
||||
export function PluginSidePanelBody({ entry, focusRequest, onClose }: {
|
||||
entry: SidePanelEntry;
|
||||
focusRequest: number;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const tCommon = useTranslations('common');
|
||||
const extraProps = useMemo(() => ({ focusRequest }), [focusRequest]);
|
||||
return (
|
||||
<>
|
||||
<div className="flex shrink-0 items-center gap-1 ps-4 pe-2 pt-3 pb-1">
|
||||
<div className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">{entry.title}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="grid h-8 w-8 place-items-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
title={tCommon('close')}
|
||||
aria-label={tCommon('close')}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
data-plugin-slot="side-panel"
|
||||
className={cn('min-h-0 flex-1', entry.fill ? 'flex flex-col' : 'overflow-y-auto')}
|
||||
>
|
||||
<PluginIframeSlot key={entry.pluginId} pluginId={entry.pluginId} slot="side-panel" extraProps={extraProps} fill={entry.fill} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The default layout's side panel: a slim rail at the right edge with a
|
||||
* button per plugin, and the open plugin's panel left of it. Nothing
|
||||
* without a plugin offering one.
|
||||
*/
|
||||
export function PluginSidePanel() {
|
||||
const t = useTranslations('sidebar');
|
||||
const { entries, open, choose, focusRequest } = usePluginSidePanel();
|
||||
if (entries.length === 0) return null;
|
||||
const active = entries.find((e) => e.pluginId === open) ?? null;
|
||||
const inRail = entries.filter((e) => e.inRail);
|
||||
return (
|
||||
<>
|
||||
{active && (
|
||||
<aside
|
||||
data-plugin-side-panel={active.pluginId}
|
||||
className="flex w-[300px] shrink-0 flex-col overflow-hidden border-s border-border bg-background"
|
||||
aria-label={active.title}
|
||||
>
|
||||
<PluginSidePanelBody entry={active} focusRequest={focusRequest} onClose={() => choose(null)} />
|
||||
</aside>
|
||||
)}
|
||||
{inRail.length > 0 && <nav
|
||||
data-plugin-side-rail=""
|
||||
className="flex w-12 shrink-0 flex-col items-center gap-1 border-s border-border bg-background pt-2"
|
||||
aria-label={t('mv_side_panel')}
|
||||
>
|
||||
{inRail.map((entry) => (
|
||||
<SidePanelButton
|
||||
key={entry.pluginId}
|
||||
entry={entry}
|
||||
pressed={open === entry.pluginId}
|
||||
onClick={() => choose(open === entry.pluginId ? null : entry.pluginId)}
|
||||
className="h-9 w-9 rounded-md hover:bg-muted"
|
||||
pressedClassName="bg-muted text-foreground"
|
||||
/>
|
||||
))}
|
||||
</nav>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** A plugin's button in a side panel's rail. */
|
||||
export function SidePanelButton({ entry, pressed, onClick, className, pressedClassName }: {
|
||||
entry: SidePanelEntry;
|
||||
pressed: boolean;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
pressedClassName?: string;
|
||||
}) {
|
||||
const { Icon } = entry;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
data-plugin-side-panel-button={entry.pluginId}
|
||||
className={cn('grid place-items-center text-muted-foreground transition-colors', className, pressed && pressedClassName)}
|
||||
title={entry.title}
|
||||
aria-label={entry.title}
|
||||
aria-pressed={pressed}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo, useState, type ReactNode } from 'react';
|
||||
import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers';
|
||||
import type { TaskViewFilter } from '@/stores/task-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PluginIframeSlot } from './plugin-iframe-slot';
|
||||
|
||||
// The `calendar-task-view` slot: a plugin drawing the calendar's task view
|
||||
// below its toolbar, in place of Bulwark's task list. One plugin at a time,
|
||||
// the first offering it (by its offer's order, then the plugins' order);
|
||||
// none, and the calendar shows its own list. So does a plugin whose
|
||||
// shouldShow says no or whose frame fails to start.
|
||||
|
||||
/** The plugin drawing the task view, or null when none offers it. */
|
||||
export function usePluginTaskView(): { pluginId: string; fill: boolean } | null {
|
||||
const offers = usePluginSlotOffers('calendar-task-view');
|
||||
const first = offers[0];
|
||||
return useMemo(() => (first ? { pluginId: first.pluginId, fill: !!first.fill } : null), [first]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin's frame in the area below the task toolbar: filled when the
|
||||
* plugin asks for it (it scrolls inside), else the area scrolls. Its props:
|
||||
* the toolbar's filter and the calendars ticked in the sidebar. `fallback`
|
||||
* (Bulwark's list) stands in while the frame is not drawn.
|
||||
*/
|
||||
export function PluginTaskView({ pluginId, fill, filter, calendarIds, fallback }: {
|
||||
pluginId: string;
|
||||
fill: boolean;
|
||||
filter: TaskViewFilter;
|
||||
calendarIds: readonly string[];
|
||||
fallback: ReactNode;
|
||||
}) {
|
||||
// One array per set of ids, so the frame gets no update while they stay.
|
||||
const idsKey = calendarIds.join('\u0000');
|
||||
const ids = useMemo(() => (idsKey ? idsKey.split('\u0000') : []), [idsKey]);
|
||||
const extraProps = useMemo(() => ({ filter, calendarIds: ids }), [filter, ids]);
|
||||
// The plugin whose frame is not drawn. The frame stays mounted (hidden),
|
||||
// so a shouldShow that says yes for another filter takes the view back.
|
||||
const [notShownBy, setNotShownBy] = useState<string | null>(null);
|
||||
const notShown = notShownBy === pluginId;
|
||||
const onShownChange = useCallback((shown: boolean) => setNotShownBy(shown ? null : pluginId), [pluginId]);
|
||||
return (
|
||||
<>
|
||||
{notShown && fallback}
|
||||
<div
|
||||
data-plugin-slot="calendar-task-view"
|
||||
data-plugin-task-view={pluginId}
|
||||
className={cn('min-h-0 flex-1', fill ? 'flex flex-col' : 'overflow-y-auto', notShown && 'hidden')}
|
||||
>
|
||||
<PluginIframeSlot key={pluginId} pluginId={pluginId} slot="calendar-task-view" extraProps={extraProps} fill={fill} onShownChange={onShownChange} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { cleanup, render } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// The Pro shell takes over the standard routes and hands their deep link to
|
||||
@@ -11,7 +11,9 @@ vi.mock('@/i18n/navigation', () => ({
|
||||
usePathname: () => nav.pathname,
|
||||
useRouter: () => ({ replace: nav.replace }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-media-query', () => ({ useIsDesktop: () => true }));
|
||||
const device = vi.hoisted(() => ({ desktop: true }));
|
||||
vi.mock('@/hooks/use-media-query', () => ({ useIsDesktop: () => device.desktop }));
|
||||
vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
|
||||
vi.mock('@/stores/settings-store', () => ({
|
||||
useSettingsStore: (selector: (s: { proInterface: boolean }) => unknown) => selector({ proInterface: true }),
|
||||
}));
|
||||
@@ -25,6 +27,7 @@ const { ProInterfaceRedirect } = await import('../pro-interface-redirect');
|
||||
describe('ProInterfaceRedirect', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
device.desktop = true;
|
||||
});
|
||||
|
||||
function visit(path: string, search: string) {
|
||||
@@ -56,4 +59,24 @@ describe('ProInterfaceRedirect', () => {
|
||||
expect(setPendingDeepLink).not.toHaveBeenCalled();
|
||||
expect(openTab).toHaveBeenLastCalledWith('files');
|
||||
});
|
||||
|
||||
it('covers the standard page with the Pro skeleton until /pro renders', () => {
|
||||
visit('/calendar/week/2026-11-04', '');
|
||||
const cover = document.querySelector('[data-pro-takeover]');
|
||||
expect(cover?.querySelector('[data-boot-skeleton="pro"]')).not.toBeNull();
|
||||
expect(cover?.className).toContain('fixed');
|
||||
});
|
||||
|
||||
it('draws nothing on /pro itself, below desktop width, or on other pages', () => {
|
||||
visit('/pro', '');
|
||||
expect(document.querySelector('[data-pro-takeover]')).toBeNull();
|
||||
cleanup();
|
||||
visit('/login', '');
|
||||
expect(document.querySelector('[data-pro-takeover]')).toBeNull();
|
||||
cleanup();
|
||||
device.desktop = false;
|
||||
visit('/mail', '');
|
||||
expect(document.querySelector('[data-pro-takeover]')).toBeNull();
|
||||
expect(nav.replace).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@ import { useIsDesktop } from "@/hooks/use-media-query";
|
||||
import { useProTabStore, type ProAppTabKind } from "@/stores/pro-tab-store";
|
||||
import { matchSurface, type AppSurface } from "@/lib/deep-links";
|
||||
import { setPendingDeepLink } from "@/lib/deep-link-handoff";
|
||||
import { ProSkeleton } from "@/components/boot-skeleton/pro-skeleton";
|
||||
import { useSkeletonLabels } from "@/components/boot-skeleton/use-skeleton-labels";
|
||||
|
||||
const SURFACE_TO_TAB: Record<AppSurface, ProAppTabKind> = {
|
||||
mail: 'mail',
|
||||
@@ -37,12 +39,21 @@ export function useProInterfaceActive(): boolean {
|
||||
* segments are handed to the tab that opens (#733), so `/mail/message/<id>`
|
||||
* opens that message in a Pro mail tab instead of silently landing on the
|
||||
* inbox.
|
||||
*
|
||||
* Until /pro has rendered, the Pro skeleton covers the standard page, which
|
||||
* may finish loading first (a fast session restore, a slow /pro chunk): the
|
||||
* visitor sees the Pro frame from the first paint to the loaded Pro shell,
|
||||
* never the standard page in between. The server HTML and the hydration pass
|
||||
* draw nothing here (isDesktop is false there); the page's own skeleton is
|
||||
* already the Pro frame then (components/boot-skeleton/shell.tsx).
|
||||
*/
|
||||
export function ProInterfaceRedirect() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const proInterface = useSettingsStore((s) => s.proInterface);
|
||||
const isDesktop = useIsDesktop();
|
||||
const { label } = useSkeletonLabels();
|
||||
const takingOver = proInterface && isDesktop && matchSurface(pathname) !== null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!proInterface || !isDesktop) return;
|
||||
@@ -63,5 +74,10 @@ export function ProInterfaceRedirect() {
|
||||
router.replace('/pro');
|
||||
}, [proInterface, isDesktop, pathname, router]);
|
||||
|
||||
return null;
|
||||
if (!takingOver) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 z-50" data-pro-takeover="">
|
||||
<ProSkeleton label={label} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/lib/protocol-handlers/session', () => ({
|
||||
// Still asking an open tab whether it takes the message.
|
||||
requestOpenMailtoInExistingClient: vi.fn(() => new Promise(() => {})),
|
||||
savePendingMailto: vi.fn(),
|
||||
}));
|
||||
|
||||
const { MailtoProtocolClient } = await import('../mailto-protocol-client');
|
||||
|
||||
afterEach(() => {
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
|
||||
describe('mailto handler page', () => {
|
||||
// It hands over to the mail page, where the composer opens: it draws that
|
||||
// page's skeleton instead of a line of text on a blank page.
|
||||
it('draws the mail skeleton with the opening text as its status', () => {
|
||||
window.history.replaceState(null, '', '/protocol/mailto?url=mailto%3Aa%40example.com');
|
||||
const { container } = render(<MailtoProtocolClient openingText="Opening email…" />);
|
||||
const region = container.querySelector('[data-boot-skeleton="mail"]');
|
||||
expect(region).not.toBeNull();
|
||||
expect(region!.querySelector('[role="status"]')?.textContent).toBe('Opening email…');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { MailSkeleton } from "@/components/boot-skeleton/mail-skeleton";
|
||||
import type { RailLabels } from "@/components/boot-skeleton/shell";
|
||||
import { parseMailto } from "@/lib/protocol-handlers/mailto";
|
||||
import { requestOpenMailtoInExistingClient, savePendingMailto } from "@/lib/protocol-handlers/session";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
@@ -51,9 +53,11 @@ async function focusExistingClient() {
|
||||
|
||||
interface MailtoProtocolClientProps {
|
||||
openingText: string;
|
||||
/** The phone bottom bar's labels for the skeleton. */
|
||||
labels?: RailLabels;
|
||||
}
|
||||
|
||||
export function MailtoProtocolClient({ openingText }: MailtoProtocolClientProps) {
|
||||
export function MailtoProtocolClient({ openingText, labels }: MailtoProtocolClientProps) {
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
@@ -99,9 +103,7 @@ export function MailtoProtocolClient({ openingText }: MailtoProtocolClientProps)
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center">
|
||||
<p>{openingText}</p>
|
||||
</main>
|
||||
);
|
||||
// The mail page this hands over to, where the composer opens: its skeleton,
|
||||
// with the opening text as the status message.
|
||||
return <MailSkeleton label={openingText} labels={labels} />;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useEffect } from "react";
|
||||
import { parseWebcal } from "@/lib/protocol-handlers/webcal";
|
||||
import { savePendingWebcal } from "@/lib/protocol-handlers/session";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { CalendarSkeleton, type CalendarSkeletonText } from "@/components/boot-skeleton/calendar-skeleton";
|
||||
import type { RailLabels } from "@/components/boot-skeleton/shell";
|
||||
|
||||
type StandaloneNavigator = Navigator & { standalone?: boolean };
|
||||
|
||||
@@ -39,9 +41,13 @@ function shouldOpenFallbackAppTab(): boolean {
|
||||
|
||||
interface WebcalProtocolClientProps {
|
||||
openingText: string;
|
||||
/** Rail labels for the phone bottom bar (sidebar.*). */
|
||||
labels?: RailLabels;
|
||||
/** The calendar skeleton's words (calendar.*). */
|
||||
text?: CalendarSkeletonText;
|
||||
}
|
||||
|
||||
export function WebcalProtocolClient({ openingText }: WebcalProtocolClientProps) {
|
||||
export function WebcalProtocolClient({ openingText, labels, text }: WebcalProtocolClientProps) {
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const raw = params.get("url");
|
||||
@@ -65,9 +71,7 @@ export function WebcalProtocolClient({ openingText }: WebcalProtocolClientProps)
|
||||
window.location.replace(`${getProtocolPathPrefix()}/calendar`);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center">
|
||||
<p>{openingText}</p>
|
||||
</main>
|
||||
);
|
||||
// The calendar is where this goes next: its skeleton, with "Opening…" as
|
||||
// the page's status.
|
||||
return <CalendarSkeleton label={openingText} labels={labels} text={text} />;
|
||||
}
|
||||
|
||||
@@ -5,10 +5,16 @@ import { useTranslations } from 'next-intl';
|
||||
import { IntlProvider } from '../intl-provider';
|
||||
import { useLocaleStore } from '@/stores/locale-store';
|
||||
|
||||
const nav = vi.hoisted(() => ({ pathname: null as string | null }));
|
||||
vi.mock('next/navigation', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('next/navigation')>()),
|
||||
usePathname: () => nav.pathname,
|
||||
}));
|
||||
|
||||
// The Lite export leaves the catalog out of the [locale] layout, because the
|
||||
// prop was baked into every exported page and pushed the Stalwart zip past its
|
||||
// size limit. The provider then loads the catalog itself and must render
|
||||
// nothing until it has it: English text would flash on a German page, and the
|
||||
// no text until it has it (only the page's skeleton, which has none): English text would flash on a German page, and the
|
||||
// prerendered HTML has to match the client's first render.
|
||||
|
||||
vi.unmock('next-intl');
|
||||
@@ -20,12 +26,22 @@ function Cancel() {
|
||||
describe('IntlProvider without server messages (Lite)', () => {
|
||||
afterEach(() => {
|
||||
useLocaleStore.setState({ locale: '' });
|
||||
nav.pathname = null;
|
||||
});
|
||||
|
||||
it('prerenders nothing for a non-English locale', () => {
|
||||
expect(renderToString(<IntlProvider locale="de"><Cancel /></IntlProvider>)).toBe('');
|
||||
});
|
||||
|
||||
it('prerenders the page skeleton, without any text, while the catalog is missing', () => {
|
||||
nav.pathname = '/de/calendar';
|
||||
const html = renderToString(<IntlProvider locale="de"><Cancel /></IntlProvider>);
|
||||
expect(html).toContain('data-boot-skeleton="calendar"');
|
||||
expect(html).toContain('aria-busy="true"');
|
||||
expect(html).not.toContain('Cancel');
|
||||
expect(html).not.toContain('role="status"');
|
||||
});
|
||||
|
||||
it('prerenders English straight away from the bundled catalog', () => {
|
||||
expect(renderToString(<IntlProvider locale="en"><Cancel /></IntlProvider>)).toContain('Cancel');
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user