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