fix(notifications): a notice keeps what to say and says it in the reader's language when shown

This commit is contained in:
2026-10-29 22:37:14 +01:00
parent 6986eb1937
commit 4ef772d37b
125 changed files with 973 additions and 674 deletions
+63 -11
View File
@@ -2,9 +2,56 @@
// 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, so a pre-translated string does not type-check.
// A value is formatted when shown too: `{ number }` / `{ bytes }` group digits the reader's way.
export type NotifValue = string | 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",
@@ -56,8 +103,8 @@ export type NotifMeta =
export interface Notification {
id: number;
level: NotifLevel;
title?: string;
message: string;
title?: NotifText;
message: NotifText;
action?: NotifAction;
// A second, lesser action beside it (a done download's "Open" after its "Copy link").
secondaryAction?: NotifAction;
@@ -79,9 +126,9 @@ export interface Notification {
}
export interface NotifyInput {
message: string;
message: NotifText;
level?: NotifLevel;
title?: string;
title?: NotifText;
action?: NotifAction;
secondaryAction?: NotifAction;
meta?: NotifMeta;
@@ -175,17 +222,19 @@ let cachedUnread = 0;
function load(): Notification[] {
try {
const raw = readAccount<unknown>(HISTORY_KEY, []);
if (!Array.isArray(raw)) return [];
const stored = readAccount<unknown>(HISTORY_KEY, []);
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 raw.map(
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,
@@ -261,9 +310,12 @@ const COALESCE_WINDOW_MS = 5000;
export function notify(input: NotifyInput): number {
const requiresInteraction = input.requiresInteraction ?? false;
const level = input.level ?? "info";
log.at(LOG_LEVEL[level], "ui.notify", input.message, {
// 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: input.title } : {}),
...(input.title ? { title: sayNotif(input.title, en) } : {}),
...(input.meta ? { kind: input.meta.kind } : {}),
});
const now = Date.now();
@@ -294,8 +346,8 @@ export function notify(input: NotifyInput): number {
!n.action &&
now - n.ts <= COALESCE_WINDOW_MS &&
n.level === level &&
(n.title ?? "") === (input.title ?? "") &&
n.message === input.message,
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.