fix(notifications): a notice keeps what to say and says it in the reader's language when shown
This commit is contained in:
@@ -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") },
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user