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/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/backend/app/routes/saved_views.py b/backend/app/routes/saved_views.py index f258b230..2af427ea 100644 --- a/backend/app/routes/saved_views.py +++ b/backend/app/routes/saved_views.py @@ -128,7 +128,8 @@ def update_view( db.commit() except IntegrityError: db.rollback() - raise refused(409, "view_name_taken", name=view.name) from None + # The name asked for: the rollback has put the view's old name back on `view`. + raise refused(409, "view_name_taken", name=name) from None return _serialize(view) diff --git a/backend/app/routes/tags.py b/backend/app/routes/tags.py index 47e7e8cc..250206ec 100644 --- a/backend/app/routes/tags.py +++ b/backend/app/routes/tags.py @@ -107,7 +107,8 @@ def update_tag( db.commit() except IntegrityError: db.rollback() - raise refused(409, "tag_name_taken", name=tag.name) from None + # The name asked for: the rollback has put the tag's old name back on `tag`. + raise refused(409, "tag_name_taken", name=name) from None return { "ok": True, "id": tag.id, diff --git a/backend/tests/test_name_taken_detail.py b/backend/tests/test_name_taken_detail.py new file mode 100644 index 00000000..e1f75acb --- /dev/null +++ b/backend/tests/test_name_taken_detail.py @@ -0,0 +1,46 @@ +"""A rename onto a name already taken says WHICH name is taken: the one asked for, not the row's +old name (the rollback that follows the conflict puts the old name back on the row). DB lane. +""" + +from fastapi.testclient import TestClient + +import app.state as state +from app.auth import current_user +from app.db import get_db +from app.main import app +from app.models import User + + +def _client(db, monkeypatch, override_dep): + u = User(email="taken@example.com") + db.add(u) + db.commit() + monkeypatch.setattr( + state, "_configured_cache", True + ) # the request gate 503s an unconfigured app + override_dep(get_db, db.info["request"]) + override_dep(current_user, lambda: u) + return TestClient(app) + + +def test_renaming_a_view_onto_a_taken_name_names_the_taken_one(db, monkeypatch, override_dep): + c = _client(db, monkeypatch, override_dep) + gaming = c.post("/api/saved-views", json={"name": "gaming", "filters": {}, "source": "feed"}) + assert gaming.status_code == 200 + hh = c.post("/api/saved-views", json={"name": "hh", "filters": {}, "source": "feed"}).json() + + r = c.patch(f"/api/saved-views/{hh['id']}", json={"name": "gaming"}) + + assert r.status_code == 409 + assert r.json()["detail"] == {"code": "refused", "reason": "view_name_taken", "name": "gaming"} + + +def test_renaming_a_tag_onto_a_taken_name_names_the_taken_one(db, monkeypatch, override_dep): + c = _client(db, monkeypatch, override_dep) + assert c.post("/api/tags", json={"name": "music"}).status_code == 200 + news = c.post("/api/tags", json={"name": "news"}).json() + + r = c.patch(f"/api/tags/{news['id']}", json={"name": "music"}) + + assert r.status_code == 409 + assert r.json()["detail"] == {"code": "refused", "reason": "tag_name_taken", "name": "music"} 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 e4ddef32..3358e076 100644 --- a/frontend/e2e/helpers.ts +++ b/frontend/e2e/helpers.ts @@ -33,23 +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. + * 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 }); - 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); + }, + undefined, + { timeout: 1000 }, + ) + .catch(() => false); + if (hittable) return i; } - } - return false; + return -1; + }; + 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(); } /** 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..cbd94c1d 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/components/ui/copyItems.tsx b/frontend/src/components/ui/copyItems.tsx index 87ce7819..493a3901 100644 --- a/frontend/src/components/ui/copyItems.tsx +++ b/frontend/src/components/ui/copyItems.tsx @@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next"; import { Copy, Link2 } from "lucide-react"; import { copyText } from "../../lib/clipboard"; -import { notify } from "../../lib/notifications"; +import { msg, type NotifText, notify } from "../../lib/notifications"; import { type ActionMenuItem } from "./ActionMenu"; // "Copy title" and "Copy link", the two rows every object's menu ends its middle group with @@ -17,12 +17,12 @@ type Named = "title" | "name"; export function useCopyItems() { const { t } = useTranslation(); - const copy = (text: string, done: string) => { + const copy = (text: string, done: NotifText) => { void copyText(text).then((ok) => notify( ok ? { level: "success", message: done } - : { level: "error", message: t("common.copy.failed") }, + : { level: "error", message: msg("common.copy.failed") }, ), ); }; @@ -43,7 +43,7 @@ export function useCopyItems() { label: t(named === "title" ? "common.copy.title" : "common.copy.name"), icon: , inGesture: true, - onSelect: () => copy(text, t("common.copy.done")), + onSelect: () => copy(text, msg("common.copy.done")), }, ] : []), @@ -54,7 +54,7 @@ export function useCopyItems() { label: linkLabel ?? t("common.copy.link"), icon: , inGesture: true, - onSelect: () => copy(link, t("common.copy.linkDone")), + onSelect: () => copy(link, msg("common.copy.linkDone")), }, ] : []), 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/components/ui/useCopyFeedback.dom.test.ts b/frontend/src/components/ui/useCopyFeedback.dom.test.ts index 830bb4f9..50ab60e2 100644 --- a/frontend/src/components/ui/useCopyFeedback.dom.test.ts +++ b/frontend/src/components/ui/useCopyFeedback.dom.test.ts @@ -11,8 +11,12 @@ vi.mock("react-i18next", async (importOriginal) => ({ useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), })); vi.mock("../../lib/clipboard", () => ({ copyText: h.copyText })); -vi.mock("../../lib/notifications", () => ({ notify: h.notify })); +vi.mock("../../lib/notifications", async (importOriginal) => ({ + ...(await importOriginal()), + notify: h.notify, +})); +import { msg } from "../../lib/notifications"; import { useCopyFeedback } from "./useCopyFeedback"; afterEach(() => { @@ -27,7 +31,7 @@ describe("useCopyFeedback", () => { await act(() => result.current.copy("sfl_x")); expect(h.copyText).toHaveBeenCalledWith("sfl_x"); expect(result.current.copied).toBe(true); - expect(h.notify).toHaveBeenCalledWith({ level: "success", message: "common.copy.done" }); + expect(h.notify).toHaveBeenCalledWith({ level: "success", message: msg("common.copy.done") }); act(() => void vi.advanceTimersByTime(2000)); expect(result.current.copied).toBe(false); }); @@ -49,6 +53,6 @@ describe("useCopyFeedback", () => { const { result } = renderHook(() => useCopyFeedback()); await act(() => result.current.copy("sfl_x")); expect(result.current.copied).toBe(false); - expect(h.notify).toHaveBeenCalledWith({ level: "warning", message: "common.copy.failed" }); + expect(h.notify).toHaveBeenCalledWith({ level: "warning", message: msg("common.copy.failed") }); }); }); diff --git a/frontend/src/components/ui/useCopyFeedback.ts b/frontend/src/components/ui/useCopyFeedback.ts index 9c12c0a4..2efa2069 100644 --- a/frontend/src/components/ui/useCopyFeedback.ts +++ b/frontend/src/components/ui/useCopyFeedback.ts @@ -1,8 +1,7 @@ import { useEffect, useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; import { copyText } from "../../lib/clipboard"; -import { notify } from "../../lib/notifications"; +import { msg, notify } from "../../lib/notifications"; // How long a Copy button reads "Copied" after it worked. const COPIED_MS = 2000; @@ -15,7 +14,6 @@ const COPIED_MS = 2000; * * A feature file, not `ui/`: it speaks the app's notifications (as `copyItems` does). */ export function useCopyFeedback(): { copied: boolean; copy: (value: string) => Promise } { - const { t } = useTranslation(); const [copied, setCopied] = useState(false); // One reset at a time: a copy again within the moment starts it over, not cut short by the last. const timer = useRef(undefined); @@ -23,12 +21,12 @@ export function useCopyFeedback(): { copied: boolean; copy: (value: string) => P const copy = async (value: string) => { if (await copyText(value)) { setCopied(true); - notify({ level: "success", message: t("common.copy.done") }); + notify({ level: "success", message: msg("common.copy.done") }); window.clearTimeout(timer.current); timer.current = window.setTimeout(() => setCopied(false), COPIED_MS); return; } - notify({ level: "warning", message: t("common.copy.failed") }); + notify({ level: "warning", message: msg("common.copy.failed") }); }; return { copied, copy }; } 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", diff --git a/frontend/src/features/channel/TagManager.tsx b/frontend/src/features/channel/TagManager.tsx index f93309a6..c0aad6ed 100644 --- a/frontend/src/features/channel/TagManager.tsx +++ b/frontend/src/features/channel/TagManager.tsx @@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../../lib/queryKeys"; import { Plus, Trash2 } from "lucide-react"; import { api, type Tag } from "../../lib/api"; -import { notify } from "../../lib/notifications"; +import { msg, type NotifText, notify, raw } from "../../lib/notifications"; import { notifyActionError } from "../../lib/actionErrors"; import { useScrollFade } from "../../lib/useScrollFade"; import { Button } from "../../components/ui/Button"; @@ -137,22 +137,22 @@ export default function TagManager({ }); // A failure is said and the list refetched — a tag deleted in another tab used to fail in silence // (a 404 `not_found/tag` gets no global dialog) and stay listed. - const onFailed = (e: unknown, fallback: string) => { + const onFailed = (e: unknown, fallback: NotifText) => { notifyActionError(e, fallback); invalidate(); }; const rename = useMutation({ mutationFn: (v: { id: number; name: string }) => api.updateTag(v.id, { name: v.name }), onSuccess: invalidate, - onError: (e) => onFailed(e, t("tagManager.renameFailed")), + onError: (e) => onFailed(e, msg("tagManager.renameFailed")), }); const del = useMutation({ mutationFn: (v: { id: number; name: string }) => api.deleteTag(v.id), onSuccess: (_d, v) => { invalidate(); - notify({ level: "success", message: t("tagManager.deleted", { name: v.name }) }); + notify({ level: "success", message: msg("tagManager.deleted", { name: raw(v.name) }) }); }, - onError: (e) => onFailed(e, t("tagManager.deleteFailed")), + onError: (e) => onFailed(e, msg("tagManager.deleteFailed")), }); // Picking a channel from a tag's popover: close the dialog, then focus it in the manager. diff --git a/frontend/src/features/comfort/comfortPin.tsx b/frontend/src/features/comfort/comfortPin.tsx index 1ecef7ec..a0a42525 100644 --- a/frontend/src/features/comfort/comfortPin.tsx +++ b/frontend/src/features/comfort/comfortPin.tsx @@ -6,7 +6,7 @@ import { Loader2, Pin, PinOff } from "lucide-react"; import { plexLang } from "../../i18n"; import { api, type DiscoverCard, type PinKind } from "../../lib/api"; import { notifyActionError } from "../../lib/actionErrors"; -import { notify } from "../../lib/notifications"; +import { msg, type NotifText, notify } from "../../lib/notifications"; import { qk } from "../../lib/queryKeys"; import { type ActionMenuItem } from "../../components/ui/ActionMenu"; @@ -81,9 +81,9 @@ export interface ComfortPin { /** `pinIdOf` and `toggle` stay module-local until a second consumer needs them: publishing them * invites the next surface to re-derive the three-state loading dance (pinned / not pinned / * not known yet) that `item` already encapsulates, which is what this file exists to prevent. */ - remove: (pinId: number, title: string) => void; + remove: (pinId: number, title: NotifText) => void; /** The pin/unpin row, ready to spread into any `ActionMenuItem[]`. Empty for a null target. */ - item: (target: PinTarget | null, title: string) => ActionMenuItem[]; + item: (target: PinTarget | null, title: NotifText) => ActionMenuItem[]; } /** @@ -134,37 +134,37 @@ export function useComfortPin(open: boolean): ComfortPin { }, [qc]); const add = useCallback( - async (target: PinTarget, title: string) => { + async (target: PinTarget, title: NotifText) => { try { await api.addComfortPin(target.kind, target.ref); // The shelf lives on another page, so without this the click has no visible consequence at // all from the feed — the one place a toast genuinely carries the whole feedback. - notify({ level: "success", message: t("home.pin.added", { title }) }); + notify({ level: "success", message: msg("home.pin.added", { title }) }); } catch (e) { - notifyActionError(e, t("home.pin.addFailed")); + notifyActionError(e, msg("home.pin.addFailed")); } invalidate(); }, - [invalidate, t], + [invalidate], ); const remove = useCallback( - (pinId: number, title: string) => { + (pinId: number, title: NotifText) => { void (async () => { try { await api.removeComfortPin(pinId); - notify({ level: "success", message: t("home.pin.removed", { title }) }); + notify({ level: "success", message: msg("home.pin.removed", { title }) }); } catch (e) { - notifyActionError(e, t("home.pin.removeFailed")); + notifyActionError(e, msg("home.pin.removeFailed")); } invalidate(); })(); }, - [invalidate, t], + [invalidate], ); const toggle = useCallback( - (target: PinTarget, title: string) => { + (target: PinTarget, title: NotifText) => { const pinId = pinIdOf(target); if (pinId == null) void add(target, title); else remove(pinId, title); @@ -173,7 +173,7 @@ export function useComfortPin(open: boolean): ComfortPin { ); const item = useCallback( - (target: PinTarget | null, title: string): ActionMenuItem[] => { + (target: PinTarget | null, title: NotifText): ActionMenuItem[] => { if (!target) return []; const pinId = pinIdOf(target); // Still loading: an honest "we don't know yet" row rather than a confident "Pin" over diff --git a/frontend/src/features/diagnostics/DebugRecorder.tsx b/frontend/src/features/diagnostics/DebugRecorder.tsx index 91e1033f..22f74770 100644 --- a/frontend/src/features/diagnostics/DebugRecorder.tsx +++ b/frontend/src/features/diagnostics/DebugRecorder.tsx @@ -18,7 +18,7 @@ import { } from "../../lib/debugRecorder"; import { formatDateTimeSec, formatDuration } from "../../lib/format"; import { useLayer } from "../../lib/layerStack"; -import { notify } from "../../lib/notifications"; +import { msg, notify } from "../../lib/notifications"; import { qk } from "../../lib/queryKeys"; import { dockUnderToasts } from "../../lib/toastDock"; import { useMe } from "../../lib/useMe"; @@ -73,7 +73,7 @@ export default function DebugRecorder() { if (!id || !until) return; const wait = Math.min(Date.parse(until) - Date.now(), 2 ** 31 - 1); const timer = window.setTimeout(() => { - notify({ level: "warning", message: t("recorder.pill.expired", { count: minutes ?? 0 }) }); + notify({ level: "warning", message: msg("recorder.pill.expired", { count: minutes ?? 0 }) }); openComposer(id); void qc.invalidateQueries({ queryKey: qk.me() }); }, wait); diff --git a/frontend/src/features/diagnostics/IssueComposer.tsx b/frontend/src/features/diagnostics/IssueComposer.tsx index ff7a1024..cb635105 100644 --- a/frontend/src/features/diagnostics/IssueComposer.tsx +++ b/frontend/src/features/diagnostics/IssueComposer.tsx @@ -6,7 +6,7 @@ import { ExternalLink, FileText, Plus, Server, X } from "lucide-react"; import { api, type DebugScreenshot, type DebugSession, type ServerLogRow } from "../../lib/api"; import { closeComposer } from "../../lib/debugRecorder"; import { formatBytes, formatDateTimeSec, formatUnit } from "../../lib/format"; -import { notify } from "../../lib/notifications"; +import { msg, notify } from "../../lib/notifications"; import { useMe } from "../../lib/useMe"; import { draftSteps, @@ -217,7 +217,7 @@ function ComposerBody({ meta: { failTitle: "recorder.composer.sendFailed" }, onSuccess: (r) => { qc.setQueryData(qk.debugSession(session.id), r.session); - notify({ level: "success", message: t("recorder.composer.sentOk") }); + notify({ level: "success", message: msg("recorder.composer.sentOk") }); }, // A failure half-way still created the issue: show what the server now has. onSettled: async () => { diff --git a/frontend/src/features/diagnostics/useDebugRecording.ts b/frontend/src/features/diagnostics/useDebugRecording.ts index c0a2b914..79fc095a 100644 --- a/frontend/src/features/diagnostics/useDebugRecording.ts +++ b/frontend/src/features/diagnostics/useDebugRecording.ts @@ -1,6 +1,5 @@ import { useCallback } from "react"; import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query"; -import { useTranslation } from "react-i18next"; import { api, type DebugSession, type DebugSessionLevel } from "../../lib/api"; import { notifyActionError } from "../../lib/actionErrors"; @@ -14,7 +13,7 @@ import { } from "../../lib/debugRecorder"; import { flushNow, liveSession } from "../../lib/diagnostics"; import { log } from "../../lib/log"; -import { notify } from "../../lib/notifications"; +import { msg, notify } from "../../lib/notifications"; import { qk } from "../../lib/queryKeys"; import { updateMeCache, useMe } from "../../lib/useMe"; @@ -22,7 +21,6 @@ import { updateMeCache, useMe } from "../../lib/useMe"; * Diagnosztika and the command palette. The session itself is `me.debug_session` — the server's * answer, so a reload, another tab or the cap all show the same thing. */ export function useDebugRecording() { - const { t } = useTranslation(); const qc = useQueryClient(); const me = useMe(); const session = liveSession(me?.debug_session); @@ -54,7 +52,7 @@ export function useDebugRecording() { meta: { failTitle: "recorder.pill.stopFailed" }, onSuccess: (r) => { setSession(null); - notify({ level: "success", message: t("recorder.pill.stopped") }); + notify({ level: "success", message: msg("recorder.pill.stopped") }); openComposer(r.session.id); }, }); @@ -78,7 +76,7 @@ export function useDebugRecording() { ); // In the evidence too, where the step list is drafted from: which step it belongs to. log.info("debug.screenshot", "screenshot taken", { screenshot: r.screenshot.id }); - notify({ level: "success", message: t("recorder.pill.shotSaved") }); + notify({ level: "success", message: msg("recorder.pill.shotSaved") }); }, }); @@ -95,9 +93,9 @@ export function useDebugRecording() { const n = nextStep(sessionId); const text = label.trim(); log.info("debug.step", text || `step ${n}`, { n, ...(text ? { label: text } : {}) }); - notify({ level: "info", message: t("recorder.pill.stepMarked", { n }) }); + notify({ level: "info", message: msg("recorder.pill.stepMarked", { n }) }); }, - [sessionId, t], + [sessionId], ); /** Capture this tab and attach it. `hide` runs around the capture so the pill is not in the @@ -111,7 +109,7 @@ export function useDebugRecording() { await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); image = await captureTab(); } catch (err) { - notifyActionError(err, t("recorder.pill.shotFailed")); + notifyActionError(err, msg("recorder.pill.shotFailed")); } hide(false); if (image) shoot.mutate({ id: session.id, image }); diff --git a/frontend/src/features/downloads/DownloadDialog.tsx b/frontend/src/features/downloads/DownloadDialog.tsx index c680672d..41b88318 100644 --- a/frontend/src/features/downloads/DownloadDialog.tsx +++ b/frontend/src/features/downloads/DownloadDialog.tsx @@ -9,7 +9,7 @@ import { invalidateDownloads } from "../../lib/downloads"; // Lazy: the full profile editor only opens from the dialog's "manage presets" affordance. const ProfileEditor = lazy(() => import("../../modules/settings/ProfileEditor")); -import { notify } from "../../lib/notifications"; +import { msg, notify } from "../../lib/notifications"; import { notifyActionError } from "../../lib/actionErrors"; import { showInTray } from "../../lib/downloadQueue"; import { profileName } from "../../lib/vocab"; @@ -63,15 +63,15 @@ export default function DownloadDialog({ setLastProfile(chosen); notify({ level: "success", - message: t("downloads.dialog.added"), - action: { label: t("downloads.dialog.view"), onClick: () => showInTray(job.id) }, + message: msg("downloads.dialog.added"), + action: { label: msg("downloads.dialog.view"), onClick: () => showInTray(job.id) }, }); onClose(); } catch (e) { // A quota or validation refusal (422/409) is already in the global error dialog, which the // reporter skips; a 403/404/429 or a lost connection is not, and used to leave the dialog // open with nothing said. - notifyActionError(e, t("errors.writeFailed.enqueueDownload")); + notifyActionError(e, msg("errors.writeFailed.enqueueDownload")); setBusy(false); } }; diff --git a/frontend/src/features/downloads/DownloadTray.dom.test.tsx b/frontend/src/features/downloads/DownloadTray.dom.test.tsx index 69096443..9d18d198 100644 --- a/frontend/src/features/downloads/DownloadTray.dom.test.tsx +++ b/frontend/src/features/downloads/DownloadTray.dom.test.tsx @@ -70,6 +70,7 @@ vi.mock("../../lib/notifications", async (importOriginal) => ({ })); import DownloadTray from "./DownloadTray"; +import { msg, type NotifText, raw } from "../../lib/notifications"; import { noteQueue, resetQueueWatch, showInTray } from "../../lib/downloadQueue"; import { pushLayer, removeLayer } from "../../lib/layerStack"; import { qk } from "../../lib/queryKeys"; @@ -199,14 +200,15 @@ describe("DownloadTray", () => { poll([job(1, "done")], view); await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1)); const notice = h.notify.mock.calls[0]![0] as { - title: string; - message: string; - action: { label: string; onClick: () => void }; - secondaryAction: { label: string; onClick: () => void }; + title: NotifText; + message: NotifText; + action: { label: NotifText; onClick: () => void }; + secondaryAction: { label: NotifText; onClick: () => void }; }; - expect(notice.title).toBe("tray.done.title:Clip 1"); - expect(notice.message).toBe("tray.done.phone"); - expect(notice.action.label).toBe("tray.done.copyLink"); + expect(notice.title).toEqual(msg("tray.done.title", { title: raw("Clip 1") })); + expect(notice.message).toEqual(msg("tray.done.phone")); + 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(); @@ -225,9 +227,9 @@ describe("DownloadTray", () => { await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1)); poll([job(1, "done"), job(2, "done")], view); await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(2)); - expect(h.notify.mock.calls.map((c) => (c[0] as { title: string }).title)).toEqual([ - "tray.done.title:Clip 1", - "tray.done.title:Clip 2", + expect(h.notify.mock.calls.map((c) => (c[0] as { title: NotifText }).title)).toEqual([ + msg("tray.done.title", { title: raw("Clip 1") }), + msg("tray.done.title", { title: raw("Clip 2") }), ]); }); @@ -236,7 +238,9 @@ describe("DownloadTray", () => { const view = mount(); poll([job(1, "done", { asset: { media_kind: "file" } as never })], view); await waitFor(() => - expect(h.notify).toHaveBeenCalledWith(expect.objectContaining({ message: "tray.done.file" })), + expect(h.notify).toHaveBeenCalledWith( + expect.objectContaining({ message: msg("tray.done.file") }), + ), ); }); @@ -257,7 +261,9 @@ describe("DownloadTray", () => { poll([job(1, "done", { source_key: "upload" })], view); await waitFor(() => expect(h.notify).toHaveBeenCalledWith( - expect.objectContaining({ title: "tray.done.uploadedTitle:Clip 1" }), + expect.objectContaining({ + title: msg("tray.done.uploadedTitle", { title: raw("Clip 1") }), + }), ), ); }); diff --git a/frontend/src/features/downloads/DownloadTray.tsx b/frontend/src/features/downloads/DownloadTray.tsx index c7c80331..1d56c3f2 100644 --- a/frontend/src/features/downloads/DownloadTray.tsx +++ b/frontend/src/features/downloads/DownloadTray.tsx @@ -30,7 +30,7 @@ import { } from "../../lib/downloadQueue"; import { anyBlockingLayer } from "../../lib/layerStack"; import { focusNavItem, navItemAttrs } from "../../lib/listNav"; -import { notify } from "../../lib/notifications"; +import { msg, notify, raw } from "../../lib/notifications"; import { qk } from "../../lib/queryKeys"; import { LS } from "../../lib/storage"; import { useLiveQuery } from "../../lib/useLiveQuery"; @@ -191,16 +191,16 @@ export default function DownloadTray() { notify({ level: "success", // An upload the worker had to convert first is done here too — it was never downloaded. - title: t(job.source_key === "upload" ? "tray.done.uploadedTitle" : "tray.done.title", { - title, + title: msg(job.source_key === "upload" ? "tray.done.uploadedTitle" : "tray.done.title", { + title: raw(title), }), - message: t(job.asset?.media_kind === "file" ? "tray.done.file" : "tray.done.phone"), + 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/downloads/useDownloadActions.tsx b/frontend/src/features/downloads/useDownloadActions.tsx index 436f3b95..cb896aba 100644 --- a/frontend/src/features/downloads/useDownloadActions.tsx +++ b/frontend/src/features/downloads/useDownloadActions.tsx @@ -9,6 +9,7 @@ import { useCopyItems } from "../../components/ui/copyItems"; import { jobTitle } from "./downloadTitle"; import { type ActionMenuItem } from "../../components/ui/ActionMenu"; import { menuGroups } from "../../components/ui/menuGroups"; +import { msg } from "../../lib/notifications"; // A queued download's ONE action list: the row's "…", its right-click, long-press and Shift+F10. The // row keeps its primary action (pause / resume / retry) and its state (the error and its details) in @@ -56,7 +57,7 @@ export function useDownloadActions() { ? api.resumeDownload(id) : api.cancelDownload(id), onSuccess: () => invalidateDownloads(qc), - onError: (e, v) => notifyActionError(e, t(`errors.writeFailed.job.${v.op}`)), + onError: (e, v) => notifyActionError(e, msg(`errors.writeFailed.job.${v.op}`)), }); const confirmThen = async (title: string, body: string, action: string, fn: () => void) => { diff --git a/frontend/src/features/filters/useSavedViewsStore.ts b/frontend/src/features/filters/useSavedViewsStore.ts index cc599934..53f47ff8 100644 --- a/frontend/src/features/filters/useSavedViewsStore.ts +++ b/frontend/src/features/filters/useSavedViewsStore.ts @@ -8,6 +8,7 @@ import { notifyActionError } from "../../lib/actionErrors"; import { ownKey } from "../../lib/storage"; import { useConfirm } from "../../components/ui/confirmContext"; import type { SavedViewsContext, ViewSource } from "./savedViewsContext"; +import { msg, type NotifText } from "../../lib/notifications"; // Saved "smart views" serve two modules — the feed and Plex — and each owns one store of them in its // own folder, built on this hook. The two never switch into each other: a visit to Plex leaves the @@ -114,7 +115,7 @@ export function useSavedViewsStore(cfg: SavedViewsConfig): SavedViewsContext { const isDirty = displayView != null && activeView == null; const refresh = () => void qc.invalidateQueries({ queryKey: qk.savedViews(source) }); - async function runViewMutation(fn: () => Promise, failMsg: string): Promise { + async function runViewMutation(fn: () => Promise, failMsg: NotifText): Promise { let ok = true; try { await fn(); @@ -146,28 +147,28 @@ export function useSavedViewsStore(cfg: SavedViewsConfig): SavedViewsContext { if (!clean) return false; return runViewMutation( () => api.createView(clean, cfg.stripForSave(cfg.filters), source), - t("views.actionFailed"), + msg("views.actionFailed"), ); }, rename: async (id, name) => { const next = name.trim(); if (!next) return false; - return runViewMutation(() => api.updateView(id, { name: next }), t("views.actionFailed")); + return runViewMutation(() => api.updateView(id, { name: next }), msg("views.actionFailed")); }, updateFilters: (id, filters) => - runViewMutation(() => api.updateView(id, { filters }), t("views.actionFailed")), + runViewMutation(() => api.updateView(id, { filters }), msg("views.actionFailed")), saveCurrent: async (id) => { const filters = cfg.stripForSave(cfg.filters); const ok = await runViewMutation( () => api.updateView(id, { filters }), - t("views.actionFailed"), + msg("views.actionFailed"), ); return ok ? filters : null; }, toggleDefault: (v) => runViewMutation( () => api.updateView(v.id, { is_default: !v.is_default }), - t("views.actionFailed"), + msg("views.actionFailed"), ).then(() => undefined), remove: async (v) => { const ok = await confirm({ @@ -177,14 +178,14 @@ export function useSavedViewsStore(cfg: SavedViewsConfig): SavedViewsContext { }); if (!ok) return false; if (stickyId === v.id) setStickyId(null); - return runViewMutation(() => api.deleteView(v.id), t("views.actionFailed")); + return runViewMutation(() => api.deleteView(v.id), msg("views.actionFailed")); }, reorder: async (nextIds) => { qc.setQueryData( qk.savedViews(source), nextIds.map((id) => views.find((v) => v.id === id)!).filter(Boolean), ); - await runViewMutation(() => api.reorderViews(nextIds), t("views.reorderFailed")); + await runViewMutation(() => api.reorderViews(nextIds), msg("views.reorderFailed")); }, }; } diff --git a/frontend/src/features/player/ClipPanel.dom.test.tsx b/frontend/src/features/player/ClipPanel.dom.test.tsx index 5fd20636..650cfcbc 100644 --- a/frontend/src/features/player/ClipPanel.dom.test.tsx +++ b/frontend/src/features/player/ClipPanel.dom.test.tsx @@ -30,10 +30,11 @@ vi.mock("../../lib/downloadQueue", () => ({ showInTray: (id: number) => showInTr const notifyActionError = vi.fn(); vi.mock("../../lib/actionErrors", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, notifyActionError: (e: unknown, t: string) => notifyActionError(e, t) }; + return { ...actual, notifyActionError: (e: unknown, t: NotifText) => notifyActionError(e, t) }; }); import ClipPanel, { type ClipCrop, type ClipSource } from "./ClipPanel"; +import { msg, type NotifText } from "../../lib/notifications"; afterEach(() => { cleanup(); @@ -188,7 +189,7 @@ describe("ClipPanel", () => { display_name: "The bit", }); expect(notify).toHaveBeenCalledWith( - expect.objectContaining({ level: "success", message: "clip.queued" }), + expect.objectContaining({ level: "success", message: msg("clip.queued") }), ); // Its View opens the tray on the new clip. ( @@ -214,7 +215,7 @@ describe("ClipPanel", () => { fireEvent.click(screen.getByRole("button", { name: "clip.cut" })); await waitFor(() => expect(notifyActionError).toHaveBeenCalledTimes(1)); expect(notifyActionError.mock.calls[0]![0]).toBe(err); - expect(notifyActionError.mock.calls[0]![1]).toBe("clip.failed"); + expect(notifyActionError.mock.calls[0]![1]).toEqual(msg("clip.failed")); expect(onClose).not.toHaveBeenCalled(); expect((screen.getByRole("button", { name: "clip.cut" }) as HTMLButtonElement).disabled).toBe( false, diff --git a/frontend/src/features/player/ClipPanel.tsx b/frontend/src/features/player/ClipPanel.tsx index 9e1ee6cd..24ef24ae 100644 --- a/frontend/src/features/player/ClipPanel.tsx +++ b/frontend/src/features/player/ClipPanel.tsx @@ -5,7 +5,7 @@ import { Crop, Crosshair, Maximize, Minus, Plus, Repeat, Scissors, X } from "luc import { api, type ClipRequest } from "../../lib/api"; import { DISPLAY_NAME_MAX } from "../../lib/api/downloads"; import { invalidateDownloads } from "../../lib/downloads"; -import { notify } from "../../lib/notifications"; +import { msg, notify } from "../../lib/notifications"; import { notifyActionError } from "../../lib/actionErrors"; import { showInTray } from "../../lib/downloadQueue"; import { log } from "../../lib/log"; @@ -197,14 +197,14 @@ export default function ClipPanel({ log.info("clip.queued", "clip queued", { source: src.source, seconds: Math.round(length) }); notify({ level: "success", - message: t("clip.queued"), - action: { label: t("clip.view"), onClick: () => showInTray(job.id) }, + message: msg("clip.queued"), + action: { label: msg("clip.view"), onClick: () => showInTray(job.id) }, }); onClose(); } catch (err) { // A refusal the server explains (too long, quota) is already in the error dialog; a lost // connection or a 404 is not, and must not leave the panel looking as if nothing happened. - notifyActionError(err, t("clip.failed")); + notifyActionError(err, msg("clip.failed")); setBusy(false); } }; diff --git a/frontend/src/features/player/LinkedPlayer.tsx b/frontend/src/features/player/LinkedPlayer.tsx index f6f86231..d139fcf2 100644 --- a/frontend/src/features/player/LinkedPlayer.tsx +++ b/frontend/src/features/player/LinkedPlayer.tsx @@ -1,5 +1,4 @@ import { lazy, Suspense, useState } from "react"; -import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; import { type Video, type VideoStatus } from "../../lib/api"; import { qk } from "../../lib/queryKeys"; @@ -7,6 +6,7 @@ import { notifyActionError } from "../../lib/actionErrors"; import { useStableCallback } from "../../lib/useLatest"; import { writeVideoState } from "../video/videoWrites"; import { useOpenLinkedPlayer } from "./useOpenLinkedPlayer"; +import { msg } from "../../lib/notifications"; const PlayerModal = lazy(() => import("./PlayerModal")); @@ -14,7 +14,6 @@ const PlayerModal = lazy(() => import("./PlayerModal")); * channel manager, where the watched-videos dialog played it; any page a link names): the video * alone. Decided at boot, once — `hosted` is whether the page on screen then opens it itself. */ export function LinkedPlayer({ hosted }: { hosted: boolean }) { - const { t } = useTranslation(); const qc = useQueryClient(); const [own] = useState(!hosted); const [video, setVideo] = useState