feat: add a notifications panel for plugin notifications and app errors

This commit is contained in:
Linus Rath
2026-10-14 05:25:16 +02:00
parent 86e593a752
commit 8bf4df4513
38 changed files with 930 additions and 8 deletions
@@ -56,8 +56,11 @@ describe('phone header', () => {
render(<MobileHeader title="Inbox" />);
fireEvent.click(screen.getByRole('button', { name: 'more' }));
expect(screen.getAllByRole('menuitem').map((m) => m.textContent)).toEqual(['title']);
fireEvent.click(screen.getByRole('menuitem', { name: 'title' }));
// 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);
});
+20 -6
View File
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Menu, ArrowLeft, Plus, Search, X, MoreVertical, Terminal } from "@/components/icons";
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";
@@ -13,18 +13,22 @@ 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 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.
* 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), []);
@@ -47,12 +51,15 @@ function MobileHeaderMenu() {
variant="ghost"
size="icon"
onClick={() => setOpen((v) => !v)}
className={cn("h-11 w-11", open && "bg-accent")}
aria-label={t('more')}
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
@@ -62,6 +69,12 @@ function MobileHeaderMenu() {
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')}
@@ -70,6 +83,7 @@ function MobileHeaderMenu() {
<PluginActions surface="top-bar-actions" context={TOP_BAR_CONTEXT} variant="menu" onRan={close} />
</div>
)}
{showNotifications && <NotificationSheet onClose={() => setShowNotifications(false)} />}
</div>
);
}
+2
View File
@@ -34,6 +34,7 @@ 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. */
@@ -243,6 +244,7 @@ export function MvTopBar({
<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"
+3
View File
@@ -28,6 +28,7 @@ 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;
@@ -710,6 +711,8 @@ export function NavigationRail({
</a>
)}
<NotificationBell placement="side" className="w-10 h-10 rounded-md" />
<Link
href="/settings"
onClick={handleNavClick('settings')}
@@ -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,
)}
</>
);
}
@@ -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);
});
});
+4
View File
@@ -6,6 +6,7 @@ import type { InstalledPlugin, NotificationInput } from '../plugin-types';
import { toast as appToast } from '@/stores/toast-store';
import { toastHooks } from '../plugin-hooks';
import { readObject, readString, withinRate } from './host-api-input';
import { recordNotification } from '@/stores/notification-center-store';
export const MAX_NOTIFICATION_TITLE = 120;
export const MAX_NOTIFICATION_BODY = 300;
@@ -39,6 +40,9 @@ export function doNotificationsShow(plugin: InstalledPlugin, input: unknown): 's
}
const source = plugin.name || plugin.id;
// Also listed in the app's notification panel (the bell), where a click
// reaches the plugin the same way as a click on the system notification.
recordNotification({ level: 'info', title, body, pluginId: plugin.id, pluginName: source, ...(tag ? { tag } : {}) });
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
try {
const notification = new Notification(title, {
+11
View File
@@ -4365,5 +4365,16 @@
"group_navigation": "الانتقال إلى",
"group_actions": "إجراءات",
"group_plugins": "الإضافات"
},
"notification_center": {
"title": "الإشعارات",
"mark_all_read": "تعليم الكل كمقروء",
"clear": "مسح",
"empty": "لا توجد إشعارات",
"close": "إغلاق",
"unread": "غير مقروء",
"unread_count": "غير المقروءة: {count}",
"level_error": "خطأ",
"level_warning": "تحذير"
}
}
+11
View File
@@ -4365,5 +4365,16 @@
"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"
}
}
+11
View File
@@ -4365,5 +4365,16 @@
"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í"
}
}
+11
View File
@@ -4365,5 +4365,16 @@
"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"
}
}
+11
View File
@@ -4365,5 +4365,16 @@
"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"
}
}
+11
View File
@@ -4365,5 +4365,16 @@
"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"
}
}
+11
View File
@@ -4365,5 +4365,16 @@
"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"
}
}
+11
View File
@@ -4365,5 +4365,16 @@
"group_navigation": "رفتن به",
"group_actions": "کارها",
"group_plugins": "افزونه‌ها"
},
"notification_center": {
"title": "اعلان‌ها",
"mark_all_read": "علامت‌گذاری همه به‌عنوان خوانده‌شده",
"clear": "پاک کردن",
"empty": "اعلانی نیست",
"close": "بستن",
"unread": "خوانده‌نشده",
"unread_count": "{count} خوانده‌نشده",
"level_error": "خطا",
"level_warning": "هشدار"
}
}
+11
View File
@@ -4365,5 +4365,16 @@
"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"
}
}
+11
View File
@@ -4366,5 +4366,16 @@
"group_navigation": "מעבר אל",
"group_actions": "פעולות",
"group_plugins": "תוספים"
},
"notification_center": {
"title": "התראות",
"mark_all_read": "סימון הכול כנקרא",
"clear": "ניקוי",
"empty": "אין התראות",
"close": "סגירה",
"unread": "לא נקרא",
"unread_count": "לא נקראו: {count}",
"level_error": "שגיאה",
"level_warning": "אזהרה"
}
}
+11
View File
@@ -4365,5 +4365,16 @@
"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"
}
}
+11
View File
@@ -4365,5 +4365,16 @@
"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"
}
}
+11
View File
@@ -4365,5 +4365,16 @@
"group_navigation": "移動",
"group_actions": "操作",
"group_plugins": "プラグイン"
},
"notification_center": {
"title": "通知",
"mark_all_read": "すべて既読にする",
"clear": "クリア",
"empty": "通知はありません",
"close": "閉じる",
"unread": "未読",
"unread_count": "未読 {count} 件",
"level_error": "エラー",
"level_warning": "警告"
}
}
+11
View File
@@ -4365,5 +4365,16 @@
"group_navigation": "이동",
"group_actions": "작업",
"group_plugins": "플러그인"
},
"notification_center": {
"title": "알림",
"mark_all_read": "모두 읽음으로 표시",
"clear": "지우기",
"empty": "알림이 없습니다",
"close": "닫기",
"unread": "읽지 않음",
"unread_count": "읽지 않음 {count}개",
"level_error": "오류",
"level_warning": "경고"
}
}
+11
View File
@@ -4365,5 +4365,16 @@
"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"
}
}
+11
View File
@@ -4365,5 +4365,16 @@
"group_navigation": "Очих",
"group_actions": "Үйлдлүүд",
"group_plugins": "Залгаасууд"
},
"notification_center": {
"title": "Мэдэгдэл",
"mark_all_read": "Бүгдийг уншсан болгох",
"clear": "Цэвэрлэх",
"empty": "Мэдэгдэл алга",
"close": "Хаах",
"unread": "Уншаагүй",
"unread_count": "Уншаагүй: {count}",
"level_error": "Алдаа",
"level_warning": "Анхааруулга"
}
}
+11
View File
@@ -4365,5 +4365,16 @@
"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"
}
}
+11
View File
@@ -4365,5 +4365,16 @@
"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"
}
}
+11
View File
@@ -4365,5 +4365,16 @@
"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"
}
}
+11
View File
@@ -4365,5 +4365,16 @@
"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"
}
}
+11
View File
@@ -4365,5 +4365,16 @@
"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"
}
}
+11
View File
@@ -4365,5 +4365,16 @@
"group_navigation": "Перейти",
"group_actions": "Действия",
"group_plugins": "Плагины"
},
"notification_center": {
"title": "Уведомления",
"mark_all_read": "Отметить все как прочитанные",
"clear": "Очистить",
"empty": "Нет уведомлений",
"close": "Закрыть",
"unread": "Непрочитанное",
"unread_count": "Непрочитанных: {count}",
"level_error": "Ошибка",
"level_warning": "Предупреждение"
}
}
+11
View File
@@ -4365,5 +4365,16 @@
"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"
}
}
+11
View File
@@ -4365,5 +4365,16 @@
"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ı"
}
}
+11
View File
@@ -4365,5 +4365,16 @@
"group_navigation": "Перейти",
"group_actions": "Дії",
"group_plugins": "Плагіни"
},
"notification_center": {
"title": "Сповіщення",
"mark_all_read": "Позначити всі як прочитані",
"clear": "Очистити",
"empty": "Немає сповіщень",
"close": "Закрити",
"unread": "Непрочитане",
"unread_count": "Непрочитаних: {count}",
"level_error": "Помилка",
"level_warning": "Попередження"
}
}
+11
View File
@@ -4365,5 +4365,16 @@
"group_navigation": "前往",
"group_actions": "動作",
"group_plugins": "外掛程式"
},
"notification_center": {
"title": "通知",
"mark_all_read": "全部標示為已讀",
"clear": "清除",
"empty": "沒有通知",
"close": "關閉",
"unread": "未讀",
"unread_count": "{count} 則未讀",
"level_error": "錯誤",
"level_warning": "警告"
}
}
+11
View File
@@ -4365,5 +4365,16 @@
"group_navigation": "前往",
"group_actions": "操作",
"group_plugins": "插件"
},
"notification_center": {
"title": "通知",
"mark_all_read": "全部标为已读",
"clear": "清除",
"empty": "没有通知",
"close": "关闭",
"unread": "未读",
"unread_count": "{count} 条未读",
"level_error": "错误",
"level_warning": "警告"
}
}
@@ -0,0 +1,70 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
MAX_ENTRIES,
reloadNotificationCenter,
selectUnreadCount,
useNotificationCenterStore,
} from '../notification-center-store';
import { toast } from '../toast-store';
const store = () => useNotificationCenterStore.getState();
beforeEach(() => {
localStorage.clear();
store().clear();
});
afterEach(() => vi.restoreAllMocks());
describe('notification center', () => {
it('keeps the newest first and only the last 50', () => {
for (let i = 0; i < MAX_ENTRIES + 5; i++) store().add({ level: 'info', title: `n${i}`, body: '' });
expect(store().entries).toHaveLength(MAX_ENTRIES);
expect(store().entries[0].title).toBe(`n${MAX_ENTRIES + 4}`);
expect(store().entries.at(-1)!.title).toBe('n5');
});
it('survives a reload of the page', () => {
store().add({ level: 'info', title: 'From the plugin', body: 'b', pluginId: 'rss', pluginName: 'RSS', tag: 't1' });
useNotificationCenterStore.setState({ entries: [] });
reloadNotificationCenter();
expect(store().entries).toMatchObject([{ title: 'From the plugin', pluginId: 'rss', pluginName: 'RSS', tag: 't1', read: false }]);
});
it('counts unread, marks all read and clears', () => {
store().add({ level: 'error', title: 'a', body: '' });
store().add({ level: 'warning', title: 'b', body: '' });
expect(selectUnreadCount(store())).toBe(2);
store().markAllRead();
expect(selectUnreadCount(store())).toBe(0);
reloadNotificationCenter();
expect(selectUnreadCount(store())).toBe(0);
store().clear();
reloadNotificationCenter();
expect(store().entries).toEqual([]);
});
it('works without storage', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('blocked'); });
vi.spyOn(localStorage, 'setItem').mockImplementation(() => { throw new Error('blocked'); });
expect(() => store().add({ level: 'info', title: 'x', body: '' })).not.toThrow();
expect(store().entries).toHaveLength(1);
});
it('drops malformed stored entries', () => {
localStorage.setItem('notification-center', JSON.stringify([{ id: 1 }, 'x', { id: 'a', time: 1, title: 't', body: '', read: false, level: 'info' }]));
reloadNotificationCenter();
expect(store().entries.map((e) => e.id)).toEqual(['a']);
});
it('records the app\'s error and warning toasts, not the others', () => {
toast.error('Could not send', 'Server said no');
toast.warning('Quota almost full');
toast.success('Sent');
toast.info('Heads up');
expect(store().entries.map((e) => [e.level, e.title, e.body])).toEqual([
['warning', 'Quota almost full', ''],
['error', 'Could not send', 'Server said no'],
]);
expect(store().entries[0].pluginId).toBeUndefined();
});
});
+114
View File
@@ -0,0 +1,114 @@
import { create } from 'zustand';
import { generateUUID } from '@/lib/utils';
// The in-app notification list behind the bell: what plugins showed with
// api.notifications.show and the app's own error and warning toasts. Kept in
// this browser only (localStorage), the last MAX_ENTRIES of them.
export const MAX_ENTRIES = 50;
const STORAGE_KEY = 'notification-center';
export interface CenterEntry {
id: string;
/** Unix ms. */
time: number;
level: 'error' | 'warning' | 'info';
title: string;
body: string;
/** Set for plugin notifications: whose they are, and the tag the plugin passed. */
pluginId?: string;
pluginName?: string;
tag?: string;
read: boolean;
}
export type CenterEntryInput = Omit<CenterEntry, 'id' | 'time' | 'read'> & { time?: number };
interface NotificationCenterState {
entries: CenterEntry[];
add: (input: CenterEntryInput) => void;
markRead: (id: string) => void;
markAllRead: () => void;
clear: () => void;
}
function isEntry(value: unknown): value is CenterEntry {
if (!value || typeof value !== 'object') return false;
const e = value as Record<string, unknown>;
return typeof e.id === 'string' && typeof e.time === 'number' && typeof e.title === 'string'
&& typeof e.body === 'string' && typeof e.read === 'boolean'
&& (e.level === 'error' || e.level === 'warning' || e.level === 'info');
}
function load(): CenterEntry[] {
try {
const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null;
if (!raw) return [];
const parsed: unknown = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter(isEntry).slice(0, MAX_ENTRIES) : [];
} catch {
return [];
}
}
function save(entries: CenterEntry[]): void {
try {
if (entries.length === 0) localStorage.removeItem(STORAGE_KEY);
else localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
} catch {
// Storage blocked or full: the list still works for this page load.
}
}
export const useNotificationCenterStore = create<NotificationCenterState>((set, get) => ({
entries: load(),
add: (input) => {
const entry: CenterEntry = {
id: generateUUID(),
time: input.time ?? Date.now(),
level: input.level,
title: input.title,
body: input.body,
...(input.pluginId ? { pluginId: input.pluginId, pluginName: input.pluginName ?? input.pluginId } : {}),
...(input.tag ? { tag: input.tag } : {}),
read: false,
};
const entries = [entry, ...get().entries].slice(0, MAX_ENTRIES);
set({ entries });
save(entries);
},
markRead: (id) => {
const entries = get().entries.map((e) => (e.id === id && !e.read ? { ...e, read: true } : e));
set({ entries });
save(entries);
},
markAllRead: () => {
const entries = get().entries.map((e) => (e.read ? e : { ...e, read: true }));
set({ entries });
save(entries);
},
clear: () => {
set({ entries: [] });
save([]);
},
}));
export const selectUnreadCount = (s: NotificationCenterState) => s.entries.reduce((n, e) => n + (e.read ? 0 : 1), 0);
/** Add an entry from outside React (toast store, plugin host API). */
export function recordNotification(input: CenterEntryInput): void {
try {
useNotificationCenterStore.getState().add(input);
} catch {
// Never let the list break the thing that is being reported.
}
}
/** Test helper: read the list back from storage as a fresh page load would. */
export function reloadNotificationCenter(): void {
useNotificationCenterStore.setState({ entries: load() });
}
+5
View File
@@ -2,6 +2,7 @@ import { create } from "zustand";
import { Toast, ToastAction } from "@/components/ui/toast";
import { generateUUID } from "@/lib/utils";
import { toastHooks } from "@/lib/plugin-hooks";
import { recordNotification } from "@/stores/notification-center-store";
interface ToastStore {
toasts: Toast[];
@@ -33,6 +34,10 @@ export const useToastStore = create<ToastStore>((set, get) => ({
void toastHooks.onToastShow.emit({
id, type: toast.type, title: toast.title, message: toast.message ?? "",
});
// Errors and warnings stay in the bell's list after the toast is gone.
if (toast.type === "error" || toast.type === "warning") {
recordNotification({ level: toast.type, title: toast.title, body: toast.message ?? "" });
}
return id;
},