2026-06-11 19:26:34 +02:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 19:46:58 +02:00
|
|
|
// Structured payload that lets the Notification Center offer in-app actions even
|
|
|
|
|
// after a reload (when live `action` callbacks are gone).
|
2026-06-12 17:39:28 +02:00
|
|
|
export type VideoHiddenMeta = {
|
2026-06-11 19:46:58 +02:00
|
|
|
kind: "video-hidden";
|
|
|
|
|
videoId: string;
|
|
|
|
|
title: string;
|
|
|
|
|
channelId: string;
|
|
|
|
|
channelName: string;
|
|
|
|
|
};
|
2026-06-12 17:39:28 +02:00
|
|
|
export type VideoWatchedMeta = {
|
|
|
|
|
kind: "video-watched";
|
|
|
|
|
videoId: string;
|
|
|
|
|
title: string;
|
|
|
|
|
};
|
|
|
|
|
export type NotifMeta = VideoHiddenMeta | VideoWatchedMeta;
|
2026-06-11 19:46:58 +02:00
|
|
|
|
2026-06-11 19:26:34 +02:00
|
|
|
export interface Notification {
|
|
|
|
|
id: number;
|
|
|
|
|
level: NotifLevel;
|
|
|
|
|
title?: string;
|
|
|
|
|
message: string;
|
|
|
|
|
action?: NotifAction;
|
2026-06-11 19:46:58 +02:00
|
|
|
meta?: NotifMeta;
|
2026-06-11 19:26:34 +02:00
|
|
|
requiresInteraction: boolean;
|
2026-06-11 19:46:58 +02:00
|
|
|
duration?: number; // auto-dismiss ms (undefined when it stays until acted on)
|
2026-06-11 19:26:34 +02:00
|
|
|
ts: number;
|
|
|
|
|
read: boolean;
|
|
|
|
|
dismissed: boolean; // transient toast surface closed (still kept in history)
|
2026-06-18 23:17:32 +02:00
|
|
|
// 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;
|
2026-06-11 19:26:34 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface NotifyInput {
|
|
|
|
|
message: string;
|
|
|
|
|
level?: NotifLevel;
|
|
|
|
|
title?: string;
|
|
|
|
|
action?: NotifAction;
|
2026-06-11 19:46:58 +02:00
|
|
|
meta?: NotifMeta;
|
2026-06-11 19:26:34 +02:00
|
|
|
requiresInteraction?: boolean;
|
2026-06-11 21:30:25 +02:00
|
|
|
sound?: boolean; // force the alert tone (when sound is enabled) even for an info toast
|
2026-06-18 23:17:32 +02:00
|
|
|
transient?: boolean; // live status: sticky toast, not persisted (see Notification.transient)
|
2026-06-11 19:26:34 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const HISTORY_KEY = "subfeed.notifications";
|
feat(m5a): channel manager, tabbed settings panel, per-user sync status
Backend: /api/channels (list + PATCH priority/hidden + attach/detach user tags),
user-tag CRUD on /api/tags, /api/sync/my-status (per-user channel sync counts).
Frontend: feed|channels page nav (URL-synced) from the account menu; a slide-in
tabbed Settings panel (Appearance, Notifications=6b sound+duration, Sync status +
actions + admin pause/resume, Account); a channel manager with priority, hide,
per-channel user tags, sync badges and 'view in feed'. Notifications now honor the
configurable sound + auto-dismiss settings.
2026-06-11 20:45:48 +02:00
|
|
|
const SETTINGS_KEY = "subfeed.notifSettings";
|
2026-06-11 19:26:34 +02:00
|
|
|
const MAX_HISTORY = 100;
|
|
|
|
|
const ERROR_TTL = 15000;
|
|
|
|
|
|
feat(m5a): channel manager, tabbed settings panel, per-user sync status
Backend: /api/channels (list + PATCH priority/hidden + attach/detach user tags),
user-tag CRUD on /api/tags, /api/sync/my-status (per-user channel sync counts).
Frontend: feed|channels page nav (URL-synced) from the account menu; a slide-in
tabbed Settings panel (Appearance, Notifications=6b sound+duration, Sync status +
actions + admin pause/resume, Account); a channel manager with priority, hide,
per-channel user tags, sync badges and 'view in feed'. Notifications now honor the
configurable sound + auto-dismiss settings.
2026-06-11 20:45:48 +02:00
|
|
|
// 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();
|
feat(ui): liquid-glass design system, settings polish, hints, notif fixes
- Add a theme-aware glass surface system (.glass/.glass-card + ambient backdrop,
performance-mode opt-out) and apply it across panels, popovers, toasts, cards,
sidebar widgets, channel rows, video cards and login.
- SettingsPanel: slide in/out animation, glass styling, wrapping pill tabs (no
horizontal scrollbar) with a prominent active state.
- Notifications: auto-dismiss can be switched off (stays until closed); the test
notification now also triggers the alert sound; resume a suspended AudioContext.
- Add an app-wide, toggleable hint/tooltip system (lib/hints + Tooltip) and wire
hints across the settings and channel-manager surfaces; persisted per account.
2026-06-11 21:08:35 +02:00
|
|
|
if (audioCtx.state === "suspended") void audioCtx.resume();
|
feat(m5a): channel manager, tabbed settings panel, per-user sync status
Backend: /api/channels (list + PATCH priority/hidden + attach/detach user tags),
user-tag CRUD on /api/tags, /api/sync/my-status (per-user channel sync counts).
Frontend: feed|channels page nav (URL-synced) from the account menu; a slide-in
tabbed Settings panel (Appearance, Notifications=6b sound+duration, Sync status +
actions + admin pause/resume, Account); a channel manager with priority, hide,
per-channel user tags, sync badges and 'view in feed'. Notifications now honor the
configurable sound + auto-dismiss settings.
2026-06-11 20:45:48 +02:00
|
|
|
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 */
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 19:26:34 +02:00
|
|
|
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 [];
|
2026-06-12 18:07:31 +02:00
|
|
|
// 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.
|
2026-06-18 23:17:32 +02:00
|
|
|
return raw.map((n) => ({ ...n, action: undefined, dismissed: true, transient: false }) as Notification);
|
2026-06-11 19:26:34 +02:00
|
|
|
} catch {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function persist() {
|
|
|
|
|
try {
|
2026-06-18 23:17:32 +02:00
|
|
|
// 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);
|
2026-06-11 19:26:34 +02:00
|
|
|
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);
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function notify(input: NotifyInput): number {
|
|
|
|
|
const id = counter++;
|
|
|
|
|
const requiresInteraction = input.requiresInteraction ?? false;
|
|
|
|
|
const level = input.level ?? "info";
|
feat(ui): liquid-glass design system, settings polish, hints, notif fixes
- Add a theme-aware glass surface system (.glass/.glass-card + ambient backdrop,
performance-mode opt-out) and apply it across panels, popovers, toasts, cards,
sidebar widgets, channel rows, video cards and login.
- SettingsPanel: slide in/out animation, glass styling, wrapping pill tabs (no
horizontal scrollbar) with a prominent active state.
- Notifications: auto-dismiss can be switched off (stays until closed); the test
notification now also triggers the alert sound; resume a suspended AudioContext.
- Add an app-wide, toggleable hint/tooltip system (lib/hints + Tooltip) and wire
hints across the settings and channel-manager surfaces; persisted per account.
2026-06-11 21:08:35 +02:00
|
|
|
// config.durationMs === 0 means "don't auto-dismiss" (toast stays until dismissed).
|
2026-06-18 23:17:32 +02:00
|
|
|
// 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
|
2026-06-11 19:46:58 +02:00
|
|
|
? undefined
|
|
|
|
|
: level === "error" || level === "fatal"
|
|
|
|
|
? ERROR_TTL
|
feat(ui): liquid-glass design system, settings polish, hints, notif fixes
- Add a theme-aware glass surface system (.glass/.glass-card + ambient backdrop,
performance-mode opt-out) and apply it across panels, popovers, toasts, cards,
sidebar widgets, channel rows, video cards and login.
- SettingsPanel: slide in/out animation, glass styling, wrapping pill tabs (no
horizontal scrollbar) with a prominent active state.
- Notifications: auto-dismiss can be switched off (stays until closed); the test
notification now also triggers the alert sound; resume a suspended AudioContext.
- Add an app-wide, toggleable hint/tooltip system (lib/hints + Tooltip) and wire
hints across the settings and channel-manager surfaces; persisted per account.
2026-06-11 21:08:35 +02:00
|
|
|
: config.durationMs > 0
|
|
|
|
|
? config.durationMs
|
|
|
|
|
: undefined;
|
2026-06-11 19:26:34 +02:00
|
|
|
items = [
|
|
|
|
|
...items,
|
|
|
|
|
{
|
|
|
|
|
id,
|
|
|
|
|
level,
|
|
|
|
|
title: input.title,
|
|
|
|
|
message: input.message,
|
|
|
|
|
action: input.action,
|
2026-06-11 19:46:58 +02:00
|
|
|
meta: input.meta,
|
2026-06-11 19:26:34 +02:00
|
|
|
requiresInteraction,
|
2026-06-11 19:46:58 +02:00
|
|
|
duration,
|
2026-06-11 19:26:34 +02:00
|
|
|
ts: Date.now(),
|
|
|
|
|
read: false,
|
|
|
|
|
dismissed: false,
|
2026-06-18 23:17:32 +02:00
|
|
|
transient: input.transient ?? false,
|
2026-06-11 19:26:34 +02:00
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
emit();
|
2026-06-11 21:30:25 +02:00
|
|
|
if (config.sound && (input.sound || requiresInteraction || level === "error" || level === "fatal")) {
|
feat(m5a): channel manager, tabbed settings panel, per-user sync status
Backend: /api/channels (list + PATCH priority/hidden + attach/detach user tags),
user-tag CRUD on /api/tags, /api/sync/my-status (per-user channel sync counts).
Frontend: feed|channels page nav (URL-synced) from the account menu; a slide-in
tabbed Settings panel (Appearance, Notifications=6b sound+duration, Sync status +
actions + admin pause/resume, Account); a channel manager with priority, hide,
per-channel user tags, sync badges and 'view in feed'. Notifications now honor the
configurable sound + auto-dismiss settings.
2026-06-11 20:45:48 +02:00
|
|
|
beep();
|
|
|
|
|
}
|
2026-06-11 19:46:58 +02:00
|
|
|
if (duration) setTimeout(() => dismiss(id), duration);
|
2026-06-11 19:26:34 +02:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 04:37:20 +02:00
|
|
|
/** 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?.videoId !== videoId);
|
|
|
|
|
if (items.length !== before) emit();
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 19:26:34 +02:00
|
|
|
export const getActiveToasts = (): Notification[] => cachedActive;
|
|
|
|
|
export const getNotifications = (): Notification[] => cachedReversed;
|
|
|
|
|
export const getUnreadCount = (): number => cachedUnread;
|