From 4ef772d37b7f7fdd7c08897d9c77218db9eb35ad Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 29 Oct 2026 22:37:14 +0100 Subject: [PATCH] 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", () => {