diff --git a/app/(main)/[locale]/apps/[[...segments]]/page.tsx b/app/(main)/[locale]/apps/[[...segments]]/page.tsx new file mode 100644 index 000000000..bd91cb2f0 --- /dev/null +++ b/app/(main)/[locale]/apps/[[...segments]]/page.tsx @@ -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//[/]. +// 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 ; +} diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index de115e8a8..b4e444621 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -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({ {children} + diff --git a/components/__tests__/keyboard-shortcuts-palette.test.tsx b/components/__tests__/keyboard-shortcuts-palette.test.tsx new file mode 100644 index 000000000..87567955a --- /dev/null +++ b/components/__tests__/keyboard-shortcuts-palette.test.tsx @@ -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( {}} />); + 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( {}} />); + expect(screen.getByText('shortcuts.global.global_search')).toBeInTheDocument(); + expect(screen.queryByText('command_palette.open')).toBeNull(); + }); +}); diff --git a/components/command-palette/__tests__/command-palette.test.tsx b/components/command-palette/__tests__/command-palette.test.tsx new file mode 100644 index 000000000..b7869b0bb --- /dev/null +++ b/components/command-palette/__tests__/command-palette.test.tsx @@ -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) => + 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 ?
shortcuts help
: 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(); + 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(); + act(() => appCommands.openCommandPalette()); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('leaves Ctrl+K alone while typing in a field', () => { + render(<>); + 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(); + pressCtrlK(); + expect(screen.queryByRole('dialog')).toBeNull(); + unmount(); + + nav.pathname = '/'; + useAuthStore.setState({ isAuthenticated: false } as never); + render(); + pressCtrlK(); + expect(screen.queryByRole('dialog')).toBeNull(); + }); + + it('lists the app commands and no plugin group without plugin entries', () => { + render(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + }); +}); diff --git a/components/command-palette/command-palette.tsx b/components/command-palette/command-palette.tsx new file mode 100644 index 000000000..d3c4b8d4d --- /dev/null +++ b/components/command-palette/command-palette.tsx @@ -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 && setOpen(false)} onShowShortcuts={() => setShowShortcuts(true)} />} + 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(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(() => { + 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(`[data-index="${activeIndex}"]`); + node?.scrollIntoView?.({ block: "nearest" }); + }, [activeIndex]); + + const runCommand = useCallback((command: PaletteCommand) => { + onClose(); + command.run(); + }, [onClose]); + + const onKeyDown = (event: React.KeyboardEvent) => { + 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 ( +
{ if (e.target === e.currentTarget) onClose(); }} + > +
+
+ + = 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" + /> + Esc +
+
    + {visible.length === 0 && ( +
  • {t("no_results")}
  • + )} + {visible.map((command, index) => { + const Icon = command.icon; + const showGroup = index === 0 || visible[index - 1].group !== command.group; + return ( +
  • + {showGroup && ( + + )} +
    { 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", + )} + > + + {command.label} + {command.source && ( + {command.source} + )} +
    +
  • + ); + })} +
+
+
+ ); +} diff --git a/components/keyboard-shortcuts-modal.tsx b/components/keyboard-shortcuts-modal.tsx index bddd6041a..50bdea13e 100644 --- a/components/keyboard-shortcuts-modal.tsx +++ b/components/keyboard-shortcuts-modal.tsx @@ -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, diff --git a/components/layout/__tests__/navigation-plugin-pages.test.tsx b/components/layout/__tests__/navigation-plugin-pages.test.tsx new file mode 100644 index 000000000..e0e84e679 --- /dev/null +++ b/components/layout/__tests__/navigation-plugin-pages.test.tsx @@ -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 }) => {children}, +})); + +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(); + 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(); + + 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(); + 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(); + }); +}); diff --git a/components/layout/__tests__/sidebar-plugin-section.test.tsx b/components/layout/__tests__/sidebar-plugin-section.test.tsx new file mode 100644 index 000000000..412f2f42e --- /dev/null +++ b/components/layout/__tests__/sidebar-plugin-section.test.tsx @@ -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(); + + 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(); + + 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; 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(); + 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(); + expect(screen.getByRole('button', { name: 'Agenda' })).toHaveAttribute('aria-expanded', 'false'); + await new Promise((r) => setTimeout(r, 20)); + expect(createSlotInstance).not.toHaveBeenCalled(); + }); +}); diff --git a/components/layout/__tests__/top-bar-plugin-actions.test.tsx b/components/layout/__tests__/top-bar-plugin-actions.test.tsx new file mode 100644 index 000000000..d4e4c48ae --- /dev/null +++ b/components/layout/__tests__/top-bar-plugin-actions.test.tsx @@ -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: () => })); +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(); + 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(); + + 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(); + 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(); + + 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(); + }); +}); diff --git a/components/layout/mobile-header.tsx b/components/layout/mobile-header.tsx index 7e929ddae..3dc6db4cd 100644 --- a/components/layout/mobile-header.tsx +++ b/components/layout/mobile-header.tsx @@ -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 = {}; + +/** + * 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(null); + const close = useCallback(() => setOpen(false), []); + const { menuRef, onKeyDown } = useMenuNavigation({ 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 ( +
+ + {open && ( +
+ 0 ? formatBadgeCount(unread) : undefined} + onClick={() => { close(); setShowNotifications(true); }} + /> + { close(); openCommandPalette(); }} + /> + +
+ )} + {showNotifications && setShowNotifications(false)} />} +
+ ); +} interface MobileHeaderProps { title: string; @@ -166,6 +245,8 @@ export function MobileHeader({ )} + + {/* 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 diff --git a/components/layout/mv-top-bar.tsx b/components/layout/mv-top-bar.tsx index d1a0b484d..399ec8f2c 100644 --- a/components/layout/mv-top-bar.tsx +++ b/components/layout/mv-top-bar.tsx @@ -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 = {}; + /** 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({ )} + {/* Plugin buttons (top-bar-actions), next to the search field */} + + {/* Spacer keeps the right cluster pinned while search stays left */}
+ {onShowShortcuts && ( + {/* Plugin buttons (top-bar-actions). On phones they sit in + the header's overflow menu instead (MobileHeader). */} + {!isMobile && ( + + )}
)} diff --git a/components/notifications/__tests__/notification-center.test.tsx b/components/notifications/__tests__/notification-center.test.tsx new file mode 100644 index 000000000..ffdd36d7f --- /dev/null +++ b/components/notifications/__tests__/notification-center.test.tsx @@ -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) => + 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(); + 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(); + + 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(); + 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(); + 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(); + 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(); + + 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(); + }); +}); diff --git a/components/notifications/notification-center.tsx b/components/notifications/notification-center.tsx new file mode 100644 index 000000000..8d42cf953 --- /dev/null +++ b/components/notifications/notification-center.tsx @@ -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 ; + if (entry.level === "warning") return ; + if (entry.pluginId) return ; + return ; +} + +/** + * 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 ( +
+
+

{t("title")}

+ + + {sheet && ( + + )} +
+ {entries.length === 0 ? ( +

{t("empty")}

+ ) : ( +
    + {entries.map((entry) => { + const source = entry.pluginName ?? appName; + const content = ( + <> + + + + {entry.title} + + + {entry.body && {entry.body}} + + {entry.level !== "info" && {t(entry.level === "error" ? "level_error" : "level_warning")}: } + {source} + + + {!entry.read && ( + + )} + + ); + return ( +
  • + {entry.pluginId ? ( + + ) : ( +
    + {content} +
    + )} +
  • + ); + })} +
+ )} +
+ ); +} + +/** 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( +
+ +
, + 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(null); + const panelRef = useRef(null); + const [style, setStyle] = useState({}); + 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 ( + <> + + {open && createPortal( +
+ +
, + document.body, + )} + + ); +} diff --git a/components/plugins/__tests__/plugin-page-app.test.tsx b/components/plugins/__tests__/plugin-page-app.test.tsx new file mode 100644 index 000000000..249a567e7 --- /dev/null +++ b/components/plugins/__tests__/plugin-page-app.test.tsx @@ -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//, 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 }) =>