Files
siftlode/frontend/src/lib/notifications.ts
T
peter a210992bfc feat: client log with request ids, joined to the server's
The browser now keeps its half of the one event shape logs.py defines:
lib/log.ts is a levelled, redacted, bounded (2000 events) in-memory ring
with the server's level names and severity numbers. Nothing leaves the
browser yet; sending is the consented diagnostics level (S5).

- lib/http.ts `send` is the one fetch: every request carries an
  X-Request-Id and becomes one http.request event. The server echoes the
  matched route template in X-Route (only for a matched route, so a raw
  path with a value in it is never sent back); without one the client
  templates the path itself. req, beacon, the upload XHR, the public
  watch page and the legal pages all go through it.
- Hooks: app start snapshot, uncaught errors and rejections, History
  push/replace/popstate (a scroll-only replace is not an event; parameter
  values only at DEBUG, typed search only at TRACE), notifications, the
  error dialog, the crash boundary, failed queries and mutations (key
  head only), and the messages socket (lifecycle, frame types at DEBUG).
- Redaction is one contract: backend/tests/fixtures/log_redaction_cases.json
  runs against both redact() implementations.
- Lint: fetch, XMLHttpRequest and WebSocket each belong to one logging
  module, console to none; a contract test proves the rules fire.

Fixes a socket takeover: the close of a socket released by the last
subscriber cleared the newer socket a quick re-subscribe had opened and
scheduled a third, leaving two live sockets that delivered every message
twice (measured in Chromium: 3 created / 2 live before, 2 / 1 after).
2026-09-17 01:44:29 +02:00

382 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 { activeAccountId, onAccountPinned } from "./activeAccount";
import { LS, readAccount, readAccountMerged, writeAccount } from "./storage";
import { log, type Level } from "./log";
// 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<NotifLevel, Level> = {
info: "INFO",
success: "INFO",
warning: "WARNING",
error: "ERROR",
fatal: "CRITICAL",
};
export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal";
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;
};
// 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?: 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;
// 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: 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 = LS.notifHistory;
const SETTINGS_KEY = LS.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 {
return readAccountMerged(SETTINGS_KEY, DEFAULT_SETTINGS);
}
export function getNotifSettings(): NotifSettings {
return config;
}
export function configureNotifications(p: Partial<NotifSettings>): 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<number, ReturnType<typeof setTimeout>>();
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 {
const raw = readAccount<unknown>(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) =>
({
repeat: 1,
...(n as object),
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);
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, which in a new tab is before BootGate pins its
// account — so they read as nobody's. Read them again for the account once it is pinned: otherwise the
// tab's first write would replace the account's stored history with its own empty one. A notice raised
// before the pin (a failed boot probe) belongs to no account, so it stays, after the history.
let loadedFor = activeAccountId();
onAccountPinned(() => {
const unpinned = loadedFor == null ? items : [];
loadedFor = activeAccountId();
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, ...unpinned].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";
log.at(LOG_LEVEL[level], "ui.notify", input.message, {
level,
...(input.title ? { title: input.title } : {}),
...(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
? 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 &&
(n.title ?? "") === (input.title ?? "") &&
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,
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;