Files
siftlode/frontend/src/lib/notifications.ts
T
peter b515c16117 fix(notifications): a notice's button is said when shown, like its text
An action label was translated when the notice was raised, so after a language switch a live
toast or center row read in one language with its button in the other. The access-requests nudge
no longer re-issues on a switch for it. New tests: the button and a server refusal follow a
switch.
2026-10-30 00:10:38 +01:00

449 lines
17 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 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<Record<string, NotifValue>> }
| { raw: string }
| { detail: unknown };
/** The notice text for `t(key, values)`, said when it is shown. */
export const msg = (key: string, values?: Record<string, NotifValue>): 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<NotifLevel, Level> = {
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<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 {
// The old key only until the new one exists: its rows carry forward, said text as `raw`.
const stored =
readAccount<unknown>(HISTORY_KEY, null) ?? readAccount<unknown>(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;