mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
Merge branch 'slots/nav' into feat/plugin-system-audit
# Conflicts: # lib/__tests__/plugin-slot-catalog.test.ts
This commit is contained in:
@@ -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)} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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}/`);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
@@ -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": "تحذير"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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í"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "هشدار"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "אזהרה"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "警告"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "경고"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Анхааруулга"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Предупреждение"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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ı"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Попередження"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "警告"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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() });
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user