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.
449 lines
17 KiB
TypeScript
449 lines
17 KiB
TypeScript
// 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;
|