From 6986eb1937e06a8fbf12afdc98c4cf2d2780b764 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 29 Oct 2026 22:37:14 +0100 Subject: [PATCH 01/10] test(e2e): look for a hittable channel link until one is, not once on a fresh load --- frontend/e2e/helpers.ts | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/frontend/e2e/helpers.ts b/frontend/e2e/helpers.ts index e4ddef32..b9407f91 100644 --- a/frontend/e2e/helpers.ts +++ b/frontend/e2e/helpers.ts @@ -34,22 +34,34 @@ export async function gotoPage(page: Page, id: string): Promise { * can sit under the page chrome above the scroller (the update banner, the toolbar band), and the * covered click makes Playwright scroll and retry — the same move (webkit failed 3 in 24). * So the link must be the element hit at its own centre. Returns false if none found. + * Looked for again until one is, not once: a fresh load is still settling when the first card shows + * (the links move down ~55px), and webkit can answer the hit test at a link's new centre with the + * card under it — every link missed in 1 of 30 runs. */ export async function clickVisibleChannelLink(page: Page): Promise { const links = page.getByTestId("video-card-channel").filter({ visible: true }); - const count = await links.count(); - for (let i = 0; i < count; i++) { - const hittable = await links.nth(i).evaluate((el) => { - const r = el.getBoundingClientRect(); - const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); - return hit !== null && el.contains(hit); - }); - if (hittable) { - await links.nth(i).click(); - return true; + let index = -1; + const findHittable = async () => { + const count = await links.count(); + for (let i = 0; i < count; i++) { + const hittable = await links.nth(i).evaluate((el) => { + const r = el.getBoundingClientRect(); + const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); + return hit !== null && el.contains(hit); + }); + if (hittable) return i; } + return -1; + }; + try { + await expect + .poll(async () => (index = await findHittable()), { timeout: 5000 }) + .toBeGreaterThan(-1); + } catch { + return false; } - return false; + await links.nth(index).click(); + return true; } /** From 4ef772d37b7f7fdd7c08897d9c77218db9eb35ad Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 29 Oct 2026 22:37:14 +0100 Subject: [PATCH 02/10] fix(notifications): a notice keeps what to say and says it in the reader's language when shown --- .../src/PlaylistEditFailures.dom.test.tsx | 13 +- .../src/WriteFailureReporting.dom.test.tsx | 13 +- frontend/src/components/ui/Modal.dom.test.tsx | 8 +- .../src/components/ui/Toaster.dom.test.tsx | 30 ++--- .../ui/Toaster.language.dom.test.tsx | 73 +++++++++++ .../ui/Toaster.strictmode.dom.test.tsx | 4 +- frontend/src/components/ui/Toaster.tsx | 8 +- frontend/src/components/ui/copyItems.tsx | 10 +- .../components/ui/useCopyFeedback.dom.test.ts | 10 +- frontend/src/components/ui/useCopyFeedback.ts | 8 +- frontend/src/features/channel/TagManager.tsx | 10 +- frontend/src/features/comfort/comfortPin.tsx | 14 +- .../features/diagnostics/DebugRecorder.tsx | 4 +- .../features/diagnostics/IssueComposer.tsx | 4 +- .../features/diagnostics/useDebugRecording.ts | 14 +- .../src/features/downloads/DownloadDialog.tsx | 6 +- .../downloads/DownloadTray.dom.test.tsx | 21 +-- .../src/features/downloads/DownloadTray.tsx | 6 +- .../features/downloads/useDownloadActions.tsx | 3 +- .../features/filters/useSavedViewsStore.ts | 17 +-- .../features/player/ClipPanel.dom.test.tsx | 7 +- frontend/src/features/player/ClipPanel.tsx | 6 +- frontend/src/features/player/LinkedPlayer.tsx | 5 +- .../features/player/PlayerModal.dom.test.tsx | 6 +- .../src/features/player/RelatedVideos.tsx | 5 +- .../player/useOpenLinkedPlayer.dom.test.tsx | 3 +- .../features/player/useOpenLinkedPlayer.ts | 5 +- .../features/plexCards/PlexCollectionAdd.tsx | 5 +- .../features/plexCards/PlexPlaylistAdd.tsx | 5 +- frontend/src/features/video/AddToPlaylist.tsx | 5 +- frontend/src/features/video/stateNotice.ts | 6 +- .../OnboardingWizard.dialog.dom.test.tsx | 6 +- frontend/src/i18n/backendIds.contract.test.ts | 2 +- frontend/src/lib/actionErrors.test.ts | 70 ++++++---- frontend/src/lib/actionErrors.ts | 20 ++- frontend/src/lib/api/core.test.ts | 7 +- frontend/src/lib/api/core.ts | 123 +----------------- frontend/src/lib/api/diagnostics.dom.test.ts | 3 +- frontend/src/lib/api/refusalText.ts | 120 +++++++++++++++++ .../src/lib/channelWriteFailures.dom.test.tsx | 20 +-- frontend/src/lib/claimedStores.dom.test.ts | 13 +- frontend/src/lib/downloads.test.ts | 14 +- frontend/src/lib/downloads.ts | 8 +- .../lib/notifications.actionTtl.dom.test.ts | 9 +- frontend/src/lib/notifications.ts | 74 +++++++++-- frontend/src/lib/playlistName.ts | 6 + frontend/src/lib/plexWatch.ts | 5 +- frontend/src/lib/queryClient.dom.test.ts | 22 ++-- frontend/src/lib/queryClient.ts | 4 +- .../lib/useAccountSwitchNotice.dom.test.ts | 3 +- frontend/src/lib/useAccountSwitchNotice.ts | 4 +- frontend/src/lib/useChannelPriority.ts | 4 +- frontend/src/lib/useChannelTagActions.ts | 7 +- .../src/lib/usePref.saveFailure.dom.test.ts | 6 +- frontend/src/lib/usePref.ts | 4 +- frontend/src/lib/useRail.ts | 5 +- frontend/src/lib/youtubeErrors.ts | 8 +- .../src/modules/activity/ActivityAudit.tsx | 6 +- frontend/src/modules/activity/AdminLogs.tsx | 6 +- .../src/modules/channels/ChannelDiscovery.tsx | 8 +- frontend/src/modules/channels/Channels.tsx | 14 +- frontend/src/modules/channels/SyncStatus.tsx | 3 +- .../modules/channels/WatchedVideosDialog.tsx | 3 +- .../channels/useChannelActions.dom.test.tsx | 5 +- .../modules/channels/useChannelActions.tsx | 15 ++- .../channels/useWatchedChannelActions.ts | 10 +- frontend/src/modules/config/ConfigPanel.tsx | 16 +-- frontend/src/modules/feed/ChannelPage.tsx | 16 +-- frontend/src/modules/feed/Feed.tsx | 17 +-- .../src/modules/files/Files.menu.dom.test.tsx | 23 ++-- frontend/src/modules/files/Files.tsx | 13 +- frontend/src/modules/files/ShareDialog.tsx | 6 +- .../modules/files/UploadDialog.dom.test.tsx | 11 +- frontend/src/modules/files/UploadDialog.tsx | 6 +- .../files/VideoEditor.partial.dom.test.tsx | 6 +- frontend/src/modules/files/VideoEditor.tsx | 8 +- frontend/src/modules/files/useFileActions.tsx | 3 +- .../src/modules/files/useFolderActions.tsx | 3 +- frontend/src/modules/home/Home.tsx | 17 +-- .../NotificationsPanel.menu.dom.test.tsx | 4 +- .../NotificationsPanel.order.dom.test.tsx | 15 ++- .../notifications/NotificationsPanel.tsx | 22 ++-- .../modules/playlists/Playlists.dom.test.tsx | 13 +- frontend/src/modules/playlists/Playlists.tsx | 20 +-- .../src/modules/playlists/PlaylistsRail.tsx | 6 +- .../playlists/usePlaylistActions.dom.test.tsx | 13 +- .../modules/playlists/usePlaylistActions.tsx | 33 +++-- frontend/src/modules/plex/PlexBrowse.tsx | 2 +- .../src/modules/plex/PlexCollectionEditor.tsx | 9 +- .../plex/PlexCollectionView.dom.test.tsx | 3 +- .../src/modules/plex/PlexCollectionView.tsx | 18 +-- frontend/src/modules/plex/PlexDiscover.tsx | 2 +- frontend/src/modules/plex/PlexInfo.tsx | 5 +- .../plex/PlexPlayer.session.dom.test.tsx | 4 +- frontend/src/modules/plex/PlexPlayer.tsx | 5 +- .../plex/PlexPlaylistView.menu.dom.test.tsx | 15 ++- .../src/modules/plex/PlexPlaylistView.tsx | 7 +- frontend/src/modules/plex/PlexSidebar.tsx | 3 +- frontend/src/modules/plex/plexBrowseLists.tsx | 3 +- frontend/src/modules/plex/plexViews.tsx | 11 +- .../src/modules/plex/usePlayShow.dom.test.tsx | 3 +- frontend/src/modules/plex/usePlayShow.ts | 5 +- .../plex/usePlexPlaylistEdits.dom.test.tsx | 16 ++- .../src/modules/plex/usePlexPlaylistEdits.ts | 10 +- frontend/src/modules/scheduler/Scheduler.tsx | 14 +- frontend/src/modules/settings/MyLogViewer.tsx | 6 +- .../src/modules/settings/SettingsPanel.tsx | 20 +-- .../settings/TakeoutImport.dom.test.tsx | 31 +++-- .../src/modules/settings/TakeoutImport.tsx | 12 +- frontend/src/modules/stats/Stats.tsx | 10 +- frontend/src/modules/users/AccountPanel.tsx | 10 +- .../src/modules/users/AdminUsers.dom.test.tsx | 9 +- frontend/src/modules/users/AdminUsers.tsx | 57 ++++---- .../modules/users/accountPanel.dom.test.tsx | 30 +++-- .../modules/users/adminInvites.dom.test.tsx | 18 ++- .../users/adminPendingAccess.dom.test.tsx | 16 ++- frontend/src/shell/App.tsx | 3 +- .../shell/ErrorBoundary.message.dom.test.tsx | 10 +- .../ErrorBoundary.staleBuild.dom.test.tsx | 5 +- frontend/src/shell/ErrorBoundary.tsx | 6 +- .../src/shell/NavSidebar.account.dom.test.tsx | 3 +- frontend/src/shell/NavSidebar.tsx | 4 +- .../src/shell/clientLogWiring.dom.test.tsx | 6 +- frontend/src/shell/useAccessRequestsNudge.ts | 6 +- frontend/src/shell/useEntryParams.ts | 8 +- 125 files changed, 973 insertions(+), 674 deletions(-) create mode 100644 frontend/src/components/ui/Toaster.language.dom.test.tsx create mode 100644 frontend/src/lib/api/refusalText.ts diff --git a/frontend/src/PlaylistEditFailures.dom.test.tsx b/frontend/src/PlaylistEditFailures.dom.test.tsx index 16dff803..4ea1fc2b 100644 --- a/frontend/src/PlaylistEditFailures.dom.test.tsx +++ b/frontend/src/PlaylistEditFailures.dom.test.tsx @@ -22,7 +22,7 @@ vi.mock("react-i18next", async (importOriginal) => { }); vi.mock("./components/ui/confirmContext", () => ({ useConfirm: () => async () => true })); const { notify } = vi.hoisted(() => ({ - notify: vi.fn((_n: { level: string; message: string }) => 0), + notify: vi.fn((_n: { level: string; message: NotifText }) => 0), })); vi.mock("./lib/notifications", async (importOriginal) => ({ ...(await importOriginal()), @@ -35,6 +35,7 @@ import PlexPlaylistAdd from "./features/plexCards/PlexPlaylistAdd"; import TagManager from "./features/channel/TagManager"; import { api, HttpError, type PlexCard } from "./lib/api"; import { qk } from "./lib/queryKeys"; +import type { NotifText } from "./lib/notifications"; beforeAll(() => { class Stub { @@ -98,7 +99,7 @@ describe("the Plex playlist page", () => { () => fireEvent.contextMenu(screen.getByText("Movie 1").closest("[data-nav-index]")!), "plex.playlist.removeFromPlaylist", ); - await waitFor(() => expect(errors()).toEqual(["gone playlist"])); + await waitFor(() => expect(errors()).toEqual([{ detail: gone("playlist").detailData }])); await act(async () => void (await new Promise((r) => setTimeout(r, 20)))); expect(screen.getByText("Movie 1")).toBeTruthy(); }); @@ -119,7 +120,7 @@ describe("the Plex playlist page", () => { const field = await screen.findByDisplayValue("Films"); fireEvent.change(field, { target: { value: "Movies" } }); fireEvent.keyDown(field, { key: "Enter" }); - await waitFor(() => expect(errors()).toEqual(["gone playlist"])); + await waitFor(() => expect(errors()).toEqual([{ detail: gone("playlist").detailData }])); expect(screen.getByDisplayValue("Movies")).toBeTruthy(); // still open to try again }); @@ -154,7 +155,7 @@ describe("the add-to-playlist popover", () => { ); fireEvent.click(screen.getByLabelText("playlists.addToPlaylist")); fireEvent.click(await screen.findByRole("menuitemcheckbox", { hidden: true })); - await waitFor(() => expect(errors()).toEqual(["gone playlist"])); + await waitFor(() => expect(errors()).toEqual([{ detail: gone("playlist").detailData }])); await waitFor(() => expect(lists.mock.calls.length).toBeGreaterThan(1)); }); @@ -282,7 +283,7 @@ describe("the add-to-playlist popover", () => { fireEvent.click(await screen.findByText("Gone")); // The server's own localized reason wins over the caller's fallback when the detail is // STRUCTURED — same as the twin above. What is under test is that anything is said at all. - await waitFor(() => expect(errors()).toEqual(["gone playlist"])); + await waitFor(() => expect(errors()).toEqual([{ detail: gone("playlist").detailData }])); await waitFor(() => expect(lists.mock.calls.length).toBeGreaterThan(1)); }); }); @@ -300,7 +301,7 @@ describe("the tag manager", () => { , ); fireEvent.click(await screen.findByLabelText("tagManager.delete")); - await waitFor(() => expect(errors()).toEqual(["gone tag"])); + await waitFor(() => expect(errors()).toEqual([{ detail: gone("tag").detailData }])); await waitFor(() => expect(tags.mock.calls.length).toBeGreaterThan(1)); }); }); diff --git a/frontend/src/WriteFailureReporting.dom.test.tsx b/frontend/src/WriteFailureReporting.dom.test.tsx index abffcd22..a8aa1779 100644 --- a/frontend/src/WriteFailureReporting.dom.test.tsx +++ b/frontend/src/WriteFailureReporting.dom.test.tsx @@ -19,7 +19,7 @@ vi.mock("react-i18next", async (importOriginal) => { }); vi.mock("./components/ui/confirmContext", () => ({ useConfirm: () => async () => true })); const { notify } = vi.hoisted(() => ({ - notify: vi.fn((_n: { level: string; title?: string; message: string }) => 0), + notify: vi.fn((_n: { level: string; title?: NotifText; message: NotifText }) => 0), })); vi.mock("./lib/notifications", async (importOriginal) => ({ ...(await importOriginal()), @@ -30,6 +30,7 @@ import PlexCollectionEditor from "./modules/plex/PlexCollectionEditor"; import UploadDialog from "./modules/files/UploadDialog"; import DownloadDialog from "./features/downloads/DownloadDialog"; import { api, HttpError } from "./lib/api"; +import { msg, type NotifText } from "./lib/notifications"; beforeAll(() => { class Stub { @@ -87,7 +88,7 @@ describe("the Plex collection editor", () => { ); mount(); fireEvent.click(await screen.findByText("Marathon")); - await waitFor(() => expect(errors()).toEqual(["plex.collection.editFailed"])); + await waitFor(() => expect(errors()).toEqual([msg("plex.collection.editFailed")])); }); it("says so when enabling editing is refused", async () => { @@ -97,7 +98,7 @@ describe("the Plex collection editor", () => { ); mount(); fireEvent.click(await screen.findByText("plex.collEditor.makeEditable")); - await waitFor(() => expect(errors()).toEqual(["plex.collection.editFailed"])); + await waitFor(() => expect(errors()).toEqual([msg("plex.collection.editFailed")])); }); it("says so when a CONFIRMED delete does not happen", async () => { @@ -108,7 +109,7 @@ describe("the Plex collection editor", () => { ); mount(); fireEvent.click(await screen.findByText("plex.collEditor.delete")); - await waitFor(() => expect(errors()).toEqual(["plex.collection.deleteFailed"])); + await waitFor(() => expect(errors()).toEqual([msg("plex.collection.deleteFailed")])); }); }); @@ -144,7 +145,7 @@ describe("the upload dialog", () => { mount(); pick(); fireEvent.click(screen.getByText("downloads.upload.submit")); - await waitFor(() => expect(errors()).toEqual(["downloads.upload.failed"])); + await waitFor(() => expect(errors()).toEqual([msg("downloads.upload.failed")])); }); it("says nothing when the user cancelled it themselves", async () => { @@ -172,6 +173,6 @@ describe("the download dialog", () => { ); await screen.findByText("Best"); fireEvent.click(screen.getByText("downloads.dialog.submit")); - await waitFor(() => expect(errors()).toEqual(["errors.writeFailed.enqueueDownload"])); + await waitFor(() => expect(errors()).toEqual([msg("errors.writeFailed.enqueueDownload")])); }); }); diff --git a/frontend/src/components/ui/Modal.dom.test.tsx b/frontend/src/components/ui/Modal.dom.test.tsx index 4169ed69..06abb5e0 100644 --- a/frontend/src/components/ui/Modal.dom.test.tsx +++ b/frontend/src/components/ui/Modal.dom.test.tsx @@ -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')", () => { , ); - 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')", () => { , ); - 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", () => { , ); - 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."); }); }); diff --git a/frontend/src/components/ui/Toaster.dom.test.tsx b/frontend/src/components/ui/Toaster.dom.test.tsx index 69b50ab6..91b124cd 100644 --- a/frontend/src/components/ui/Toaster.dom.test.tsx +++ b/frontend/src/components/ui/Toaster.dom.test.tsx @@ -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", () => { , ); - 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 = () => ; const stop = dockUnderToasts(Docked); - notify({ level: "info", message: "hello" }); + notify({ level: "info", message: raw("hello") }); render(); const region = document.querySelector("[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", () => { , ); - 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(); - act(() => void notify({ level: "error", message: "old news" })); + act(() => void notify({ level: "error", message: raw("old news") })); render(); 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(); - 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(); - act(() => void notify({ level: "info", message: "saved" })); + act(() => void notify({ level: "info", message: raw("saved") })); const overlay = render(); 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", () => { ), )} 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]); } From 74f58ea00173dfa6acfcfa49650e437ba04411de Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 30 Oct 2026 00:15:22 +0100 Subject: [PATCH 07/10] fix(notifications): the English log formats its numbers in English; the ui/ contract sees msg() keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A formatted value took the reader's language even when the notice was said in English for the log. The platform-namespace contract matched only t("…"), so a domain key passed through msg() in components/ui/ went unseen. Three comments pointed at localizeDetail's old home. --- backend/app/refusal.py | 2 +- backend/app/routes/files.py | 2 +- .../src/components/ui/uiI18n.contract.test.ts | 6 +++-- frontend/src/lib/notifications.ts | 6 +++-- .../src/modules/settings/SettingsPanel.tsx | 2 +- .../src/shell/clientLogWiring.dom.test.tsx | 24 ++++++++++++++++++- 6 files changed, 34 insertions(+), 8 deletions(-) diff --git a/backend/app/refusal.py b/backend/app/refusal.py index fc70f7b6..95b2cf04 100644 --- a/backend/app/refusal.py +++ b/backend/app/refusal.py @@ -1,7 +1,7 @@ """A request the app refuses, said in a way the UI can put in the reader's language. Every refusal the API answers carries a structured `detail` — `{code, reason, …params}` — and the client -translates it (`lib/api/core.ts` `localizeDetail`). A bare English string reached the Hungarian UI +translates it (`lib/api/refusalText.ts` `localizeDetail`). A bare English string reached the Hungarian UI verbatim: the blocking error dialog, and every inline surface that printed `HttpError.detail`, showed whatever sentence the route happened to hand-write. `refused` is the generic family (`errors.refused.`, the params interpolated into the sentence); the older specific codes diff --git a/backend/app/routes/files.py b/backend/app/routes/files.py index 27645322..d0f17765 100644 --- a/backend/app/routes/files.py +++ b/backend/app/routes/files.py @@ -139,7 +139,7 @@ def own_job_for_update(db: Session, user: User, job_id: int) -> "DownloadJob": def quota_detail(e: quota.QuotaExceeded) -> dict: """Structured error the trilingual client localizes (reason key + numbers), instead of a - pre-baked English string with a dot-decimal GB value. See lib/api.ts localizeDetail().""" + pre-baked English string with a dot-decimal GB value. See lib/api/refusalText.ts localizeDetail().""" return {"code": "quota", "reason": e.reason, "limit": e.limit, "current": e.current} diff --git a/frontend/src/components/ui/uiI18n.contract.test.ts b/frontend/src/components/ui/uiI18n.contract.test.ts index c3c93275..934d3873 100644 --- a/frontend/src/components/ui/uiI18n.contract.test.ts +++ b/frontend/src/components/ui/uiI18n.contract.test.ts @@ -19,8 +19,9 @@ const PLATFORM_NAMESPACES = new Set([ ]); const files = readSources(__dirname, "./**/*.{ts,tsx}"); -// `t("ns.…")` and `t(\`ns.…\`)` — the namespace is everything before the first dot. -const KEY = /\bt\(\s*["`]([A-Za-z]+)\./g; +// `t("ns.…")` and `t(\`ns.…\`)`, and a notice's `msg("ns.…")` (the same key, said when shown) — the +// namespace is everything before the first dot. +const KEY = /\b(?:t|msg)\(\s*["`]([A-Za-z]+)\./g; describe("components/ui/ speaks only platform i18n namespaces", () => { it("names no feature namespace", () => { @@ -37,5 +38,6 @@ describe("components/ui/ speaks only platform i18n namespaces", () => { it("sees a feature namespace when one is used", () => { expect([...'t("plex.loading")'.matchAll(KEY)].map((m) => m[1])).toEqual(["plex"]); + expect([...'msg("plex.loading")'.matchAll(KEY)].map((m) => m[1])).toEqual(["plex"]); }); }); diff --git a/frontend/src/lib/notifications.ts b/frontend/src/lib/notifications.ts index 566ccfcb..993abbf7 100644 --- a/frontend/src/lib/notifications.ts +++ b/frontend/src/lib/notifications.ts @@ -42,8 +42,10 @@ export function sayNotif(text: NotifText, t: TFunction = i18n.t.bind(i18n)): str function sayValue(v: NotifValue, t: TFunction): string | number { if (typeof v !== "object") return v; - if ("number" in v) return formatNumber(v.number, i18n.language); - if ("bytes" in v) return formatBytes(v.bytes, i18n.language); + // In the language `t` speaks: a fixed one (the English log) names it, a component's follows i18n. + const lang = (t as { lng?: string }).lng ?? i18n.language; + if ("number" in v) return formatNumber(v.number, lang); + if ("bytes" in v) return formatBytes(v.bytes, lang); return sayNotif(v, t); } diff --git a/frontend/src/modules/settings/SettingsPanel.tsx b/frontend/src/modules/settings/SettingsPanel.tsx index 2b416c66..58673a5c 100644 --- a/frontend/src/modules/settings/SettingsPanel.tsx +++ b/frontend/src/modules/settings/SettingsPanel.tsx @@ -643,7 +643,7 @@ export function DownloadAuth() { notify({ level: "success", message: msg("settings.downloadAuth.saved") }); }, // Validation errors (empty / not-a-cookies.txt) surface via the global error modal, which - // localizes the structured { code: "cookies" } detail (see lib/api core localizeDetail); the + // localizes the structured { code: "cookies" } detail (see lib/api/refusalText.ts localizeDetail); the // statuses it does not cover go to the app-wide reporter. meta: { failTitle: "settings.downloadAuth.failed" }, }); diff --git a/frontend/src/shell/clientLogWiring.dom.test.tsx b/frontend/src/shell/clientLogWiring.dom.test.tsx index 2f4981b6..0a9d4675 100644 --- a/frontend/src/shell/clientLogWiring.dom.test.tsx +++ b/frontend/src/shell/clientLogWiring.dom.test.tsx @@ -7,7 +7,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, render } from "@testing-library/react"; import { clear, events } from "../lib/log"; -import { notify, raw } from "../lib/notifications"; +import { msg, notify, raw } from "../lib/notifications"; +import i18n from "../i18n"; import { reportError } from "../lib/errorDialog"; import { createQueryClient } from "../lib/queryClient"; import ErrorBoundary from "./ErrorBoundary"; @@ -31,6 +32,27 @@ describe("client log wiring", () => { ]); }); + it("records a notice in English whatever the reader's language, its numbers included", async () => { + await i18n.changeLanguage("hu"); + try { + notify({ + message: msg("settings.takeout.read", { + name: raw("watch-history.json"), + count: 12345, + n: { number: 12345 }, + }), + }); + } finally { + await i18n.changeLanguage("en"); + } + const english = i18n.getFixedT("en")("settings.takeout.read", { + name: "watch-history.json", + count: 12345, + n: "12,345", + }); + expect(logged("ui.notify").map((e) => e.msg)).toEqual([english]); + }); + it("records the blocking error dialog", () => { reportError("The server refused", "Couldn't save"); expect(logged("ui.error_dialog")).toMatchObject([ From 046dde44a4f890d9334f159527742c8a1ca36db7 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 30 Oct 2026 00:17:46 +0100 Subject: [PATCH 08/10] test(notifications): the center re-says its rows after a switch, a repeat coalesces by content; e2e helper fails with a cause The center's rows (title, message, refusal, live button) were never rendered across a language switch, and nothing held the structural compare that coalescing now needs. The channel-link helper swallowed whatever ended its poll into a bare false: a link that goes away mid-look now counts as not hit, and no hittable link fails with the poll's own message. --- frontend/e2e/bookmark.spec.ts | 2 +- frontend/e2e/channel.spec.ts | 2 +- frontend/e2e/helpers.ts | 42 ++++--- .../lib/notifications.coalesce.dom.test.ts | 37 ++++++ .../NotificationsPanel.language.dom.test.tsx | 106 ++++++++++++++++++ 5 files changed, 169 insertions(+), 20 deletions(-) create mode 100644 frontend/src/lib/notifications.coalesce.dom.test.ts create mode 100644 frontend/src/modules/notifications/NotificationsPanel.language.dom.test.tsx diff --git a/frontend/e2e/bookmark.spec.ts b/frontend/e2e/bookmark.spec.ts index a72aa2dd..99251ae3 100644 --- a/frontend/e2e/bookmark.spec.ts +++ b/frontend/e2e/bookmark.spec.ts @@ -196,7 +196,7 @@ test.describe("a bookmark reopens the view it was taken of", () => { test("a channel page's own tab, over the page it was opened from", async ({ page, browser }) => { await openApp(page, "/?page=feed"); await expect(page.getByTestId("video-card").first()).toBeVisible(); - expect(await clickVisibleChannelLink(page)).toBe(true); + await clickVisibleChannelLink(page); const title = await page.getByTestId("channel-title").textContent(); await tab(page, "About").click(); const fresh = await bookmarked(browser, page); diff --git a/frontend/e2e/channel.spec.ts b/frontend/e2e/channel.spec.ts index e7b080a6..09c78984 100644 --- a/frontend/e2e/channel.spec.ts +++ b/frontend/e2e/channel.spec.ts @@ -48,7 +48,7 @@ test.describe("channel view", () => { // `.first()` here would grab an overscan card ABOVE the viewport, and Playwright's scroll-into- // view would move the feed before navigating, so Back would restore that moved position, not // the one under test. (This is what made the test fail while the app was in fact correct.) - expect(await clickVisibleChannelLink(page)).toBe(true); + await clickVisibleChannelLink(page); await expect(page.getByTestId("channel-title")).toBeVisible(); await page.getByTestId("channel-back").click(); await expect(page.getByTestId("video-card").first()).toBeVisible(); diff --git a/frontend/e2e/helpers.ts b/frontend/e2e/helpers.ts index b9407f91..3358e076 100644 --- a/frontend/e2e/helpers.ts +++ b/frontend/e2e/helpers.ts @@ -33,35 +33,41 @@ export async function gotoPage(page: Page, id: string): Promise { * that then asserts on where the feed was. Being inside the viewport is not enough either: a card * can sit under the page chrome above the scroller (the update banner, the toolbar band), and the * covered click makes Playwright scroll and retry — the same move (webkit failed 3 in 24). - * So the link must be the element hit at its own centre. Returns false if none found. - * Looked for again until one is, not once: a fresh load is still settling when the first card shows - * (the links move down ~55px), and webkit can answer the hit test at a link's new centre with the - * card under it — every link missed in 1 of 30 runs. + * So the link must be the element hit at its own centre; none within the wait fails the test, with + * the poll's own message. Looked for again until one is, not once: a fresh load is still settling + * when the first card shows (the links move down ~55px), and webkit can answer the hit test at a + * link's new centre with the card under it — every link missed in 1 of 30 runs. A link that goes + * away mid-look (its evaluate waits for it, or throws) counts as not hit, so the next look runs. */ -export async function clickVisibleChannelLink(page: Page): Promise { +export async function clickVisibleChannelLink(page: Page): Promise { const links = page.getByTestId("video-card-channel").filter({ visible: true }); let index = -1; const findHittable = async () => { const count = await links.count(); for (let i = 0; i < count; i++) { - const hittable = await links.nth(i).evaluate((el) => { - const r = el.getBoundingClientRect(); - const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); - return hit !== null && el.contains(hit); - }); + const hittable = await links + .nth(i) + .evaluate( + (el) => { + const r = el.getBoundingClientRect(); + const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); + return hit !== null && el.contains(hit); + }, + undefined, + { timeout: 1000 }, + ) + .catch(() => false); if (hittable) return i; } return -1; }; - try { - await expect - .poll(async () => (index = await findHittable()), { timeout: 5000 }) - .toBeGreaterThan(-1); - } catch { - return false; - } + await expect + .poll(async () => (index = await findHittable()), { + message: "a channel link hit at its own centre", + timeout: 5000, + }) + .toBeGreaterThan(-1); await links.nth(index).click(); - return true; } /** diff --git a/frontend/src/lib/notifications.coalesce.dom.test.ts b/frontend/src/lib/notifications.coalesce.dom.test.ts new file mode 100644 index 00000000..e339f6f1 --- /dev/null +++ b/frontend/src/lib/notifications.coalesce.dom.test.ts @@ -0,0 +1,37 @@ +// @vitest-environment jsdom +// +// A burst of the same notice is one entry with a count (×N), not a pile of copies. The texts are +// objects now (what to say, built afresh by every msg() call), so "the same" is the same key and +// values, never the same object: a compare by reference would let every repeat stack. +import { afterEach, describe, expect, it } from "vitest"; +import { clearAll, getNotifications, msg, notify, raw } from "./notifications"; + +afterEach(() => clearAll()); + +describe("coalescing a repeated notice", () => { + it("folds the same key and values into one entry, counted", () => { + notify({ + level: "error", + title: msg("errors.title"), + message: msg("errors.reason.unreachable"), + }); + notify({ + level: "error", + title: msg("errors.title"), + message: msg("errors.reason.unreachable"), + }); + expect(getNotifications().map((n) => n.repeat)).toEqual([2]); + }); + + it("folds the same server refusal too", () => { + notify({ level: "error", message: { detail: { code: "not_found", reason: "view" } } }); + notify({ level: "error", message: { detail: { code: "not_found", reason: "view" } } }); + expect(getNotifications().map((n) => n.repeat)).toEqual([2]); + }); + + it("keeps two notices apart when only a value differs", () => { + notify({ message: msg("channels.notify.hidden", { name: raw("One") }) }); + notify({ message: msg("channels.notify.hidden", { name: raw("Two") }) }); + expect(getNotifications().map((n) => n.repeat)).toEqual([1, 1]); + }); +}); diff --git a/frontend/src/modules/notifications/NotificationsPanel.language.dom.test.tsx b/frontend/src/modules/notifications/NotificationsPanel.language.dom.test.tsx new file mode 100644 index 00000000..81d4237b --- /dev/null +++ b/frontend/src/modules/notifications/NotificationsPanel.language.dom.test.tsx @@ -0,0 +1,106 @@ +// @vitest-environment jsdom +// +// The notification center says this browser's notices in the reader's language of the moment: a +// row raised in English reads Hungarian after a switch, its title, its message, a server refusal +// and the button of a notice still live. The real i18n, not the key passthrough the other panel +// tests use. +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import i18n from "../../i18n"; +import { api, type AppNotification } from "../../lib/api"; +import type { Notification as ClientNotif } from "../../lib/notifications"; + +const h = vi.hoisted(() => ({ client: [] as unknown[] })); + +vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => vi.fn() })); +vi.mock("../../lib/notifications", async (importOriginal) => ({ + ...(await importOriginal()), + subscribe: () => () => {}, + getNotifications: () => h.client, + getUnreadCount: () => 0, + markAllRead: () => {}, +})); +vi.mock("../../features/navigation/navigationContext", () => ({ + useNavigationActions: () => ({ setPage: vi.fn() }), +})); +vi.mock("../files/filesContext", () => ({ useFilesActions: () => ({ openFile: vi.fn() }) })); +vi.mock("../channels/channelsContext", () => ({ + useChannelsActions: () => ({ focusChannel: vi.fn() }), +})); +vi.mock("../feed/feedFiltersContext", () => ({ + useFeedFilters: () => ({}), + useFeedFiltersActions: () => ({ setFilters: vi.fn() }), +})); +vi.mock("../../components/ui/PageShell", async (importOriginal) => ({ + ...(await importOriginal()), + PageToolbar: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +import NotificationsPanel from "./NotificationsPanel"; +import { msg } from "../../lib/notifications"; + +beforeEach(async () => { + await i18n.changeLanguage("en"); + vi.spyOn(api, "notifications").mockResolvedValue({ + items: [] as AppNotification[], + total: 0, + }); +}); +afterEach(async () => { + cleanup(); + vi.restoreAllMocks(); + await i18n.changeLanguage("en"); +}); + +const notice = (id: number, extra: Partial) => + ({ + id, + level: "info", + message: msg("common.copy.done"), + requiresInteraction: false, + ts: Date.now() - 60_000, + read: false, + dismissed: false, + transient: false, + repeat: 1, + ...extra, + }) as unknown as ClientNotif; + +function mount() { + render( + +
+ +
+
, + ); +} + +describe("NotificationsPanel · the reader's language", () => { + it("says a row's title, message, refusal and live button again after a switch", async () => { + h.client = [ + notice(1, { + title: msg("views.actionFailed"), + message: { detail: { code: "not_found", reason: "view" } }, + level: "error", + }), + notice(2, { action: { label: msg("common.undo"), onClick: () => {} } }), + ]; + mount(); + const en = i18n.getFixedT("en"); + const hu = i18n.getFixedT("hu"); + expect(await screen.findByText(en("views.actionFailed"))).toBeTruthy(); + expect(screen.getByText("This view doesn't exist any more")).toBeTruthy(); + expect(screen.getByText(en("common.copy.done"))).toBeTruthy(); + expect(screen.getByRole("button", { name: en("common.undo") })).toBeTruthy(); + + await act(async () => void (await i18n.changeLanguage("hu"))); + + expect(screen.getByText(hu("views.actionFailed"))).toBeTruthy(); + expect(screen.getByText("Ez a nézet már nem létezik")).toBeTruthy(); + expect(screen.getByText(hu("common.copy.done"))).toBeTruthy(); + expect(screen.getByRole("button", { name: hu("common.undo") })).toBeTruthy(); + expect(screen.queryByText(en("views.actionFailed"))).toBeNull(); + }); +}); From 2bdcebdb34ef905861d4236a6d462fdf01452e4b Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 30 Oct 2026 01:54:00 +0100 Subject: [PATCH 09/10] fix(notifications): a confirm names its user and channel again; t() and a notice's text are kept apart by a contract The value sweep wrapped two plain t() values in raw(), so the delete-user and the YouTube subscribe confirms read "[object Object]" where the email or the channel was. tsc cannot see that seam, so a syntax contract now refuses a notice value handed to t() and an already-said text kept in a notice. The raw() doc says what it is: text kept as fetched (a Plex title in the language it was fetched in). seasonTitleText's no-number branches are tested. --- .../plexCards/plexCards.lang.dom.test.tsx | 2 + frontend/src/lib/notifText.contract.test.ts | 73 +++++++++++++++++++ frontend/src/lib/notifications.ts | 11 +-- ...WatchedChannelActions.confirm.dom.test.tsx | 39 ++++++++++ .../channels/useWatchedChannelActions.ts | 2 +- .../users/AdminUsers.confirm.dom.test.tsx | 58 +++++++++++++++ frontend/src/modules/users/AdminUsers.tsx | 2 +- 7 files changed, 180 insertions(+), 7 deletions(-) create mode 100644 frontend/src/lib/notifText.contract.test.ts create mode 100644 frontend/src/modules/channels/useWatchedChannelActions.confirm.dom.test.tsx create mode 100644 frontend/src/modules/users/AdminUsers.confirm.dom.test.tsx diff --git a/frontend/src/features/plexCards/plexCards.lang.dom.test.tsx b/frontend/src/features/plexCards/plexCards.lang.dom.test.tsx index 62d7fc95..0a24a64c 100644 --- a/frontend/src/features/plexCards/plexCards.lang.dom.test.tsx +++ b/frontend/src/features/plexCards/plexCards.lang.dom.test.tsx @@ -74,6 +74,8 @@ describe("a season's name", () => { ["Season 1", 1, "plex.seasonTitle(1)"], ["Specials", 0, "plex.specials"], ["Book One: Water", 1, "Book One: Water"], + ["Season 4", null, "Season 4"], + ["", null, "plex.playlist.seasonUnknown"], ])("Plex's %j (season %s) reads %j", (title, n, shown) => { render(); expect(screen.getByText(shown)).toBeTruthy(); diff --git a/frontend/src/lib/notifText.contract.test.ts b/frontend/src/lib/notifText.contract.test.ts new file mode 100644 index 00000000..82f53a4e --- /dev/null +++ b/frontend/src/lib/notifText.contract.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import ts from "typescript"; +import { readSources } from "./rawSources.testkit"; + +// A notice says WHAT to say (lib/notifications NotifText) and is said when shown; a plain `t()` says +// it now. tsc cannot tell the two apart where they meet — `msg`'s key is a string, `detail` is +// unknown, and i18next's options take any value — so the slips are caught here, on the syntax: +// - a NotifText handed to `t()` as a value (`t("k", { name: raw(x) })`) reads "[object Object]"; +// - an already-said text kept in a notice (`msg(t("k"))`, `raw(t("k"))`, `{ detail: t("k") }`) +// stays in the language it was raised in. + +const isT = (e: ts.Expression) => + (ts.isIdentifier(e) && e.text === "t") || + (ts.isPropertyAccessExpression(e) && e.name.text === "t"); +const isCallTo = (e: ts.Node, names: string[]): e is ts.CallExpression => + ts.isCallExpression(e) && ts.isIdentifier(e.expression) && names.includes(e.expression.text); +const isTCall = (e: ts.Node): e is ts.CallExpression => ts.isCallExpression(e) && isT(e.expression); + +function mixedNotifText(name: string, src: string): string[] { + const file = ts.createSourceFile(name, src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const out: string[] = []; + const at = (n: ts.Node, what: string) => + out.push(`${name}:${file.getLineAndCharacterOfPosition(n.getStart()).line + 1} ${what}`); + const visit = (n: ts.Node) => { + if (isTCall(n)) { + for (const arg of n.arguments.slice(1)) { + if (!ts.isObjectLiteralExpression(arg)) continue; + for (const p of arg.properties) { + if (ts.isPropertyAssignment(p) && isCallTo(p.initializer, ["raw", "msg"])) + at(p, "a notice value handed to t()"); + } + } + } + if (isCallTo(n, ["msg", "raw"]) && n.arguments[0] && isTCall(n.arguments[0])) + at(n, "a said text kept in a notice"); + if ( + ts.isPropertyAssignment(n) && + ts.isIdentifier(n.name) && + n.name.text === "detail" && + isTCall(n.initializer) + ) + at(n, "a said text kept as a notice's detail"); + ts.forEachChild(n, visit); + }; + visit(file); + return out; +} + +describe("a notice's text and a plain t() do not mix", () => { + it("holds across src", () => { + const files = readSources(__dirname, ["../**/*.ts", "../**/*.tsx", "!../**/*.test.*"]); + expect(Object.keys(files).length).toBeGreaterThan(100); // the glob matched the app + expect(Object.entries(files).flatMap(([p, src]) => mixedNotifText(p, src))).toEqual([]); + }); + + it.each([ + ['t("users.delete.confirm", { email: raw(u.email) })', "a notice value handed to t()"], + ['i18n.t("k", { n: msg("x") })', "a notice value handed to t()"], + ['notify({ message: msg(t("feed.saved")) })', "a said text kept in a notice"], + ['notify({ message: raw(t("feed.saved")) })', "a said text kept in a notice"], + ['notify({ message: { detail: t("x") } })', "a said text kept as a notice's detail"], + ])("sees %s", (src, what) => { + expect(mixedNotifText("x.tsx", src)).toEqual([`x.tsx:1 ${what}`]); + }); + + it.each([ + 'msg("channels.discovery.subscribedBody", { name: raw(nameOf(c)) })', + 't("channels.discovery.confirmSubscribe", { name: nameOf(c) })', + "sayNotif(msg(key), t)", + ])("lets %s through", (src) => { + expect(mixedNotifText("x.tsx", src)).toEqual([]); + }); +}); diff --git a/frontend/src/lib/notifications.ts b/frontend/src/lib/notifications.ts index 993abbf7..f2ff2875 100644 --- a/frontend/src/lib/notifications.ts +++ b/frontend/src/lib/notifications.ts @@ -12,10 +12,11 @@ import { log, type Level } from "./log"; // What a notice SAYS, kept as what to say rather than the said text: a key and its values, said in // the reader's language each time it is shown. The text a caller translated at raise time was kept // in the history, so a language switch left every notice in the language it was raised in. `raw` is -// text that has no translation (a video's title, a name); `detail` is a server refusal, localized at -// display like the key. `notify` takes nothing else, and a value is no plain string either (a name -// goes in as `raw`, a translated word as its own `msg`), so a pre-translated string does not -// type-check anywhere in a notice — only `raw(t(…))` would say it once, and that reads as what it is. +// text kept as it was fetched (a video's title, a name — a Plex title in the language it was fetched +// in, since the card holds only that one); `detail` is a server refusal, localized at display like +// the key. `notify` takes nothing else, and tsc refuses a plain string as a value (a name goes in as +// `raw`, a translated word as its own `msg`). What tsc cannot see — `msg(t(…))`, `raw(t(…))`, +// `{ detail: t(…) }`, or a `raw`/`msg` handed to a plain `t()` — notifText.contract.test refuses. // A value is formatted when shown too: `{ number }` / `{ bytes }` group digits the reader's way. export type NotifValue = number | NotifText | { number: number } | { bytes: number }; export type NotifText = @@ -26,7 +27,7 @@ export type NotifText = /** The notice text for `t(key, values)`, said when it is shown. */ export const msg = (key: string, values?: Record): NotifText => values ? { key, values } : { key }; -/** Text with no translation of its own (a title, a name), shown as it is. */ +/** Text kept as it was fetched (a title, a name), shown as it is — never something `t()` said. */ export const raw = (text: string): NotifText => ({ raw: text }); /** A notice text in the language `t` speaks — the reader's, from a component's `useTranslation`. */ diff --git a/frontend/src/modules/channels/useWatchedChannelActions.confirm.dom.test.tsx b/frontend/src/modules/channels/useWatchedChannelActions.confirm.dom.test.tsx new file mode 100644 index 00000000..42176e17 --- /dev/null +++ b/frontend/src/modules/channels/useWatchedChannelActions.confirm.dom.test.tsx @@ -0,0 +1,39 @@ +// @vitest-environment jsdom +// +// Subscribing changes the reader's real YouTube account, so the confirm names the channel it +// subscribes to — in both languages, through the real i18n. A notice's value (`raw`, `msg`) is said +// only by sayNotif: handed to a plain `t()` it reads "[object Object]". +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, renderHook } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import i18n from "../../i18n"; + +const h = vi.hoisted(() => ({ confirm: vi.fn(async (_o: { message: string }) => false) })); +vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => h.confirm })); + +import { useWatchedChannelActions } from "./useWatchedChannelActions"; +import type { WatchedChannel } from "../../lib/api"; + +afterEach(async () => { + cleanup(); + h.confirm.mockClear(); + await i18n.changeLanguage("en"); +}); + +const channel = { id: "UC1", title: "Kurzgesagt" } as WatchedChannel; + +const wrapper = ({ children }: { children: ReactNode }) => ( + {children} +); + +describe("the subscribe confirm", () => { + it.each(["en", "hu"])("names the channel (%s)", async (lng) => { + await i18n.changeLanguage(lng); + const { result } = renderHook(() => useWatchedChannelActions(() => {}), { wrapper }); + await act(() => result.current.onSubscribe(channel)); + const said = h.confirm.mock.lastCall![0].message; + expect(said).toContain("Kurzgesagt"); + expect(said).not.toContain("[object Object]"); + }); +}); diff --git a/frontend/src/modules/channels/useWatchedChannelActions.ts b/frontend/src/modules/channels/useWatchedChannelActions.ts index 9017cc0e..d5c35772 100644 --- a/frontend/src/modules/channels/useWatchedChannelActions.ts +++ b/frontend/src/modules/channels/useWatchedChannelActions.ts @@ -54,7 +54,7 @@ export function useWatchedChannelActions(onOpenWizard: () => void) { const onSubscribe = async (c: WatchedChannel): Promise => { const ok = await confirm({ title: t("channels.discovery.subscribe"), - message: t("channels.discovery.confirmSubscribe", { name: raw(nameOf(c)) }), + message: t("channels.discovery.confirmSubscribe", { name: nameOf(c) }), confirmLabel: t("channels.discovery.subscribe"), }); if (ok) subscribe.mutate(c); diff --git a/frontend/src/modules/users/AdminUsers.confirm.dom.test.tsx b/frontend/src/modules/users/AdminUsers.confirm.dom.test.tsx new file mode 100644 index 00000000..55da4e78 --- /dev/null +++ b/frontend/src/modules/users/AdminUsers.confirm.dom.test.tsx @@ -0,0 +1,58 @@ +// @vitest-environment jsdom +// +// Deleting a user cannot be undone, so its confirm names the account — in both languages, through +// the real i18n. A notice's value (`raw`, `msg`) is said only by sayNotif: handed to a plain `t()` +// it reads "[object Object]". +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import i18n from "../../i18n"; + +const h = vi.hoisted(() => ({ confirm: vi.fn(async (_o: { message: string }) => false) })); +vi.mock("../../lib/useMe", () => ({ + useMe: () => ({ + id: 1, + email: "admin@x", + role: "admin", + pending_invites: 0, + pending_activations: 0, + }), +})); +vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => h.confirm })); + +import AdminUsers from "./AdminUsers"; +import { api, type AdminUserRow } from "../../lib/api"; + +afterEach(async () => { + cleanup(); + vi.restoreAllMocks(); + h.confirm.mockClear(); + await i18n.changeLanguage("en"); +}); + +const row = { + id: 2, + email: "someone@example.org", + role: "user", + is_suspended: false, + email_verified: true, + is_active: true, +} as unknown as AdminUserRow; + +describe("the delete-user confirm", () => { + it.each(["en", "hu"])("names the account (%s)", async (lng) => { + await i18n.changeLanguage(lng); + vi.spyOn(api, "adminUsers").mockResolvedValue([row] as never); + render( + + + , + ); + fireEvent.click(await screen.findByRole("button", { name: i18n.t("users.rowMenu") })); + fireEvent.click(await screen.findByText(i18n.t("users.delete.action"))); + await waitFor(() => expect(h.confirm).toHaveBeenCalled()); + const said = h.confirm.mock.lastCall![0].message; + expect(said).toContain("someone@example.org"); + expect(said).not.toContain("[object Object]"); + }); +}); diff --git a/frontend/src/modules/users/AdminUsers.tsx b/frontend/src/modules/users/AdminUsers.tsx index 54f1e7e3..bafdc152 100644 --- a/frontend/src/modules/users/AdminUsers.tsx +++ b/frontend/src/modules/users/AdminUsers.tsx @@ -209,7 +209,7 @@ function UsersRoles({ me }: { me: Me }) { const onDelete = async (u: AdminUserRow) => { const ok = await confirm({ title: t("users.delete.title"), - message: t("users.delete.confirm", { email: raw(u.email) }), + message: t("users.delete.confirm", { email: u.email }), confirmLabel: t("users.delete.action"), danger: true, }); From 6944622085bcc8cee069664448e47a5e0916e8bf Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 30 Oct 2026 02:58:51 +0100 Subject: [PATCH 10/10] chore(release): 0.114.1 --- CHANGELOG.md | 6 ++++++ VERSION | 2 +- frontend/src/content/release-notes/en.json | 8 ++++++++ frontend/src/content/release-notes/hu.json | 8 ++++++++ 4 files changed, 23 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89b34da5..83a5aa65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ edit the release notes and re-run the generator. Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading). +## 0.114.1 — 2026-10-30 + +### Fixed + +- Notifications follow a language switch: a notice on screen and every row in the notification center are said again in the new language, their buttons too. + ## 0.114.0 — 2026-10-29 ### New diff --git a/VERSION b/VERSION index 18455b77..aeb6ab15 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.114.0 +0.114.1 diff --git a/frontend/src/content/release-notes/en.json b/frontend/src/content/release-notes/en.json index 1020ac4f..15424709 100644 --- a/frontend/src/content/release-notes/en.json +++ b/frontend/src/content/release-notes/en.json @@ -1,4 +1,12 @@ [ + { + "version": "0.114.1", + "date": "2026-10-30", + "new": [], + "fixed": [ + "Notifications follow a language switch: a notice on screen and every row in the notification center are said again in the new language, their buttons too." + ] + }, { "version": "0.114.0", "date": "2026-10-29", diff --git a/frontend/src/content/release-notes/hu.json b/frontend/src/content/release-notes/hu.json index 654764f4..c758282d 100644 --- a/frontend/src/content/release-notes/hu.json +++ b/frontend/src/content/release-notes/hu.json @@ -1,4 +1,12 @@ [ + { + "version": "0.114.1", + "date": "2026-10-30", + "new": [], + "fixed": [ + "Az értesítések követik a nyelvváltást: a képernyőn lévő értesítés és az értesítési központ minden sora az új nyelven szól, a gombjaik is." + ] + }, { "version": "0.114.0", "date": "2026-10-29",