siftlode/frontend/src/lib/notifications.ts
npeter83 c994fee45b fix(inbox): access-requests nudge points to Users + a jump link, no pile-up
The pending-access-requests notification still said "review in Settings -> Account",
but that moved to the new Users page in 5a. Update the text (EN/HU/DE), and give the
notification a durable inbox link ("Review" -> Users page) via a new access-requests
meta kind (persists across reload, unlike a live action callback). Also stop it
piling up: persisted notices reload as dismissed history, so the old dedupe never
matched and a fresh copy was added every load — now removeByMetaKind drops any prior
access-requests notice before re-issuing, keeping exactly one current nudge.
2026-06-19 16:10:08 +02:00

279 lines
9 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.
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;
};
// 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.
export 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;
}
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<NotifSettings>): 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);
};
}
/** 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();
}
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;