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
@@ -12,7 +12,7 @@ import {
type LevelGroup as Filter,
type LogEvent,
} from "../../lib/log";
import { notify } from "../../lib/notifications";
import { msg, notify } from "../../lib/notifications";
import LogLine from "../../features/diagnostics/LogLine";
import NavGrid from "../../components/ui/NavGrid";
import { Button } from "../../components/ui/Button";
@@ -115,8 +115,8 @@ export default function MyLogViewer() {
const ok = await copyText(text);
notify(
ok
? { level: "success", message: t("settings.myLog.copied", { count: shown.length }) }
: { level: "error", message: t("settings.myLog.copyFailed") },
? { level: "success", message: msg("settings.myLog.copied", { count: shown.length }) }
: { level: "error", message: msg("settings.myLog.copyFailed") },
);
};
+10 -10
View File
@@ -29,7 +29,7 @@ import { LS, useTabPersistedState } from "../../lib/storage";
import { SETTINGS_TAB_KEY, settingsTabParams } from "../../lib/settingsTab";
import { useUrlMirror } from "../../lib/useUrlMirror";
import Avatar from "../../components/ui/Avatar";
import { notify, type NotifSettings } from "../../lib/notifications";
import { msg, type NotifSettings, notify } from "../../lib/notifications";
import Tooltip from "../../components/ui/Tooltip";
import { Section, SwitchRow } from "../../components/ui/form";
import { Badge } from "../../components/ui/Badge";
@@ -297,8 +297,8 @@ function Notifications({ prefs }: { prefs: PrefsController }) {
onClick={() =>
notify({
level: "info",
title: t("settings.notifications.testTitle"),
message: t("settings.notifications.testMessage"),
title: msg("settings.notifications.testTitle"),
message: msg("settings.notifications.testMessage"),
sound: true,
})
}
@@ -379,9 +379,9 @@ export function SignInMethods({ me }: { me: Me }) {
// Every API token went with the other sessions: the Tokens tab asks again when it next opens,
// rather than trusting its cached list for another half minute.
void qc.invalidateQueries({ queryKey: qk.apiTokens() });
notify({ level: "success", message: t("settings.account.sessions.done") });
notify({ level: "success", message: msg("settings.account.sessions.done") });
} catch (e) {
notifyActionError(e, t("settings.account.sessions.failed"));
notifyActionError(e, msg("settings.account.sessions.failed"));
}
setSessBusy(false);
};
@@ -402,7 +402,7 @@ export function SignInMethods({ me }: { me: Me }) {
await qc.invalidateQueries({ queryKey: qk.me() });
notify({
level: "success",
message: t("settings.account.password.saved", { email: me.email }),
message: msg("settings.account.password.saved", { email: me.email }),
});
} catch (e) {
setErr(
@@ -540,7 +540,7 @@ function PlexWatchSync() {
if (imp) {
notify({
level: "success",
message: t("settings.plexSync.imported", {
message: msg("settings.plexSync.imported", {
watched: imp.watched,
in_progress: imp.in_progress,
}),
@@ -640,7 +640,7 @@ export function DownloadAuth() {
qc.setQueryData(qk.downloadCredentials(), res);
setOpenHost(null);
setDraft("");
notify({ level: "success", message: t("settings.downloadAuth.saved") });
notify({ level: "success", message: msg("settings.downloadAuth.saved") });
},
// Validation errors (empty / not-a-cookies.txt) surface via the global error modal, which
// localizes the structured { code: "cookies" } detail (see lib/api core localizeDetail); the
@@ -652,7 +652,7 @@ export function DownloadAuth() {
meta: { failTitle: "errors.writeFailed.downloadCookiesRemove" },
onSuccess: (res) => {
qc.setQueryData(qk.downloadCredentials(), res);
notify({ level: "success", message: t("settings.downloadAuth.removed") });
notify({ level: "success", message: msg("settings.downloadAuth.removed") });
},
});
@@ -837,7 +837,7 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
} catch (e) {
// A refusal with a reason (the last admin, a 409) is already in the global error dialog, which
// the reporter skips; anything else used to end the confirmed delete with nothing said.
notifyActionError(e, t("errors.writeFailed.deleteAccount"));
notifyActionError(e, msg("errors.writeFailed.deleteAccount"));
}
};
return (
@@ -10,14 +10,16 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-libra
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { api, HttpError, type TakeoutStatus, type TakeoutUpload } from "../../lib/api";
const mocks = vi.hoisted(() => ({ notify: vi.fn(), notifyActionError: vi.fn() }));
const mocks = vi.hoisted(() => ({
notify: vi.fn(),
notifyActionError: vi.fn(),
// The values ride along, so an assertion can see what each said.
t: (key: string, o?: Record<string, unknown>) =>
o && Object.keys(o).length ? `${key}(${Object.values(o).join(",")})` : key,
}));
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({
i18n: { language: "en" },
t: (key: string, o?: Record<string, unknown>) =>
o && Object.keys(o).length ? `${key}(${Object.values(o).join(",")})` : key,
}),
useTranslation: () => ({ i18n: { language: "en" }, t: mocks.t }),
}));
vi.mock("../../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../../lib/notifications")>()),
@@ -29,7 +31,9 @@ vi.mock("../../lib/format", async (importOriginal) => ({
formatDateTime: (iso: string) => `at ${iso}`,
}));
import type { TFunction } from "i18next";
import TakeoutImport from "./TakeoutImport";
import { msg, sayNotif, type NotifText } from "../../lib/notifications";
afterEach(() => {
cleanup();
@@ -184,13 +188,16 @@ describe("YouTube history import · the upload", () => {
await waitFor(() => expect(line()).toContain("settings.takeout.count.watched(7,7)"));
expect(sent).toEqual(["watch-history.json", "bad.html", "Watch later-videos.csv"]);
expect(mocks.notify.mock.calls.map(([n]) => (n as { message: string }).message)).toEqual([
// What each notice says, said by the same `t` the page speaks.
const said = (n: unknown) =>
sayNotif((n as { message: NotifText }).message, mocks.t as TFunction);
expect(mocks.notify.mock.calls.map(([n]) => said(n))).toEqual([
"settings.takeout.read(watch-history.json,9034,9,034)",
"settings.takeout.read(Watch later-videos.csv,56,56)",
]);
expect(mocks.notifyActionError).toHaveBeenCalledWith(
refused,
"errors.writeFailed.takeoutUpload",
msg("errors.writeFailed.takeoutUpload"),
);
});
@@ -274,8 +281,14 @@ describe("YouTube history import · while it runs", () => {
expect(send).not.toHaveBeenCalled();
expect(mocks.notify).toHaveBeenCalledWith({
level: "error",
message: "settings.takeout.tooLarge(watch-history.json,10 B)",
message: msg("settings.takeout.tooLarge", {
name: "watch-history.json",
limit: { bytes: 10 },
}),
});
expect(sayNotif(mocks.notify.mock.calls[0]![0].message, mocks.t as TFunction)).toBe(
"settings.takeout.tooLarge(watch-history.json,10 B)",
);
// Nothing was taken: the zone stays open for the right file, and so after a refusal.
await waitFor(() => expect(zone()).toBeTruthy());
send.mockRejectedValue(new HttpError(422, "refused"));
@@ -9,9 +9,9 @@ import { StateMessage } from "../../components/ui/QueryState";
import { api, type TakeoutStatus, type TakeoutUpload } from "../../lib/api";
import type { TAKEOUT_OUTCOMES } from "../../lib/api/feed";
import { qk } from "../../lib/queryKeys";
import { notify } from "../../lib/notifications";
import { msg, notify } from "../../lib/notifications";
import { notifyActionError } from "../../lib/actionErrors";
import { formatBytes, formatDateTime, formatNumber } from "../../lib/format";
import { formatDateTime, formatNumber } from "../../lib/format";
import { importBusy, invalidateImported, watchTakeout } from "./takeoutWatch";
// Settings › Account: the user's Google Takeout export, imported. What they watched on YouTube and
@@ -129,7 +129,7 @@ export default function TakeoutImport() {
// Refused here, not after minutes of upload, and with the limit named.
notify({
level: "error",
message: t("settings.takeout.tooLarge", { name: file.name, limit: formatBytes(max, lang) }),
message: msg("settings.takeout.tooLarge", { name: file.name, limit: { bytes: max } }),
});
return false;
}
@@ -139,15 +139,15 @@ export default function TakeoutImport() {
res = await api.takeoutUpload(file);
} catch (e) {
// A refusal names its reason (not a Takeout file, nothing in it); the next file still goes.
notifyActionError(e, t("errors.writeFailed.takeoutUpload"));
notifyActionError(e, msg("errors.writeFailed.takeoutUpload"));
}
if (res === null) return false;
notify({
level: "success",
message: t("settings.takeout.read", {
message: msg("settings.takeout.read", {
name: file.name,
count: res.videos,
n: formatNumber(res.videos, lang),
n: { number: res.videos },
}),
});
return true;