Merge branch 'slots/nav' into feat/plugin-system-audit

# Conflicts:
#	lib/__tests__/plugin-slot-catalog.test.ts
This commit is contained in:
Linus Rath
2026-10-14 05:30:34 +02:00
59 changed files with 3292 additions and 13 deletions
@@ -0,0 +1,26 @@
import { PluginPageApp } from "@/components/plugins/plugin-page-app";
import { generateLiteSegmentParams } from "@/lib/lite-static-params";
// Pages plugins add with api.ui.setPages: /apps/<pluginId>/<pageId>[/<path>].
// Anything after the page id reaches the plugin's `app-page` slot as `path`.
// A catch-all rather than [pluginId]/[pageId] so the static Lite export can
// prerender the bare route; plugins never run in Lite, so it only ever shows
// the "not available" note there (lib/lite-static-params.ts).
export const generateStaticParams = generateLiteSegmentParams;
function decodeOnce(segment: string): string {
try {
return decodeURIComponent(segment);
} catch {
return segment;
}
}
export default async function PluginPageRoute({
params,
}: {
params: Promise<{ segments?: string[] }>;
}) {
const { segments } = await params;
return <PluginPageApp linkSegments={(segments ?? []).map(decodeOnce)} />;
}
+2
View File
@@ -9,6 +9,7 @@ import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-la
import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
import { ImpersonationReconciler } from "@/components/impersonation/impersonation-reconciler";
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
import { CommandPaletteHost } from "@/components/command-palette/command-palette";
import { PluginConnectDialog } from "@/components/plugins/plugin-connect-dialog";
import { QuickRuleHost } from "@/components/filters/quick-rule-host";
import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
@@ -58,6 +59,7 @@ export default async function LocaleLayout({
<ImpersonationReconciler />
{children}
<PluginDialogHost />
<CommandPaletteHost />
<PluginConnectDialog />
<QuickRuleHost />
<PluginConsentDialog />
@@ -0,0 +1,24 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
const pro = vi.hoisted(() => ({ active: false }));
vi.mock('@/components/pro/pro-interface-redirect', () => ({ useProInterfaceActive: () => pro.active }));
vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ startTour: vi.fn() }) }));
const { KeyboardShortcutsModal } = await import('../keyboard-shortcuts-modal');
describe('keyboard shortcuts help', () => {
it('lists Ctrl+K as the command palette outside the Pro shell', () => {
pro.active = false;
render(<KeyboardShortcutsModal isOpen onClose={() => {}} />);
expect(screen.getByText('command_palette.open')).toBeInTheDocument();
expect(screen.queryByText('shortcuts.global.global_search')).toBeNull();
});
it('lists Ctrl+K as global search in the Pro shell', () => {
pro.active = true;
render(<KeyboardShortcutsModal isOpen onClose={() => {}} />);
expect(screen.getByText('shortcuts.global.global_search')).toBeInTheDocument();
expect(screen.queryByText('command_palette.open')).toBeNull();
});
});
@@ -0,0 +1,174 @@
import React from 'react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
const nav = vi.hoisted(() => ({ pathname: '/', push: vi.fn() }));
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, params?: Record<string, string>) =>
params ? `${key}(${Object.values(params).join(',')})` : key,
useLocale: () => 'en',
}));
vi.mock('@/i18n/navigation', () => ({
useRouter: () => ({ push: nav.push, back: vi.fn(), replace: vi.fn() }),
usePathname: () => nav.pathname,
Link: ({ children }: { children: React.ReactNode }) => children,
}));
vi.mock('@/components/keyboard-shortcuts-modal', () => ({
KeyboardShortcutsModal: ({ isOpen }: { isOpen: boolean }) => (isOpen ? <div>shortcuts help</div> : null),
}));
const { CommandPaletteHost } = await import('../command-palette');
const { useAuthStore } = await import('@/stores/auth-store');
const { useThemeStore } = await import('@/stores/theme-store');
const appCommands = await import('@/lib/app-commands');
const dispatch = vi.fn().mockResolvedValue(undefined);
const pressCtrlK = (target: Element = document.body) =>
act(() => { fireEvent.keyDown(target, { key: 'k', code: 'KeyK', ctrlKey: true }); });
beforeEach(() => {
nav.pathname = '/';
nav.push.mockClear();
resetActions();
dispatch.mockClear();
setActionDispatcher(dispatch);
useAuthStore.setState({ isAuthenticated: true, client: null } as never);
});
afterEach(() => {
appCommands.parkAppCommand(null as never);
});
describe('command palette', () => {
it('opens on Ctrl+K with the search field focused, and closes on Escape', () => {
render(<CommandPaletteHost />);
expect(screen.queryByRole('dialog')).toBeNull();
pressCtrlK();
expect(screen.getByRole('dialog', { name: 'title' })).toBeInTheDocument();
const input = screen.getByRole('combobox');
expect(input).toHaveFocus();
expect(input).toHaveAttribute('aria-controls', screen.getByRole('listbox').id);
act(() => { fireEvent.keyDown(input, { key: 'Escape' }); });
expect(screen.queryByRole('dialog')).toBeNull();
});
it('opens from a menu entry (phones)', () => {
render(<CommandPaletteHost />);
act(() => appCommands.openCommandPalette());
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
it('leaves Ctrl+K alone while typing in a field', () => {
render(<><input aria-label="other field" /><CommandPaletteHost /></>);
pressCtrlK(screen.getByLabelText('other field'));
expect(screen.queryByRole('dialog')).toBeNull();
});
it('leaves Ctrl+K to global search in the Pro shell, and does nothing signed out', () => {
nav.pathname = '/pro';
const { unmount } = render(<CommandPaletteHost />);
pressCtrlK();
expect(screen.queryByRole('dialog')).toBeNull();
unmount();
nav.pathname = '/';
useAuthStore.setState({ isAuthenticated: false } as never);
render(<CommandPaletteHost />);
pressCtrlK();
expect(screen.queryByRole('dialog')).toBeNull();
});
it('lists the app commands and no plugin group without plugin entries', () => {
render(<CommandPaletteHost />);
pressCtrlK();
const labels = screen.getAllByRole('option').map((o) => o.textContent);
expect(labels).toContain('go_to(mail)');
expect(labels).toContain('go_to(settings)');
expect(labels).toContain('compose');
expect(labels).toContain('shortcuts');
expect(screen.queryByText('group_plugins')).toBeNull();
});
it('shows plugin entries with the plugin name and runs them with Enter', async () => {
act(() => setPluginActions('jira', 'Jira', 'command-palette', [{ id: 'new-issue', label: 'Create issue' }]));
render(<CommandPaletteHost />);
pressCtrlK();
expect(screen.getByText('group_plugins')).toBeInTheDocument();
const option = screen.getByRole('option', { name: /Create issue/ });
expect(option).toHaveTextContent('Jira');
const input = screen.getByRole('combobox');
act(() => { fireEvent.change(input, { target: { value: 'issue' } }); });
expect(screen.getAllByRole('option')).toHaveLength(1);
expect(screen.getByRole('option')).toHaveAttribute('aria-selected', 'true');
expect(input).toHaveAttribute('aria-activedescendant', screen.getByRole('option').id);
await act(async () => { fireEvent.keyDown(input, { key: 'Enter' }); });
expect(dispatch).toHaveBeenCalledWith('jira', 'command-palette', 'new-issue', {});
expect(screen.queryByRole('dialog')).toBeNull();
});
it('finds plugin entries by plugin name too', () => {
act(() => setPluginActions('jira', 'Jira', 'command-palette', [{ id: 'new-issue', label: 'Create issue' }]));
render(<CommandPaletteHost />);
pressCtrlK();
act(() => { fireEvent.change(screen.getByRole('combobox'), { target: { value: 'jir' } }); });
expect(screen.getAllByRole('option').map((o) => o.getAttribute('data-command-id'))).toEqual(['plugin:jira:new-issue']);
});
it('moves with the arrow keys and wraps around', () => {
render(<CommandPaletteHost />);
pressCtrlK();
const input = screen.getByRole('combobox');
const options = () => screen.getAllByRole('option');
expect(options()[0]).toHaveAttribute('aria-selected', 'true');
act(() => { fireEvent.keyDown(input, { key: 'ArrowDown' }); });
expect(options()[1]).toHaveAttribute('aria-selected', 'true');
act(() => { fireEvent.keyDown(input, { key: 'ArrowUp' }); });
act(() => { fireEvent.keyDown(input, { key: 'ArrowUp' }); });
expect(options()[options().length - 1]).toHaveAttribute('aria-selected', 'true');
});
it('navigates with a go-to command', () => {
render(<CommandPaletteHost />);
pressCtrlK();
act(() => { fireEvent.click(screen.getByRole('option', { name: 'go_to(settings)' })); });
expect(nav.push).toHaveBeenCalledWith('/settings');
});
it('hands compose to the mail page, or opens mail first and parks it', () => {
const taken = vi.fn((e: Event) => e.preventDefault());
window.addEventListener('bulwark:app-command', taken);
render(<CommandPaletteHost />);
pressCtrlK();
act(() => { fireEvent.click(screen.getByRole('option', { name: 'compose' })); });
expect(taken).toHaveBeenCalled();
expect(nav.push).not.toHaveBeenCalled();
window.removeEventListener('bulwark:app-command', taken);
pressCtrlK();
act(() => { fireEvent.click(screen.getByRole('option', { name: 'compose' })); });
expect(nav.push).toHaveBeenCalledWith('/');
expect(appCommands.peekParkedAppCommand()).toBe('compose');
});
it('switches the theme and shows the keyboard shortcuts', () => {
useThemeStore.setState({ resolvedTheme: 'light' } as never);
const setTheme = vi.fn();
useThemeStore.setState({ setTheme } as never);
render(<CommandPaletteHost />);
pressCtrlK();
act(() => { fireEvent.click(screen.getByRole('option', { name: 'theme_dark' })); });
expect(setTheme).toHaveBeenCalledWith('dark');
pressCtrlK();
act(() => { fireEvent.click(screen.getByRole('option', { name: 'shortcuts' })); });
expect(screen.getByText('shortcuts help')).toBeInTheDocument();
});
});
@@ -0,0 +1,288 @@
"use client";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import {
BookUser,
Calendar,
HardDrive,
Keyboard,
Mail,
Moon,
PenLine,
Puzzle,
Search,
Settings,
Sun,
iconForName,
} from "@/components/icons";
import type { AppIcon } from "@/components/icons";
import { usePathname, useRouter } from "@/i18n/navigation";
import { useAuthStore } from "@/stores/auth-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { usePolicyStore } from "@/stores/policy-store";
import { useThemeStore } from "@/stores/theme-store";
import { usePluginActions } from "@/components/plugins/plugin-actions";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { runAction } from "@/lib/plugin-sandbox/actions";
import { COMMAND_PALETTE_OPEN_EVENT, parkAppCommand, requestAppCommand, type AppCommand } from "@/lib/app-commands";
import { isEditableEventTarget } from "@/lib/keyboard";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { cn } from "@/lib/utils";
interface PaletteCommand {
id: string;
label: string;
icon: AppIcon;
/** Shown after the label: the plugin that added the entry. */
source?: string;
group: "navigation" | "actions" | "plugins";
run: () => void;
}
/** The action surface plugins declare palette entries on (api.ui.setActions). */
const surface = "command-palette";
/** Surfaces where the palette does not take Ctrl+K: the Pro shell uses it for global search. */
const isProShell = (pathname: string) => pathname === "/pro" || pathname.startsWith("/pro/");
/**
* The command palette (Ctrl+K / ⌘K): a searchable list of the app's own
* commands and of the entries plugins declare for `command-palette`.
* Mounted once in the locale layout; it only listens while signed in.
*/
export function CommandPaletteHost() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const pathname = usePathname();
const [open, setOpen] = useState(false);
const [showShortcuts, setShowShortcuts] = useState(false);
const enabled = isAuthenticated && !isProShell(pathname);
useEffect(() => {
if (!enabled) return;
const onKeyDown = (event: KeyboardEvent) => {
if (!(event.ctrlKey || event.metaKey) || event.altKey || event.shiftKey) return;
// event.code: the K key on any keyboard layout, Latin or not.
if (event.code !== "KeyK" && event.key.toLowerCase() !== "k") return;
if (isEditableEventTarget(event)) return;
event.preventDefault();
setOpen((v) => !v);
};
const onOpen = () => setOpen(true);
window.addEventListener("keydown", onKeyDown);
window.addEventListener(COMMAND_PALETTE_OPEN_EVENT, onOpen);
return () => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener(COMMAND_PALETTE_OPEN_EVENT, onOpen);
};
}, [enabled]);
useEffect(() => {
if (!enabled) setOpen(false);
}, [enabled]);
return (
<>
{open && <CommandPalette onClose={() => setOpen(false)} onShowShortcuts={() => setShowShortcuts(true)} />}
<KeyboardShortcutsModal isOpen={showShortcuts} onClose={() => setShowShortcuts(false)} />
</>
);
}
function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onShowShortcuts: () => void }) {
const t = useTranslations("command_palette");
const tSidebar = useTranslations("sidebar");
const router = useRouter();
const pathname = usePathname();
const listId = useId();
const optionId = (index: number) => `${listId}-option-${index}`;
const [query, setQuery] = useState("");
const [active, setActive] = useState(0);
const listRef = useRef<HTMLUListElement>(null);
const client = useAuthStore((s) => s.client);
const supportsCalendar = useCalendarStore((s) => s.supportsCalendar);
const isFeatureEnabled = usePolicyStore((s) => s.isFeatureEnabled);
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const pluginActions = usePluginActions(surface);
const dialogRef = useFocusTrap({ isActive: true, onEscape: onClose, restoreFocus: true });
const commands = useMemo<PaletteCommand[]>(() => {
const go = (href: string) => () => router.push(href);
// Compose and search belong to the mail page: run there, or open mail first.
const onMail = (command: AppCommand) => () => {
if (requestAppCommand(command)) return;
parkAppCommand(command);
router.push("/");
};
const list: PaletteCommand[] = [
{ id: "go-mail", group: "navigation", icon: Mail, label: t("go_to", { name: tSidebar("mail") }), run: go("/") },
];
if (supportsCalendar && isFeatureEnabled("calendarEnabled")) {
list.push({ id: "go-calendar", group: "navigation", icon: Calendar, label: t("go_to", { name: tSidebar("calendar") }), run: go("/calendar") });
}
if ((client?.supportsContacts?.() ?? false) && isFeatureEnabled("contactsEnabled")) {
list.push({ id: "go-contacts", group: "navigation", icon: BookUser, label: t("go_to", { name: tSidebar("contacts") }), run: go("/contacts") });
}
if ((client?.supportsFiles?.() ?? false) && isFeatureEnabled("filesEnabled")) {
list.push({ id: "go-files", group: "navigation", icon: HardDrive, label: t("go_to", { name: tSidebar("files") }), run: go("/files") });
}
list.push(
{ id: "go-settings", group: "navigation", icon: Settings, label: t("go_to", { name: tSidebar("settings") }), run: go("/settings") },
{ id: "compose", group: "actions", icon: PenLine, label: t("compose"), run: onMail("compose") },
{ id: "search", group: "actions", icon: Search, label: t("search"), run: onMail("search") },
{
id: "theme",
group: "actions",
icon: resolvedTheme === "dark" ? Sun : Moon,
label: resolvedTheme === "dark" ? t("theme_light") : t("theme_dark"),
run: () => useThemeStore.getState().setTheme(resolvedTheme === "dark" ? "light" : "dark"),
},
{ id: "shortcuts", group: "actions", icon: Keyboard, label: t("shortcuts"), run: onShowShortcuts },
);
for (const action of pluginActions) {
list.push({
id: `plugin:${action.pluginId}:${action.id}`,
group: "plugins",
icon: iconForName(action.icon) ?? Puzzle,
label: action.label,
source: action.pluginName,
run: () => { void runAction(action, surface, {}); },
});
}
return list;
}, [client, supportsCalendar, isFeatureEnabled, resolvedTheme, pluginActions, router, t, tSidebar, onShowShortcuts]);
const needle = query.trim().toLowerCase();
const visible = needle
? commands.filter((c) => c.label.toLowerCase().includes(needle) || (c.source?.toLowerCase().includes(needle) ?? false))
: commands;
const activeIndex = visible.length === 0 ? -1 : Math.min(active, visible.length - 1);
// Close when the page changes underneath (a "go to" command ran).
const startPath = useRef(pathname);
useEffect(() => {
if (pathname !== startPath.current) onClose();
}, [pathname, onClose]);
useEffect(() => {
if (activeIndex < 0) return;
const node = listRef.current?.querySelector<HTMLElement>(`[data-index="${activeIndex}"]`);
node?.scrollIntoView?.({ block: "nearest" });
}, [activeIndex]);
const runCommand = useCallback((command: PaletteCommand) => {
onClose();
command.run();
}, [onClose]);
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "ArrowDown") {
event.preventDefault();
if (visible.length) setActive((activeIndex + 1) % visible.length);
} else if (event.key === "ArrowUp") {
event.preventDefault();
if (visible.length) setActive((activeIndex - 1 + visible.length) % visible.length);
} else if (event.key === "Home") {
event.preventDefault();
setActive(0);
} else if (event.key === "End") {
event.preventDefault();
setActive(Math.max(0, visible.length - 1));
} else if (event.key === "Enter") {
event.preventDefault();
if (activeIndex >= 0) runCommand(visible[activeIndex]);
} else if ((event.ctrlKey || event.metaKey) && (event.code === "KeyK" || event.key.toLowerCase() === "k")) {
event.preventDefault();
onClose();
}
};
const groupLabel = (group: PaletteCommand["group"]) =>
group === "navigation" ? t("group_navigation") : group === "actions" ? t("group_actions") : t("group_plugins");
return (
<div
className="fixed inset-0 z-[60] flex items-start justify-center bg-black/50 p-4 pt-[12vh] max-sm:p-0 max-sm:pt-0"
onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={t("title")}
className={cn(
"w-full max-w-xl overflow-hidden rounded-lg border border-border bg-background text-foreground shadow-xl",
"max-sm:h-full max-sm:max-w-none max-sm:rounded-none max-sm:border-0 max-sm:pt-[env(safe-area-inset-top)]",
"flex flex-col",
)}
>
<div className="flex items-center gap-2 border-b border-border px-3">
<Search className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
<input
type="text"
role="combobox"
aria-expanded="true"
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={activeIndex >= 0 ? optionId(activeIndex) : undefined}
aria-label={t("title")}
value={query}
onChange={(e) => { setQuery(e.target.value); setActive(0); }}
onKeyDown={onKeyDown}
placeholder={t("placeholder")}
autoComplete="off"
spellCheck={false}
className="h-12 flex-1 min-w-0 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
/>
<kbd className="hidden sm:inline-flex items-center rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">Esc</kbd>
</div>
<ul
ref={listRef}
id={listId}
role="listbox"
aria-label={t("title")}
className="max-h-[60vh] max-sm:max-h-none max-sm:flex-1 overflow-y-auto py-1"
>
{visible.length === 0 && (
<li role="presentation" className="px-4 py-6 text-center text-sm text-muted-foreground">{t("no_results")}</li>
)}
{visible.map((command, index) => {
const Icon = command.icon;
const showGroup = index === 0 || visible[index - 1].group !== command.group;
return (
<li key={command.id} role="presentation">
{showGroup && (
<div aria-hidden="true" className="px-3 pt-2 pb-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{groupLabel(command.group)}
</div>
)}
<div
id={optionId(index)}
role="option"
aria-selected={index === activeIndex}
data-index={index}
data-command-id={command.id}
onMouseMove={() => { if (index !== activeIndex) setActive(index); }}
onMouseDown={(e) => e.preventDefault()}
onClick={() => runCommand(command)}
className={cn(
"mx-1 flex cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-2 text-sm max-sm:min-h-[44px]",
index === activeIndex ? "bg-muted text-foreground" : "text-foreground/90",
)}
>
<Icon className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
<span className="flex-1 min-w-0 truncate">{command.label}</span>
{command.source && (
<span className="flex-shrink-0 truncate max-w-[40%] text-xs text-muted-foreground">{command.source}</span>
)}
</div>
</li>
);
})}
</ul>
</div>
</div>
);
}
+4 -2
View File
@@ -7,6 +7,7 @@ import { useSettingsStore } from "@/stores/settings-store";
import { cn } from "@/lib/utils";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useTour } from "@/components/tour/tour-provider";
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
interface KeyboardShortcutsModalProps {
isOpen: boolean;
@@ -17,9 +18,10 @@ export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsMod
const t = useTranslations();
const { startTour } = useTour();
const interfaceLayout = useSettingsStore((state) => state.interfaceLayout);
// Rows that belong to one interface layout are listed only under it.
const shell = useProInterfaceActive() ? "pro" : "standard";
// Rows that belong to one interface layout (or one shell) are listed only under it.
const shown = (entries: readonly ShortcutEntry[]) =>
entries.filter((entry) => !entry.layout || entry.layout === interfaceLayout);
entries.filter((entry) => (!entry.layout || entry.layout === interfaceLayout) && (!entry.shell || entry.shell === shell));
const modalRef = useFocusTrap({
isActive: isOpen,
@@ -0,0 +1,67 @@
import React from 'react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Every plugin page gets an entry in the navigation, after the built-in apps:
// the rail (desktop), the bottom bar (phones) and Mountain View's app grid.
vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null }));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/components/keyboard-shortcuts-modal', () => ({ KeyboardShortcutsModal: () => null }));
vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
vi.mock('@/hooks/use-stalwart-admin', () => ({ useStalwartAdmin: () => false }));
vi.mock('@/i18n/navigation', () => ({
useRouter: () => ({ push: vi.fn(), back: vi.fn(), replace: vi.fn() }),
usePathname: () => '/apps/kanban/board',
Link: ({ children, href, ...rest }: { children: React.ReactNode; href: string }) => <a href={href} {...rest}>{children}</a>,
}));
const { NavigationRail } = await import('../navigation-rail');
const { MvTopBar } = await import('../mv-top-bar');
const pages = await import('@/lib/plugin-sandbox/pages');
const { useUpdateStore } = await import('@/stores/update-store');
beforeEach(() => {
useUpdateStore.setState({ startPolling: vi.fn() } as never);
});
afterEach(() => pages.resetPages());
const register = () => act(() => pages.setPluginPages('kanban', 'Kanban', [
{ id: 'board', title: 'Board', icon: 'LayoutGrid' },
{ id: 'stats', title: 'Stats' },
]));
describe.each(['vertical', 'horizontal'] as const)('navigation rail (%s)', (orientation) => {
it('lists no plugin pages when none are registered', () => {
const { container } = render(<NavigationRail orientation={orientation} />);
expect(container.querySelector('[data-plugin-page-link]')).toBeNull();
});
it('links each page after the built-in apps and marks the open one', () => {
register();
const { container } = render(<NavigationRail orientation={orientation} />);
const links = [...container.querySelectorAll('[data-plugin-page-link]')];
expect(links.map((a) => a.getAttribute('href'))).toEqual(['/apps/kanban/board', '/apps/kanban/stats']);
const mail = container.querySelector('a[href="/"]')!;
expect(mail.compareDocumentPosition(links[0]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(links[0]).toHaveAttribute('aria-current', 'page');
expect(links[1]).not.toHaveAttribute('aria-current');
// Title in the entry (as label on the collapsed rail).
expect(screen.getAllByText('Board').length + screen.queryAllByLabelText('Board').length).toBeGreaterThan(0);
});
});
describe('Mountain View app grid', () => {
it('offers plugin pages next to the built-in apps', () => {
register();
const { container } = render(<MvTopBar />);
fireEvent.click(screen.getByRole('button', { name: 'add_app' }));
const links = [...container.querySelectorAll('[data-plugin-page-link]')];
expect(links.map((a) => a.getAttribute('href'))).toEqual(['/apps/kanban/board', '/apps/kanban/stats']);
expect(links[0]).toHaveAttribute('role', 'menuitem');
expect(screen.getByText('Stats')).toBeInTheDocument();
});
});
@@ -0,0 +1,99 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type { Mailbox } from '@/lib/jmap/types';
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: () => ({}) }));
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 registry = await import('@/lib/plugin-sandbox/registry');
const mailboxes: Mailbox[] = [
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, totalEmails: 0, unreadEmails: 0, myRights: { mayAddItems: true } },
] as unknown as Mailbox[];
function offer(id: string, name: string, slot: string) {
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: slot as never, order: 100, hasShouldShow: false }],
hookDisposables: [],
});
}
beforeEach(() => {
createSlotInstance.mockClear();
localStorage.clear();
});
afterEach(() => {
for (const p of registry.all()) registry.deregister(p.plugin.id);
});
describe('Sidebar plugin sections', () => {
it('draws nothing when no plugin offers a section', () => {
offer('w', 'Widget only', 'sidebar-widget');
const { container } = render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
expect(container.querySelector('[data-plugin-slot="sidebar-section"]')).toBeNull();
expect(screen.queryByText('Widget only')).toBeNull();
});
it('gives each plugin a section headed by its name, below the folders, with the open folder', async () => {
offer('cal', 'Agenda', 'sidebar-section');
const { container } = render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
const header = screen.getByRole('button', { name: 'Agenda' });
expect(header).toHaveAttribute('aria-expanded', 'true');
const section = container.querySelector('[data-plugin-slot="sidebar-section"]')!;
// Below the folder list.
const inbox = container.querySelector('[data-mailbox-id="inbox"]')!;
expect(inbox.compareDocumentPosition(section) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
await waitFor(() => expect(createSlotInstance).toHaveBeenCalled());
const opts = createSlotInstance.mock.calls[0][0] as { slot: string; extraProps: Record<string, unknown>; plugin: { id: string } };
expect(opts.slot).toBe('sidebar-section');
expect(opts.plugin.id).toBe('cal');
expect(opts.extraProps).toEqual({ mailboxId: 'inbox' });
});
it('folds a section and remembers it per plugin', async () => {
offer('cal', 'Agenda', 'sidebar-section');
const first = render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
await waitFor(() => expect(createSlotInstance).toHaveBeenCalledTimes(1));
act(() => { fireEvent.click(screen.getByRole('button', { name: 'Agenda' })); });
expect(screen.getByRole('button', { name: 'Agenda' })).toHaveAttribute('aria-expanded', 'false');
first.unmount();
createSlotInstance.mockClear();
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
expect(screen.getByRole('button', { name: 'Agenda' })).toHaveAttribute('aria-expanded', 'false');
await new Promise((r) => setTimeout(r, 20));
expect(createSlotInstance).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,82 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
// The top bar's plugin buttons (top-bar-actions): next to the search field in
// Mountain View's bar, and in the phone header's overflow menu.
vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null }));
vi.mock('@/components/search/search-box', () => ({ SearchBox: () => <input data-testid="search" /> }));
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
vi.mock('@/hooks/use-stalwart-admin', () => ({ useStalwartAdmin: () => false }));
const { MvTopBar } = await import('../mv-top-bar');
const { MobileHeader } = await import('../mobile-header');
const dispatch = vi.fn().mockResolvedValue(undefined);
const search = {
query: '', onQueryChange: vi.fn(), onSubmit: vi.fn(), onClear: vi.fn(), onSelectContact: vi.fn(),
onToggleFilters: vi.fn(), filtersOpen: false, activeFilterCount: 0,
};
beforeEach(() => {
resetActions();
dispatch.mockClear();
setActionDispatcher(dispatch);
});
describe('Mountain View top bar', () => {
it('has no plugin buttons when no plugin declared any', () => {
const { container } = render(<MvTopBar search={search} />);
expect(container.querySelector('[data-plugin-actions="top-bar-actions"]')).toBeNull();
});
it('puts plugin buttons right after the search field and sends the click', async () => {
act(() => setPluginActions('tr', 'Translator', 'top-bar-actions', [{ id: 'go', label: 'Translate', icon: 'Languages' }]));
const { container } = render(<MvTopBar search={search} />);
const group = container.querySelector('[data-plugin-actions="top-bar-actions"]')!;
expect(group).not.toBeNull();
const searchField = screen.getByTestId('search');
expect(searchField.compareDocumentPosition(group) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
const button = screen.getByRole('button', { name: 'Translate' });
// Compact: icon only, the label as tooltip.
expect(button).toHaveAttribute('title', 'Translate (Translator)');
await act(async () => { fireEvent.click(button); });
expect(dispatch).toHaveBeenCalledWith('tr', 'top-bar-actions', 'go', {});
});
});
describe('phone header', () => {
it('offers only the command palette in its menu when no plugin declared buttons', () => {
const opened = vi.fn();
window.addEventListener('bulwark:command-palette', opened);
render(<MobileHeader title="Inbox" />);
fireEvent.click(screen.getByRole('button', { name: 'more' }));
// Notifications and the command palette (both labelled "title" here, as
// the translations are keys in tests); no plugin entries.
const items = screen.getAllByRole('menuitem');
expect(items.map((m) => m.textContent)).toEqual(['title', 'title']);
fireEvent.click(items[1]);
expect(opened).toHaveBeenCalled();
window.removeEventListener('bulwark:command-palette', opened);
});
it('lists plugin buttons in its overflow menu', async () => {
act(() => setPluginActions('tr', 'Translator', 'top-bar-actions', [{ id: 'go', label: 'Translate' }]));
render(<MobileHeader title="Inbox" />);
const more = screen.getByRole('button', { name: 'more' });
expect(screen.queryByRole('menuitem', { name: 'Translate' })).toBeNull();
fireEvent.click(more);
expect(more).toHaveAttribute('aria-expanded', 'true');
await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: 'Translate' })); });
expect(dispatch).toHaveBeenCalledWith('tr', 'top-bar-actions', 'go', {});
// The menu closes on the click.
expect(screen.queryByRole('menu')).toBeNull();
});
});
+82 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { Menu, ArrowLeft, Plus, Search, X } from "@/components/icons";
import { useCallback, useEffect, useRef, useState } from "react";
import { Menu, ArrowLeft, Plus, Search, X, MoreVertical, Terminal, Bell } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { useUIStore } from "@/stores/ui-store";
import { useSettingsStore } from "@/stores/settings-store";
@@ -8,6 +9,84 @@ import { useIsDesktop } from "@/hooks/use-media-query";
import { cn } from "@/lib/utils";
import { useTranslations } from "next-intl";
import { AccountSwitcher } from "@/components/layout/account-switcher";
import { PluginActions } from "@/components/plugins/plugin-actions";
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
import { ContextMenuItem } from "@/components/ui/context-menu";
import { openCommandPalette } from "@/lib/app-commands";
import { NotificationSheet, formatBadgeCount, useUnreadNotificationCount } from "@/components/notifications/notification-center";
/** Plugin buttons in the top bar are not about any one item. */
const TOP_BAR_CONTEXT: Record<string, unknown> = {};
/**
* The phone header's overflow menu: the notifications (the desktop bell), the
* command palette (no Ctrl+K on a phone) and what the desktop top bar shows
* as buttons (plugin top-bar-actions) and has no room for here.
*/
function MobileHeaderMenu() {
const t = useTranslations('top_bar');
const tPalette = useTranslations('command_palette');
const tNotifications = useTranslations('notification_center');
const unread = useUnreadNotificationCount();
const [showNotifications, setShowNotifications] = useState(false);
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const close = useCallback(() => setOpen(false), []);
const { menuRef, onKeyDown } = useMenuNavigation<HTMLDivElement>({ open, onClose: close, triggerRef });
useEffect(() => {
if (!open) return;
const onPointerDown = (e: MouseEvent) => {
if (triggerRef.current?.contains(e.target as Node) || menuRef.current?.contains(e.target as Node)) return;
setOpen(false);
};
document.addEventListener('mousedown', onPointerDown);
return () => document.removeEventListener('mousedown', onPointerDown);
}, [open, menuRef]);
return (
<div className="relative">
<Button
ref={triggerRef}
variant="ghost"
size="icon"
onClick={() => setOpen((v) => !v)}
className={cn("relative h-11 w-11", open && "bg-accent")}
aria-label={unread > 0 ? `${t('more')} (${tNotifications('unread_count', { count: unread })})` : t('more')}
aria-haspopup="menu"
aria-expanded={open}
>
<MoreVertical className="h-5 w-5" />
{unread > 0 && (
<span className="absolute top-2 end-2 w-2 h-2 rounded-full bg-red-500 ring-2 ring-background" aria-hidden="true" />
)}
</Button>
{open && (
<div
ref={menuRef}
onKeyDown={onKeyDown}
role="menu"
aria-label={t('more')}
className="absolute end-0 top-full mt-1 z-50 min-w-[200px] max-w-[calc(100vw-2rem)] rounded-md border border-border bg-background py-1 shadow-lg"
>
<ContextMenuItem
icon={Bell}
label={tNotifications('title')}
shortcut={unread > 0 ? formatBadgeCount(unread) : undefined}
onClick={() => { close(); setShowNotifications(true); }}
/>
<ContextMenuItem
icon={Terminal}
label={tPalette('title')}
onClick={() => { close(); openCommandPalette(); }}
/>
<PluginActions surface="top-bar-actions" context={TOP_BAR_CONTEXT} variant="menu" onRan={close} />
</div>
)}
{showNotifications && <NotificationSheet onClose={() => setShowNotifications(false)} />}
</div>
);
}
interface MobileHeaderProps {
title: string;
@@ -166,6 +245,8 @@ export function MobileHeader({
</Button>
)}
<MobileHeaderMenu />
{/* Which account you are reading is otherwise only visible after
opening the drawer. Last in this group so it sits on the far
edge — opposite the menu button, and mirrored under RTL because
+35
View File
@@ -9,6 +9,7 @@ import {
HelpCircle,
Mail,
Menu,
Puzzle,
Settings,
Shield,
SlidersHorizontal,
@@ -31,8 +32,14 @@ import { useUpdateStore, selectHasUpdate } from "@/stores/update-store";
import { cn } from "@/lib/utils";
import { AccountSwitcher } from "@/components/layout/account-switcher";
import { SearchBox, type ContactSearchField } from "@/components/search/search-box";
import { PluginActions } from "@/components/plugins/plugin-actions";
import { usePluginPages, pluginPageHref, isPluginPageActive } from "@/hooks/use-plugin-pages";
import { NotificationBell } from "@/components/notifications/notification-center";
import type { ContactSuggestion } from "@/lib/search-suggestions";
/** The top bar is not about any one item: plugin clicks carry no context. */
const TOP_BAR_CONTEXT: Record<string, unknown> = {};
/** Mail search, which only the mail surface puts into the bar. */
interface MvTopBarSearch {
query: string;
@@ -107,6 +114,7 @@ export function MvTopBar({
// The same gate the navigation rail puts on its "add app" button.
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled("sidebarAppsEnabled"));
const sidebarApps = useResolvedSidebarApps();
const pluginPages = usePluginPages();
const isStalwartAdmin = useStalwartAdmin();
const hasUpdate = useUpdateStore(selectHasUpdate);
// The navigation rail starts the update check, and this bar replaces it on
@@ -229,10 +237,14 @@ export function MvTopBar({
</div>
)}
{/* Plugin buttons (top-bar-actions), next to the search field */}
<PluginActions surface="top-bar-actions" context={TOP_BAR_CONTEXT} compact className="shrink-0 flex-nowrap" />
{/* Spacer keeps the right cluster pinned while search stays left */}
<div className="flex-1 min-w-0" />
<div className="flex items-center gap-1 shrink-0">
<NotificationBell placement="below" className="w-12 h-12 rounded-full" />
{onShowShortcuts && (
<button
type="button"
@@ -323,6 +335,29 @@ export function MvTopBar({
</Link>
);
})}
{pluginPages.map((page) => {
const PageIcon = iconForName(page.icon) ?? Puzzle;
return (
<Link
key={`plugin-page:${page.pluginId}:${page.id}`}
href={pluginPageHref(page)}
role="menuitem"
title={page.pluginName}
data-plugin-page-link={`${page.pluginId}/${page.id}`}
onClick={() => {
if (activeAppId) onCloseInlineApp?.();
setAppsOpen(false);
}}
className={cn(
"flex flex-col items-center gap-1.5 rounded-lg py-3 px-1 text-center transition-colors hover:bg-muted",
!activeAppId && isPluginPageActive(pathname, page) && "bg-muted"
)}
>
<PageIcon className="w-6 h-6 text-muted-foreground" />
<span className="text-xs truncate max-w-full">{page.title}</span>
</Link>
);
})}
{sidebarApps.map((app) => {
const AppIcon = iconForName(app.icon);
return (
+72 -1
View File
@@ -2,7 +2,7 @@
import { useState, useRef, useEffect, useLayoutEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { Mail, Calendar, BookUser, HardDrive, Settings, Keyboard, Plus, Shield, LogOut, Check, Search } from "@/components/icons";
import { Mail, Calendar, BookUser, HardDrive, Settings, Keyboard, Plus, Shield, LogOut, Check, Search, Puzzle } from "@/components/icons";
import { AccountSwitcher } from "./account-switcher";
import { iconForName } from "@/components/icons";
import { useConfig } from "@/hooks/use-config";
@@ -27,6 +27,8 @@ import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { getPathPrefix, withBasePath } from "@/lib/browser-navigation";
import { Avatar } from "@/components/ui/avatar";
import { toUnicodeEmail } from "@/lib/idn";
import { usePluginPages, pluginPageHref, isPluginPageActive } from "@/hooks/use-plugin-pages";
import { NotificationBell } from "@/components/notifications/notification-center";
interface NavItem {
id: string;
@@ -220,6 +222,8 @@ export function NavigationRail({
// Operator-provided apps (#931) plus the user's own; the gate above only
// removes the user's, so a pinned set still shows when custom apps are off.
const visibleSidebarApps = useResolvedSidebarApps();
// Pages plugins added (api.ui.setPages); listed after the built-in apps.
const pluginPages = usePluginPages();
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
const isStalwartAdmin = useStalwartAdmin();
const hasUpdate = useUpdateStore(selectHasUpdate);
@@ -382,6 +386,40 @@ export function NavigationRail({
);
})}
{/* Plugin pages, after the built-in apps */}
{pluginPages.map((page) => {
const isActive = !activeAppId && isPluginPageActive(pathname, page);
const PageIcon = iconForName(page.icon) ?? Puzzle;
return (
<Link
key={`plugin-page:${page.pluginId}:${page.id}`}
href={pluginPageHref(page)}
onClick={() => { if (activeAppId) onCloseInlineApp?.(); }}
data-plugin-page-link={`${page.pluginId}/${page.id}`}
title={page.pluginName}
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
"transition-colors duration-150",
isActive
? "text-primary"
: "text-muted-foreground hover:text-foreground"
)}
aria-current={isActive ? "page" : undefined}
>
<div className={cn(
"relative",
mountainView && isActive && "min-w-14 rounded-full bg-primary/15 px-4 py-[3px]"
)}>
<PageIcon className="w-5 h-5" />
{isActive && !mountainView && (
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
)}
</div>
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{page.title}</span>
</Link>
);
})}
{/* Custom sidebar apps (per-app mobile visibility) */}
{visibleSidebarApps.filter((app) => app.showOnMobile).map((app) => {
const AppIcon = iconForName(app.icon);
@@ -554,6 +592,37 @@ export function NavigationRail({
);
})}
{/* Plugin pages, after the built-in apps */}
{pluginPages.map((page) => {
const isActive = !activeAppId && isPluginPageActive(pathname, page);
const PageIcon = iconForName(page.icon) ?? Puzzle;
return (
<Link
key={`plugin-page:${page.pluginId}:${page.id}`}
href={pluginPageHref(page)}
onClick={() => { if (activeAppId) onCloseInlineApp?.(); }}
data-plugin-page-link={`${page.pluginId}/${page.id}`}
className={cn(
"relative flex items-center gap-2.5 rounded-md transition-colors duration-150",
collapsed
? "justify-center w-10 h-10"
: "px-2.5 text-sm",
"max-lg:min-h-[44px]",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
aria-current={isActive ? "page" : undefined}
aria-label={collapsed ? page.title : undefined}
title={collapsed ? `${page.title} (${page.pluginName})` : page.pluginName}
style={collapsed ? undefined : { paddingBlock: 'var(--density-sidebar-py)' }}
>
<PageIcon className={cn("w-[18px] h-[18px] flex-shrink-0", isActive && "text-primary")} />
{!collapsed && <span className="truncate">{page.title}</span>}
</Link>
);
})}
{/* Custom sidebar apps */}
{visibleSidebarApps.length > 0 && (
<div
@@ -642,6 +711,8 @@ export function NavigationRail({
</a>
)}
<NotificationBell placement="side" className="w-10 h-10 rounded-md" />
<Link
href="/settings"
onClick={handleNavClick('settings')}
+2
View File
@@ -4,6 +4,7 @@ import { useState, useEffect, useMemo, Fragment, ReactNode } from "react";
import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/navigation";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { PluginSidebarSections } from "@/components/plugins/plugin-sidebar-sections";
import { Button } from "@/components/ui/button";
import {
Inbox,
@@ -1709,6 +1710,7 @@ export function Sidebar({
</div>
)}
{!isCollapsed && <PluginSidebarSections mailboxId={selectedKeyword ? null : selectedMailbox || null} />}
{!isCollapsed && <PluginSlot name="sidebar-widget" className="border-t border-border" />}
</div>
@@ -0,0 +1,122 @@
import React from 'react';
import { act, render, screen } 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 { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
import { resetActions, setPluginActions } from '@/lib/plugin-sandbox/actions';
// The standard layout has no global top bar: its search row sits on top of
// the message list. Plugin top-bar buttons go there, after refresh.
const device = vi.hoisted(() => ({ isMobile: false }));
vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null }));
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
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/email/message-list-tabs', () => ({ MessageListTabs: () => null }));
vi.mock('@/components/search/search-box', () => ({ SearchBox: () => <input data-testid="search" /> }));
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: () => {} }));
vi.mock('@/hooks/use-media-query', () => ({
useDeviceDetection: () => ({ isMobile: device.isMobile, isTablet: false, isDesktop: !device.isMobile }),
useMediaQuery: () => false,
useIsDesktop: () => !device.isMobile,
useIsMobile: () => device.isMobile,
}));
vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
vi.mock('@/components/email/email-list', () => ({ EmailList: () => <div>message list</div> }));
const { MailApp } = await import('../mail-app');
const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox' } as Mailbox;
beforeEach(() => {
resetActions();
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) });
const client = {
getAccountId: () => 'acc', getUsername: () => 'me@example.com',
getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null,
};
useAuthStore.setState({
isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login', identities: [],
getClientForAccount: () => client as never,
getAllConnectedClients: () => new Map([['login', client]]) as never,
});
useSettingsStore.setState({ enableUnifiedMailbox: false, mailLayout: 'split', interfaceLayout: 'default' } as never);
useEmailStore.setState({
emails: [], selectedEmail: null, selectedMailbox: inbox.id, mailboxes: [inbox],
viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '',
searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', selectedKeyword: null,
selectedEmailKeys: new Set(),
fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined),
fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined),
refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined),
} as never);
});
afterEach(() => {
device.isMobile = false;
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe('top-bar-actions in the standard layout', () => {
it('draws nothing in the search row without plugins', () => {
const { container } = render(<MailApp />);
expect(screen.getByTestId('search')).toBeInTheDocument();
expect(container.querySelector('[data-plugin-actions="top-bar-actions"]')).toBeNull();
});
it('puts plugin buttons in the search row after refresh', () => {
act(() => setPluginActions('tr', 'Translator', 'top-bar-actions', [{ id: 'go', label: 'Translate' }]));
const { container } = render(<MailApp />);
const group = container.querySelector('[data-plugin-actions="top-bar-actions"]')!;
expect(group).not.toBeNull();
const refresh = screen.getByRole('button', { name: 'refresh' });
expect(group.parentElement).toBe(refresh.parentElement);
expect(refresh.compareDocumentPosition(group) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(screen.getByRole('button', { name: 'Translate' })).toBeInTheDocument();
});
it('leaves them to the header menu on phones', () => {
device.isMobile = true;
act(() => setPluginActions('tr', 'Translator', 'top-bar-actions', [{ id: 'go', label: 'Translate' }]));
const { container } = render(<MailApp />);
expect(container.querySelector('[data-plugin-actions="top-bar-actions"]')).toBeNull();
});
});
describe('command palette commands on the mail page', () => {
it('takes compose from the palette and opens the composer', async () => {
const { requestAppCommand } = await import('@/lib/app-commands');
render(<MailApp />);
let taken = false;
act(() => { taken = requestAppCommand('compose'); });
expect(taken).toBe(true);
});
it('runs a search parked before the page was ready', async () => {
const { parkAppCommand, peekParkedAppCommand } = await import('@/lib/app-commands');
parkAppCommand('search');
render(<MailApp />);
expect(peekParkedAppCommand()).toBeNull();
});
});
+19
View File
@@ -82,6 +82,8 @@ import { useSearchHistoryStore } from "@/stores/search-history-store";
import { WelcomeBanner } from "@/components/ui/welcome-banner";
import { NavigationRail } from "@/components/layout/navigation-rail";
import { MvTopBar } from "@/components/layout/mv-top-bar";
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 { MvListToolbar } from "@/components/email/mv-list-toolbar";
@@ -179,6 +181,9 @@ function freshMailboxes(previous: Mailbox[], timeoutMs = 10_000): Promise<Mailbo
*/
const MV_CONVERSATION_WAIT_MS = 800;
/** Plugin buttons in the top bar are not about any one item. */
const TOP_BAR_CONTEXT: Record<string, unknown> = {};
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);
@@ -930,6 +935,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
handlers: keyboardHandlers,
});
// Compose and search from the command palette (components/command-palette).
useAppCommands({
compose: keyboardHandlers.onCompose,
search: () => {
if (isMobile) setMobileSearchOpen(true);
else keyboardHandlers.onFocusSearch?.();
},
}, isAuthenticated && !!client);
// Shared mail refresh: re-fetch mailboxes (unread counts) + the active list
// via JMAP. Used by both the browser refresh gestures and the toolbar button.
const [isManualRefreshing, setIsManualRefreshing] = useState(false);
@@ -4409,6 +4423,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
>
<RotateCcw className={cn("w-4 h-4", isManualRefreshing && "animate-spin")} />
</button>
{/* Plugin buttons (top-bar-actions). On phones they sit in
the header's overflow menu instead (MobileHeader). */}
{!isMobile && (
<PluginActions surface="top-bar-actions" context={TOP_BAR_CONTEXT} compact className="flex-shrink-0 flex-nowrap" />
)}
</div>
</div>
)}
@@ -0,0 +1,121 @@
import React from 'react';
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// The bell and its panel, where they sit (rail, Mountain View's bar, the
// phone header's menu) and what a click on an entry does.
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, params?: Record<string, unknown>) =>
params ? `${key}(${Object.values(params).join(',')})` : key,
useFormatter: () => ({ relativeTime: () => 'just now' }),
useLocale: () => 'en',
}));
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Bulwark' }) }));
vi.mock('@/hooks/use-stalwart-admin', () => ({ useStalwartAdmin: () => false }));
vi.mock('@/components/layout/account-switcher', () => ({ AccountSwitcher: () => null }));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/components/keyboard-shortcuts-modal', () => ({ KeyboardShortcutsModal: () => null }));
vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
const { NavigationRail } = await import('@/components/layout/navigation-rail');
const { MvTopBar } = await import('@/components/layout/mv-top-bar');
const { MobileHeader } = await import('@/components/layout/mobile-header');
const { useNotificationCenterStore } = await import('@/stores/notification-center-store');
const { useUpdateStore } = await import('@/stores/update-store');
const { toastHooks } = await import('@/lib/plugin-hooks');
const add = useNotificationCenterStore.getState().add;
const clicked = vi.fn();
beforeEach(() => {
useUpdateStore.setState({ startPolling: vi.fn() } as never);
act(() => useNotificationCenterStore.getState().clear());
clicked.mockClear();
toastHooks.onNotificationClick.register('rss', clicked);
});
afterEach(() => toastHooks.onNotificationClick.removePlugin('rss'));
const bell = () => screen.getByRole('button', { name: /^title/ });
describe('notification bell', () => {
it('sits in the navigation rail without a count when nothing is unread', () => {
render(<NavigationRail collapsed />);
expect(bell()).toHaveAccessibleName('title');
expect(bell()).toHaveTextContent('');
});
it('shows the unread count and lists entries with their source, newest first', () => {
act(() => {
add({ level: 'error', title: 'Could not send', body: 'Server said no' });
add({ level: 'info', title: '3 new posts', body: 'Hacker News', pluginId: 'rss', pluginName: 'RSS Reader', tag: 'feed:hn' });
});
render(<NavigationRail collapsed />);
expect(bell()).toHaveAccessibleName('title (unread_count(2))');
expect(bell()).toHaveTextContent('2');
fireEvent.click(bell());
const panel = screen.getByRole('dialog', { name: 'title' });
const items = within(panel).getAllByRole('listitem');
expect(items.map((li) => li.getAttribute('data-entry-source'))).toEqual(['rss', 'app']);
expect(items[0]).toHaveTextContent('3 new posts');
expect(items[0]).toHaveTextContent('RSS Reader');
expect(items[1]).toHaveTextContent('Could not send');
expect(items[1]).toHaveTextContent('Bulwark');
expect(items[1]).toHaveTextContent('level_error');
});
it('tells the plugin when its entry is clicked, with the tag, and marks it read', async () => {
act(() => add({ level: 'info', title: '3 new posts', body: '', pluginId: 'rss', pluginName: 'RSS Reader', tag: 'feed:hn' }));
render(<NavigationRail collapsed />);
fireEvent.click(bell());
await act(async () => { fireEvent.click(screen.getByRole('button', { name: /3 new posts/ })); });
expect(clicked).toHaveBeenCalledWith({ tag: 'feed:hn' });
expect(useNotificationCenterStore.getState().entries[0].read).toBe(true);
expect(screen.queryByRole('dialog')).toBeNull();
});
it('marks all read and clears', () => {
act(() => {
add({ level: 'warning', title: 'a', body: '' });
add({ level: 'error', title: 'b', body: '' });
});
render(<NavigationRail collapsed />);
fireEvent.click(bell());
act(() => { fireEvent.click(screen.getByRole('button', { name: 'mark_all_read' })); });
expect(bell()).toHaveAccessibleName('title');
expect(screen.getAllByRole('listitem')).toHaveLength(2);
act(() => { fireEvent.click(screen.getByRole('button', { name: 'clear' })); });
expect(screen.queryAllByRole('listitem')).toHaveLength(0);
expect(screen.getByText('empty')).toBeInTheDocument();
});
it('sits in Mountain View\'s top bar', () => {
act(() => add({ level: 'error', title: 'a', body: '' }));
render(<MvTopBar />);
expect(bell()).toHaveAccessibleName('title (unread_count(1))');
});
it('is an entry in the phone header menu that opens the list full screen', () => {
act(() => add({ level: 'error', title: 'Could not send', body: '' }));
render(<MobileHeader title="Inbox" />);
const more = screen.getByRole('button', { name: /^more/ });
expect(more).toHaveAccessibleName('more (unread_count(1))');
fireEvent.click(more);
// First entry: notifications (with the count), then the command palette.
const [notifications] = screen.getAllByRole('menuitem');
expect(notifications).toHaveTextContent('1');
fireEvent.click(notifications);
const sheet = screen.getByRole('dialog', { name: 'title' });
expect(sheet).toHaveAttribute('aria-modal', 'true');
expect(within(sheet).getByText('Could not send')).toBeInTheDocument();
fireEvent.click(within(sheet).getByRole('button', { name: 'close' }));
expect(screen.queryByRole('dialog')).toBeNull();
});
});
@@ -0,0 +1,262 @@
"use client";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useFormatter, useTranslations } from "next-intl";
import { AlertCircle, AlertTriangle, Bell, CheckCheck, Puzzle, Trash2, X } from "@/components/icons";
import { useConfig } from "@/hooks/use-config";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { isDocumentRTL } from "@/i18n/direction";
import { toastHooks } from "@/lib/plugin-hooks";
import { cn } from "@/lib/utils";
import {
selectUnreadCount,
useNotificationCenterStore,
type CenterEntry,
} from "@/stores/notification-center-store";
/** "99+" past two digits, like the other badges. */
export function formatBadgeCount(count: number): string {
return count > 99 ? "99+" : String(count);
}
export function useUnreadNotificationCount(): number {
return useNotificationCenterStore(selectUnreadCount);
}
function EntryIcon({ entry }: { entry: CenterEntry }) {
if (entry.level === "error") return <AlertCircle className="w-4 h-4 text-destructive" />;
if (entry.level === "warning") return <AlertTriangle className="w-4 h-4 text-warning" />;
if (entry.pluginId) return <Puzzle className="w-4 h-4 text-muted-foreground" />;
return <Bell className="w-4 h-4 text-muted-foreground" />;
}
/**
* The list behind the bell: recent notifications from plugins and the app's
* error and warning toasts, newest first. Clicking a plugin's entry tells
* that plugin (onNotificationClick), as a click on the system notification
* does.
*/
export function NotificationPanel({ onClose, className, sheet = false }: { onClose: () => void; className?: string; sheet?: boolean }) {
const t = useTranslations("notification_center");
const format = useFormatter();
const { appName } = useConfig();
const entries = useNotificationCenterStore((s) => s.entries);
const unread = useNotificationCenterStore(selectUnreadCount);
const markRead = useNotificationCenterStore((s) => s.markRead);
const markAllRead = useNotificationCenterStore((s) => s.markAllRead);
const clear = useNotificationCenterStore((s) => s.clear);
const [now] = useState(() => Date.now());
const openEntry = (entry: CenterEntry) => {
markRead(entry.id);
if (!entry.pluginId) return;
onClose();
void toastHooks.onNotificationClick.emitTo(entry.pluginId, { tag: entry.tag ?? "" });
};
return (
<div className={cn("flex flex-col bg-background text-foreground", className)} data-notification-panel="">
<div className="flex items-center gap-1 px-3 h-12 border-b border-border shrink-0">
<h2 className="flex-1 min-w-0 truncate text-sm font-semibold">{t("title")}</h2>
<button
type="button"
onClick={markAllRead}
disabled={unread === 0}
className="inline-flex items-center gap-1 h-8 px-2 rounded-md text-xs text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-50 disabled:pointer-events-none transition-colors"
>
<CheckCheck className="w-4 h-4" />
<span>{t("mark_all_read")}</span>
</button>
<button
type="button"
onClick={clear}
disabled={entries.length === 0}
className="inline-flex items-center gap-1 h-8 px-2 rounded-md text-xs text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-50 disabled:pointer-events-none transition-colors"
>
<Trash2 className="w-4 h-4" />
<span>{t("clear")}</span>
</button>
{sheet && (
<button
type="button"
onClick={onClose}
className="grid place-items-center h-10 w-10 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
aria-label={t("close")}
>
<X className="w-5 h-5" />
</button>
)}
</div>
{entries.length === 0 ? (
<p className="px-4 py-8 text-center text-sm text-muted-foreground">{t("empty")}</p>
) : (
<ul className="flex-1 min-h-0 overflow-y-auto py-1" aria-label={t("title")}>
{entries.map((entry) => {
const source = entry.pluginName ?? appName;
const content = (
<>
<span className="mt-0.5 flex-shrink-0" aria-hidden="true"><EntryIcon entry={entry} /></span>
<span className="flex-1 min-w-0">
<span className="flex items-baseline gap-2">
<span className={cn("flex-1 min-w-0 truncate text-sm", !entry.read && "font-semibold")}>{entry.title}</span>
<time dateTime={new Date(entry.time).toISOString()} className="flex-shrink-0 text-[11px] text-muted-foreground tabular-nums">
{format.relativeTime(new Date(entry.time), now)}
</time>
</span>
{entry.body && <span className="block text-xs text-muted-foreground line-clamp-2 break-words">{entry.body}</span>}
<span className="block text-[11px] text-muted-foreground truncate">
{entry.level !== "info" && <span className="sr-only">{t(entry.level === "error" ? "level_error" : "level_warning")}: </span>}
{source}
</span>
</span>
{!entry.read && (
<span className="mt-1.5 w-2 h-2 rounded-full bg-primary flex-shrink-0" aria-label={t("unread")} />
)}
</>
);
return (
<li key={entry.id} data-entry-source={entry.pluginId ?? "app"}>
{entry.pluginId ? (
<button
type="button"
onClick={() => openEntry(entry)}
className="w-full flex items-start gap-2.5 px-3 py-2 text-start hover:bg-muted focus-visible:bg-muted focus-visible:outline-none transition-colors"
>
{content}
</button>
) : (
<div className="flex items-start gap-2.5 px-3 py-2">
{content}
</div>
)}
</li>
);
})}
</ul>
)}
</div>
);
}
/** The phone version: the panel over the whole screen. */
export function NotificationSheet({ onClose }: { onClose: () => void }) {
const t = useTranslations("notification_center");
const ref = useFocusTrap({ isActive: true, onEscape: onClose, restoreFocus: true });
return createPortal(
<div
ref={ref}
role="dialog"
aria-modal="true"
aria-label={t("title")}
className="fixed inset-0 z-50 flex flex-col bg-background pt-[env(safe-area-inset-top)]"
>
<NotificationPanel onClose={onClose} sheet className="flex-1 min-h-0" />
</div>,
document.body,
);
}
/**
* The bell with the unread count. `side` opens the panel beside the button
* (navigation rail), `below` drops it under the button (Mountain View's top
* bar).
*/
export function NotificationBell({ placement, className }: { placement: "side" | "below"; className?: string }) {
const t = useTranslations("notification_center");
const unread = useUnreadNotificationCount();
const [open, setOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [style, setStyle] = useState<React.CSSProperties>({});
const close = useCallback(() => setOpen(false), []);
const position = useCallback(() => {
const rect = buttonRef.current?.getBoundingClientRect();
if (!rect) return;
if (placement === "side") {
const bottom = Math.max(8, window.innerHeight - rect.bottom);
setStyle(isDocumentRTL()
? { position: "fixed", right: window.innerWidth - rect.left + 8, bottom }
: { position: "fixed", left: rect.right + 8, bottom });
} else {
const top = rect.bottom + 4;
setStyle(isDocumentRTL()
? { position: "fixed", left: Math.max(8, rect.left), top }
: { position: "fixed", right: Math.max(8, window.innerWidth - rect.right), top });
}
}, [placement]);
useLayoutEffect(() => {
if (open) position();
}, [open, position]);
useEffect(() => {
if (!open) return;
const onPointerDown = (e: MouseEvent) => {
if (buttonRef.current?.contains(e.target as Node) || panelRef.current?.contains(e.target as Node)) return;
setOpen(false);
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setOpen(false);
buttonRef.current?.focus();
}
};
document.addEventListener("mousedown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
window.addEventListener("resize", position);
return () => {
document.removeEventListener("mousedown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
window.removeEventListener("resize", position);
};
}, [open, position]);
const label = unread > 0 ? `${t("title")} (${t("unread_count", { count: unread })})` : t("title");
return (
<>
<button
ref={buttonRef}
type="button"
onClick={() => setOpen((v) => !v)}
className={cn(
"relative grid place-items-center transition-colors",
open ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground",
className,
)}
title={t("title")}
aria-label={label}
aria-haspopup="dialog"
aria-expanded={open}
data-notification-bell=""
>
<Bell className={placement === "side" ? "w-[18px] h-[18px]" : "w-5 h-5"} />
{unread > 0 && (
<span
className={cn(
"absolute flex items-center justify-center min-w-[16px] h-4 px-1 rounded-full bg-red-500 text-white text-[10px] font-bold",
placement === "side" ? "-top-0.5 -end-0.5" : "top-1.5 end-1.5",
)}
aria-hidden="true"
>
{formatBadgeCount(unread)}
</span>
)}
</button>
{open && createPortal(
<div
ref={panelRef}
role="dialog"
aria-label={t("title")}
style={style}
className="z-50 w-[22rem] max-w-[calc(100vw-1rem)] max-h-[min(32rem,80vh)] flex flex-col overflow-hidden rounded-lg border border-border bg-background shadow-lg"
>
<NotificationPanel onClose={close} className="flex-1 min-h-0" />
</div>,
document.body,
)}
</>
);
}
@@ -0,0 +1,104 @@
import React from 'react';
import { act, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { InstalledPlugin } from '@/lib/plugin-types';
// The app-page slot: a plugin's page at /apps/<pluginId>/<pageId>, inside the
// app's own frame.
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: () => ({}) }));
vi.mock('@/components/layout/navigation-rail', () => ({
NavigationRail: ({ orientation }: { orientation?: string }) => <nav data-testid={`rail-${orientation ?? 'vertical'}`} />,
}));
vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null }));
vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null }));
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
vi.mock('@/i18n/navigation', () => ({
useRouter: () => ({ push: vi.fn(), back: vi.fn(), replace: vi.fn() }),
usePathname: () => '/apps/kanban/board',
Link: ({ children, href, ...rest }: { children: React.ReactNode; href: string }) => <a href={href} {...rest}>{children}</a>,
}));
const { PluginPageApp } = await import('../plugin-page-app');
const registry = await import('@/lib/plugin-sandbox/registry');
const pages = await import('@/lib/plugin-sandbox/pages');
const { useAuthStore } = await import('@/stores/auth-store');
const { usePluginStore } = await import('@/stores/plugin-store');
function activate(id: string, slots: string[]) {
const plugin: InstalledPlugin = {
id, name: 'Kanban', version: '1.0.0', author: '', description: '', type: 'ui-extension',
permissions: ['ui:app-page'], grantedPermissions: ['ui:app-page'], entrypoint: 'index.js',
enabled: true, status: 'running', settings: {},
};
registry.register({
plugin, code: '', tier: 'untrusted',
background: { evaluateShouldShow: vi.fn(async () => true) } as never,
slotOffers: slots.map((name) => ({ name: name as never, order: 100, hasShouldShow: false })),
hookDisposables: [],
});
}
beforeEach(() => {
createSlotInstance.mockClear();
useAuthStore.setState({ isAuthenticated: true, isLoading: false, client: {} as never });
usePluginStore.setState({ initialized: true, initializePlugins: vi.fn().mockResolvedValue(undefined) });
});
afterEach(() => {
for (const p of registry.all()) registry.deregister(p.plugin.id);
pages.resetPages();
});
describe('plugin pages', () => {
it('fills the content area with the plugin page and hands it the rest of the path', async () => {
activate('kanban', ['app-page']);
act(() => pages.setPluginPages('kanban', 'Kanban', [{ id: 'board', title: 'Board' }]));
const { container } = render(<PluginPageApp linkSegments={['kanban', 'board', 'cards', '42']} />);
expect(screen.getByTestId('rail-vertical')).toBeInTheDocument();
expect(container.querySelector('[data-plugin-page="kanban/board"]')).not.toBeNull();
await waitFor(() => expect(createSlotInstance).toHaveBeenCalled());
const opts = createSlotInstance.mock.calls[0][0] as { slot: string; extraProps: Record<string, unknown>; plugin: { id: string } };
expect(opts.slot).toBe('app-page');
expect(opts.plugin.id).toBe('kanban');
expect(opts.extraProps).toEqual({ path: 'cards/42' });
});
it('passes an empty path for the page itself', async () => {
activate('kanban', ['app-page']);
act(() => pages.setPluginPages('kanban', 'Kanban', [{ id: 'board', title: 'Board' }]));
render(<PluginPageApp linkSegments={['kanban', 'board']} />);
await waitFor(() => expect(createSlotInstance).toHaveBeenCalled());
expect((createSlotInstance.mock.calls[0][0] as { extraProps: unknown }).extraProps).toEqual({ path: '' });
});
it('says the page is not available, with a way back to Mail, once the plugin is gone', () => {
render(<PluginPageApp linkSegments={['kanban', 'board']} />);
expect(screen.getByText('not_available_title')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'back_to_mail' })).toHaveAttribute('href', '/');
expect(createSlotInstance).not.toHaveBeenCalled();
});
it('waits for the plugins to start before saying so', () => {
usePluginStore.setState({ initialized: false });
render(<PluginPageApp linkSegments={['kanban', 'board']} />);
expect(screen.queryByText('not_available_title')).toBeNull();
expect(screen.getByLabelText('loading')).toBeInTheDocument();
});
it('does not mount another plugin page under a wrong page id', () => {
activate('kanban', ['app-page']);
act(() => pages.setPluginPages('kanban', 'Kanban', [{ id: 'board', title: 'Board' }]));
render(<PluginPageApp linkSegments={['kanban', 'other']} />);
expect(screen.getByText('not_available_title')).toBeInTheDocument();
});
});
+165
View File
@@ -0,0 +1,165 @@
"use client";
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { useTranslations } from "next-intl";
import { Link } from "@/i18n/navigation";
import { Loader2, Puzzle } from "@/components/icons";
import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { usePluginStore } from "@/stores/plugin-store";
import { NavigationRail } from "@/components/layout/navigation-rail";
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view";
import { MvTopBar } from "@/components/layout/mv-top-bar";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { PluginIframeSlot } from "@/components/plugins/plugin-iframe-slot";
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { useIsMobile } from "@/hooks/use-media-query";
import { useMountainViewShell } from "@/hooks/use-mountain-view-shell";
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";
export interface PluginPageAppProps {
/** Path segments after `/apps`: `[pluginId, pageId, ...subPath]`. */
linkSegments?: string[];
}
/**
* A page a plugin added with api.ui.setPages, at /apps/<pluginId>/<pageId>.
* The app's own frame (navigation rail, or Mountain View's top bar, and the
* phone navigation) stays around it; the plugin's `app-page` slot fills the
* content area and gets the rest of the path as `path`.
*/
export function PluginPageApp({ linkSegments = [] }: PluginPageAppProps) {
const t = useTranslations("plugin_pages");
const [pluginId = "", pageId = "", ...rest] = linkSegments;
const subPath = rest.join("/");
const { isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
const [initialCheckDone, setInitialCheckDone] = useState(
() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client,
);
const { quota, isPushConnected } = useEmailStore();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const isMobile = useIsMobile();
const isEmbedded = useIsEmbedded();
const mvShell = useMountainViewShell(isEmbedded);
const pluginsReady = usePluginStore((s) => s.initialized);
const page = useSyncExternalStore(
subscribePages,
() => findPage(pluginId, pageId),
() => null,
);
const offersPage = useSyncExternalStore(
subscribeSlots,
() => offersForSlot("app-page").some((o) => o.pluginId === pluginId),
() => false,
);
useDocumentTitle(page?.title ?? null);
useEffect(() => {
const state = useAuthStore.getState();
if (state.isAuthenticated && state.client) {
setInitialCheckDone(true);
return;
}
checkAuth().finally(() => setInitialCheckDone(true));
}, [checkAuth]);
useEffect(() => {
if (initialCheckDone && !isAuthenticated && !authLoading) {
saveRedirectAfterLogin();
redirectToLogin();
}
}, [initialCheckDone, isAuthenticated, authLoading]);
// Opened straight from a link or a reload: the plugins have not been
// started yet on this page load (mail and settings do the same).
useEffect(() => {
if (isAuthenticated) void usePluginStore.getState().initializePlugins();
}, [isAuthenticated]);
const extraProps = useMemo(() => ({ path: subPath }), [subPath]);
if (!isAuthenticated) return null;
const available = !!page && offersPage;
let content: React.ReactNode;
if (available) {
content = (
<div className="flex-1 min-h-0 overflow-y-auto" data-plugin-page={`${pluginId}/${pageId}`}>
<PluginIframeSlot key={`${pluginId}/${pageId}`} pluginId={pluginId} slot="app-page" extraProps={extraProps} />
</div>
);
} else if (!pluginsReady) {
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>
);
} else {
content = (
<div className="flex flex-1 items-center justify-center p-4">
<div className="max-w-sm text-center space-y-3">
<Puzzle className="w-10 h-10 text-muted-foreground mx-auto" />
<p className="text-sm font-medium text-foreground">{t("not_available_title")}</p>
<p className="text-xs text-muted-foreground">{t("not_available_description")}</p>
<Link href="/" className="inline-block text-sm text-primary hover:underline underline-offset-2">
{t("back_to_mail")}
</Link>
</div>
</div>
);
}
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")}>
<AppTopBannerSlot />
{mvShell && (
<MvTopBar
showMenuButton={false}
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/>
)}
<div data-mv-card={mvShell ? "" : undefined} className={cn("flex flex-1 min-h-0 overflow-hidden", mvShell && "mb-4 mx-4")}>
{!isMobile && !isEmbedded && !mvShell && (
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: "1px solid rgba(128, 128, 128, 0.3)" }}>
<NavigationRail
collapsed
quota={quota}
isPushConnected={isPushConnected}
onLogout={logout}
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/>
</div>
)}
<div className="flex flex-col flex-1 min-w-0">
{inlineApp && <InlineAppView apps={loadedApps} activeAppId={inlineApp.id} onClose={closeInlineApp} />}
<div className={cn("flex flex-col flex-1 min-h-0", inlineApp && "hidden")}>
{content}
</div>
{isMobile && !isEmbedded && (
<NavigationRail
orientation="horizontal"
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/>
)}
</div>
</div>
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
</div>
);
}
@@ -0,0 +1,83 @@
'use client';
import { useId, useMemo, useState, useSyncExternalStore } from 'react';
import { ChevronDown, ChevronRight } from '@/components/icons';
import { get as getActivePlugin, offersForSlot, subscribe } from '@/lib/plugin-sandbox/registry';
import { PluginIframeSlot } from './plugin-iframe-slot';
const STORAGE_PREFIX = 'plugin-sidebar-section-collapsed:';
function readCollapsed(pluginId: string): boolean {
try {
return localStorage.getItem(STORAGE_PREFIX + pluginId) === '1';
} catch {
return false;
}
}
function writeCollapsed(pluginId: string, collapsed: boolean): void {
try {
if (collapsed) localStorage.setItem(STORAGE_PREFIX + pluginId, '1');
else localStorage.removeItem(STORAGE_PREFIX + pluginId);
} catch {
// Storage may be blocked; the section just opens expanded next time.
}
}
function PluginSidebarSection({ pluginId, extraProps }: { pluginId: string; extraProps: Record<string, unknown> }) {
const [collapsed, setCollapsed] = useState(() => readCollapsed(pluginId));
const bodyId = useId();
const name = getActivePlugin(pluginId)?.plugin.name || pluginId;
const toggle = () => {
setCollapsed((prev) => {
writeCollapsed(pluginId, !prev);
return !prev;
});
};
return (
<div data-plugin-sidebar-section={pluginId}>
{/* Same look as the sidebar's own section headers (Folders, Tags). */}
<button
type="button"
onClick={toggle}
aria-expanded={!collapsed}
aria-controls={bodyId}
className="group w-full flex items-center pb-1 pt-5 px-3 select-none rounded-sm hover:bg-muted/40 transition-colors"
>
{collapsed ? (
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0 rtl:-scale-x-100" />
) : (
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
)}
<span className="ms-1.5 text-sm font-semibold text-foreground truncate">{name}</span>
</button>
{/* A collapsed section has no iframe at all, so it costs nothing. */}
<div id={bodyId} hidden={collapsed}>
{!collapsed && <PluginIframeSlot pluginId={pluginId} slot="sidebar-section" extraProps={extraProps} />}
</div>
</div>
);
}
/**
* The `sidebar-section` slot: one collapsible section per plugin in the mail
* sidebar, below the folders and tags. The header carries the plugin's name;
* whether it is folded is remembered per plugin in this browser.
*/
export function PluginSidebarSections({ mailboxId }: { mailboxId: string | null }) {
const getSnapshot = () => offersForSlot('sidebar-section');
const offers = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const extraProps = useMemo(() => ({ mailboxId }), [mailboxId]);
if (offers.length === 0) return null;
return (
<div data-plugin-slot="sidebar-section">
{offers.map((offer) => (
<PluginSidebarSection key={offer.pluginId} pluginId={offer.pluginId} extraProps={extraProps} />
))}
</div>
);
}
@@ -0,0 +1,104 @@
import React from 'react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { InstalledPlugin } from '@/lib/plugin-types';
// settings-page: a page of its own in Settings for each plugin that offers
// one, listed under a "Plugins" group with the plugin's name.
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: () => ({}) }));
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', () => ({
useIsDesktop: () => true,
useIsMobile: () => false,
useMediaQuery: () => false,
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
}));
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail', stalwartFeaturesEnabled: false }) }));
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 registry = await import('@/lib/plugin-sandbox/registry');
const { useAuthStore } = await import('@/stores/auth-store');
const { usePluginStore } = await import('@/stores/plugin-store');
const { useThemeStore } = await import('@/stores/theme-store');
function activate(id: string, name: string, slots: string[]) {
const plugin: InstalledPlugin = {
id, name, version: '1.0.0', author: '', description: '', type: 'ui-extension',
permissions: ['ui:settings-section'], grantedPermissions: ['ui:settings-section'], entrypoint: 'index.js',
enabled: true, status: 'running', settings: {},
};
registry.register({
plugin, code: '', tier: 'untrusted',
background: { evaluateShouldShow: vi.fn(async () => true) } as never,
slotOffers: slots.map((slot) => ({ name: slot as never, order: 100, hasShouldShow: false })),
hookDisposables: [],
});
}
beforeEach(() => {
createSlotInstance.mockClear();
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
try { localStorage.setItem('settings-active-tab', 'appearance'); } catch { /* ignore */ }
// Every capability check answers "no"; ids are plain strings.
const client = new Proxy({}, {
get: (_t, prop) => (prop === 'then' ? undefined : (prop === 'getAccountId' ? () => 'acc' : () => null)),
});
useAuthStore.setState({ isAuthenticated: true, isLoading: false, client: client as never });
usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) } as never);
});
afterEach(() => {
for (const p of registry.all()) registry.deregister(p.plugin.id);
vi.unstubAllGlobals();
});
describe('Settings plugin pages', () => {
it('has no Plugins group without a plugin that offers a page', () => {
activate('smime', 'S/MIME', ['settings-section']);
render(<SettingsApp linkSegments={[]} />);
expect(screen.queryByText('plugin_pages.settings_group')).toBeNull();
});
it('lists the plugin by name under Plugins and opens its page full width', async () => {
activate('kanban', 'Kanban', ['settings-page']);
const { container } = render(<SettingsApp linkSegments={[]} />);
const group = screen.getByText('plugin_pages.settings_group');
const entry = screen.getByRole('button', { name: /Kanban/ });
expect(group.compareDocumentPosition(entry) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(createSlotInstance).not.toHaveBeenCalled();
act(() => { fireEvent.click(entry); });
const pane = container.querySelector('[data-plugin-slot="settings-page"]')!;
expect(pane).not.toBeNull();
expect(pane.parentElement?.className).not.toContain('max-w-3xl');
await waitFor(() => expect(createSlotInstance).toHaveBeenCalled());
const opts = createSlotInstance.mock.calls[0][0] as { slot: string; plugin: { id: string } };
expect(opts.slot).toBe('settings-page');
expect(opts.plugin.id).toBe('kanban');
});
it('opens the page from a /settings/plugin-page:<id> link', async () => {
activate('kanban', 'Kanban', ['settings-page']);
render(<SettingsApp linkSegments={['plugin-page:kanban']} />);
await waitFor(() => expect(createSlotInstance).toHaveBeenCalled());
expect((createSlotInstance.mock.calls[0][0] as { slot: string }).slot).toBe('settings-page');
});
});
+34 -5
View File
@@ -108,13 +108,17 @@ import { useMountainViewShell } from "@/hooks/use-mountain-view-shell";
type Tab = SettingsSearchTab;
type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced';
type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'plugins' | 'advanced';
// 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
// discoverable as a menu point rather than buried inside another panel.
type PluginTabId = `plugin:${string}`;
type SettingsTabId = Tab | PluginTabId;
// A plugin that offers a `settings-page` slot gets a page of its own under the
// "Plugins" group, keyed `plugin-page:<id>`, drawn at the full content width.
type PluginPageTabId = `plugin-page:${string}`;
type SettingsTabId = Tab | PluginTabId | PluginPageTabId;
const PLUGIN_PAGE_PREFIX = 'plugin-page:';
interface TabDef {
id: SettingsTabId;
@@ -151,7 +155,7 @@ const tabIcons: Record<Tab, AppIcon> = {
debug: Bug,
};
const tabGroupOrder: TabGroup[] = ['general', 'appearance', 'mail', 'privacy', 'apps', 'advanced'];
const tabGroupOrder: TabGroup[] = ['general', 'appearance', 'mail', 'privacy', 'apps', 'plugins', 'advanced'];
// Map legacy tab IDs to current ones; runs once on read of localStorage.
const LEGACY_TAB_MAP: Record<string, Tab> = {
@@ -194,6 +198,7 @@ function tabFromDeepLink(segments: string[] | null | undefined): SettingsTabId |
const migrated = raw in LEGACY_TAB_MAP ? LEGACY_TAB_MAP[raw] : raw;
if (migrated in tabIcons) return migrated as SettingsTabId;
if (migrated.startsWith('plugin:')) return migrated as SettingsTabId;
if (migrated.startsWith(PLUGIN_PAGE_PREFIX)) return migrated as SettingsTabId;
return null;
}
@@ -242,6 +247,13 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
() => pluginOffersForSlot('settings-section'),
() => pluginOffersForSlot('settings-section'),
);
// Active plugins that offer a `settings-page` slot: one page each.
const pluginPageOffers = useSyncExternalStore(
pluginRegistrySubscribe,
() => pluginOffersForSlot('settings-page'),
() => pluginOffersForSlot('settings-page'),
);
const tPluginPages = useTranslations('plugin_pages');
// Consume the one-shot deep-link key so a section gear only steers this one
// open, never the persisted default for future Settings-button clicks.
useEffect(() => {
@@ -579,6 +591,14 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
group: 'apps',
})),
// Plugins: a page of its own for each plugin that offers one.
...pluginPageOffers.map((offer): TabDef => ({
id: `${PLUGIN_PAGE_PREFIX}${offer.pluginId}` as PluginPageTabId,
label: getActivePlugin(offer.pluginId)?.plugin.name || offer.pluginId,
icon: Puzzle,
group: 'plugins',
})),
// Advanced
{ id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: 'advanced' },
...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'advanced' as TabGroup }] : []),
@@ -605,7 +625,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
const groupedTabs = tabGroupOrder
.map((group) => ({
group,
label: t(`tab_groups.${group}`),
label: group === 'plugins' ? tPluginPages('settings_group') : t(`tab_groups.${group}`),
items: visibleTabs.filter((tab) => tab.group === group),
}))
.filter((g) => g.items.length > 0);
@@ -715,6 +735,15 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
slot="settings-section"
/>
)}
{effectiveActiveTab.startsWith(PLUGIN_PAGE_PREFIX) && (
<div className="w-full" data-plugin-slot="settings-page">
<PluginIframeSlot
key={effectiveActiveTab}
pluginId={effectiveActiveTab.slice(PLUGIN_PAGE_PREFIX.length)}
slot="settings-page"
/>
</div>
)}
</>
);
@@ -1010,7 +1039,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
/>
<div ref={attachContentScroll} className="flex-1 overflow-y-auto">
<div className="max-w-3xl mx-auto px-6 py-6">
<div className={effectiveActiveTab.startsWith(PLUGIN_PAGE_PREFIX) ? "px-6 py-6" : "max-w-3xl mx-auto px-6 py-6"}>
{renderTabContent()}
</div>
</div>
+4 -1
View File
@@ -364,6 +364,8 @@ export interface ShortcutEntry {
readonly key: string;
readonly description: string;
readonly layout?: InterfaceLayout;
/** Listed only in the Pro shell, or only outside it. */
readonly shell?: "pro" | "standard";
}
// Shortcut definitions for the help modal
@@ -389,7 +391,8 @@ export const KEYBOARD_SHORTCUTS = {
global: [
{ key: "c", description: "shortcuts.global.compose" },
{ key: "/", description: "shortcuts.global.search" },
{ key: "Ctrl/⌘ + K", description: "shortcuts.global.global_search" },
{ key: "Ctrl/⌘ + K", description: "shortcuts.global.global_search", shell: "pro" },
{ key: "Ctrl/⌘ + K", description: "command_palette.open", shell: "standard" },
{ key: "?", description: "shortcuts.global.help" },
{ key: "Shift + G", description: "shortcuts.global.refresh" },
{ key: "Ctrl + A", description: "shortcuts.global.select_all" },
+20
View File
@@ -0,0 +1,20 @@
"use client";
import { useSyncExternalStore } from 'react';
import { allPages, subscribePages, type RegisteredPage } from '@/lib/plugin-sandbox/pages';
/** Pages plugins registered with api.ui.setPages, in install order. */
export function usePluginPages(): RegisteredPage[] {
return useSyncExternalStore(subscribePages, allPages, allPages);
}
/** The in-app route of a plugin page: /apps/<pluginId>/<pageId>. */
export function pluginPageHref(page: Pick<RegisteredPage, 'pluginId' | 'id'>): string {
return `/apps/${encodeURIComponent(page.pluginId)}/${encodeURIComponent(page.id)}`;
}
/** Whether `pathname` (locale already stripped) shows this page. */
export function isPluginPageActive(pathname: string, page: Pick<RegisteredPage, 'pluginId' | 'id'>): boolean {
const href = pluginPageHref(page);
return pathname === href || pathname.startsWith(`${href}/`);
}
+38
View File
@@ -0,0 +1,38 @@
import { render } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { parkAppCommand, peekParkedAppCommand, requestAppCommand, useAppCommands } from '../app-commands';
function Surface({ compose, enabled = true }: { compose: () => void; enabled?: boolean }) {
useAppCommands({ compose }, enabled);
return null;
}
describe('palette commands for the mail page', () => {
it('reports a command nobody took', () => {
expect(requestAppCommand('compose')).toBe(false);
});
it('runs a command on the mounted surface', () => {
const compose = vi.fn();
render(<Surface compose={compose} />);
expect(requestAppCommand('compose')).toBe(true);
expect(compose).toHaveBeenCalledTimes(1);
// A command the surface has no handler for stays untaken.
expect(requestAppCommand('search')).toBe(false);
});
it('runs a parked command once the surface is ready, and only once', () => {
const compose = vi.fn();
parkAppCommand('compose');
const { rerender } = render(<Surface compose={compose} enabled={false} />);
expect(compose).not.toHaveBeenCalled();
rerender(<Surface compose={compose} enabled />);
expect(compose).toHaveBeenCalledTimes(1);
expect(peekParkedAppCommand()).toBeNull();
rerender(<Surface compose={compose} enabled={false} />);
rerender(<Surface compose={compose} enabled />);
expect(compose).toHaveBeenCalledTimes(1);
});
});
@@ -113,3 +113,30 @@ describe('api.notifications.show', () => {
expect(FakeNotification.shown[0].options.body).toBe('a b\nRSS Reader');
});
});
describe('api.notifications.show in the notification panel', () => {
beforeEach(async () => {
const { useNotificationCenterStore } = await import('@/stores/notification-center-store');
useNotificationCenterStore.getState().clear();
});
const entries = async () => (await import('@/stores/notification-center-store')).useNotificationCenterStore.getState().entries;
it('lists each one with the plugin and its tag, shown as system notification or toast', async () => {
await call(RSS, { title: '3 new posts', body: 'From Hacker News', tag: 'feed:hn' });
FakeNotification.permission = 'default';
await call(RSS, { title: 'Synced' });
expect((await entries()).map((e) => [e.title, e.body, e.pluginId, e.pluginName, e.tag])).toEqual([
['Synced', '', 'rss', 'RSS Reader', undefined],
['3 new posts', 'From Hacker News', 'rss', 'RSS Reader', 'feed:hn'],
]);
});
it('lists nothing the plugin was refused', async () => {
await expect(call(RSS, { title: 'ok', tag: 'has space' })).rejects.toThrow();
for (let i = 0; i < 5; i++) await call(RSS, { title: `n${i}` });
await expect(call(RSS, { title: 'too many' })).rejects.toThrow();
expect((await entries()).map((e) => e.title)).not.toContain('too many');
expect(await entries()).toHaveLength(5);
});
});
+1 -3
View File
@@ -33,9 +33,7 @@ function isMounted(name: string): boolean {
// Areas whose surfaces are still being mounted on this branch. Each commit
// that finishes an area removes it here; the test then insists every
// surface in it has a mount site.
const NOT_YET_MOUNTED = new Set<SlotArea>([
'app', 'navigation', 'settings',
]);
const NOT_YET_MOUNTED = new Set<SlotArea>([]);
describe('slot catalogue', () => {
it('gates every surface behind permissions the host knows', () => {
+66
View File
@@ -0,0 +1,66 @@
"use client";
// Commands the command palette hands to the surface that can run them (the
// mail page composes and searches). Loosely coupled through a window event,
// like the in-app mailto: requests, because that state lives deep inside the
// page component. When no surface takes a command, the palette parks it and
// navigates to mail, which runs it once it is ready.
import { useEffect, useRef } from 'react';
export type AppCommand = 'compose' | 'search';
export const COMMAND_PALETTE_OPEN_EVENT = 'bulwark:command-palette';
/** Open the command palette from a button or menu (phones have no Ctrl+K). */
export function openCommandPalette(): void {
if (typeof window !== 'undefined') window.dispatchEvent(new Event(COMMAND_PALETTE_OPEN_EVENT));
}
const EVENT = 'bulwark:app-command';
let pending: AppCommand | null = null;
/** Run a command on a mounted surface. False when nothing took it. */
export function requestAppCommand(command: AppCommand): boolean {
if (typeof window === 'undefined') return false;
const uncancelled = window.dispatchEvent(new CustomEvent<AppCommand>(EVENT, { detail: command, cancelable: true }));
return !uncancelled;
}
/** Keep a command for the next surface that registers a handler for it. */
export function parkAppCommand(command: AppCommand): void {
pending = command;
}
/** Test helper. */
export function peekParkedAppCommand(): AppCommand | null {
return pending;
}
/**
* Let this surface run palette commands while `enabled`. A command parked
* before the surface was ready runs as soon as it is.
*/
export function useAppCommands(handlers: Partial<Record<AppCommand, () => void>>, enabled: boolean): void {
const ref = useRef(handlers);
useEffect(() => {
ref.current = handlers;
});
useEffect(() => {
if (!enabled) return;
const onCommand = (event: Event) => {
const handler = ref.current[(event as CustomEvent<AppCommand>).detail];
if (!handler) return;
event.preventDefault();
handler();
};
window.addEventListener(EVENT, onCommand);
if (pending && ref.current[pending]) {
const handler = ref.current[pending]!;
pending = null;
handler();
}
return () => window.removeEventListener(EVENT, onCommand);
}, [enabled]);
}
+4
View File
@@ -6,6 +6,7 @@ import type { InstalledPlugin, NotificationInput } from '../plugin-types';
import { toast as appToast } from '@/stores/toast-store';
import { toastHooks } from '../plugin-hooks';
import { readObject, readString, withinRate } from './host-api-input';
import { recordNotification } from '@/stores/notification-center-store';
export const MAX_NOTIFICATION_TITLE = 120;
export const MAX_NOTIFICATION_BODY = 300;
@@ -39,6 +40,9 @@ export function doNotificationsShow(plugin: InstalledPlugin, input: unknown): 's
}
const source = plugin.name || plugin.id;
// Also listed in the app's notification panel (the bell), where a click
// reaches the plugin the same way as a click on the system notification.
recordNotification({ level: 'info', title, body, pluginId: plugin.id, pluginName: source, ...(tag ? { tag } : {}) });
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
try {
const notification = new Notification(title, {
+36
View File
@@ -4340,5 +4340,41 @@
"preview_empty": "اختر نتيجة لمعاينتها",
"open_full": "فتح",
"view_label": "العرض"
},
"top_bar": {
"more": "خيارات أخرى"
},
"plugin_pages": {
"loading": "جارٍ تحميل الصفحة",
"not_available_title": "هذه الصفحة غير متاحة",
"not_available_description": "الإضافة التي أضافتها متوقفة أو لم تعد مثبتة.",
"back_to_mail": "العودة إلى البريد",
"settings_group": "الإضافات"
},
"command_palette": {
"title": "لوحة الأوامر",
"open": "فتح لوحة الأوامر",
"placeholder": "اكتب أمرًا",
"no_results": "لا توجد أوامر مطابقة",
"go_to": "الانتقال إلى {name}",
"compose": "رسالة جديدة",
"search": "البحث في البريد",
"theme_dark": "التبديل إلى المظهر الداكن",
"theme_light": "التبديل إلى المظهر الفاتح",
"shortcuts": "عرض اختصارات لوحة المفاتيح",
"group_navigation": "الانتقال إلى",
"group_actions": "إجراءات",
"group_plugins": "الإضافات"
},
"notification_center": {
"title": "الإشعارات",
"mark_all_read": "تعليم الكل كمقروء",
"clear": "مسح",
"empty": "لا توجد إشعارات",
"close": "إغلاق",
"unread": "غير مقروء",
"unread_count": "غير المقروءة: {count}",
"level_error": "خطأ",
"level_warning": "تحذير"
}
}
+36
View File
@@ -4340,5 +4340,41 @@
"preview_empty": "Seleccioneu un resultat per previsualitzar-lo",
"open_full": "Obre",
"view_label": "Vista"
},
"top_bar": {
"more": "Més opcions"
},
"plugin_pages": {
"loading": "S'està carregant la pàgina",
"not_available_title": "Aquesta pàgina no està disponible",
"not_available_description": "El connector que l'ha afegida està desactivat o ja no està instal·lat.",
"back_to_mail": "Torna al correu",
"settings_group": "Connectors"
},
"command_palette": {
"title": "Paleta d'ordres",
"open": "Obre la paleta d'ordres",
"placeholder": "Escriviu una ordre",
"no_results": "Cap ordre coincident",
"go_to": "Ves a {name}",
"compose": "Missatge nou",
"search": "Cerca al correu",
"theme_dark": "Canvia al tema fosc",
"theme_light": "Canvia al tema clar",
"shortcuts": "Mostra les dreceres de teclat",
"group_navigation": "Ves a",
"group_actions": "Accions",
"group_plugins": "Connectors"
},
"notification_center": {
"title": "Notificacions",
"mark_all_read": "Marca-ho tot com a llegit",
"clear": "Esborra",
"empty": "No hi ha notificacions",
"close": "Tanca",
"unread": "No llegida",
"unread_count": "{count} sense llegir",
"level_error": "Error",
"level_warning": "Avís"
}
}
+36
View File
@@ -4340,5 +4340,41 @@
"preview_empty": "Vyberte výsledek pro náhled",
"open_full": "Otevřít",
"view_label": "Zobrazení"
},
"top_bar": {
"more": "Další možnosti"
},
"plugin_pages": {
"loading": "Načítání stránky",
"not_available_title": "Tato stránka není k dispozici",
"not_available_description": "Plugin, který ji přidal, je vypnutý nebo už není nainstalovaný.",
"back_to_mail": "Zpět na poštu",
"settings_group": "Pluginy"
},
"command_palette": {
"title": "Paleta příkazů",
"open": "Otevřít paletu příkazů",
"placeholder": "Zadejte příkaz",
"no_results": "Žádné odpovídající příkazy",
"go_to": "Přejít na {name}",
"compose": "Nová zpráva",
"search": "Hledat v poště",
"theme_dark": "Přepnout na tmavý motiv",
"theme_light": "Přepnout na světlý motiv",
"shortcuts": "Zobrazit klávesové zkratky",
"group_navigation": "Přejít na",
"group_actions": "Akce",
"group_plugins": "Pluginy"
},
"notification_center": {
"title": "Oznámení",
"mark_all_read": "Označit vše jako přečtené",
"clear": "Vymazat",
"empty": "Žádná oznámení",
"close": "Zavřít",
"unread": "Nepřečtené",
"unread_count": "Nepřečtené: {count}",
"level_error": "Chyba",
"level_warning": "Upozornění"
}
}
+36
View File
@@ -4340,5 +4340,41 @@
"preview_empty": "Vælg et resultat for at se det",
"open_full": "Åbn",
"view_label": "Visning"
},
"top_bar": {
"more": "Flere muligheder"
},
"plugin_pages": {
"loading": "Indlæser side",
"not_available_title": "Denne side er ikke tilgængelig",
"not_available_description": "Det plugin, der tilføjede den, er slået fra eller ikke længere installeret.",
"back_to_mail": "Tilbage til mail",
"settings_group": "Plugins"
},
"command_palette": {
"title": "Kommandopalet",
"open": "Åbn kommandopaletten",
"placeholder": "Skriv en kommando",
"no_results": "Ingen matchende kommandoer",
"go_to": "Gå til {name}",
"compose": "Ny besked",
"search": "Søg i mail",
"theme_dark": "Skift til mørkt tema",
"theme_light": "Skift til lyst tema",
"shortcuts": "Vis tastaturgenveje",
"group_navigation": "Gå til",
"group_actions": "Handlinger",
"group_plugins": "Plugins"
},
"notification_center": {
"title": "Notifikationer",
"mark_all_read": "Markér alle som læst",
"clear": "Ryd",
"empty": "Ingen notifikationer",
"close": "Luk",
"unread": "Ulæst",
"unread_count": "{count} ulæste",
"level_error": "Fejl",
"level_warning": "Advarsel"
}
}
+36
View File
@@ -4340,5 +4340,41 @@
"preview_empty": "Ergebnis auswählen, um es anzuzeigen",
"open_full": "Öffnen",
"view_label": "Ansicht"
},
"top_bar": {
"more": "Weitere Optionen"
},
"plugin_pages": {
"loading": "Seite wird geladen",
"not_available_title": "Diese Seite ist nicht verfügbar",
"not_available_description": "Das Plugin, das sie hinzugefügt hat, ist ausgeschaltet oder nicht mehr installiert.",
"back_to_mail": "Zurück zu E-Mail",
"settings_group": "Plugins"
},
"command_palette": {
"title": "Befehlspalette",
"open": "Befehlspalette öffnen",
"placeholder": "Befehl eingeben",
"no_results": "Keine passenden Befehle",
"go_to": "Zu {name} wechseln",
"compose": "Neue Nachricht",
"search": "E-Mails durchsuchen",
"theme_dark": "Dunkles Design verwenden",
"theme_light": "Helles Design verwenden",
"shortcuts": "Tastaturkürzel anzeigen",
"group_navigation": "Wechseln zu",
"group_actions": "Aktionen",
"group_plugins": "Plugins"
},
"notification_center": {
"title": "Benachrichtigungen",
"mark_all_read": "Alle als gelesen markieren",
"clear": "Leeren",
"empty": "Keine Benachrichtigungen",
"close": "Schließen",
"unread": "Ungelesen",
"unread_count": "{count} ungelesen",
"level_error": "Fehler",
"level_warning": "Warnung"
}
}
+36
View File
@@ -4340,5 +4340,41 @@
"preview_empty": "Select a result to preview it",
"open_full": "Open",
"view_label": "View"
},
"top_bar": {
"more": "More options"
},
"plugin_pages": {
"loading": "Loading page",
"not_available_title": "This page is not available",
"not_available_description": "The plugin that added it is turned off or no longer installed.",
"back_to_mail": "Back to Mail",
"settings_group": "Plugins"
},
"command_palette": {
"title": "Command palette",
"open": "Open the command palette",
"placeholder": "Type a command",
"no_results": "No matching commands",
"go_to": "Go to {name}",
"compose": "New message",
"search": "Search mail",
"theme_dark": "Switch to dark theme",
"theme_light": "Switch to light theme",
"shortcuts": "Show keyboard shortcuts",
"group_navigation": "Go to",
"group_actions": "Actions",
"group_plugins": "Plugins"
},
"notification_center": {
"title": "Notifications",
"mark_all_read": "Mark all read",
"clear": "Clear",
"empty": "No notifications",
"close": "Close",
"unread": "Unread",
"unread_count": "{count} unread",
"level_error": "Error",
"level_warning": "Warning"
}
}
+36
View File
@@ -4340,5 +4340,41 @@
"preview_empty": "Selecciona un resultado para previsualizarlo",
"open_full": "Abrir",
"view_label": "Vista"
},
"top_bar": {
"more": "Más opciones"
},
"plugin_pages": {
"loading": "Cargando la página",
"not_available_title": "Esta página no está disponible",
"not_available_description": "El complemento que la añadió está desactivado o ya no está instalado.",
"back_to_mail": "Volver al correo",
"settings_group": "Complementos"
},
"command_palette": {
"title": "Paleta de comandos",
"open": "Abrir la paleta de comandos",
"placeholder": "Escribe un comando",
"no_results": "No hay comandos que coincidan",
"go_to": "Ir a {name}",
"compose": "Mensaje nuevo",
"search": "Buscar en el correo",
"theme_dark": "Cambiar al tema oscuro",
"theme_light": "Cambiar al tema claro",
"shortcuts": "Mostrar atajos de teclado",
"group_navigation": "Ir a",
"group_actions": "Acciones",
"group_plugins": "Complementos"
},
"notification_center": {
"title": "Notificaciones",
"mark_all_read": "Marcar todo como leído",
"clear": "Borrar",
"empty": "No hay notificaciones",
"close": "Cerrar",
"unread": "Sin leer",
"unread_count": "{count} sin leer",
"level_error": "Error",
"level_warning": "Advertencia"
}
}
+36
View File
@@ -4340,5 +4340,41 @@
"preview_empty": "برای پیش‌نمایش یک نتیجه انتخاب کنید",
"open_full": "باز کردن",
"view_label": "نما"
},
"top_bar": {
"more": "گزینه‌های بیشتر"
},
"plugin_pages": {
"loading": "در حال بارگذاری صفحه",
"not_available_title": "این صفحه در دسترس نیست",
"not_available_description": "افزونه‌ای که آن را اضافه کرده خاموش است یا دیگر نصب نیست.",
"back_to_mail": "بازگشت به ایمیل",
"settings_group": "افزونه‌ها"
},
"command_palette": {
"title": "پالت فرمان",
"open": "باز کردن پالت فرمان",
"placeholder": "یک فرمان بنویسید",
"no_results": "فرمان منطبقی پیدا نشد",
"go_to": "رفتن به {name}",
"compose": "پیام جدید",
"search": "جستجو در ایمیل",
"theme_dark": "رفتن به پوسته تیره",
"theme_light": "رفتن به پوسته روشن",
"shortcuts": "نمایش میانبرهای صفحه کلید",
"group_navigation": "رفتن به",
"group_actions": "کارها",
"group_plugins": "افزونه‌ها"
},
"notification_center": {
"title": "اعلان‌ها",
"mark_all_read": "علامت‌گذاری همه به‌عنوان خوانده‌شده",
"clear": "پاک کردن",
"empty": "اعلانی نیست",
"close": "بستن",
"unread": "خوانده‌نشده",
"unread_count": "{count} خوانده‌نشده",
"level_error": "خطا",
"level_warning": "هشدار"
}
}
+36
View File
@@ -4340,5 +4340,41 @@
"preview_empty": "Sélectionnez un résultat pour l'aperçu",
"open_full": "Ouvrir",
"view_label": "Affichage"
},
"top_bar": {
"more": "Plus d'options"
},
"plugin_pages": {
"loading": "Chargement de la page",
"not_available_title": "Cette page n'est pas disponible",
"not_available_description": "Le plugin qui l'a ajoutée est désactivé ou n'est plus installé.",
"back_to_mail": "Retour à la messagerie",
"settings_group": "Plugins"
},
"command_palette": {
"title": "Palette de commandes",
"open": "Ouvrir la palette de commandes",
"placeholder": "Tapez une commande",
"no_results": "Aucune commande correspondante",
"go_to": "Aller à {name}",
"compose": "Nouveau message",
"search": "Rechercher dans la messagerie",
"theme_dark": "Passer au thème sombre",
"theme_light": "Passer au thème clair",
"shortcuts": "Afficher les raccourcis clavier",
"group_navigation": "Aller à",
"group_actions": "Actions",
"group_plugins": "Plugins"
},
"notification_center": {
"title": "Notifications",
"mark_all_read": "Tout marquer comme lu",
"clear": "Effacer",
"empty": "Aucune notification",
"close": "Fermer",
"unread": "Non lu",
"unread_count": "Non lues : {count}",
"level_error": "Erreur",
"level_warning": "Avertissement"
}
}
+36
View File
@@ -4341,5 +4341,41 @@
"preview_empty": "בחרו תוצאה לתצוגה מקדימה",
"open_full": "פתיחה",
"view_label": "תצוגה"
},
"top_bar": {
"more": "אפשרויות נוספות"
},
"plugin_pages": {
"loading": "טוען את הדף",
"not_available_title": "הדף הזה אינו זמין",
"not_available_description": "התוסף שהוסיף אותו כבוי או שאינו מותקן עוד.",
"back_to_mail": "חזרה לדואר",
"settings_group": "תוספים"
},
"command_palette": {
"title": "לוח פקודות",
"open": "פתיחת לוח הפקודות",
"placeholder": "הקלידו פקודה",
"no_results": "אין פקודות תואמות",
"go_to": "מעבר אל {name}",
"compose": "הודעה חדשה",
"search": "חיפוש בדואר",
"theme_dark": "מעבר לערכת נושא כהה",
"theme_light": "מעבר לערכת נושא בהירה",
"shortcuts": "הצגת קיצורי מקשים",
"group_navigation": "מעבר אל",
"group_actions": "פעולות",
"group_plugins": "תוספים"
},
"notification_center": {
"title": "התראות",
"mark_all_read": "סימון הכול כנקרא",
"clear": "ניקוי",
"empty": "אין התראות",
"close": "סגירה",
"unread": "לא נקרא",
"unread_count": "לא נקראו: {count}",
"level_error": "שגיאה",
"level_warning": "אזהרה"
}
}
+36
View File
@@ -4340,5 +4340,41 @@
"preview_empty": "Válasszon egy találatot az előnézethez",
"open_full": "Megnyitás",
"view_label": "Nézet"
},
"top_bar": {
"more": "További lehetőségek"
},
"plugin_pages": {
"loading": "Oldal betöltése",
"not_available_title": "Ez az oldal nem érhető el",
"not_available_description": "A bővítmény, amely hozzáadta, ki van kapcsolva, vagy már nincs telepítve.",
"back_to_mail": "Vissza a levelekhez",
"settings_group": "Bővítmények"
},
"command_palette": {
"title": "Parancspaletta",
"open": "Parancspaletta megnyitása",
"placeholder": "Írjon be egy parancsot",
"no_results": "Nincs egyező parancs",
"go_to": "Ugrás ide: {name}",
"compose": "Új levél",
"search": "Keresés a levelekben",
"theme_dark": "Váltás sötét témára",
"theme_light": "Váltás világos témára",
"shortcuts": "Billentyűparancsok megjelenítése",
"group_navigation": "Ugrás ide",
"group_actions": "Műveletek",
"group_plugins": "Bővítmények"
},
"notification_center": {
"title": "Értesítések",
"mark_all_read": "Összes megjelölése olvasottként",
"clear": "Törlés",
"empty": "Nincsenek értesítések",
"close": "Bezárás",
"unread": "Olvasatlan",
"unread_count": "{count} olvasatlan",
"level_error": "Hiba",
"level_warning": "Figyelmeztetés"
}
}
+36
View File
@@ -4340,5 +4340,41 @@
"preview_empty": "Seleziona un risultato per l'anteprima",
"open_full": "Apri",
"view_label": "Vista"
},
"top_bar": {
"more": "Altre opzioni"
},
"plugin_pages": {
"loading": "Caricamento della pagina",
"not_available_title": "Questa pagina non è disponibile",
"not_available_description": "Il plugin che l'ha aggiunta è disattivato o non è più installato.",
"back_to_mail": "Torna alla posta",
"settings_group": "Plugin"
},
"command_palette": {
"title": "Tavolozza comandi",
"open": "Apri la tavolozza comandi",
"placeholder": "Digita un comando",
"no_results": "Nessun comando corrispondente",
"go_to": "Vai a {name}",
"compose": "Nuovo messaggio",
"search": "Cerca nella posta",
"theme_dark": "Passa al tema scuro",
"theme_light": "Passa al tema chiaro",
"shortcuts": "Mostra le scorciatoie da tastiera",
"group_navigation": "Vai a",
"group_actions": "Azioni",
"group_plugins": "Plugin"
},
"notification_center": {
"title": "Notifiche",
"mark_all_read": "Segna tutte come lette",
"clear": "Cancella",
"empty": "Nessuna notifica",
"close": "Chiudi",
"unread": "Non letta",
"unread_count": "{count} non lette",
"level_error": "Errore",
"level_warning": "Avviso"
}
}
+36
View File
@@ -4340,5 +4340,41 @@
"preview_empty": "プレビューする結果を選択してください",
"open_full": "開く",
"view_label": "表示"
},
"top_bar": {
"more": "その他のオプション"
},
"plugin_pages": {
"loading": "ページを読み込んでいます",
"not_available_title": "このページは利用できません",
"not_available_description": "このページを追加したプラグインがオフになっているか、インストールされていません。",
"back_to_mail": "メールに戻る",
"settings_group": "プラグイン"
},
"command_palette": {
"title": "コマンドパレット",
"open": "コマンドパレットを開く",
"placeholder": "コマンドを入力",
"no_results": "一致するコマンドはありません",
"go_to": "{name}に移動",
"compose": "新規メッセージ",
"search": "メールを検索",
"theme_dark": "ダークテーマに切り替え",
"theme_light": "ライトテーマに切り替え",
"shortcuts": "キーボードショートカットを表示",
"group_navigation": "移動",
"group_actions": "操作",
"group_plugins": "プラグイン"
},
"notification_center": {
"title": "通知",
"mark_all_read": "すべて既読にする",
"clear": "クリア",
"empty": "通知はありません",
"close": "閉じる",
"unread": "未読",
"unread_count": "未読 {count} 件",
"level_error": "エラー",
"level_warning": "警告"
}
}
+36
View File
@@ -4340,5 +4340,41 @@
"preview_empty": "미리 볼 결과를 선택하세요",
"open_full": "열기",
"view_label": "보기"
},
"top_bar": {
"more": "추가 옵션"
},
"plugin_pages": {
"loading": "페이지를 불러오는 중",
"not_available_title": "이 페이지를 사용할 수 없습니다",
"not_available_description": "이 페이지를 추가한 플러그인이 꺼져 있거나 더 이상 설치되어 있지 않습니다.",
"back_to_mail": "메일로 돌아가기",
"settings_group": "플러그인"
},
"command_palette": {
"title": "명령 팔레트",
"open": "명령 팔레트 열기",
"placeholder": "명령을 입력하세요",
"no_results": "일치하는 명령이 없습니다",
"go_to": "{name}(으)로 이동",
"compose": "새 메일",
"search": "메일 검색",
"theme_dark": "어두운 테마로 전환",
"theme_light": "밝은 테마로 전환",
"shortcuts": "단축키 보기",
"group_navigation": "이동",
"group_actions": "작업",
"group_plugins": "플러그인"
},
"notification_center": {
"title": "알림",
"mark_all_read": "모두 읽음으로 표시",
"clear": "지우기",
"empty": "알림이 없습니다",
"close": "닫기",
"unread": "읽지 않음",
"unread_count": "읽지 않음 {count}개",
"level_error": "오류",
"level_warning": "경고"
}
}
+36
View File
@@ -4340,5 +4340,41 @@
"preview_empty": "Izvēlieties rezultātu, lai to priekšskatītu",
"open_full": "Atvērt",
"view_label": "Skats"
},
"top_bar": {
"more": "Citas iespējas"
},
"plugin_pages": {
"loading": "Ielādē lapu",
"not_available_title": "Šī lapa nav pieejama",
"not_available_description": "Spraudnis, kas to pievienoja, ir izslēgts vai vairs nav instalēts.",
"back_to_mail": "Atpakaļ uz pastu",
"settings_group": "Spraudņi"
},
"command_palette": {
"title": "Komandu palete",
"open": "Atvērt komandu paleti",
"placeholder": "Ierakstiet komandu",
"no_results": "Nav atbilstošu komandu",
"go_to": "Pāriet uz: {name}",
"compose": "Jauna vēstule",
"search": "Meklēt pastā",
"theme_dark": "Pārslēgt uz tumšo motīvu",
"theme_light": "Pārslēgt uz gaišo motīvu",
"shortcuts": "Rādīt tastatūras īsinājumtaustiņus",
"group_navigation": "Pāriet uz",
"group_actions": "Darbības",
"group_plugins": "Spraudņi"
},
"notification_center": {
"title": "Paziņojumi",
"mark_all_read": "Atzīmēt visus kā lasītus",
"clear": "Notīrīt",
"empty": "Nav paziņojumu",
"close": "Aizvērt",
"unread": "Nelasīts",
"unread_count": "Nelasīti: {count}",
"level_error": "Kļūda",
"level_warning": "Brīdinājums"
}
}
+36
View File
@@ -4340,5 +4340,41 @@
"preview_empty": "Урьдчилан харах үр дүнгээ сонгоно уу",
"open_full": "Нээх",
"view_label": "Харагдац"
},
"top_bar": {
"more": "Бусад сонголт"
},
"plugin_pages": {
"loading": "Хуудсыг ачаалж байна",
"not_available_title": "Энэ хуудас боломжгүй байна",
"not_available_description": "Үүнийг нэмсэн залгаас унтарсан эсвэл суулгаагүй болсон байна.",
"back_to_mail": "Мэйл рүү буцах",
"settings_group": "Залгаасууд"
},
"command_palette": {
"title": "Командын самбар",
"open": "Командын самбарыг нээх",
"placeholder": "Команд бичнэ үү",
"no_results": "Тохирох команд алга",
"go_to": "{name} руу очих",
"compose": "Шинэ захидал",
"search": "Мэйлээс хайх",
"theme_dark": "Бараан загвар руу шилжих",
"theme_light": "Цайвар загвар руу шилжих",
"shortcuts": "Гарын товчлолыг харуулах",
"group_navigation": "Очих",
"group_actions": "Үйлдлүүд",
"group_plugins": "Залгаасууд"
},
"notification_center": {
"title": "Мэдэгдэл",
"mark_all_read": "Бүгдийг уншсан болгох",
"clear": "Цэвэрлэх",
"empty": "Мэдэгдэл алга",
"close": "Хаах",
"unread": "Уншаагүй",
"unread_count": "Уншаагүй: {count}",
"level_error": "Алдаа",
"level_warning": "Анхааруулга"
}
}
+36
View File
@@ -4340,5 +4340,41 @@
"preview_empty": "Velg et resultat for å forhåndsvise det",
"open_full": "Åpne",
"view_label": "Visning"
},
"top_bar": {
"more": "Flere alternativer"
},
"plugin_pages": {
"loading": "Laster inn siden",
"not_available_title": "Denne siden er ikke tilgjengelig",
"not_available_description": "Tillegget som la den til, er slått av eller ikke lenger installert.",
"back_to_mail": "Tilbake til e-post",
"settings_group": "Tillegg"
},
"command_palette": {
"title": "Kommandopalett",
"open": "Åpne kommandopaletten",
"placeholder": "Skriv en kommando",
"no_results": "Ingen samsvarende kommandoer",
"go_to": "Gå til {name}",
"compose": "Ny melding",
"search": "Søk i e-post",
"theme_dark": "Bytt til mørkt tema",
"theme_light": "Bytt til lyst tema",
"shortcuts": "Vis tastatursnarveier",
"group_navigation": "Gå til",
"group_actions": "Handlinger",
"group_plugins": "Tillegg"
},
"notification_center": {
"title": "Varsler",
"mark_all_read": "Merk alle som lest",
"clear": "Tøm",
"empty": "Ingen varsler",
"close": "Lukk",
"unread": "Ulest",
"unread_count": "{count} uleste",
"level_error": "Feil",
"level_warning": "Advarsel"
}
}
+36
View File
@@ -4340,5 +4340,41 @@
"preview_empty": "Selecteer een resultaat voor een voorbeeld",
"open_full": "Openen",
"view_label": "Weergave"
},
"top_bar": {
"more": "Meer opties"
},
"plugin_pages": {
"loading": "Pagina laden",
"not_available_title": "Deze pagina is niet beschikbaar",
"not_available_description": "De plug-in die hem heeft toegevoegd, staat uit of is niet meer geïnstalleerd.",
"back_to_mail": "Terug naar e-mail",
"settings_group": "Plug-ins"
},
"command_palette": {
"title": "Opdrachtenpalet",
"open": "Opdrachtenpalet openen",
"placeholder": "Typ een opdracht",
"no_results": "Geen overeenkomende opdrachten",
"go_to": "Naar {name}",
"compose": "Nieuw bericht",
"search": "E-mail doorzoeken",
"theme_dark": "Donker thema gebruiken",
"theme_light": "Licht thema gebruiken",
"shortcuts": "Sneltoetsen tonen",
"group_navigation": "Naar",
"group_actions": "Acties",
"group_plugins": "Plug-ins"
},
"notification_center": {
"title": "Meldingen",
"mark_all_read": "Alles als gelezen markeren",
"clear": "Wissen",
"empty": "Geen meldingen",
"close": "Sluiten",
"unread": "Ongelezen",
"unread_count": "{count} ongelezen",
"level_error": "Fout",
"level_warning": "Waarschuwing"
}
}
+36
View File
@@ -4340,5 +4340,41 @@
"preview_empty": "Wybierz wynik, aby zobaczyć podgląd",
"open_full": "Otwórz",
"view_label": "Widok"
},
"top_bar": {
"more": "Więcej opcji"
},
"plugin_pages": {
"loading": "Wczytywanie strony",
"not_available_title": "Ta strona jest niedostępna",
"not_available_description": "Wtyczka, która ją dodała, jest wyłączona lub nie jest już zainstalowana.",
"back_to_mail": "Wróć do poczty",
"settings_group": "Wtyczki"
},
"command_palette": {
"title": "Paleta poleceń",
"open": "Otwórz paletę poleceń",
"placeholder": "Wpisz polecenie",
"no_results": "Brak pasujących poleceń",
"go_to": "Przejdź do: {name}",
"compose": "Nowa wiadomość",
"search": "Szukaj w poczcie",
"theme_dark": "Przełącz na ciemny motyw",
"theme_light": "Przełącz na jasny motyw",
"shortcuts": "Pokaż skróty klawiszowe",
"group_navigation": "Przejdź do",
"group_actions": "Działania",
"group_plugins": "Wtyczki"
},
"notification_center": {
"title": "Powiadomienia",
"mark_all_read": "Oznacz wszystkie jako przeczytane",
"clear": "Wyczyść",
"empty": "Brak powiadomień",
"close": "Zamknij",
"unread": "Nieprzeczytane",
"unread_count": "Nieprzeczytane: {count}",
"level_error": "Błąd",
"level_warning": "Ostrzeżenie"
}
}
+36
View File
@@ -4340,5 +4340,41 @@
"preview_empty": "Selecione um resultado para pré-visualizar",
"open_full": "Abrir",
"view_label": "Vista"
},
"top_bar": {
"more": "Mais opções"
},
"plugin_pages": {
"loading": "Carregando a página",
"not_available_title": "Esta página não está disponível",
"not_available_description": "O plugin que a adicionou está desativado ou não está mais instalado.",
"back_to_mail": "Voltar ao e-mail",
"settings_group": "Plugins"
},
"command_palette": {
"title": "Paleta de comandos",
"open": "Abrir a paleta de comandos",
"placeholder": "Digite um comando",
"no_results": "Nenhum comando correspondente",
"go_to": "Ir para {name}",
"compose": "Nova mensagem",
"search": "Pesquisar no e-mail",
"theme_dark": "Mudar para o tema escuro",
"theme_light": "Mudar para o tema claro",
"shortcuts": "Mostrar atalhos de teclado",
"group_navigation": "Ir para",
"group_actions": "Ações",
"group_plugins": "Plugins"
},
"notification_center": {
"title": "Notificações",
"mark_all_read": "Marcar todas como lidas",
"clear": "Limpar",
"empty": "Nenhuma notificação",
"close": "Fechar",
"unread": "Não lida",
"unread_count": "{count} não lidas",
"level_error": "Erro",
"level_warning": "Aviso"
}
}
+36
View File
@@ -4340,5 +4340,41 @@
"preview_empty": "Selectează un rezultat pentru previzualizare",
"open_full": "Deschide",
"view_label": "Vizualizare"
},
"top_bar": {
"more": "Mai multe opțiuni"
},
"plugin_pages": {
"loading": "Se încarcă pagina",
"not_available_title": "Această pagină nu este disponibilă",
"not_available_description": "Pluginul care a adăugat-o este dezactivat sau nu mai este instalat.",
"back_to_mail": "Înapoi la e-mail",
"settings_group": "Pluginuri"
},
"command_palette": {
"title": "Paleta de comenzi",
"open": "Deschide paleta de comenzi",
"placeholder": "Tastați o comandă",
"no_results": "Nicio comandă potrivită",
"go_to": "Mergi la {name}",
"compose": "Mesaj nou",
"search": "Caută în e-mail",
"theme_dark": "Comută la tema întunecată",
"theme_light": "Comută la tema luminoasă",
"shortcuts": "Afișează comenzile rapide de la tastatură",
"group_navigation": "Mergi la",
"group_actions": "Acțiuni",
"group_plugins": "Pluginuri"
},
"notification_center": {
"title": "Notificări",
"mark_all_read": "Marchează toate ca citite",
"clear": "Golește",
"empty": "Nicio notificare",
"close": "Închide",
"unread": "Necitită",
"unread_count": "Necitite: {count}",
"level_error": "Eroare",
"level_warning": "Avertisment"
}
}
+36
View File
@@ -4340,5 +4340,41 @@
"preview_empty": "Выберите результат для предпросмотра",
"open_full": "Открыть",
"view_label": "Вид"
},
"top_bar": {
"more": "Другие действия"
},
"plugin_pages": {
"loading": "Загрузка страницы",
"not_available_title": "Эта страница недоступна",
"not_available_description": "Плагин, который её добавил, выключен или больше не установлен.",
"back_to_mail": "Вернуться к почте",
"settings_group": "Плагины"
},
"command_palette": {
"title": "Палитра команд",
"open": "Открыть палитру команд",
"placeholder": "Введите команду",
"no_results": "Нет подходящих команд",
"go_to": "Перейти: {name}",
"compose": "Новое письмо",
"search": "Искать в почте",
"theme_dark": "Включить тёмную тему",
"theme_light": "Включить светлую тему",
"shortcuts": "Показать сочетания клавиш",
"group_navigation": "Перейти",
"group_actions": "Действия",
"group_plugins": "Плагины"
},
"notification_center": {
"title": "Уведомления",
"mark_all_read": "Отметить все как прочитанные",
"clear": "Очистить",
"empty": "Нет уведомлений",
"close": "Закрыть",
"unread": "Непрочитанное",
"unread_count": "Непрочитанных: {count}",
"level_error": "Ошибка",
"level_warning": "Предупреждение"
}
}
+36
View File
@@ -4340,5 +4340,41 @@
"preview_empty": "Vyberte výsledok pre náhľad",
"open_full": "Otvoriť",
"view_label": "Zobrazenie"
},
"top_bar": {
"more": "Ďalšie možnosti"
},
"plugin_pages": {
"loading": "Načítava sa stránka",
"not_available_title": "Táto stránka nie je k dispozícii",
"not_available_description": "Doplnok, ktorý ju pridal, je vypnutý alebo už nie je nainštalovaný.",
"back_to_mail": "Späť na poštu",
"settings_group": "Doplnky"
},
"command_palette": {
"title": "Paleta príkazov",
"open": "Otvoriť paletu príkazov",
"placeholder": "Zadajte príkaz",
"no_results": "Žiadne zodpovedajúce príkazy",
"go_to": "Prejsť na {name}",
"compose": "Nová správa",
"search": "Hľadať v pošte",
"theme_dark": "Prepnúť na tmavý motív",
"theme_light": "Prepnúť na svetlý motív",
"shortcuts": "Zobraziť klávesové skratky",
"group_navigation": "Prejsť na",
"group_actions": "Akcie",
"group_plugins": "Doplnky"
},
"notification_center": {
"title": "Oznámenia",
"mark_all_read": "Označiť všetko ako prečítané",
"clear": "Vymazať",
"empty": "Žiadne oznámenia",
"close": "Zavrieť",
"unread": "Neprečítané",
"unread_count": "Neprečítané: {count}",
"level_error": "Chyba",
"level_warning": "Varovanie"
}
}
+36
View File
@@ -4340,5 +4340,41 @@
"preview_empty": "Önizlemek için bir sonuç seçin",
"open_full": "Aç",
"view_label": "Görünüm"
},
"top_bar": {
"more": "Diğer seçenekler"
},
"plugin_pages": {
"loading": "Sayfa yükleniyor",
"not_available_title": "Bu sayfa kullanılamıyor",
"not_available_description": "Bu sayfayı ekleyen eklenti kapalı veya artık yüklü değil.",
"back_to_mail": "Postaya dön",
"settings_group": "Eklentiler"
},
"command_palette": {
"title": "Komut paleti",
"open": "Komut paletini aç",
"placeholder": "Bir komut yazın",
"no_results": "Eşleşen komut yok",
"go_to": "{name} bölümüne git",
"compose": "Yeni ileti",
"search": "Postada ara",
"theme_dark": "Koyu temaya geç",
"theme_light": "Açık temaya geç",
"shortcuts": "Klavye kısayollarını göster",
"group_navigation": "Git",
"group_actions": "Eylemler",
"group_plugins": "Eklentiler"
},
"notification_center": {
"title": "Bildirimler",
"mark_all_read": "Tümünü okundu işaretle",
"clear": "Temizle",
"empty": "Bildirim yok",
"close": "Kapat",
"unread": "Okunmamış",
"unread_count": "{count} okunmamış",
"level_error": "Hata",
"level_warning": "Uyarı"
}
}
+36
View File
@@ -4340,5 +4340,41 @@
"preview_empty": "Виберіть результат для перегляду",
"open_full": "Відкрити",
"view_label": "Вигляд"
},
"top_bar": {
"more": "Інші дії"
},
"plugin_pages": {
"loading": "Завантаження сторінки",
"not_available_title": "Ця сторінка недоступна",
"not_available_description": "Плагін, який її додав, вимкнено або більше не встановлено.",
"back_to_mail": "Повернутися до пошти",
"settings_group": "Плагіни"
},
"command_palette": {
"title": "Палітра команд",
"open": "Відкрити палітру команд",
"placeholder": "Введіть команду",
"no_results": "Немає відповідних команд",
"go_to": "Перейти: {name}",
"compose": "Новий лист",
"search": "Шукати в пошті",
"theme_dark": "Увімкнути темну тему",
"theme_light": "Увімкнути світлу тему",
"shortcuts": "Показати комбінації клавіш",
"group_navigation": "Перейти",
"group_actions": "Дії",
"group_plugins": "Плагіни"
},
"notification_center": {
"title": "Сповіщення",
"mark_all_read": "Позначити всі як прочитані",
"clear": "Очистити",
"empty": "Немає сповіщень",
"close": "Закрити",
"unread": "Непрочитане",
"unread_count": "Непрочитаних: {count}",
"level_error": "Помилка",
"level_warning": "Попередження"
}
}
+36
View File
@@ -4340,5 +4340,41 @@
"preview_empty": "選擇結果以預覽",
"open_full": "開啟",
"view_label": "檢視"
},
"top_bar": {
"more": "更多選項"
},
"plugin_pages": {
"loading": "正在載入頁面",
"not_available_title": "此頁面無法使用",
"not_available_description": "新增此頁面的外掛程式已關閉或已不再安裝。",
"back_to_mail": "返回郵件",
"settings_group": "外掛程式"
},
"command_palette": {
"title": "命令面板",
"open": "開啟命令面板",
"placeholder": "輸入命令",
"no_results": "沒有相符的命令",
"go_to": "前往{name}",
"compose": "新郵件",
"search": "搜尋郵件",
"theme_dark": "切換為深色主題",
"theme_light": "切換為淺色主題",
"shortcuts": "顯示鍵盤快速鍵",
"group_navigation": "前往",
"group_actions": "動作",
"group_plugins": "外掛程式"
},
"notification_center": {
"title": "通知",
"mark_all_read": "全部標示為已讀",
"clear": "清除",
"empty": "沒有通知",
"close": "關閉",
"unread": "未讀",
"unread_count": "{count} 則未讀",
"level_error": "錯誤",
"level_warning": "警告"
}
}
+36
View File
@@ -4340,5 +4340,41 @@
"preview_empty": "选择一个结果以预览",
"open_full": "打开",
"view_label": "视图"
},
"top_bar": {
"more": "更多选项"
},
"plugin_pages": {
"loading": "正在加载页面",
"not_available_title": "此页面不可用",
"not_available_description": "添加此页面的插件已关闭或已不再安装。",
"back_to_mail": "返回邮件",
"settings_group": "插件"
},
"command_palette": {
"title": "命令面板",
"open": "打开命令面板",
"placeholder": "输入命令",
"no_results": "没有匹配的命令",
"go_to": "前往{name}",
"compose": "新邮件",
"search": "搜索邮件",
"theme_dark": "切换到深色主题",
"theme_light": "切换到浅色主题",
"shortcuts": "显示键盘快捷键",
"group_navigation": "前往",
"group_actions": "操作",
"group_plugins": "插件"
},
"notification_center": {
"title": "通知",
"mark_all_read": "全部标为已读",
"clear": "清除",
"empty": "没有通知",
"close": "关闭",
"unread": "未读",
"unread_count": "{count} 条未读",
"level_error": "错误",
"level_warning": "警告"
}
}
@@ -0,0 +1,70 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
MAX_ENTRIES,
reloadNotificationCenter,
selectUnreadCount,
useNotificationCenterStore,
} from '../notification-center-store';
import { toast } from '../toast-store';
const store = () => useNotificationCenterStore.getState();
beforeEach(() => {
localStorage.clear();
store().clear();
});
afterEach(() => vi.restoreAllMocks());
describe('notification center', () => {
it('keeps the newest first and only the last 50', () => {
for (let i = 0; i < MAX_ENTRIES + 5; i++) store().add({ level: 'info', title: `n${i}`, body: '' });
expect(store().entries).toHaveLength(MAX_ENTRIES);
expect(store().entries[0].title).toBe(`n${MAX_ENTRIES + 4}`);
expect(store().entries.at(-1)!.title).toBe('n5');
});
it('survives a reload of the page', () => {
store().add({ level: 'info', title: 'From the plugin', body: 'b', pluginId: 'rss', pluginName: 'RSS', tag: 't1' });
useNotificationCenterStore.setState({ entries: [] });
reloadNotificationCenter();
expect(store().entries).toMatchObject([{ title: 'From the plugin', pluginId: 'rss', pluginName: 'RSS', tag: 't1', read: false }]);
});
it('counts unread, marks all read and clears', () => {
store().add({ level: 'error', title: 'a', body: '' });
store().add({ level: 'warning', title: 'b', body: '' });
expect(selectUnreadCount(store())).toBe(2);
store().markAllRead();
expect(selectUnreadCount(store())).toBe(0);
reloadNotificationCenter();
expect(selectUnreadCount(store())).toBe(0);
store().clear();
reloadNotificationCenter();
expect(store().entries).toEqual([]);
});
it('works without storage', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('blocked'); });
vi.spyOn(localStorage, 'setItem').mockImplementation(() => { throw new Error('blocked'); });
expect(() => store().add({ level: 'info', title: 'x', body: '' })).not.toThrow();
expect(store().entries).toHaveLength(1);
});
it('drops malformed stored entries', () => {
localStorage.setItem('notification-center', JSON.stringify([{ id: 1 }, 'x', { id: 'a', time: 1, title: 't', body: '', read: false, level: 'info' }]));
reloadNotificationCenter();
expect(store().entries.map((e) => e.id)).toEqual(['a']);
});
it('records the app\'s error and warning toasts, not the others', () => {
toast.error('Could not send', 'Server said no');
toast.warning('Quota almost full');
toast.success('Sent');
toast.info('Heads up');
expect(store().entries.map((e) => [e.level, e.title, e.body])).toEqual([
['warning', 'Quota almost full', ''],
['error', 'Could not send', 'Server said no'],
]);
expect(store().entries[0].pluginId).toBeUndefined();
});
});
+114
View File
@@ -0,0 +1,114 @@
import { create } from 'zustand';
import { generateUUID } from '@/lib/utils';
// The in-app notification list behind the bell: what plugins showed with
// api.notifications.show and the app's own error and warning toasts. Kept in
// this browser only (localStorage), the last MAX_ENTRIES of them.
export const MAX_ENTRIES = 50;
const STORAGE_KEY = 'notification-center';
export interface CenterEntry {
id: string;
/** Unix ms. */
time: number;
level: 'error' | 'warning' | 'info';
title: string;
body: string;
/** Set for plugin notifications: whose they are, and the tag the plugin passed. */
pluginId?: string;
pluginName?: string;
tag?: string;
read: boolean;
}
export type CenterEntryInput = Omit<CenterEntry, 'id' | 'time' | 'read'> & { time?: number };
interface NotificationCenterState {
entries: CenterEntry[];
add: (input: CenterEntryInput) => void;
markRead: (id: string) => void;
markAllRead: () => void;
clear: () => void;
}
function isEntry(value: unknown): value is CenterEntry {
if (!value || typeof value !== 'object') return false;
const e = value as Record<string, unknown>;
return typeof e.id === 'string' && typeof e.time === 'number' && typeof e.title === 'string'
&& typeof e.body === 'string' && typeof e.read === 'boolean'
&& (e.level === 'error' || e.level === 'warning' || e.level === 'info');
}
function load(): CenterEntry[] {
try {
const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null;
if (!raw) return [];
const parsed: unknown = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter(isEntry).slice(0, MAX_ENTRIES) : [];
} catch {
return [];
}
}
function save(entries: CenterEntry[]): void {
try {
if (entries.length === 0) localStorage.removeItem(STORAGE_KEY);
else localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
} catch {
// Storage blocked or full: the list still works for this page load.
}
}
export const useNotificationCenterStore = create<NotificationCenterState>((set, get) => ({
entries: load(),
add: (input) => {
const entry: CenterEntry = {
id: generateUUID(),
time: input.time ?? Date.now(),
level: input.level,
title: input.title,
body: input.body,
...(input.pluginId ? { pluginId: input.pluginId, pluginName: input.pluginName ?? input.pluginId } : {}),
...(input.tag ? { tag: input.tag } : {}),
read: false,
};
const entries = [entry, ...get().entries].slice(0, MAX_ENTRIES);
set({ entries });
save(entries);
},
markRead: (id) => {
const entries = get().entries.map((e) => (e.id === id && !e.read ? { ...e, read: true } : e));
set({ entries });
save(entries);
},
markAllRead: () => {
const entries = get().entries.map((e) => (e.read ? e : { ...e, read: true }));
set({ entries });
save(entries);
},
clear: () => {
set({ entries: [] });
save([]);
},
}));
export const selectUnreadCount = (s: NotificationCenterState) => s.entries.reduce((n, e) => n + (e.read ? 0 : 1), 0);
/** Add an entry from outside React (toast store, plugin host API). */
export function recordNotification(input: CenterEntryInput): void {
try {
useNotificationCenterStore.getState().add(input);
} catch {
// Never let the list break the thing that is being reported.
}
}
/** Test helper: read the list back from storage as a fresh page load would. */
export function reloadNotificationCenter(): void {
useNotificationCenterStore.setState({ entries: load() });
}
+5
View File
@@ -2,6 +2,7 @@ import { create } from "zustand";
import { Toast, ToastAction } from "@/components/ui/toast";
import { generateUUID } from "@/lib/utils";
import { toastHooks } from "@/lib/plugin-hooks";
import { recordNotification } from "@/stores/notification-center-store";
interface ToastStore {
toasts: Toast[];
@@ -33,6 +34,10 @@ export const useToastStore = create<ToastStore>((set, get) => ({
void toastHooks.onToastShow.emit({
id, type: toast.type, title: toast.title, message: toast.message ?? "",
});
// Errors and warnings stay in the bell's list after the toast is gone.
if (toast.type === "error" || toast.type === "warning") {
recordNotification({ level: toast.type, title: toast.title, body: toast.message ?? "" });
}
return id;
},