// Global toast notifications (top-centre stack). // // Imperative API usable from anywhere: `toast(text, kind)`, `toastError(err)`; render // once near the app root. Identical messages shown within a short window are merged (with a ×N // counter) instead of stacking, and the stack is capped so an error storm cannot flood the screen. import { h } from '../../vendor/preact.module.js'; import { useEffect, useReducer } from '../../vendor/hooks.module.js'; import htm from '../../vendor/htm.module.js'; import { ERR_TEXT } from '../../../shared/constants.js'; import { t } from '../../../shared/i18n.js'; import { recordError } from '../diag.js'; // no shared static vnodes (see components.js hFresh: htm's static cache would retain unmounted DOM) function hFresh(type, props, ...children) { if (this && typeof this === 'object') this[0] = 3; return h(type, props, ...children); } const html = htm.bind(hFresh); export const TOAST_KINDS = ['info', 'success', 'warn', 'error']; const MAX_TOASTS = 4; const DEFAULT_MS = { info: 2600, success: 2400, warn: 3200, error: 3600 }; const MERGE_WINDOW_MS = 2500; let seq = 0; /** @type {{id:number, text:string, kind:string, count:number, at:number, ttl:number, leaving:boolean}[]} */ let toasts = []; const listeners = new Set(); const timers = new Map(); const emit = () => { for (const fn of [...listeners]) { try { fn(toasts); } catch (err) { console.error(err); } } }; function schedule(t) { clearTimeout(timers.get(t.id)); timers.set(t.id, setTimeout(() => dismissToast(t.id), t.ttl)); } /** * Show a toast. * @param {string} text message (plain text) * @param {'info'|'success'|'warn'|'error'} [kind] * @param {{ ttl?: number }} [opts] * @returns {number} toast id */ export function toast(text, kind = 'info', opts = {}) { const k = TOAST_KINDS.includes(kind) ? kind : 'info'; const msg = String(text ?? '').slice(0, 200).trim(); if (!msg) return -1; const now = Date.now(); const ttl = Number.isFinite(opts.ttl) && opts.ttl > 0 ? opts.ttl : DEFAULT_MS[k]; const same = toasts.find((t) => !t.leaving && t.text === msg && t.kind === k && now - t.at < MERGE_WINDOW_MS + t.ttl); if (same) { toasts = toasts.map((t) => (t === same ? { ...t, count: t.count + 1, at: now, ttl } : t)); schedule(toasts.find((t) => t.id === same.id)); emit(); return same.id; } const t = { id: ++seq, text: msg, kind: k, count: 1, at: now, ttl, leaving: false }; toasts = [...toasts, t]; // Cap the stack: drop the oldest immediately. while (toasts.length > MAX_TOASTS) { const old = toasts[0]; clearTimeout(timers.get(old.id)); timers.delete(old.id); toasts = toasts.slice(1); } schedule(t); emit(); return t.id; } /** * Dismiss a toast (plays the exit animation, then removes it). * @param {number} id */ export function dismissToast(id) { const t = toasts.find((x) => x.id === id); if (!t) return; clearTimeout(timers.get(id)); if (t.leaving) return; toasts = toasts.map((x) => (x.id === id ? { ...x, leaving: true } : x)); emit(); timers.set(id, setTimeout(() => { timers.delete(id); toasts = toasts.filter((x) => x.id !== id); emit(); }, 200)); } /** * Text for an error-ish value: NetError / {code,msg} / Error / string — in the current language (the Chinese texts are * msgids: ERR_TEXT, net.js CLIENT_ERR_TEXT, a server `msg`; anything else is shown as it is). * @param {any} err * @returns {string} */ export function describeError(err) { if (!err) return t('发生未知错误'); if (typeof err === 'string') return t(Object.hasOwn(ERR_TEXT, err) ? ERR_TEXT[err] : err); if (err.code && Object.hasOwn(ERR_TEXT, err.code)) return t(ERR_TEXT[err.code]); if (typeof err.message === 'string' && err.message) return t(err.message); if (typeof err.msg === 'string' && err.msg) return t(err.msg); return t('发生未知错误'); } /** * Show an error toast for a failed request / error push. * @param {any} err * @returns {number} */ export function toastError(err) { recordError('request', err); return toast(describeError(err), 'error'); } const ICONS = { info: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm1 15h-2v-6h2v6Zm0-8h-2V7h2v2Z', success: 'M9.5 16.2 5.3 12l-1.4 1.4 5.6 5.6L20.1 8.4 18.7 7z', warn: 'M1 21h22L12 2 1 21Zm12-3h-2v-2h2v2Zm0-4h-2v-4h2v4Z', error: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm5 13.6L15.6 17 12 13.4 8.4 17 7 15.6 10.6 12 7 8.4 8.4 7 12 10.6 15.6 7 17 8.4 13.4 12 17 15.6Z', }; /** Renders the toast stack. Mount once. */ export function ToastHost() { const [, force] = useReducer((c) => c + 1, 0); useEffect(() => { listeners.add(force); return () => listeners.delete(force); }, []); return html`
${toasts.map((t) => html`
dismissToast(t.id)}> ${t.text} ${t.count > 1 ? html`×${t.count}` : null}
`)}
`; }