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
+2 -1
View File
@@ -37,6 +37,7 @@ import VerifyBanner from "./VerifyBanner";
// pulls the release-notes loader into the eager App chunk and defeats the lazy ReleaseNotes
// split. FRONTEND_VERSION equals the newest release note's version by release convention (both bump together).
import { FRONTEND_VERSION } from "../lib/version";
import { msg } from "../lib/notifications";
// The page modules (and their side rails) are code-split per route: each module's definition imports
// them lazily (features/navigation/moduleRegistry), and so does the feed's for the channel page. App
@@ -94,7 +95,7 @@ export default function App() {
// Also from the signed-out login page, where the save 401s — `req` marks that as handled, so
// the reporter stays quiet there.
saveLanguage(queryClient, code).catch((e: unknown) =>
notifyActionError(e, t("errors.writeFailed.prefs")),
notifyActionError(e, msg("errors.writeFailed.prefs")),
);
}
@@ -7,9 +7,13 @@ import { afterEach, expect, it, vi } from "vitest";
import { cleanup, render } from "@testing-library/react";
const notify = vi.fn();
vi.mock("../lib/notifications", () => ({ notify: (...args: unknown[]) => notify(...args) }));
vi.mock("../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/notifications")>()),
notify: (...args: unknown[]) => notify(...args),
}));
import i18n from "../i18n";
import { msg, sayNotif, type NotifText } from "../lib/notifications";
import { clear, events } from "../lib/log";
import ErrorBoundary from "./ErrorBoundary";
@@ -31,8 +35,10 @@ it("a crash is reported in words the reader reads; the engine's text goes to the
);
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "fatal", message: i18n.t("errors.boundary.notifMessage") }),
expect.objectContaining({ level: "fatal", message: msg("errors.boundary.notifMessage") }),
);
const [shown] = notify.mock.calls[0] as [{ message: NotifText }];
expect(sayNotif(shown.message)).toBe(i18n.t("errors.boundary.notifMessage"));
expect(JSON.stringify(events().filter((e) => e.event === "ui.crash"))).toContain(
"Cannot read properties of undefined",
);
@@ -8,7 +8,10 @@ import { afterEach, expect, it, vi } from "vitest";
import { cleanup, render } from "@testing-library/react";
const notify = vi.fn();
vi.mock("../lib/notifications", () => ({ notify: (...args: unknown[]) => notify(...args) }));
vi.mock("../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/notifications")>()),
notify: (...args: unknown[]) => notify(...args),
}));
import { clear, events } from "../lib/log";
import { installStaleBuildReload } from "../lib/staleBuild";
+3 -3
View File
@@ -1,6 +1,6 @@
import type { ReactNode } from "react";
import i18n from "../i18n";
import { notify } from "../lib/notifications";
import { msg, notify } from "../lib/notifications";
import { CrashBoundary, type CrashCopy } from "../components/ui/CrashBoundary";
const copy = (): CrashCopy => ({
@@ -13,10 +13,10 @@ const copy = (): CrashCopy => ({
const onCrash = () =>
notify({
level: "fatal",
title: i18n.t("errors.boundary.notifTitle"),
title: msg("errors.boundary.notifTitle"),
// Never the raw `error.message`: a JS engine's English ("Cannot read properties of undefined…")
// is no message for the reader — the `ui.crash` log carries it for diagnosis.
message: i18n.t("errors.boundary.notifMessage"),
message: msg("errors.boundary.notifMessage"),
requiresInteraction: true,
});
@@ -9,6 +9,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import i18n from "../i18n";
import { api, HttpError, type Me } from "../lib/api";
import * as actionErrors from "../lib/actionErrors";
import { sayNotif } from "../lib/notifications";
import NavSidebar from "./NavSidebar";
vi.mock("../features/navigation/navigationContext", () => ({
@@ -119,7 +120,7 @@ describe("the account panel", () => {
fireEvent.click(row(panel, "Sign out"));
await vi.waitFor(() => expect(report).toHaveBeenCalledOnce());
expect(report.mock.calls[0]![1]).toBe("Couldn't sign you out");
expect(sayNotif(report.mock.calls[0]![1])).toBe("Couldn't sign you out");
expect(reload).not.toHaveBeenCalled();
});
});
+2 -2
View File
@@ -6,7 +6,7 @@ import { ArrowLeftRight, Info, LogOut, Settings, Shield, type LucideIcon } from
import { api, type Me } from "../lib/api";
import { displayName, roleKey } from "../lib/api/account";
import { useLiveQuery } from "../lib/useLiveQuery";
import { getUnreadCount, subscribe } from "../lib/notifications";
import { getUnreadCount, msg, subscribe } from "../lib/notifications";
import type { Page } from "../lib/urlState";
import {
moduleDef,
@@ -80,7 +80,7 @@ export default function NavSidebar({
const signedOut = await api.logout().then(
() => true,
(err: unknown) => {
notifyActionError(err, t("errors.writeFailed.signOut"));
notifyActionError(err, msg("errors.writeFailed.signOut"));
return false;
},
);
@@ -7,7 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render } from "@testing-library/react";
import { clear, events } from "../lib/log";
import { notify } from "../lib/notifications";
import { notify, raw } from "../lib/notifications";
import { reportError } from "../lib/errorDialog";
import { createQueryClient } from "../lib/queryClient";
import ErrorBoundary from "./ErrorBoundary";
@@ -23,8 +23,8 @@ const logged = (code: string) => events().filter((e) => e.event === code);
describe("client log wiring", () => {
it("records every notification at its level", () => {
notify({ level: "success", title: "Saved", message: "Playlist renamed" });
notify({ level: "error", message: "Could not reach someone@mail.example" });
notify({ level: "success", title: raw("Saved"), message: raw("Playlist renamed") });
notify({ level: "error", message: raw("Could not reach someone@mail.example") });
expect(logged("ui.notify").map((e) => [e.level, e.msg, e.attrs])).toEqual([
["INFO", "Playlist renamed", { level: "success", title: "Saved" }],
["ERROR", "Could not reach [email]", { level: "error" }],
+3 -3
View File
@@ -2,7 +2,7 @@ import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import type { Me } from "../lib/api";
import type { Page } from "../lib/urlState";
import { notify, removeByMetaKind } from "../lib/notifications";
import { msg, notify, removeByMetaKind } from "../lib/notifications";
import { focusAccessRequestsTab } from "../lib/adminUsersTab";
type SetPage = (next: Page, then?: () => void) => void;
@@ -30,8 +30,8 @@ export function useAccessRequestsNudge(me: Me | null | undefined, setPage: SetPa
if (pending > 0) {
notify({
level: "warning",
title: t("common.accessRequestsTitle"),
message: t("common.accessRequestsMessage", { count: pending }),
title: msg("common.accessRequestsTitle"),
message: msg("common.accessRequestsMessage", { count: pending }),
// Durable inbox link (survives reload, unlike a live action callback); the toast also
// gets a click via the action.
meta: { kind: "access-requests" },
+4 -4
View File
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import type { Me } from "../lib/api";
import { clearParams, type Page } from "../lib/urlState";
import { useAccountSwitchNotice } from "../lib/useAccountSwitchNotice";
import { notify } from "../lib/notifications";
import { msg, notify } from "../lib/notifications";
import { focusAccessRequestsTab } from "../lib/adminUsersTab";
import { focusSettingsTab } from "../lib/settingsTab";
@@ -46,13 +46,13 @@ export function useEntryParams(
if (!result) return;
linkAnswered.current = true;
if (result === "ok") {
notify({ level: "success", message: t("settings.account.googleLink.linked") });
notify({ level: "success", message: msg("settings.account.googleLink.linked") });
void refetchMe();
focusSettingsTab("account");
setPage("settings");
} else {
const key = result === "conflict" || result === "mismatch" ? result : "error";
notify({ level: "error", message: t(`settings.account.googleLink.${key}`) });
notify({ level: "error", message: msg(`settings.account.googleLink.${key}`) });
}
clearParams("link");
}, [t, setPage, refetchMe]);
@@ -66,7 +66,7 @@ export function useEntryParams(
if (oauthAnswered.current) return;
if (new URLSearchParams(window.location.search).get("oauth") !== "cancelled") return;
oauthAnswered.current = true;
notify({ level: "info", message: t("settings.account.googleCancelled") });
notify({ level: "info", message: msg("settings.account.googleCancelled") });
clearParams("oauth");
}, [t]);