feat: show the settings page's shape while the app loads

This commit is contained in:
Linus Rath
2026-11-01 19:24:13 +01:00
parent 8130074680
commit 5166cd0a5a
10 changed files with 645 additions and 53 deletions
+38
View File
@@ -91,3 +91,41 @@
animation: none;
}
}
/* 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"] *));
@@ -0,0 +1,81 @@
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();
}
function mount(html: string) {
document.body.innerHTML = html;
return 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('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();
});
});
+281 -7
View File
@@ -1,3 +1,8 @@
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 {
@@ -8,18 +13,287 @@ export interface SettingsSkeletonProps {
}
/**
* 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).
* 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.
*/
export function SettingsSkeletonBody({ embedded = false }: { embedded?: boolean }) {
return <div className="flex-1 min-w-0" data-embedded={embedded ? "" : undefined} />;
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>
);
}
/** Skeleton of the settings page. Stub: filled in per surface. */
/** 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 (
<>
<div className="flex flex-wrap gap-1.5 mb-6">
{repeat(4, (i) => (
<Block className="h-8" w={`${4.5 + ((i * 3) % 4)}rem`} />
))}
</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">
<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,104 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Until the session is up, Settings draws its page skeleton (the one the
// server drew before any script ran) instead of nothing, and the nav waits
// for the client so it renders once with the account's pages.
vi.mock('next-intl', () => ({
useTranslations: (ns?: string) => (key: string) => (ns ? `${ns}.${key}` : key),
useMessages: () => ({}),
useLocale: () => 'en',
useFormatter: () => ({ dateTime: String, relativeTime: String, number: String }),
}));
vi.mock('@/hooks/use-media-query', async (importOriginal) => ({
atLeast: (await importOriginal<typeof import('@/hooks/use-media-query')>()).atLeast,
useIsDesktop: () => true,
useIsMobile: () => false,
useMediaQuery: () => false,
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
}));
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail', stalwartFeaturesEnabled: true }) }));
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null }));
vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null }));
vi.mock('@/components/settings/appearance-settings', () => ({ AppearanceSettings: () => <div>appearance pane</div> }));
const { SettingsApp } = await import('../settings-app');
const { useAuthStore } = await import('@/stores/auth-store');
const { usePluginStore } = await import('@/stores/plugin-store');
const { useThemeStore } = await import('@/stores/theme-store');
const { SETTINGS_NAV_GROUPS } = await import('../settings-tab-icons');
const { BOOT_HINTS_KEY } = await import('@/lib/boot-skeleton/keys');
const { resetRailHintsMemo } = await import('@/lib/boot-skeleton/hints');
/** A client that supports everything it is asked about. */
const fullClient = new Proxy({}, {
get: (_t, prop) => {
if (prop === 'then') return undefined;
if (prop === 'getAccountId') return () => 'acc';
if (typeof prop === 'string' && prop.startsWith('supports')) return () => true;
if (prop === 'getShareTargets') return () => Promise.resolve([]);
return () => null;
},
});
/** A client with none of the optional capabilities. */
const bareClient = new Proxy({}, {
get: (_t, prop) => (prop === 'then' ? undefined : prop === 'getAccountId' ? () => 'acc' : () => null),
});
const skeleton = () => document.querySelector('[data-boot-skeleton="settings"]');
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
localStorage.clear();
resetRailHintsMemo();
localStorage.setItem('settings-active-tab', 'appearance');
usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) } as never);
// The restore stays pending: the page is still waiting for the session.
useAuthStore.setState({ checkAuth: vi.fn(() => new Promise<void>(() => {})) } as never);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('Settings while the session restores', () => {
it('draws the settings skeleton when signed out', () => {
useAuthStore.setState({ isAuthenticated: false, isLoading: true, client: null });
render(<SettingsApp linkSegments={[]} />);
expect(skeleton()).not.toBeNull();
expect(skeleton()!.getAttribute('aria-busy')).toBe('true');
expect(skeleton()!.querySelector('[role="status"]')!.textContent).toBe('common.loading');
});
it('keeps the skeleton and no nav while the persisted sign-in waits for its client', () => {
useAuthStore.setState({ isAuthenticated: true, isLoading: true, client: null });
render(<SettingsApp linkSegments={[]} />);
expect(skeleton()).not.toBeNull();
expect(screen.queryByTestId('settings-tab-appearance')).toBeNull();
expect(screen.queryByText('appearance pane')).toBeNull();
});
it('renders the nav once the client is up, in the order the skeleton draws it', () => {
useAuthStore.setState({ isAuthenticated: true, isLoading: false, client: fullClient as never });
render(<SettingsApp linkSegments={[]} />);
expect(skeleton()).toBeNull();
const shown = [...document.querySelectorAll('[data-testid^="settings-tab-"]')]
.map((el) => el.getAttribute('data-testid')!.slice('settings-tab-'.length));
// Recovery waits for a server probe this test doesn't answer.
const drawn = SETTINGS_NAV_GROUPS.flatMap((g) => g.tabs).filter((tab) => tab !== 'recovery');
expect(shown).toEqual(drawn);
});
it('leaves the next load the pages this account has', () => {
useAuthStore.setState({ isAuthenticated: true, isLoading: false, client: bareClient as never });
render(<SettingsApp linkSegments={[]} />);
const hints = JSON.parse(localStorage.getItem(BOOT_HINTS_KEY) ?? 'null');
expect(hints?.settings).toContain('appearance');
expect(hints?.settings).not.toContain('filters');
expect(hints?.settings).not.toContain('vacation');
});
});
+26 -41
View File
@@ -6,30 +6,17 @@ import { useState, useEffect, useCallback, useRef, useMemo, useSyncExternalStore
import { useRouter } from '@/i18n/navigation';
import { useTranslations, useMessages } from 'next-intl';
import {
Accessibility,
ArrowLeft,
ChevronRight,
LogOut,
Settings as SettingsIcon,
Palette,
User,
Shield,
PalmtreeIcon,
Calendar,
Filter,
FolderOpen,
HardDrive,
BookUser,
Bell,
Puzzle,
BookOpen,
PenLine,
LayoutList,
Database,
Users,
RotateCcw,
type AppIcon,
} from '@/components/icons';
import { SETTINGS_TAB_ICONS, type SettingsTabGroup } from '@/components/settings/settings-tab-icons';
import { SettingsSkeleton, SettingsSkeletonBody } from '@/components/boot-skeleton/settings-skeleton';
import { useSkeletonLabels } from '@/components/boot-skeleton/use-skeleton-labels';
import { writeSettingsHints } from '@/lib/boot-skeleton/hints';
import { useRegisterCommands } from '@/lib/command-registry';
import { Button } from '@/components/ui/button';
import { IconButton } from '@/components/ui/icon-button';
@@ -110,7 +97,7 @@ type Tab = SettingsSearchTab;
// `top` and `bottom` have no heading: the first five pages, and Backup & reset
// after a rule at the end.
type TabGroup = 'top' | 'mail' | 'apps' | 'bottom';
type TabGroup = SettingsTabGroup;
// A plugin that exposes a `settings-section` slot gets its own first-class
// Settings entry, keyed `plugin:<id>`, so its UI (e.g. S/MIME key import) is
@@ -131,27 +118,8 @@ interface TabDef {
sub?: boolean;
}
const tabIcons: Record<Tab, AppIcon> = {
account: User,
general: SettingsIcon,
notifications: Bell,
appearance: Palette,
accessibility: Accessibility,
message_list: LayoutList,
reading: BookOpen,
composing: PenLine,
vacation: PalmtreeIcon,
filters: Filter,
organize: FolderOpen,
security: Shield,
sharing: Users,
recovery: RotateCcw,
calendar: Calendar,
contacts: BookUser,
files: HardDrive,
addons: Puzzle,
backup: Database,
};
// Shared with the boot skeleton, which draws the same nav before any script runs.
const tabIcons = SETTINGS_TAB_ICONS;
const tabGroupOrder: TabGroup[] = ['top', 'mail', 'apps', 'bottom'];
@@ -265,6 +233,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
const tShell = useTranslations('shell');
const tDelegates = useTranslations('delegates');
const tPalette = useTranslations('command_palette');
const skeletonLabels = useSkeletonLabels();
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const isEmbedded = useIsEmbedded();
@@ -825,8 +794,24 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
isAuthenticated,
);
if (!isAuthenticated) {
return null;
// Which pages this account has, for the next load's skeleton nav
// (lib/boot-skeleton/hints.ts). Only the account's own list, once its
// capabilities are known; plugin entries are left out.
const hintedTabs = client && !managedAccountId
? visibleTabs.filter((tab) => !tab.id.includes(':')).map((tab) => tab.id).join(' ')
: null;
useEffect(() => {
if (hintedTabs) writeSettingsHints(hintedTabs.split(' '));
}, [hintedTabs]);
// Until the session is up, the page's skeleton (the same one the server
// drew). Waiting for the client, not only the persisted sign-in, means the
// nav renders once with the account's pages instead of growing as the
// capabilities arrive.
if (!isAuthenticated || !client) {
return isEmbedded
? <SettingsSkeletonBody embedded />
: <SettingsSkeleton label={skeletonLabels.label} labels={skeletonLabels.labels} />;
}
const renderTabContent = () => (
+65
View File
@@ -0,0 +1,65 @@
import {
Accessibility,
Settings as SettingsIcon,
Palette,
User,
Shield,
PalmtreeIcon,
Calendar,
Filter,
FolderOpen,
HardDrive,
BookUser,
Bell,
Puzzle,
BookOpen,
PenLine,
LayoutList,
Database,
Users,
RotateCcw,
type AppIcon,
} from '@/components/icons';
import type { SettingsSearchTab } from '@/lib/settings-search';
/**
* The icon of each settings page in the nav. Shared by the settings page and
* its boot skeleton (components/boot-skeleton/settings-skeleton.tsx), which
* draws the same icons before any script runs.
*/
export const SETTINGS_TAB_ICONS: Record<SettingsSearchTab, AppIcon> = {
account: User,
general: SettingsIcon,
notifications: Bell,
appearance: Palette,
accessibility: Accessibility,
message_list: LayoutList,
reading: BookOpen,
composing: PenLine,
vacation: PalmtreeIcon,
filters: Filter,
organize: FolderOpen,
security: Shield,
sharing: Users,
recovery: RotateCcw,
calendar: Calendar,
contacts: BookUser,
files: HardDrive,
addons: Puzzle,
backup: Database,
};
export type SettingsTabGroup = 'top' | 'mail' | 'apps' | 'bottom';
/**
* The settings nav in order, by group, with every page a server can offer.
* The settings page leaves out what the account or the admin doesn't have
* (see the `tabs` list in settings-app.tsx); the skeleton hides those rows
* from the last session's hints.
*/
export const SETTINGS_NAV_GROUPS: readonly { group: SettingsTabGroup; tabs: readonly SettingsSearchTab[] }[] = [
{ group: 'top', tabs: ['account', 'security', 'sharing', 'notifications', 'general', 'appearance', 'accessibility'] },
{ group: 'mail', tabs: ['message_list', 'reading', 'composing', 'organize', 'filters', 'vacation', 'recovery'] },
{ group: 'apps', tabs: ['calendar', 'contacts', 'files', 'addons'] },
{ group: 'bottom', tabs: ['backup'] },
];
@@ -406,6 +406,17 @@ describe('boot script: rail hints', () => {
boot('/en/mail');
expect(attr('rail')).toBe('mail calendar quota');
});
it('passes the settings pages the nav listed on, known ids only', () => {
localStorage.setItem('bulwark-boot-hints', JSON.stringify({ v: 1, rail: ['mail'], settings: ['account', 'filters', 'recovery', 'plugin:x', 'nope', 7] }));
boot('/en/settings');
expect(attr('settings-tabs')).toBe('account filters recovery');
});
it('leaves the settings pages out without hints', () => {
boot('/en/settings');
expect(attr('settings-tabs')).toBeNull();
});
});
describe('boot script: Lite', () => {
+9 -1
View File
@@ -3,7 +3,7 @@ import { TARGET_NAMES, getTarget } from '@/lib/connector/registry';
import { locales } from '@/i18n/routing';
import { CONNECTOR_SURFACES, surfaceFromPath } from '../surfaces';
import { BOOT_HINTS_KEY } from '../keys';
import { resetRailHintsMemo, writeRailHints } from '../hints';
import { resetRailHintsMemo, writeRailHints, writeSettingsHints } from '../hints';
describe('connector targets', () => {
it('every target the connector can resolve has a skeleton surface', () => {
@@ -75,4 +75,12 @@ describe('rail hints', () => {
writeRailHints(['files', 'admin']);
expect(JSON.parse(localStorage.getItem(BOOT_HINTS_KEY)!).rail).toEqual(['mail', 'files', 'admin']);
});
it('keeps the rail and the settings pages side by side', () => {
writeRailHints(['calendar']);
writeSettingsHints(['account', 'filters']);
expect(JSON.parse(localStorage.getItem(BOOT_HINTS_KEY)!)).toEqual({ v: 1, rail: ['mail', 'calendar'], settings: ['account', 'filters'] });
writeRailHints(['files']);
expect(JSON.parse(localStorage.getItem(BOOT_HINTS_KEY)!)).toEqual({ v: 1, rail: ['mail', 'files'], settings: ['account', 'filters'] });
});
});
+6
View File
@@ -253,6 +253,12 @@ export function applyBootLayout(root: HTMLElement, c: BootLayoutConfig): void {
? (hints.rail as unknown[]).filter(function (r) { return typeof r === 'string' && /^[a-z-]+$/.test(r); })
: null;
mark('rail', rail && rail.length ? rail.join(' ') : null);
// The settings pages the nav listed: the skeleton hides the optional ones
// that weren't there. Pages only, from the known list.
var settingsTabs = hints && Array.isArray(hints.settings)
? (hints.settings as unknown[]).filter(function (t) { return has(c.tabs, t); })
: null;
mark('settings-tabs', settingsTabs && settingsTabs.length ? settingsTabs.join(' ') : null);
// --- Probably signed out? --------------------------------------------
// No redirect here: a cookie-only session (impersonation, a server hand-off)
+24 -4
View File
@@ -10,18 +10,38 @@ export type RailHint = 'calendar' | 'contacts' | 'files' | 'add-app' | 'admin' |
let lastWritten: string | null = null;
export function writeRailHints(items: RailHint[]): void {
const rail = ['mail', ...items];
const value = JSON.stringify({ v: 1, rail });
/** Replaces one part of the stored hints and keeps the others. */
function writeHints(patch: { rail?: string[]; settings?: string[] }): void {
let stored: Record<string, unknown> = {};
try {
const parsed = JSON.parse(localStorage.getItem(BOOT_HINTS_KEY) || 'null');
if (parsed && typeof parsed === 'object' && parsed.v === 1) stored = parsed;
} catch {
/* corrupt or blocked: start over */
}
const value = JSON.stringify({ ...stored, ...patch, v: 1 });
if (value === lastWritten) return;
lastWritten = value;
try {
localStorage.setItem(BOOT_HINTS_KEY, value);
} catch {
/* storage full or blocked: the next skeleton draws every app */
/* storage full or blocked: the next skeleton draws its defaults */
}
}
export function writeRailHints(items: RailHint[]): void {
writeHints({ rail: ['mail', ...items] });
}
/**
* The pages the settings nav listed for this account, which depend on its
* capabilities and the admin's policy. The boot script turns it into
* `data-boot-settings-tabs`.
*/
export function writeSettingsHints(tabs: string[]): void {
writeHints({ settings: tabs });
}
/** For tests. */
export function resetRailHintsMemo(): void {
lastWritten = null;