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
@@ -21,7 +21,7 @@ vi.mock("react-i18next", async (importOriginal) => {
vi.mock("../../lib/history", () => ({ useBackToClose: () => {} }));
import Modal from "./Modal";
import { clearAll, notify } from "../../lib/notifications";
import { clearAll, notify, raw } from "../../lib/notifications";
import { pushLayer, removeLayer, useLayer } from "../../lib/layerStack";
import { createPortal, flushSync } from "react-dom";
@@ -121,7 +121,7 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
<Body />
</Modal>,
);
act(() => void notify({ level: "info", message: "Saved" }));
act(() => void notify({ level: "info", message: raw("Saved") }));
try {
const dismissToast = screen.getByTitle("notifications.dismiss");
// The panel's last control is no longer the trap's edge: Tab carries on (natively) to the toast.
@@ -234,7 +234,7 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
<Body />
</Modal>,
);
act(() => void notify({ level: "error", message: "Nope" }));
act(() => void notify({ level: "error", message: raw("Nope") }));
const dismissToast = screen.getByTitle("notifications.dismiss");
dismissToast.focus();
expect(document.activeElement).toBe(dismissToast); // the scenario is actually being driven
@@ -455,7 +455,7 @@ describe("Modal — toasts raised while it is open", () => {
<Body />
</Modal>,
);
act(() => void notify({ level: "error", message: "Couldn't share it." }));
act(() => void notify({ level: "error", message: raw("Couldn't share it.") }));
expect(screen.getByRole("dialog").textContent).toContain("Couldn't share it.");
});
});
+15 -15
View File
@@ -14,7 +14,7 @@ vi.mock("react-i18next", async (importOriginal) => {
});
import Toaster from "./Toaster";
import { clearAll, notify } from "../../lib/notifications";
import { clearAll, notify, raw } from "../../lib/notifications";
import { dockUnderToasts } from "../../lib/toastDock";
afterEach(() => {
@@ -31,7 +31,7 @@ describe("Toaster — one host shows the toasts", () => {
<Toaster />
</div>,
);
act(() => void notify({ level: "error", message: "Couldn't remove the video." }));
act(() => void notify({ level: "error", message: raw("Couldn't remove the video.") }));
expect(screen.getByTestId("app").textContent).toContain("Couldn't remove the video.");
const overlay = render(
@@ -54,7 +54,7 @@ describe("Toaster — one host shows the toasts", () => {
it("lets a click through where there is no toast and nothing docked, and not where there is", () => {
const Docked = () => <button>dock</button>;
const stop = dockUnderToasts(Docked);
notify({ level: "info", message: "hello" });
notify({ level: "info", message: raw("hello") });
render(<Toaster />);
const region = document.querySelector<HTMLElement>("[data-toast-region]")!;
// The empty part of the region (beside and above a docked control) must not swallow the page's
@@ -75,7 +75,7 @@ describe("Toaster — one host shows the toasts", () => {
<Toaster overlay />
</div>,
);
act(() => void notify({ level: "error", message: "boom" }));
act(() => void notify({ level: "error", message: raw("boom") }));
fireEvent.click(screen.getByText("boom"));
expect(backdrop).not.toHaveBeenCalled();
});
@@ -86,11 +86,11 @@ describe("Toaster — handing the toasts to another host", () => {
// The new host gets them as NEW nodes: a screen reader read every one out again, an error
// assertively, each time a player (or any dialog) opened or closed.
render(<Toaster />);
act(() => void notify({ level: "error", message: "old news" }));
act(() => void notify({ level: "error", message: raw("old news") }));
render(<Toaster overlay />);
const old = screen.getByText("old news").closest(".toast-card")!;
expect(old.getAttribute("role")).toBeNull();
act(() => void notify({ level: "error", message: "fresh" }));
act(() => void notify({ level: "error", message: raw("fresh") }));
expect(screen.getByText("fresh").closest(".toast-card")!.getAttribute("role")).toBe("alert");
});
@@ -99,8 +99,8 @@ describe("Toaster — handing the toasts to another host", () => {
// host last showed — re-read on every render, this host's own "first" turned silent the moment
// "second" arrived (its own screen had become the handover by then).
render(<Toaster />);
act(() => void notify({ level: "error", message: "first" }));
act(() => void notify({ level: "error", message: "second" }));
act(() => void notify({ level: "error", message: raw("first") }));
act(() => void notify({ level: "error", message: raw("second") }));
const card = (text: string) => screen.getByText(text).closest(".toast-card")!;
expect(card("first").getAttribute("role")).toBe("alert");
expect(card("second").getAttribute("role")).toBe("alert");
@@ -111,7 +111,7 @@ describe("Toaster — handing the toasts to another host", () => {
// inserted announces them anyway. React flushes effects in the SAME task as that render, so the
// arming has to wait for the next one — a screen reader reads the region's state after the task.
render(<Toaster />);
act(() => void notify({ level: "info", message: "saved" }));
act(() => void notify({ level: "info", message: raw("saved") }));
const overlay = render(<Toaster overlay />);
const region = overlay.container.querySelector("[aria-live]")!;
expect(region.textContent).toContain("saved"); // it took the toast over…
@@ -135,7 +135,7 @@ describe("Toaster — handing the toasts to another host", () => {
<Toaster overlay />
<button
onClick={() => {
notify({ level: "success", message: "upload queued" });
notify({ level: "success", message: raw("upload queued") });
setOpen(false);
}}
>
@@ -147,7 +147,7 @@ describe("Toaster — handing the toasts to another host", () => {
);
}
render(<Stage />);
act(() => void notify({ level: "error", message: "old news" }));
act(() => void notify({ level: "error", message: raw("old news") }));
fireEvent.click(screen.getByText("upload"));
const card = (text: string) => screen.getByText(text).closest(".toast-card")!;
@@ -168,7 +168,7 @@ describe("Toaster — handing the toasts to another host", () => {
<Toaster overlay order={2} />
</div>,
);
act(() => void notify({ level: "error", message: "boom" }));
act(() => void notify({ level: "error", message: raw("boom") }));
expect(screen.getByTestId("dialog").textContent).toContain("boom");
// The player swaps its host (leaving true fullscreen) while the dialog stays open.
player.rerender(
@@ -188,7 +188,7 @@ describe("Toaster — handing the toasts to another host", () => {
const t0 = Date.now();
const now = vi.spyOn(Date, "now").mockReturnValue(t0);
render(<Toaster />);
act(() => void notify({ level: "info", message: "saved" }));
act(() => void notify({ level: "info", message: raw("saved") }));
now.mockReturnValue(t0 + 4000);
render(<Toaster overlay />);
const bar = screen.getByText("saved").closest(".toast-card")!.lastElementChild as HTMLElement;
@@ -199,7 +199,7 @@ describe("Toaster — handing the toasts to another host", () => {
// At the app's own spot (bottom-4) the stack sat on the Plex transport row and PlayerModal's
// docked bar, above them at z-overlay, and took their clicks for as long as a toast was up.
render(<Toaster overlay />);
act(() => void notify({ level: "error", message: "boom" }));
act(() => void notify({ level: "error", message: raw("boom") }));
const host = hosts()[0]!.closest("[data-toast-region]")!;
expect(host.className).toContain("bottom-24");
expect(host.className).toContain("pointer-events-none");
@@ -218,7 +218,7 @@ describe("Toaster — a toast's actions", () => {
() =>
void notify({
level: "success",
message: "Downloaded",
message: raw("Downloaded"),
action: { label: "Copy link", onClick: copy },
secondaryAction: { label: "Open", onClick: open },
}),
@@ -0,0 +1,73 @@
// @vitest-environment jsdom
//
// A notice is said in the reader's language when it is shown, not in the one it was raised in. The
// text used to be translated by the caller and kept so: after a language switch every toast and every
// row of the notification center stayed in the old language, and so did the history after a reload.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import i18n from "../../i18n";
import Toaster from "./Toaster";
import { clearAll, msg, notify, raw, sayNotif } from "../../lib/notifications";
beforeEach(async () => {
await i18n.changeLanguage("en");
});
afterEach(async () => {
cleanup();
act(() => clearAll());
await i18n.changeLanguage("en");
});
describe("a notice speaks the reader's language of the moment", () => {
it("a toast raised in English reads Hungarian after the switch", async () => {
render(<Toaster />);
act(() => void notify({ level: "success", message: msg("common.copy.done") }));
const en = i18n.getFixedT("en")("common.copy.done");
const hu = i18n.getFixedT("hu")("common.copy.done");
expect(en).not.toBe(hu);
expect(screen.getByText(en)).toBeTruthy();
await act(async () => void (await i18n.changeLanguage("hu")));
expect(screen.getByText(hu)).toBeTruthy();
expect(screen.queryByText(en)).toBeNull();
});
it("a value inside the text is said again too, and a number is grouped the reader's way", async () => {
const text = msg("settings.takeout.read", {
name: raw("watch-history.json"),
count: 12345,
n: { number: 12345 },
});
const en = sayNotif(text);
await i18n.changeLanguage("hu");
const hu = sayNotif(text);
expect(en).toContain("12,345");
expect(hu).not.toContain("12,345");
expect(hu).toContain("watch-history.json");
expect(hu).not.toBe(en);
});
});
describe("the history kept from before", () => {
beforeEach(() => {
localStorage.clear();
vi.resetModules();
});
it("shows a notice stored as text as it was stored", async () => {
localStorage.setItem("siftlode.owner", "5");
localStorage.setItem(
"siftlode.notifications",
JSON.stringify([
{ id: 1, level: "info", title: "Old title", message: "Old text", ts: 1, read: true },
]),
);
const notifications = await import("../../lib/notifications");
const { claimLocalData } = await import("../../lib/storage");
claimLocalData(5);
const n = notifications.getNotifications()[0]!;
expect(n.message).toEqual(raw("Old text"));
expect(n.title).toEqual(raw("Old title"));
expect(notifications.sayNotif(n.message)).toBe("Old text");
});
});
@@ -23,7 +23,7 @@ vi.mock("react-i18next", async (importOriginal) => {
import Toaster from "./Toaster";
import { useOverlayOrder } from "./overlayOrder";
import { clearAll, notify } from "../../lib/notifications";
import { clearAll, notify, raw } from "../../lib/notifications";
afterEach(() => {
cleanup();
@@ -37,7 +37,7 @@ function Harness() {
<>
<button
onClick={() => {
notify({ level: "success", message: "Uploaded" });
notify({ level: "success", message: raw("Uploaded") });
setOpen(false); // …in the SAME handler: one batch, one commit
}}
>
+5 -3
View File
@@ -1,7 +1,7 @@
import { useEffect, useId, useLayoutEffect, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import { dismiss, getActiveToasts, subscribe } from "../../lib/notifications";
import { dismiss, getActiveToasts, sayNotif, subscribe } from "../../lib/notifications";
import { createStore } from "../../lib/store";
import { useToastDock } from "../../lib/toastDock";
import { useOverlayOrder } from "./overlayOrder";
@@ -162,9 +162,11 @@ export default function Toaster({
>
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
<div className="min-w-0 flex-1">
{toast.title && <div className="text-sm font-semibold">{toast.title}</div>}
{toast.title && (
<div className="text-sm font-semibold">{sayNotif(toast.title, t)}</div>
)}
<div className="text-sm wrap-break-word">
{toast.message}
{sayNotif(toast.message, t)}
{toast.repeat > 1 && (
<span className="ml-1.5 align-middle text-2xs font-semibold text-muted tabular-nums">
×{toast.repeat}
+5 -5
View File
@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
import { Copy, Link2 } from "lucide-react";
import { copyText } from "../../lib/clipboard";
import { notify } from "../../lib/notifications";
import { msg, type NotifText, notify } from "../../lib/notifications";
import { type ActionMenuItem } from "./ActionMenu";
// "Copy title" and "Copy link", the two rows every object's menu ends its middle group with
@@ -17,12 +17,12 @@ type Named = "title" | "name";
export function useCopyItems() {
const { t } = useTranslation();
const copy = (text: string, done: string) => {
const copy = (text: string, done: NotifText) => {
void copyText(text).then((ok) =>
notify(
ok
? { level: "success", message: done }
: { level: "error", message: t("common.copy.failed") },
: { level: "error", message: msg("common.copy.failed") },
),
);
};
@@ -43,7 +43,7 @@ export function useCopyItems() {
label: t(named === "title" ? "common.copy.title" : "common.copy.name"),
icon: <Copy className="w-4 h-4" />,
inGesture: true,
onSelect: () => copy(text, t("common.copy.done")),
onSelect: () => copy(text, msg("common.copy.done")),
},
]
: []),
@@ -54,7 +54,7 @@ export function useCopyItems() {
label: linkLabel ?? t("common.copy.link"),
icon: <Link2 className="w-4 h-4" />,
inGesture: true,
onSelect: () => copy(link, t("common.copy.linkDone")),
onSelect: () => copy(link, msg("common.copy.linkDone")),
},
]
: []),
@@ -11,8 +11,12 @@ vi.mock("react-i18next", async (importOriginal) => ({
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
}));
vi.mock("../../lib/clipboard", () => ({ copyText: h.copyText }));
vi.mock("../../lib/notifications", () => ({ notify: h.notify }));
vi.mock("../../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../../lib/notifications")>()),
notify: h.notify,
}));
import { msg } from "../../lib/notifications";
import { useCopyFeedback } from "./useCopyFeedback";
afterEach(() => {
@@ -27,7 +31,7 @@ describe("useCopyFeedback", () => {
await act(() => result.current.copy("sfl_x"));
expect(h.copyText).toHaveBeenCalledWith("sfl_x");
expect(result.current.copied).toBe(true);
expect(h.notify).toHaveBeenCalledWith({ level: "success", message: "common.copy.done" });
expect(h.notify).toHaveBeenCalledWith({ level: "success", message: msg("common.copy.done") });
act(() => void vi.advanceTimersByTime(2000));
expect(result.current.copied).toBe(false);
});
@@ -49,6 +53,6 @@ describe("useCopyFeedback", () => {
const { result } = renderHook(() => useCopyFeedback());
await act(() => result.current.copy("sfl_x"));
expect(result.current.copied).toBe(false);
expect(h.notify).toHaveBeenCalledWith({ level: "warning", message: "common.copy.failed" });
expect(h.notify).toHaveBeenCalledWith({ level: "warning", message: msg("common.copy.failed") });
});
});
@@ -1,8 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { copyText } from "../../lib/clipboard";
import { notify } from "../../lib/notifications";
import { msg, notify } from "../../lib/notifications";
// How long a Copy button reads "Copied" after it worked.
const COPIED_MS = 2000;
@@ -15,7 +14,6 @@ const COPIED_MS = 2000;
*
* A feature file, not `ui/`: it speaks the app's notifications (as `copyItems` does). */
export function useCopyFeedback(): { copied: boolean; copy: (value: string) => Promise<void> } {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
// One reset at a time: a copy again within the moment starts it over, not cut short by the last.
const timer = useRef<number | undefined>(undefined);
@@ -23,12 +21,12 @@ export function useCopyFeedback(): { copied: boolean; copy: (value: string) => P
const copy = async (value: string) => {
if (await copyText(value)) {
setCopied(true);
notify({ level: "success", message: t("common.copy.done") });
notify({ level: "success", message: msg("common.copy.done") });
window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setCopied(false), COPIED_MS);
return;
}
notify({ level: "warning", message: t("common.copy.failed") });
notify({ level: "warning", message: msg("common.copy.failed") });
};
return { copied, copy };
}