import { useEffect, useRef, useState } from "react"; import { IconBell, IconX } from "@tabler/icons-react"; export interface NextNotification { id: string; taskId: string | null; title: string; detail: string; kind: "ready" | "interrupted" | "confirmation" | "error"; read: boolean; timestamp: string; } export interface NextNotificationState { revision: number; items: NextNotification[]; } // Saved notifications arrive newest first. Keep the latest copy of each chat message. function latestChatMessages(items: NextNotification[]) { const seen = new Set(); return items.filter((item) => { if (!item.taskId) return true; const key = JSON.stringify([item.taskId, item.kind, item.title, item.detail]); if (seen.has(key)) return false; seen.add(key); return true; }); } export function NextNotificationCenter({ state, toasts, onAction, onOpen, onDismiss }: { state: NextNotificationState; toasts: NextNotification[]; onAction: (action: "read" | "read_all" | "clear", id?: string) => Promise; onOpen: (item: NextNotification) => void; onDismiss: (id: string) => void; }) { const [opened, setOpened] = useState(false); const closeTimer = useRef | null>(null); const cancelClose = () => { if (closeTimer.current !== null) clearTimeout(closeTimer.current); closeTimer.current = null; }; const closeCenter = () => { cancelClose(); setOpened(false); }; const openOnHover = (event: React.PointerEvent) => { if (event.pointerType === "touch") return; cancelClose(); setOpened(true); }; const closeAfterHover = (event: React.PointerEvent) => { if (event.pointerType === "touch") return; cancelClose(); closeTimer.current = setTimeout(() => { setOpened(false); closeTimer.current = null; }, 200); }; useEffect(() => () => cancelClose(), []); const [filter, setFilter] = useState<"all" | "unread">("all"); const [busy, setBusy] = useState(false); const root = useRef(null); const bell = useRef(null); const list = useRef(null); const reading = useRef(new Set()); const actionRef = useRef(onAction); actionRef.current = onAction; const latest = latestChatMessages(state.items); const unread = latest.filter((item) => !item.read).length; const items = latest.filter((item) => filter === "all" || !item.read); useEffect(() => { if (!opened) return; const close = (event: PointerEvent) => { if (!root.current?.contains(event.target as Node)) closeCenter(); }; const escape = (event: KeyboardEvent) => { if (event.key === "Escape") { closeCenter(); bell.current?.focus(); } }; document.addEventListener("pointerdown", close); document.addEventListener("keydown", escape); return () => { document.removeEventListener("pointerdown", close); document.removeEventListener("keydown", escape); }; }, [opened]); const unreadKey = JSON.stringify(items.filter(item => !item.read).map(item => item.id)); useEffect(() => { if (!opened || !list.current || typeof IntersectionObserver === "undefined") return; const observer = new IntersectionObserver(entries => { for (const entry of entries) { const id = (entry.target as HTMLElement).dataset.notificationId; if (!entry.isIntersecting || !id || reading.current.has(id)) continue; reading.current.add(id); void actionRef.current("read", id).catch(() => reading.current.delete(id)); } }, { root: list.current, threshold: 0.01 }); for (const item of list.current.querySelectorAll("[data-notification-id][data-unread=true]")) observer.observe(item); return () => observer.disconnect(); }, [opened, unreadKey]); async function mutate(action: "read" | "read_all" | "clear", id?: string) { setBusy(true); try { await onAction(action, id); } catch { /* The caller displays the error. */ } finally { setBusy(false); } } return
{opened &&
Notifications
{items.length === 0 &&

{filter === "unread" ? "No unread notifications" : "No notifications yet"}

} {items.map((item) =>
)}
}
{toasts.map((item) =>
)}
; }