From b515c161178e9df805637ed023a48c7ab60148f2 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 30 Oct 2026 00:10:38 +0100 Subject: [PATCH] fix(notifications): a notice's button is said when shown, like its text An action label was translated when the notice was raised, so after a language switch a live toast or center row read in one language with its button in the other. The access-requests nudge no longer re-issues on a switch for it. New tests: the button and a server refusal follow a switch. --- .../src/components/ui/Toaster.dom.test.tsx | 4 +-- .../ui/Toaster.language.dom.test.tsx | 31 +++++++++++++++++++ frontend/src/components/ui/Toaster.tsx | 6 ++-- .../src/features/downloads/DownloadDialog.tsx | 2 +- .../downloads/DownloadTray.dom.test.tsx | 7 +++-- .../src/features/downloads/DownloadTray.tsx | 8 ++--- frontend/src/features/player/ClipPanel.tsx | 2 +- frontend/src/features/video/stateNotice.ts | 2 +- frontend/src/lib/downloads.test.ts | 21 ++++++------- frontend/src/lib/downloads.ts | 8 ++--- .../lib/notifications.actionTtl.dom.test.ts | 2 +- frontend/src/lib/notifications.ts | 3 +- frontend/src/lib/youtubeErrors.ts | 5 +-- .../channels/WatchedChannels.dom.test.tsx | 7 +++-- .../modules/channels/useChannelActions.tsx | 2 +- .../channels/useWatchedChannelActions.ts | 2 +- frontend/src/modules/files/UploadDialog.tsx | 2 +- frontend/src/modules/files/useFileActions.tsx | 2 +- .../src/modules/files/useFolderActions.tsx | 4 +-- .../NotificationsPanel.menu.dom.test.tsx | 8 ++--- .../notifications/NotificationsPanel.tsx | 8 ++--- .../modules/playlists/Playlists.dom.test.tsx | 6 ++-- frontend/src/modules/playlists/Playlists.tsx | 2 +- .../plex/usePlexPlaylistEdits.dom.test.tsx | 9 ++++-- .../src/modules/plex/usePlexPlaylistEdits.ts | 4 +-- frontend/src/shell/useAccessRequestsNudge.ts | 11 +++---- 26 files changed, 98 insertions(+), 70 deletions(-) diff --git a/frontend/src/components/ui/Toaster.dom.test.tsx b/frontend/src/components/ui/Toaster.dom.test.tsx index 91b124cd..cbd94c1d 100644 --- a/frontend/src/components/ui/Toaster.dom.test.tsx +++ b/frontend/src/components/ui/Toaster.dom.test.tsx @@ -219,8 +219,8 @@ describe("Toaster — a toast's actions", () => { void notify({ level: "success", message: raw("Downloaded"), - action: { label: "Copy link", onClick: copy }, - secondaryAction: { label: "Open", onClick: open }, + action: { label: raw("Copy link"), onClick: copy }, + secondaryAction: { label: raw("Open"), onClick: open }, }), ); expect(screen.getByRole("button", { name: "Copy link" })).toBeTruthy(); diff --git a/frontend/src/components/ui/Toaster.language.dom.test.tsx b/frontend/src/components/ui/Toaster.language.dom.test.tsx index 2bcfd42c..d6c1bf65 100644 --- a/frontend/src/components/ui/Toaster.language.dom.test.tsx +++ b/frontend/src/components/ui/Toaster.language.dom.test.tsx @@ -32,6 +32,37 @@ describe("a notice speaks the reader's language of the moment", () => { expect(screen.queryByText(en)).toBeNull(); }); + it("its button is said again too, beside the text it acts on", async () => { + render(); + act( + () => + void notify({ + message: msg("common.copy.done"), + action: { label: msg("common.undo"), onClick: () => {} }, + }), + ); + expect(screen.getByRole("button", { name: "Undo" })).toBeTruthy(); + + await act(async () => void (await i18n.changeLanguage("hu"))); + expect(screen.getByRole("button", { name: "Visszavonás" })).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Undo" })).toBeNull(); + }); + + it("a server refusal is said again in the language of the moment", async () => { + render(); + act( + () => + void notify({ + level: "error", + message: { detail: { code: "not_found", reason: "view" } }, + }), + ); + expect(screen.getByText("This view doesn't exist any more")).toBeTruthy(); + + await act(async () => void (await i18n.changeLanguage("hu"))); + expect(screen.getByText("Ez a nézet már nem létezik")).toBeTruthy(); + }); + 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"), diff --git a/frontend/src/components/ui/Toaster.tsx b/frontend/src/components/ui/Toaster.tsx index c6eeffbe..7c55b56d 100644 --- a/frontend/src/components/ui/Toaster.tsx +++ b/frontend/src/components/ui/Toaster.tsx @@ -176,10 +176,10 @@ export default function Toaster({ {(toast.action || toast.secondaryAction) && (
{[toast.action, toast.secondaryAction].map( - (action) => + (action, i) => action && ( ), )} diff --git a/frontend/src/features/downloads/DownloadDialog.tsx b/frontend/src/features/downloads/DownloadDialog.tsx index 837c8ed9..41b88318 100644 --- a/frontend/src/features/downloads/DownloadDialog.tsx +++ b/frontend/src/features/downloads/DownloadDialog.tsx @@ -64,7 +64,7 @@ export default function DownloadDialog({ notify({ level: "success", message: msg("downloads.dialog.added"), - action: { label: t("downloads.dialog.view"), onClick: () => showInTray(job.id) }, + action: { label: msg("downloads.dialog.view"), onClick: () => showInTray(job.id) }, }); onClose(); } catch (e) { diff --git a/frontend/src/features/downloads/DownloadTray.dom.test.tsx b/frontend/src/features/downloads/DownloadTray.dom.test.tsx index f1be5ebd..9d18d198 100644 --- a/frontend/src/features/downloads/DownloadTray.dom.test.tsx +++ b/frontend/src/features/downloads/DownloadTray.dom.test.tsx @@ -202,12 +202,13 @@ describe("DownloadTray", () => { const notice = h.notify.mock.calls[0]![0] as { title: NotifText; message: NotifText; - action: { label: string; onClick: () => void }; - secondaryAction: { label: string; onClick: () => void }; + action: { label: NotifText; onClick: () => void }; + secondaryAction: { label: NotifText; onClick: () => void }; }; expect(notice.title).toEqual(msg("tray.done.title", { title: raw("Clip 1") })); expect(notice.message).toEqual(msg("tray.done.phone")); - expect(notice.action.label).toBe("tray.done.copyLink"); + expect(notice.action.label).toEqual(msg("tray.done.copyLink")); + expect(notice.secondaryAction.label).toEqual(msg("tray.done.open")); notice.action.onClick(); expect(h.share).toHaveBeenCalled(); notice.secondaryAction.onClick(); diff --git a/frontend/src/features/downloads/DownloadTray.tsx b/frontend/src/features/downloads/DownloadTray.tsx index b59056b3..1d56c3f2 100644 --- a/frontend/src/features/downloads/DownloadTray.tsx +++ b/frontend/src/features/downloads/DownloadTray.tsx @@ -196,11 +196,11 @@ export default function DownloadTray() { }), message: msg(job.asset?.media_kind === "file" ? "tray.done.file" : "tray.done.phone"), action: { - label: t("tray.done.copyLink"), - onClick: () => void quickShareLink(qc, { kind: "file", id: job.id }, t), + label: msg("tray.done.copyLink"), + onClick: () => void quickShareLink(qc, { kind: "file", id: job.id }), }, secondaryAction: { - label: t("tray.done.open"), + label: msg("tray.done.open"), onClick: () => openFile(job.id, job.folder_id), }, }); @@ -298,7 +298,7 @@ export default function DownloadTray() { id: "copyLink", label: t("tray.done.copyLink"), icon: , - onSelect: () => void quickShareLink(qc, { kind: "file", id: job.id }, t), + onSelect: () => void quickShareLink(qc, { kind: "file", id: job.id }), }, ]); const menuItems = menu.target diff --git a/frontend/src/features/player/ClipPanel.tsx b/frontend/src/features/player/ClipPanel.tsx index 08690fd6..24ef24ae 100644 --- a/frontend/src/features/player/ClipPanel.tsx +++ b/frontend/src/features/player/ClipPanel.tsx @@ -198,7 +198,7 @@ export default function ClipPanel({ notify({ level: "success", message: msg("clip.queued"), - action: { label: t("clip.view"), onClick: () => showInTray(job.id) }, + action: { label: msg("clip.view"), onClick: () => showInTray(job.id) }, }); onClose(); } catch (err) { diff --git a/frontend/src/features/video/stateNotice.ts b/frontend/src/features/video/stateNotice.ts index 743d51f0..e06126de 100644 --- a/frontend/src/features/video/stateNotice.ts +++ b/frontend/src/features/video/stateNotice.ts @@ -26,7 +26,7 @@ export function announceVideoState( message: video?.title ? msg(hidden ? "feed.hiddenNamed" : "feed.markedWatchedNamed", { title: raw(video.title) }) : msg(hidden ? "feed.hidden" : "feed.markedWatched"), - action: { label: i18n.t(hidden ? "feed.undo" : "feed.unwatch"), onClick: undo }, + action: { label: msg(hidden ? "feed.undo" : "feed.unwatch"), onClick: undo }, meta: { kind: hidden ? "video-hidden" : "video-watched", videoId: id, diff --git a/frontend/src/lib/downloads.test.ts b/frontend/src/lib/downloads.test.ts index 8cb6f0dd..579f3963 100644 --- a/frontend/src/lib/downloads.test.ts +++ b/frontend/src/lib/downloads.test.ts @@ -30,13 +30,12 @@ import { quickShareLink } from "./downloads"; import { qk } from "./queryKeys"; import { msg, type NotifText } from "./notifications"; -const t = (k: string) => k; const origin = window.location.origin; type ToastArg = { level: string; message: NotifText; - action?: { label: string; onClick: () => void }; + action?: { label: NotifText; onClick: () => void }; }; const firstToast = () => notify.mock.calls[0]![0] as ToastArg; @@ -59,7 +58,7 @@ describe("quickShareLink", () => { createDownloadLink.mockResolvedValue({ id: 7, url: "/watch/tok7" }); const openSpy = vi.spyOn(window, "open").mockReturnValue(null); - const url = await quickShareLink(qc(), { kind: "file", id: 42 }, t); + const url = await quickShareLink(qc(), { kind: "file", id: 42 }); expect(url).toBe(`${origin}/watch/tok7`); // Minted with an EMPTY body so the backend default (allow_download=true) applies. @@ -68,7 +67,7 @@ describe("quickShareLink", () => { const arg = firstToast(); expect(arg.level).toBe("success"); expect(arg.message).toEqual(msg("downloads.share.linkCopied")); - expect(arg.action!.label).toBe("downloads.share.openNewTab"); + expect(arg.action!.label).toEqual(msg("downloads.share.openNewTab")); arg.action!.onClick(); expect(openSpy).toHaveBeenCalledWith(`${origin}/watch/tok7`, "_blank", "noopener,noreferrer"); }); @@ -76,7 +75,7 @@ describe("quickShareLink", () => { it("reuses an existing link instead of minting a duplicate", async () => { downloadLinks.mockResolvedValue([{ id: 3, url: "/watch/old" }]); - const url = await quickShareLink(qc(), { kind: "file", id: 1 }, t); + const url = await quickShareLink(qc(), { kind: "file", id: 1 }); expect(url).toBe(`${origin}/watch/old`); expect(createDownloadLink).not.toHaveBeenCalled(); @@ -89,7 +88,7 @@ describe("quickShareLink", () => { { id: 3, url: "/watch/older" }, ]); - const url = await quickShareLink(qc(), { kind: "file", id: 1 }, t); + const url = await quickShareLink(qc(), { kind: "file", id: 1 }); expect(url).toBe(`${origin}/watch/older`); expect(createDownloadLink).not.toHaveBeenCalled(); @@ -101,7 +100,7 @@ describe("quickShareLink", () => { createDownloadLink.mockResolvedValue({ id: 8, url: "/watch/new" }); const client = qc(); - const url = await quickShareLink(client, { kind: "file", id: 6 }, t); + const url = await quickShareLink(client, { kind: "file", id: 6 }); expect(url).toBe(`${origin}/watch/new`); expect(copyText).toHaveBeenCalledWith(`${origin}/watch/new`); @@ -117,7 +116,7 @@ describe("quickShareLink", () => { const client = qc(); client.setQueryData(qk.fileFolders(), []); - const url = await quickShareLink(client, { kind: "folder", id: 5 }, t); + const url = await quickShareLink(client, { kind: "folder", id: 5 }); expect(url).toBe(`${origin}/watch/fold`); expect(folderLinks).toHaveBeenCalledWith(5); @@ -133,20 +132,20 @@ describe("quickShareLink", () => { downloadLinks.mockResolvedValue([{ id: 5, url: "/watch/x" }]); copyText.mockResolvedValue(false); - const url = await quickShareLink(qc(), { kind: "file", id: 2 }, t); + const url = await quickShareLink(qc(), { kind: "file", id: 2 }); expect(url).toBe(`${origin}/watch/x`); // the link is never a dead end const arg = firstToast(); expect(arg.level).toBe("warning"); expect(arg.message).toEqual(msg("downloads.share.copyFailed")); - expect(arg.action!.label).toBe("downloads.share.openNewTab"); + expect(arg.action!.label).toEqual(msg("downloads.share.openNewTab")); }); it("reports an error and returns null when link creation fails", async () => { downloadLinks.mockResolvedValue([]); createDownloadLink.mockRejectedValue(new Error("boom")); - const url = await quickShareLink(qc(), { kind: "file", id: 9 }, t); + const url = await quickShareLink(qc(), { kind: "file", id: 9 }); expect(url).toBeNull(); expect(copyText).not.toHaveBeenCalled(); diff --git a/frontend/src/lib/downloads.ts b/frontend/src/lib/downloads.ts index 104ea690..173ad223 100644 --- a/frontend/src/lib/downloads.ts +++ b/frontend/src/lib/downloads.ts @@ -44,11 +44,7 @@ export function invalidateLinks(qc: QueryClient, target: LinkTarget) { // new tab" action. A copy failure still toasts (warning) WITH that action, so the link is never a dead // end. Advanced controls (password, expiry, revoke, share-with-a-user) live in the Share dialog. // Returns the shared URL, or null if link creation failed — so the caller/tests can react to the outcome. -export async function quickShareLink( - qc: QueryClient, - target: LinkTarget, - t: (key: string) => string, -): Promise { +export async function quickShareLink(qc: QueryClient, target: LinkTarget): Promise { let path; try { const existing = await qc.fetchQuery({ @@ -70,7 +66,7 @@ export async function quickShareLink( } const url = window.location.origin + path; const action = { - label: t("downloads.share.openNewTab"), + label: msg("downloads.share.openNewTab"), onClick: () => window.open(url, "_blank", "noopener,noreferrer"), }; notify( diff --git a/frontend/src/lib/notifications.actionTtl.dom.test.ts b/frontend/src/lib/notifications.actionTtl.dom.test.ts index 3456a9e8..6f97e464 100644 --- a/frontend/src/lib/notifications.actionTtl.dom.test.ts +++ b/frontend/src/lib/notifications.actionTtl.dom.test.ts @@ -20,7 +20,7 @@ afterEach(() => { vi.useRealTimers(); }); -const undo = { label: "Undo", onClick: () => {} }; +const undo = { label: raw("Undo"), onClick: () => {} }; const shown = (id: number) => getActiveToasts().some((n) => n.id === id); describe("an action toast's lifetime", () => { diff --git a/frontend/src/lib/notifications.ts b/frontend/src/lib/notifications.ts index 00ab7203..566ccfcb 100644 --- a/frontend/src/lib/notifications.ts +++ b/frontend/src/lib/notifications.ts @@ -66,7 +66,8 @@ const LOG_LEVEL: Record = { export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal"; interface NotifAction { - label: string; + // Said when shown, like the notice's text: a language switch must not leave the button behind. + label: NotifText; onClick: () => void; } diff --git a/frontend/src/lib/youtubeErrors.ts b/frontend/src/lib/youtubeErrors.ts index 02007230..8336a828 100644 --- a/frontend/src/lib/youtubeErrors.ts +++ b/frontend/src/lib/youtubeErrors.ts @@ -2,7 +2,6 @@ import { HttpError } from "./api"; import { markReported } from "./api/core"; import { msg, notify, type NotifText } from "./notifications"; import { notifyActionError } from "./actionErrors"; -import i18n from "../i18n"; // A YouTube-gated action (subscribe, sync, backfill, unsubscribe) that 403s means the user hasn't // granted the write scope — surface a "Connect" affordance instead of a vague failure. Pass @@ -19,9 +18,7 @@ export function notifyYouTubeActionError( notify({ level: "error", message: msg("channels.notify.needYouTube"), - action: onConnect - ? { label: i18n.t("channels.notify.connect"), onClick: onConnect } - : undefined, + action: onConnect ? { label: msg("channels.notify.connect"), onClick: onConnect } : undefined, }); markReported(err); return; diff --git a/frontend/src/modules/channels/WatchedChannels.dom.test.tsx b/frontend/src/modules/channels/WatchedChannels.dom.test.tsx index a7921d88..a368d76c 100644 --- a/frontend/src/modules/channels/WatchedChannels.dom.test.tsx +++ b/frontend/src/modules/channels/WatchedChannels.dom.test.tsx @@ -59,6 +59,7 @@ vi.mock("../../features/player/PlayerModal", () => ({ import WatchedChannels from "./WatchedChannels"; import { qk } from "../../lib/queryKeys"; import { api, type Video, type WatchedChannel, type WatchedChannels as List } from "../../lib/api"; +import { msg, type NotifText } from "../../lib/notifications"; afterEach(() => { cleanup(); @@ -149,8 +150,10 @@ describe("WatchedChannels", () => { fireEvent.click(await screen.findByRole("button", { name: "channels.watched.dismiss" })); await waitFor(() => expect(h.notify).toHaveBeenCalled()); expect(dismiss).toHaveBeenCalledWith("UC1"); - const toast = h.notify.mock.calls[0]![0] as { action: { label: string; onClick: () => void } }; - expect(toast.action.label).toBe("common.undo"); + const toast = h.notify.mock.calls[0]![0] as { + action: { label: NotifText; onClick: () => void }; + }; + expect(toast.action.label).toEqual(msg("common.undo")); act(() => toast.action.onClick()); await waitFor(() => expect(restore).toHaveBeenCalledWith("UC1")); }); diff --git a/frontend/src/modules/channels/useChannelActions.tsx b/frontend/src/modules/channels/useChannelActions.tsx index bc455ed9..b9e24365 100644 --- a/frontend/src/modules/channels/useChannelActions.tsx +++ b/frontend/src/modules/channels/useChannelActions.tsx @@ -74,7 +74,7 @@ export function useChannelActions({ level: "success", message: msg("channels.notify.hidden", { name: nameOf(c) }), action: { - label: t("common.undo"), + label: msg("common.undo"), onClick: () => patch.mutate({ id: c.id, body: { hidden: false } }), }, }); diff --git a/frontend/src/modules/channels/useWatchedChannelActions.ts b/frontend/src/modules/channels/useWatchedChannelActions.ts index 4a6f0b2e..9017cc0e 100644 --- a/frontend/src/modules/channels/useWatchedChannelActions.ts +++ b/frontend/src/modules/channels/useWatchedChannelActions.ts @@ -44,7 +44,7 @@ export function useWatchedChannelActions(onOpenWizard: () => void) { notify({ level: "success", message: msg("channels.watched.dismissed", { name: raw(nameOf(c)) }), - action: { label: t("common.undo"), onClick: () => restore.mutate(c) }, + action: { label: msg("common.undo"), onClick: () => restore.mutate(c) }, }); }, }); diff --git a/frontend/src/modules/files/UploadDialog.tsx b/frontend/src/modules/files/UploadDialog.tsx index 7ecabfec..1f545af7 100644 --- a/frontend/src/modules/files/UploadDialog.tsx +++ b/frontend/src/modules/files/UploadDialog.tsx @@ -107,7 +107,7 @@ export default function UploadDialog({ level: "success", message: msg(uploadedKey(job.status)), action: { - label: t("downloads.dialog.view"), + label: msg("downloads.dialog.view"), onClick: () => openUploaded(job), }, }); diff --git a/frontend/src/modules/files/useFileActions.tsx b/frontend/src/modules/files/useFileActions.tsx index 775c3365..d4d6d12d 100644 --- a/frontend/src/modules/files/useFileActions.tsx +++ b/frontend/src/modules/files/useFileActions.tsx @@ -109,7 +109,7 @@ export function useFileActions({ const quickShare = async (file: LibraryFile) => { if (sharingId !== null) return; setSharingId(file.id); - await quickShareLink(qc, { kind: "file", id: file.id }, t).finally(() => setSharingId(null)); + await quickShareLink(qc, { kind: "file", id: file.id }).finally(() => setSharingId(null)); }; const icon = (I: typeof PlayCircle) => ; diff --git a/frontend/src/modules/files/useFolderActions.tsx b/frontend/src/modules/files/useFolderActions.tsx index a2c28d09..119f7cd7 100644 --- a/frontend/src/modules/files/useFolderActions.tsx +++ b/frontend/src/modules/files/useFolderActions.tsx @@ -101,9 +101,7 @@ export function useFolderActions({ onRename }: { onRename: (folder: FileFolder) disabled: copying, onSelect: () => { setCopying(true); - void quickShareLink(qc, { kind: "folder", id: folder.id }, t).finally(() => - setCopying(false), - ); + void quickShareLink(qc, { kind: "folder", id: folder.id }).finally(() => setCopying(false)); }, }, { diff --git a/frontend/src/modules/notifications/NotificationsPanel.menu.dom.test.tsx b/frontend/src/modules/notifications/NotificationsPanel.menu.dom.test.tsx index 3579b89b..4efe4e7e 100644 --- a/frontend/src/modules/notifications/NotificationsPanel.menu.dom.test.tsx +++ b/frontend/src/modules/notifications/NotificationsPanel.menu.dom.test.tsx @@ -9,7 +9,7 @@ import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest"; import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { api, type AppNotification } from "../../lib/api"; -import { raw, type Notification as ClientNotif } from "../../lib/notifications"; +import { msg, type Notification as ClientNotif, raw } from "../../lib/notifications"; const h = vi.hoisted(() => ({ server: [] as unknown[], @@ -295,7 +295,7 @@ describe("NotificationsPanel · an activity row", () => { h.client = [ client(7, { dismissed: false, - action: { label: "feed.undo", onClick: () => {} }, + action: { label: msg("feed.undo"), onClick: () => {} }, meta: { kind: "video-hidden", videoId: "v", channelId: "c" }, } as never), ]; @@ -321,8 +321,8 @@ describe("NotificationsPanel · an activity row", () => { h.client = [ client(5, { dismissed: false, - action: { label: "tray.done.copyLink", onClick: copy }, - secondaryAction: { label: "tray.done.open", onClick: open }, + action: { label: msg("tray.done.copyLink"), onClick: copy }, + secondaryAction: { label: msg("tray.done.open"), onClick: open }, }), ]; mount(); diff --git a/frontend/src/modules/notifications/NotificationsPanel.tsx b/frontend/src/modules/notifications/NotificationsPanel.tsx index 5e0bce79..088ca41f 100644 --- a/frontend/src/modules/notifications/NotificationsPanel.tsx +++ b/frontend/src/modules/notifications/NotificationsPanel.tsx @@ -393,7 +393,7 @@ export default function NotificationsPanel() { : !n.dismissed ? liveActions(n).map((action, i) => ({ id: `action${i}`, - label: action.label, + label: sayNotif(action.label, t), onSelect: () => { action.onClick(); dismissClient(n.id); @@ -570,7 +570,7 @@ export default function NotificationsPanel() { } /** A client notice's live actions (they do not survive a reload): its action, then the lesser one. */ -function liveActions(n: ClientNotif): { label: string; onClick: () => void }[] { +function liveActions(n: ClientNotif): NonNullable[] { return [n.action, n.secondaryAction].filter((a) => a !== undefined); } @@ -920,7 +920,7 @@ function ClientActivityRow({
{liveActions(n).map((action, i) => ( ))}
diff --git a/frontend/src/modules/playlists/Playlists.dom.test.tsx b/frontend/src/modules/playlists/Playlists.dom.test.tsx index 2b30390a..d4c3136c 100644 --- a/frontend/src/modules/playlists/Playlists.dom.test.tsx +++ b/frontend/src/modules/playlists/Playlists.dom.test.tsx @@ -663,7 +663,9 @@ describe("Playlists — removing a row, and undoing it", () => { // removal may notify too. const lastToast = () => h.notify.mock.calls - .map((c) => c[0] as { message: NotifText; action?: { label: string; onClick: () => void } }) + .map( + (c) => c[0] as { message: NotifText; action?: { label: NotifText; onClick: () => void } }, + ) .filter((n) => "key" in n.message && n.message.key === "playlists.removedToast") .pop()!; // The list's keys are live once its effect has registered it (`data-nav-list`), a passive effect that @@ -690,7 +692,7 @@ describe("Playlists — removing a row, and undoing it", () => { await waitFor(() => expect(lastToast().message).toEqual(msg("playlists.removedToast", { title: raw("Video B") })), ); - expect(lastToast().action?.label).toBe("common.undo"); + expect(lastToast().action?.label).toEqual(msg("common.undo")); act(() => lastToast().action!.onClick()); // Index 1 of the STORED order: where it was, not the end of the list. await waitFor(() => expect(api.addToPlaylist).toHaveBeenCalledWith(1, "B", 1)); diff --git a/frontend/src/modules/playlists/Playlists.tsx b/frontend/src/modules/playlists/Playlists.tsx index 3ddd5be6..9e92bd80 100644 --- a/frontend/src/modules/playlists/Playlists.tsx +++ b/frontend/src/modules/playlists/Playlists.tsx @@ -547,7 +547,7 @@ function PlaylistDetail() { level: "success", message: msg("playlists.removedToast", { title: raw(video.title ?? "") }), action: { - label: t("common.undo"), + label: msg("common.undo"), // ONE request that puts it back at its index (the server renumbers around it): an append // plus a whole-list reorder would land it at the end whenever the second call failed. The // index is worked out against the list as it is NOW, fetched fresh. diff --git a/frontend/src/modules/plex/usePlexPlaylistEdits.dom.test.tsx b/frontend/src/modules/plex/usePlexPlaylistEdits.dom.test.tsx index 04f2630c..0971e543 100644 --- a/frontend/src/modules/plex/usePlexPlaylistEdits.dom.test.tsx +++ b/frontend/src/modules/plex/usePlexPlaylistEdits.dom.test.tsx @@ -15,8 +15,11 @@ vi.mock("react-i18next", async (importOriginal) => ({ })); const { notify } = vi.hoisted(() => ({ notify: vi.fn( - (_n: { level: string; message: NotifText; action?: { label: string; onClick: () => void } }) => - 0, + (_n: { + level: string; + message: NotifText; + action?: { label: NotifText; onClick: () => void }; + }) => 0, ), })); vi.mock("../../lib/notifications", async (importOriginal) => ({ @@ -149,7 +152,7 @@ describe("usePlexPlaylistEdits · the removal", () => { expect(toast.message).toEqual( msg("plex.playlist.removedToast", { title: raw("Show · Season 1") }), ); - expect(toast.action!.label).toBe("common.undo"); + expect(toast.action!.label).toEqual(msg("common.undo")); act(() => toast.action!.onClick()); await waitFor(() => expect(add).toHaveBeenCalledTimes(1)); expect(add).toHaveBeenCalledWith(5, ["2", "4"], [1, 3]); diff --git a/frontend/src/modules/plex/usePlexPlaylistEdits.ts b/frontend/src/modules/plex/usePlexPlaylistEdits.ts index 62ba4b34..72e32a27 100644 --- a/frontend/src/modules/plex/usePlexPlaylistEdits.ts +++ b/frontend/src/modules/plex/usePlexPlaylistEdits.ts @@ -13,7 +13,7 @@ import { reinsertAt } from "../../lib/reinsert"; * Split from `PlexPlaylistView` so the failure paths are reachable from a test: the reorder is * driven by a dnd-kit drag, and neither of its sensors activates under jsdom. */ export function usePlexPlaylistEdits(playlistId: number) { - const { t, i18n } = useTranslation(); + const { i18n } = useTranslation(); const qc = useQueryClient(); const [busy, setBusy] = useState(false); // The rows' titles are in the reader's language, so the cache entry is per language — and ONE @@ -112,7 +112,7 @@ export function usePlexPlaylistEdits(playlistId: number) { level: "success", message: msg("plex.playlist.removedToast", { title }), action: { - label: t("common.undo"), + label: msg("common.undo"), onClick: () => void qc .fetchQuery({ diff --git a/frontend/src/shell/useAccessRequestsNudge.ts b/frontend/src/shell/useAccessRequestsNudge.ts index 4fec2375..49e642a7 100644 --- a/frontend/src/shell/useAccessRequestsNudge.ts +++ b/frontend/src/shell/useAccessRequestsNudge.ts @@ -1,5 +1,4 @@ import { useEffect } from "react"; -import { useTranslation } from "react-i18next"; import type { Me } from "../lib/api"; import type { Page } from "../lib/urlState"; import { msg, notify, removeByMetaKind } from "../lib/notifications"; @@ -8,13 +7,11 @@ import { focusAccessRequestsTab } from "../lib/adminUsersTab"; type SetPage = (next: Page, then?: () => void) => void; export function useAccessRequestsNudge(me: Me | null | undefined, setPage: SetPage): void { - const { t } = useTranslation(); // Nudge admins when access requests are waiting (in lieu of a server-push bell). Keyed on the // pending COUNT so it re-resolves the moment an approval/denial changes it — not only on a // fresh load (which is why the notice used to linger until F5 after an approval). - // Honest deps, and `t` among them is a fix rather than a concession: the nudge is a persisted - // toast whose text is translated at issue time, so a language switch must re-issue it. It did not - // before — the notice stayed in the old language until the count changed. + // No `t` among the deps: the nudge keeps what it says (its title, message and button), said in the + // reader's language each time it is shown, so a language switch needs no re-issue. const role = me?.role; const pendingInvites = me?.pending_invites ?? 0; const pendingActivations = me?.pending_activations ?? 0; @@ -36,7 +33,7 @@ export function useAccessRequestsNudge(me: Me | null | undefined, setPage: SetPa // gets a click via the action. meta: { kind: "access-requests" }, action: { - label: t("common.accessRequestsReview"), + label: msg("common.accessRequestsReview"), onClick: () => { focusAccessRequestsTab(); // Clicked inside whatever overlay hosts the toast; setPage closes it before pushing. @@ -45,5 +42,5 @@ export function useAccessRequestsNudge(me: Me | null | undefined, setPage: SetPa }, }); } - }, [role, pendingInvites, pendingActivations, t, setPage]); + }, [role, pendingInvites, pendingActivations, setPage]); }