mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
feat: add a notifications panel for plugin notifications and app errors
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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": "تحذير"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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í"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "هشدار"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "אזהרה"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "警告"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "경고"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Анхааруулга"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Предупреждение"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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ı"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Попередження"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "警告"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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