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
@@ -30,10 +30,11 @@ vi.mock("../../lib/downloadQueue", () => ({ showInTray: (id: number) => showInTr
const notifyActionError = vi.fn();
vi.mock("../../lib/actionErrors", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/actionErrors")>();
return { ...actual, notifyActionError: (e: unknown, t: string) => notifyActionError(e, t) };
return { ...actual, notifyActionError: (e: unknown, t: NotifText) => notifyActionError(e, t) };
});
import ClipPanel, { type ClipCrop, type ClipSource } from "./ClipPanel";
import { msg, type NotifText } from "../../lib/notifications";
afterEach(() => {
cleanup();
@@ -188,7 +189,7 @@ describe("ClipPanel", () => {
display_name: "The bit",
});
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "success", message: "clip.queued" }),
expect.objectContaining({ level: "success", message: msg("clip.queued") }),
);
// Its View opens the tray on the new clip.
(
@@ -214,7 +215,7 @@ describe("ClipPanel", () => {
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
await waitFor(() => expect(notifyActionError).toHaveBeenCalledTimes(1));
expect(notifyActionError.mock.calls[0]![0]).toBe(err);
expect(notifyActionError.mock.calls[0]![1]).toBe("clip.failed");
expect(notifyActionError.mock.calls[0]![1]).toEqual(msg("clip.failed"));
expect(onClose).not.toHaveBeenCalled();
expect((screen.getByRole("button", { name: "clip.cut" }) as HTMLButtonElement).disabled).toBe(
false,
+3 -3
View File
@@ -5,7 +5,7 @@ import { Crop, Crosshair, Maximize, Minus, Plus, Repeat, Scissors, X } from "luc
import { api, type ClipRequest } from "../../lib/api";
import { DISPLAY_NAME_MAX } from "../../lib/api/downloads";
import { invalidateDownloads } from "../../lib/downloads";
import { notify } from "../../lib/notifications";
import { msg, notify } from "../../lib/notifications";
import { notifyActionError } from "../../lib/actionErrors";
import { showInTray } from "../../lib/downloadQueue";
import { log } from "../../lib/log";
@@ -197,14 +197,14 @@ export default function ClipPanel({
log.info("clip.queued", "clip queued", { source: src.source, seconds: Math.round(length) });
notify({
level: "success",
message: t("clip.queued"),
message: msg("clip.queued"),
action: { label: t("clip.view"), onClick: () => showInTray(job.id) },
});
onClose();
} catch (err) {
// A refusal the server explains (too long, quota) is already in the error dialog; a lost
// connection or a 404 is not, and must not leave the panel looking as if nothing happened.
notifyActionError(err, t("clip.failed"));
notifyActionError(err, msg("clip.failed"));
setBusy(false);
}
};
@@ -1,5 +1,4 @@
import { lazy, Suspense, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { type Video, type VideoStatus } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
@@ -7,6 +6,7 @@ import { notifyActionError } from "../../lib/actionErrors";
import { useStableCallback } from "../../lib/useLatest";
import { writeVideoState } from "../video/videoWrites";
import { useOpenLinkedPlayer } from "./useOpenLinkedPlayer";
import { msg } from "../../lib/notifications";
const PlayerModal = lazy(() => import("./PlayerModal"));
@@ -14,7 +14,6 @@ const PlayerModal = lazy(() => import("./PlayerModal"));
* channel manager, where the watched-videos dialog played it; any page a link names): the video
* alone. Decided at boot, once — `hosted` is whether the page on screen then opens it itself. */
export function LinkedPlayer({ hosted }: { hosted: boolean }) {
const { t } = useTranslation();
const qc = useQueryClient();
const [own] = useState(!hosted);
const [video, setVideo] = useState<Video | null>(null);
@@ -27,7 +26,7 @@ export function LinkedPlayer({ hosted }: { hosted: boolean }) {
const write = writeVideoState(qc, id, status);
write
.then(() => qc.invalidateQueries({ queryKey: qk.feed() }))
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState")));
.catch((e: unknown) => notifyActionError(e, msg("errors.writeFailed.videoState")));
return write;
});
const close = useStableCallback(() => setVideo(null));
@@ -40,7 +40,7 @@ import { closeKeyboardHelp, toggleKeyboardHelp } from "../../lib/keymap";
import { ConfirmProvider } from "../../components/ui/ConfirmProvider";
import { useConfirm } from "../../components/ui/confirmContext";
import { api, type Me, type Video, type VideoStatus } from "../../lib/api";
import { clearAll, notify } from "../../lib/notifications";
import { clearAll, notify, raw } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys";
import { writeVideoState } from "../video/videoWrites";
@@ -314,7 +314,7 @@ describe("PlayerModal — toasts raised while it is open", () => {
onState={() => Promise.resolve()}
/>,
);
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("player-modal").textContent).toContain("Couldn't remove the video.");
});
@@ -336,7 +336,7 @@ describe("PlayerModal — toasts raised while it is open", () => {
Object.defineProperty(document, "fullscreenElement", { configurable: true, get: () => stage });
try {
act(() => void document.dispatchEvent(new Event("fullscreenchange")));
act(() => void notify({ level: "error", message: "Connection lost" }));
act(() => void notify({ level: "error", message: raw("Connection lost") }));
await waitFor(() => expect(stage.textContent).toContain("Connection lost"));
} finally {
delete (document as { fullscreenElement?: unknown }).fullscreenElement;
@@ -26,6 +26,7 @@ import VideoCard from "../video/VideoCard";
import { VideoMenu } from "../video/VideoMenu";
import { announceVideoState } from "../video/stateNotice";
import { writeVideoState, writeWatchLater } from "../video/videoWrites";
import { msg } from "../../lib/notifications";
/** Which list YouTube fills in — on the video's topic or with what trends in Hungary — is a mood,
* decided at the list, and the account keeps the last one (the owner's pick). */
@@ -100,7 +101,7 @@ export function RelatedVideos({
announceVideoState(id, status, v, () => onState(id, "new"));
})
.catch((e: unknown) => {
notifyActionError(e, t("errors.writeFailed.videoState"));
notifyActionError(e, msg("errors.writeFailed.videoState"));
settle(seq);
});
};
@@ -118,7 +119,7 @@ export function RelatedVideos({
.catch((e: unknown) => {
notifyActionError(
e,
t(saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove"),
msg(saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove"),
);
settle(seq);
});
@@ -26,6 +26,7 @@ import { useOpenLinkedPlayer } from "./useOpenLinkedPlayer";
import { LinkedPlayer } from "./LinkedPlayer";
import { api, type Video } from "../../lib/api";
import { notifyActionError } from "../../lib/actionErrors";
import { msg } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys";
const vid = (id: string) => ({ id, title: id }) as unknown as Video;
@@ -76,7 +77,7 @@ describe("useOpenLinkedPlayer", () => {
const open = vi.fn();
renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper });
await waitFor(() =>
expect(notifyActionError).toHaveBeenCalledWith(gone, "player.linkedFailed"),
expect(notifyActionError).toHaveBeenCalledWith(gone, msg("player.linkedFailed")),
);
expect(open).not.toHaveBeenCalled();
// Said once: the address no longer names it, so a reload or a copied link does not say it again.
@@ -1,11 +1,11 @@
import { useEffect, useEffectEvent, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { api, type Video } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
import { linkedPlayer, PLAYER_KEYS } from "../../lib/playerUrlState";
import { clearParams } from "../../lib/urlState";
import { notifyActionError } from "../../lib/actionErrors";
import { msg } from "../../lib/notifications";
/** Opens the player an address names (a reload, a bookmark — lib/playerUrlState) on the page that
* hosts it, once: read as the host mounts, opened once its list is in (`ready`). From the list when the
@@ -22,7 +22,6 @@ export function useOpenLinkedPlayer(
open: (video: Video, inList: boolean) => void,
scope: string | number | null = null,
): void {
const { t } = useTranslation();
const [linked] = useState(() => linkedPlayer()?.watch ?? null);
const [linkedScope] = useState(scope);
// Opened, said to be gone or given up, once. A ref for the effect (a StrictMode re-run of it must
@@ -40,7 +39,7 @@ export function useOpenLinkedPlayer(
});
const openIt = useEffectEvent(open);
const failed = useEffectEvent((err: unknown) => {
notifyActionError(err, t("player.linkedFailed"));
notifyActionError(err, msg("player.linkedFailed"));
clearParams(...PLAYER_KEYS);
});
useEffect(() => {