// 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. import type { TFunction } from "i18next"; import i18n from "../i18n"; import { localizeDetail } from "./api/refusalText"; import { formatBytes, formatNumber } from "./format"; import { LS, onLocalDataClaimed, readAccount, readAccountMerged, writeAccount } from "./storage"; import { log, type Level } from "./log"; // What a notice SAYS, kept as what to say rather than the said text: a key and its values, said in // the reader's language each time it is shown. The text a caller translated at raise time was kept // in the history, so a language switch left every notice in the language it was raised in. `raw` is // text that has no translation (a video's title, a name); `detail` is a server refusal, localized at // display like the key. `notify` takes nothing else, and a value is no plain string either (a name // goes in as `raw`, a translated word as its own `msg`), so a pre-translated string does not // type-check anywhere in a notice — only `raw(t(…))` would say it once, and that reads as what it is. // A value is formatted when shown too: `{ number }` / `{ bytes }` group digits the reader's way. export type NotifValue = number | NotifText | { number: number } | { bytes: number }; export type NotifText = | { key: string; values?: Readonly> } | { raw: string } | { detail: unknown }; /** The notice text for `t(key, values)`, said when it is shown. */ export const msg = (key: string, values?: Record): NotifText => values ? { key, values } : { key }; /** Text with no translation of its own (a title, a name), shown as it is. */ export const raw = (text: string): NotifText => ({ raw: text }); /** A notice text in the language `t` speaks — the reader's, from a component's `useTranslation`. */ export function sayNotif(text: NotifText, t: TFunction = i18n.t.bind(i18n)): string { if ("raw" in text) return text.raw; if ("detail" in text) return localizeDetail(text.detail); if (!text.values) return t(text.key); const values = Object.fromEntries( Object.entries(text.values).map(([k, v]) => [k, sayValue(v, t)]), ); return t(text.key, values); } function sayValue(v: NotifValue, t: TFunction): string | number { if (typeof v !== "object") return v; if ("number" in v) return formatNumber(v.number, i18n.language); if ("bytes" in v) return formatBytes(v.bytes, i18n.language); return sayNotif(v, t); } const sameText = (a: NotifText | undefined, b: NotifText | undefined): boolean => JSON.stringify(a ?? null) === JSON.stringify(b ?? null); // A notice the history kept from before it said keys: its text, in the language it was raised in. const asText = (v: unknown): NotifText | undefined => typeof v === "string" ? raw(v) : (v as NotifText | undefined); // A notification's level in the client log: what the user was TOLD is part of the story a log tells. const LOG_LEVEL: Record = { info: "INFO", success: "INFO", warning: "WARNING", error: "ERROR", fatal: "CRITICAL", }; export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal"; interface NotifAction { // Said when shown, like the notice's text: a language switch must not leave the button behind. label: NotifText; 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; }; // Admin nudge that pending access requests are waiting — carries a durable inbox link to the // Users page (where Access requests live). No payload; the panel provides the navigation. type AccessRequestsMeta = { kind: "access-requests"; }; export type NotifMeta = | VideoHiddenMeta | VideoWatchedMeta | ChannelSubscribedMeta | AccessRequestsMeta; export interface Notification { id: number; level: NotifLevel; title?: NotifText; message: NotifText; action?: NotifAction; // A second, lesser action beside it (a done download's "Open" after its "Copy link"). secondaryAction?: 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; // How many times an identical notification (same level+title+message) fired in quick // succession and was coalesced into this one entry (see notify). 1 = fired once; shown as // "×N" in the toast/center when >1. Guards against a burst (e.g. an ErrorBoundary catching // the same crash on every retry) spamming the center with dozens of copies. repeat: number; } export interface NotifyInput { message: NotifText; level?: NotifLevel; title?: NotifText; action?: NotifAction; secondaryAction?: 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 = LS.notifHistory; const SETTINGS_KEY = LS.notifSettings; const MAX_HISTORY = 100; const ERROR_TTL = 15000; // A toast that carries an action (an "Undo") lives at least this long, whatever the reader's toast // time: its button is the only way back, and a 3 s toast leaves no time to reach it (owner's call). // The setting still decides for longer, and "never" still means never. export const ACTION_TTL_MIN = 8000; // 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 { return readAccountMerged(SETTINGS_KEY, DEFAULT_SETTINGS); } export function getNotifSettings(): NotifSettings { return config; } export function configureNotifications(p: Partial): void { config = { ...config, ...p }; writeAccount(SETTINGS_KEY, config); } 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; // Per-toast auto-dismiss timers. Keyed by id so re-arming (a coalesced repeat re-surfacing the same // toast) can CLEAR the previous timer first — otherwise the old one still fires at the original // deadline and dismisses the re-surfaced toast early (C-3.25). const dismissTimers = new Map>(); function armDismiss(id: number, duration: number | undefined): void { // Always clear any existing timer first. A falsy duration then arms nothing — so a toast that a // coalesced repeat upgraded to requiresInteraction (duration undefined) correctly STAYS, instead // of the original timer firing and dismissing the now-must-interact toast. const existing = dismissTimers.get(id); if (existing) { clearTimeout(existing); dismissTimers.delete(id); } if (duration) dismissTimers.set( id, setTimeout(() => dismiss(id), duration), ); } // 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 { // The old key only until the new one exists: its rows carry forward, said text as `raw`. const stored = readAccount(HISTORY_KEY, null) ?? readAccount(LS.legacyNotifHistory, []); if (!Array.isArray(stored)) 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 stored.map( (n) => ({ repeat: 1, ...(n as object), title: asText((n as { title?: unknown }).title), message: asText((n as { message?: unknown }).message) ?? raw(""), action: undefined, secondaryAction: 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, secondaryAction: _second, ...n }) => n); writeAccount(HISTORY_KEY, 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(); // The history and settings above are read at import, before BootGate knows the account — so they // read as nobody's. Read them again once the account is claimed (once per page: a different account // reloads it): otherwise the first write would replace the account's stored history with an empty one. // A notice raised before the claim (a failed boot probe) belongs to no account, so it stays, after the // history. onLocalDataClaimed(() => { config = loadSettings(); // Fresh ids for the history: the notices kept may already hold the low ones (a timer, a producer). const history = load().map((n) => ({ ...n, id: counter++ })); items = [...history, ...items].slice(-MAX_HISTORY); recompute(); listeners.forEach((l) => l()); }); export function subscribe(listener: () => void): () => void { listeners.push(listener); return () => { listeners = listeners.filter((l) => l !== listener); }; } /** Drop every notification (active or dismissed history) carrying this meta kind. Used to keep * a singleton nudge — e.g. re-issuing the "access requests pending" notice without piling up a * fresh copy (plus a stale history entry) on every reload. */ export function removeByMetaKind(kind: NotifMeta["kind"]): void { const before = items.length; items = items.filter((n) => n.meta?.kind !== kind); if (items.length !== before) emit(); } // Window within which an identical notification is coalesced into the prior one (see below). const COALESCE_WINDOW_MS = 5000; export function notify(input: NotifyInput): number { const requiresInteraction = input.requiresInteraction ?? false; const level = input.level ?? "info"; // In English, whatever the reader's language (a server refusal aside: it says itself in the // reader's), so one log reads the same for every reader. const en = i18n.getFixedT("en"); log.at(LOG_LEVEL[level], "ui.notify", sayNotif(input.message, en), { level, ...(input.title ? { title: sayNotif(input.title, en) } : {}), ...(input.meta ? { kind: input.meta.kind } : {}), }); const now = Date.now(); // 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 ? input.action ? Math.max(config.durationMs, ACTION_TTL_MIN) : config.durationMs : undefined; // Coalesce a rapid burst of identical notifications (same level+title+message within the // window) into one entry with a bumped repeat count, instead of appending a fresh copy each // time. This tames spam like an ErrorBoundary catching the same crash on every render retry, // or a poll that keeps failing the same way. Skipped when the input carries a live `action` // (its callback identity matters) or is a transient status (those are singleton-guarded by // their producer). Distinct events — any difference in level/title/message — are never merged. if (!input.action && !input.transient) { const prior = items.find( (n) => !n.transient && !n.action && now - n.ts <= COALESCE_WINDOW_MS && n.level === level && sameText(n.title, input.title) && sameText(n.message, input.message), ); if (prior) { // Re-surface the existing toast (undismiss + unread) and re-arm its auto-dismiss timer. items = items.map((n) => n.id === prior.id ? { ...n, ts: now, repeat: n.repeat + 1, dismissed: false, read: false, duration } : n, ); emit(); armDismiss(prior.id, duration); // clears the prior timer even when the repeat is now must-interact // Deliberately no re-beep: the first occurrence already sounded; a loop must stay quiet. return prior.id; } } const id = counter++; items = [ ...items, { id, level, title: input.title, message: input.message, action: input.action, secondaryAction: input.secondaryAction, meta: input.meta, requiresInteraction, duration, ts: now, read: false, dismissed: false, transient: input.transient ?? false, repeat: 1, }, ]; emit(); if ( config.sound && (input.sound || requiresInteraction || level === "error" || level === "fatal") ) { beep(); } armDismiss(id, duration); return id; } /** Close the transient toast surface; the entry stays in the center's history. */ export function dismiss(id: number): void { const t = dismissTimers.get(id); if (t) { clearTimeout(t); dismissTimers.delete(id); } 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;