// Client-side notification store. Surfaces transient toasts AND keeps a history that // the Notification Center shows (info events, actions awaiting interaction, and app // errors). History is persisted to localStorage so it survives reloads; action // callbacks are live-only and dropped on reload. export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal"; export interface NotifAction { label: string; onClick: () => void; } export interface Notification { id: number; level: NotifLevel; title?: string; message: string; action?: NotifAction; requiresInteraction: boolean; ts: number; read: boolean; dismissed: boolean; // transient toast surface closed (still kept in history) } export interface NotifyInput { message: string; level?: NotifLevel; title?: string; action?: NotifAction; requiresInteraction?: boolean; } const HISTORY_KEY = "subfeed.notifications"; const MAX_HISTORY = 100; const DEFAULT_TTL = 9000; const ERROR_TTL = 15000; let items: Notification[] = load(); let listeners: Array<() => void> = []; let counter = items.reduce((m, n) => Math.max(m, n.id), 0) + 1; // Cached derived snapshots — useSyncExternalStore needs stable references between // emits, so we recompute these only when `items` changes. let cachedActive: Notification[] = []; let cachedReversed: Notification[] = []; let cachedUnread = 0; function load(): Notification[] { try { const raw = JSON.parse(localStorage.getItem(HISTORY_KEY) || "[]"); if (!Array.isArray(raw)) return []; return raw.map((n) => ({ ...n, action: undefined }) as Notification); } catch { return []; } } function persist() { try { const slim = items.map(({ action: _action, ...n }) => n); localStorage.setItem(HISTORY_KEY, JSON.stringify(slim)); } catch { /* ignore quota / serialization errors */ } } function recompute() { cachedActive = items.filter((n) => !n.dismissed); cachedReversed = [...items].reverse(); cachedUnread = items.reduce((c, n) => c + (n.read ? 0 : 1), 0); } function emit() { items = items.slice(-MAX_HISTORY); recompute(); persist(); listeners.forEach((l) => l()); } recompute(); export function subscribe(listener: () => void): () => void { listeners.push(listener); return () => { listeners = listeners.filter((l) => l !== listener); }; } export function notify(input: NotifyInput): number { const id = counter++; const requiresInteraction = input.requiresInteraction ?? false; const level = input.level ?? "info"; items = [ ...items, { id, level, title: input.title, message: input.message, action: input.action, requiresInteraction, ts: Date.now(), read: false, dismissed: false, }, ]; emit(); if (!requiresInteraction) { const ttl = level === "error" || level === "fatal" ? ERROR_TTL : DEFAULT_TTL; setTimeout(() => dismiss(id), ttl); } return id; } /** Back-compat: a simple info toast with an optional inline action (e.g. Undo). */ export function toast(message: string, action?: NotifAction): number { return notify({ message, action }); } /** Close the transient toast surface; the entry stays in the center's history. */ export function dismiss(id: number): void { items = items.map((n) => (n.id === id ? { ...n, dismissed: true } : n)); emit(); } export function markAllRead(): void { if (items.every((n) => n.read)) return; items = items.map((n) => (n.read ? n : { ...n, read: true })); emit(); } export function clearAll(): void { if (items.length === 0) return; items = []; emit(); } export const getActiveToasts = (): Notification[] => cachedActive; export const getNotifications = (): Notification[] => cachedReversed; export const getUnreadCount = (): number => cachedUnread;