// 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; } // Structured payload that lets the Notification Center offer in-app actions even // after a reload (when live `action` callbacks are gone). export type VideoHiddenMeta = { kind: "video-hidden"; videoId: string; title: string; channelId: string; channelName: string; }; export type VideoWatchedMeta = { kind: "video-watched"; videoId: string; title: string; channelId: string; channelName: string; }; export type ChannelSubscribedMeta = { kind: "channel-subscribed"; channelId: string; channelName: string; }; export type NotifMeta = VideoHiddenMeta | VideoWatchedMeta | ChannelSubscribedMeta; export interface Notification { id: number; level: NotifLevel; title?: string; message: string; action?: NotifAction; meta?: NotifMeta; requiresInteraction: boolean; duration?: number; // auto-dismiss ms (undefined when it stays until acted on) ts: number; read: boolean; dismissed: boolean; // transient toast surface closed (still kept in history) // Live session-only status (e.g. "connection lost"): sticky toast (no auto-dismiss // timer) that the producer removes itself when the condition resolves. Never persisted // to history, so a reload can't orphan it with no live handle to clear it. transient: boolean; } export interface NotifyInput { message: string; level?: NotifLevel; title?: string; action?: NotifAction; meta?: NotifMeta; requiresInteraction?: boolean; sound?: boolean; // force the alert tone (when sound is enabled) even for an info toast transient?: boolean; // live status: sticky toast, not persisted (see Notification.transient) } const HISTORY_KEY = "subfeed.notifications"; const SETTINGS_KEY = "subfeed.notifSettings"; const MAX_HISTORY = 100; const ERROR_TTL = 15000; // 6b: per-account notification settings (persisted by the Settings panel into the // server `preferences`, mirrored to localStorage so they apply before login). export interface NotifSettings { sound: boolean; // play a short tone on attention-needing notifications durationMs: number; // auto-dismiss time for info/success toasts } const DEFAULT_SETTINGS: NotifSettings = { sound: false, durationMs: 6000 }; let config: NotifSettings = loadSettings(); function loadSettings(): NotifSettings { try { return { ...DEFAULT_SETTINGS, ...JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}") }; } catch { return DEFAULT_SETTINGS; } } export function getNotifSettings(): NotifSettings { return config; } export function configureNotifications(p: Partial): void { config = { ...config, ...p }; try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(config)); } catch { /* ignore */ } } let audioCtx: AudioContext | null = null; function beep(): void { try { const Ctx = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext; audioCtx = audioCtx || new Ctx(); if (audioCtx.state === "suspended") void audioCtx.resume(); const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); osc.connect(gain); gain.connect(audioCtx.destination); osc.frequency.value = 660; gain.gain.value = 0.04; osc.start(); osc.stop(audioCtx.currentTime + 0.12); } catch { /* autoplay blocked or unsupported — ignore */ } } 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 []; // Restored entries are history only. Mark them dismissed so they don't // resurrect as active toasts on reload — their auto-dismiss timers aren't // re-armed across a reload, so otherwise they'd stick forever. Also drop the // live `action` callback, which can't survive serialization. return raw.map((n) => ({ ...n, action: undefined, dismissed: true, transient: false }) as Notification); } catch { return []; } } function persist() { try { // Transient status notices are live-session only — never write them to history, // so a reload can't leave one stranded with no producer left to clear it. const slim = items.filter((n) => !n.transient).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"; // config.durationMs === 0 means "don't auto-dismiss" (toast stays until dismissed). // A transient status stays put until its producer clears it (no auto-dismiss timer), // same as an interaction-awaiting notice. const duration = requiresInteraction || input.transient ? undefined : level === "error" || level === "fatal" ? ERROR_TTL : config.durationMs > 0 ? config.durationMs : undefined; items = [ ...items, { id, level, title: input.title, message: input.message, action: input.action, meta: input.meta, requiresInteraction, duration, ts: Date.now(), read: false, dismissed: false, transient: input.transient ?? false, }, ]; emit(); if (config.sound && (input.sound || requiresInteraction || level === "error" || level === "fatal")) { beep(); } if (duration) setTimeout(() => dismiss(id), duration); 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(); } /** Remove a single history entry entirely (per-item clear in the notification center). */ export function remove(id: number): void { const before = items.length; items = items.filter((n) => n.id !== id); if (items.length !== before) emit(); } /** Resolve any history entries tied to a video — called when it's unhidden/unwatched so the * now-stale "Hidden/Watched X" notice disappears instead of lingering with a dead action. */ export function resolveVideo(videoId: string): void { const before = items.length; items = items.filter( (n) => !( (n.meta?.kind === "video-hidden" || n.meta?.kind === "video-watched") && n.meta.videoId === videoId ) ); if (items.length !== before) emit(); } export const getActiveToasts = (): Notification[] => cachedActive; export const getNotifications = (): Notification[] => cachedReversed; export const getUnreadCount = (): number => cachedUnread;