Files
ark-stronghold-protocol/public/js/ui/toasts.js
T
Cloudnyco 3f8844129b feedback7 G-SMALL: settings can copy local diagnostics for a bug report (PR #321 by @Cloudnyco)
Applied 设置 → 问题反馈. The report stays in the browser: the in-memory error log, device info, and the battle on screen only while 「附上本场战斗」 is on. Nothing is uploaded.

Redaction no longer treats a long name as a bare substring. Prose replaces each secret only as a whole token, so a name does not cut a longer word or a chess id. The attached JSON replaces a string only when it equals the secret, so chess_char_1013_chen2 and a note like "charge the char" stay, while an exact "char" value becomes the placeholder. The token boundary is a consumed prefix plus a lookahead, because a lookbehind is a SyntaxError in Safari before 16.4. PLAYING section 12 stays; the other section-12 proposal is not in this batch.

Tests: node --test test/diag.test.js — 10 pass. node --test test/client-static.test.js — 315 pass. node --test test/match/runner-pending.test.js test/match/watch-bonds.test.js test/ui/playtest6-unite.test.js test/ui/spectate-effects.test.js test/ui/feedback5-hotkeys.test.js test/i18n-server.test.js test/ui/bosshit-ticker.test.js test/ui/playtest3.test.js test/docs-consistency.test.js test/docs-paths.test.js test/version.test.js test/package.test.js — 138 pass. npm run -s lint — 0 errors. npm run -s typecheck and npm run -s check:imports passed. node tools/i18n.mjs check --all --strict — 1041/1041 in en, ja, ko and zh-TW, 0 errors.
2026-10-08 20:08:32 +08:00

141 lines
5.1 KiB
JavaScript
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.
// Global toast notifications (top-centre stack).
//
// Imperative API usable from anywhere: `toast(text, kind)`, `toastError(err)`; render <ToastHost/>
// 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`
<div class="toast-host" role="status" aria-live="polite">
${toasts.map((t) => html`
<div key=${t.id} class=${`toast toast--${t.kind}${t.leaving ? ' is-leaving' : ''}`}
onClick=${() => dismissToast(t.id)}>
<svg class="toast__icon" viewBox="0 0 24 24" aria-hidden="true"><path d=${ICONS[t.kind]} /></svg>
<span class="toast__text">${t.text}</span>
${t.count > 1 ? html`<span class="toast__count">×${t.count}</span>` : null}
</div>`)}
</div>`;
}