import { IconBell, IconBellRinging, IconLoader2, IconX, } from "@tabler/icons-react"; import { useCallback, useEffect, useRef, useState } from "react"; import type { Notification as NotificationDto, NotificationSeverity, } from "../../notifications/types.js"; import { agentNativePath, appPath } from "../api-path.js"; import { Popover, PopoverContent, PopoverTrigger, } from "../components/ui/popover.js"; import { usePausingInterval } from "../use-pausing-interval.js"; interface NotificationsBellProps { /** Poll interval in ms. Set to 0 to disable polling. Default: 10000. */ pollMs?: number; /** Optional className for the outer container. */ className?: string; /** * When true, fires a system-level `new Notification(...)` popup for each * new unread notification — handy when the tab is in the background. * Renders an "Enable browser notifications" prompt in the dropdown until * the user grants permission. Silently no-ops on denied or unsupported. */ browserNotifications?: boolean; /** Empty-state title shown when there are no notifications. */ emptyTitle?: string; /** Optional empty-state detail text. */ emptyDescription?: string; /** Optional notification for parent shells that need to coordinate overlays. */ onOpenChange?: (open: boolean) => void; } const POLL_MS_DEFAULT = 10_000; const SUPPORTS_NOTIFICATION = typeof window !== "undefined" && "Notification" in window; /** * Header-bar bell that shows the unread-notification count and a dropdown of * recent entries. Polling keeps it in sync (the framework poll loop already * bumps a version counter so notifications ride on that signal, but we poll * the count endpoint directly so the bell updates even outside an app-state * change). */ export function NotificationsBell({ pollMs = POLL_MS_DEFAULT, className, browserNotifications = false, emptyTitle = "No app notifications yet.", emptyDescription, onOpenChange, }: NotificationsBellProps) { const [unreadCount, setUnreadCount] = useState(0); const [open, setOpen] = useState(false); const [items, setItems] = useState(null); // Init to "default" unconditionally so server and client render the same // HTML — reading Notification.permission at init would diverge between SSR // ("denied", no API) and hydration ("default"/"granted"), causing a mismatch // in templates that mount the bell outside a ClientOnly boundary. We sync // to the real value in a useEffect below. const [permission, setPermission] = useState("default"); useEffect(() => { if (SUPPORTS_NOTIFICATION) setPermission(Notification.permission); }, []); // Ids already popped as browser notifications. Seeded on first run so // existing unread don't pop retroactively on page load. const seenIdsRef = useRef | null>(null); const loadItems = useCallback(async () => { try { const res = await fetch( agentNativePath("/_agent-native/notifications?limit=20"), ); if (!res.ok) return; const rows = (await res.json()) as NotificationDto[]; setItems(rows); } catch { // best-effort } }, []); // One polling callback used by both paths. When browserNotifications is on // we fetch the unread list (source of truth for both the badge count AND // the popup loop — no second /count request), and pop Notification() for // any new ids. When off, we fetch just /count. The unread-list branch also // opts out of visibility pause so popups still fire for backgrounded tabs. const refresh = useCallback(async () => { if (browserNotifications) { try { const res = await fetch( agentNativePath("/_agent-native/notifications?unread=true&limit=20"), ); if (!res.ok) return; const rows = (await res.json()) as NotificationDto[]; setUnreadCount(rows.length); // First run: treat everything as already seen so we don't pop // retroactively on page load. After that, rebuild from the current // unread list so ids for read/archived rows drop out — keeps the // set bounded to the unread fetch limit (~20). const prev = seenIdsRef.current; const seen = new Set(); for (const n of rows) { const alreadySeen = prev?.has(n.id) ?? true; seen.add(n.id); if (alreadySeen) continue; if (!SUPPORTS_NOTIFICATION) continue; if (Notification.permission !== "granted") continue; try { new Notification(n.title, { body: n.body, tag: n.id }); } catch { // Safari / restricted contexts may throw even when permission // claims to be granted — silent no-op. } } seenIdsRef.current = seen; } catch { // best-effort } return; } try { const res = await fetch( agentNativePath("/_agent-native/notifications/count"), ); if (!res.ok) return; const data = (await res.json()) as { count: number }; setUnreadCount(data.count); } catch { // best-effort } }, [browserNotifications]); usePausingInterval( refresh, pollMs, /* pauseWhenHidden */ !browserNotifications, ); useEffect(() => { if (!open) return; loadItems(); }, [open, loadItems]); const markRead = async (id: string) => { try { // `keepalive: true` lets the request survive page navigation — // without it, clicking a notification with a link aborts this // request mid-flight and the row stays unread. await fetch(agentNativePath(`/_agent-native/notifications/${id}/read`), { method: "POST", keepalive: true, }); setItems((prev) => prev ? prev.map((n) => n.id === id ? { ...n, readAt: new Date().toISOString() } : n, ) : prev, ); refresh(); } catch { // best-effort } }; // Reject any URL that isn't http(s) or a same-origin relative path. Blocks // `javascript:` execution, `data:` URIs, and absolute redirects to phishing // sites. Relative paths starting with `/` are routed through `appPath()` so // the link works in mounted deployments (e.g. /mail subdirectory). const safeNotificationLink = (link: string): string | null => { if (link.startsWith("/") && !link.startsWith("//")) { return appPath(link); } try { const url = new URL(link, window.location.origin); if (url.protocol === "http:" || url.protocol === "https:") { return url.toString(); } } catch { // fallthrough } return null; }; const markAllRead = async () => { try { await fetch(agentNativePath(`/_agent-native/notifications/read-all`), { method: "POST", }); setItems((prev) => prev ? prev.map((n) => n.readAt ? n : { ...n, readAt: new Date().toISOString() }, ) : prev, ); setUnreadCount(0); } catch { // best-effort } }; const dismiss = async (id: string) => { try { await fetch(agentNativePath(`/_agent-native/notifications/${id}`), { method: "DELETE", }); setItems((prev) => (prev ? prev.filter((n) => n.id !== id) : prev)); refresh(); } catch { // best-effort } }; const hasUnread = unreadCount > 0; const Icon = hasUnread ? IconBellRinging : IconBell; const setOpenAndNotify = (value: boolean) => { setOpen(value); onOpenChange?.(value); }; return (
Notifications {hasUnread ? ( ) : null}
{browserNotifications && SUPPORTS_NOTIFICATION && permission === "default" ? (
Get a system popup for new notifications.
) : null}
{items === null ? (
Loading…
) : items.length > 0 ? ( items.map((n) => { const rawLink = typeof n.metadata?.link === "string" ? n.metadata.link : null; const link = rawLink ? safeNotificationLink(rawLink) : null; const onItemClick = () => { if (!n.readAt) void markRead(n.id); if (link) { setOpenAndNotify(false); window.location.assign(link); } }; return (
); }) ) : (

{emptyTitle}

{emptyDescription ? (

{emptyDescription}

) : null}
)}
); } // Severity color pairs — use /20 opacity backdrops that work against both // light and dark theme backgrounds; text uses 700/300 so it stays readable // in each mode (the `dark:` prefix is one of the few places where explicit // variants are necessary since these are brand-color tokens, not semantic). function SeverityBadge({ severity }: { severity: NotificationSeverity }) { const color = severity === "critical" ? "bg-red-500/20 text-red-700 dark:text-red-300" : severity === "warning" ? "bg-amber-500/20 text-amber-700 dark:text-amber-300" : "bg-muted text-muted-foreground"; return ( {severity} ); }