fix(notifications): a notice keeps what to say and says it in the reader's language when shown

This commit is contained in:
2026-10-29 22:37:14 +01:00
parent 6986eb1937
commit 4ef772d37b
125 changed files with 973 additions and 674 deletions
@@ -22,7 +22,7 @@ vi.mock("react-i18next", async (importOriginal) => {
}); });
vi.mock("./components/ui/confirmContext", () => ({ useConfirm: () => async () => true })); vi.mock("./components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
const { notify } = vi.hoisted(() => ({ 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) => ({ vi.mock("./lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("./lib/notifications")>()), ...(await importOriginal<typeof import("./lib/notifications")>()),
@@ -35,6 +35,7 @@ import PlexPlaylistAdd from "./features/plexCards/PlexPlaylistAdd";
import TagManager from "./features/channel/TagManager"; import TagManager from "./features/channel/TagManager";
import { api, HttpError, type PlexCard } from "./lib/api"; import { api, HttpError, type PlexCard } from "./lib/api";
import { qk } from "./lib/queryKeys"; import { qk } from "./lib/queryKeys";
import type { NotifText } from "./lib/notifications";
beforeAll(() => { beforeAll(() => {
class Stub { class Stub {
@@ -98,7 +99,7 @@ describe("the Plex playlist page", () => {
() => fireEvent.contextMenu(screen.getByText("Movie 1").closest("[data-nav-index]")!), () => fireEvent.contextMenu(screen.getByText("Movie 1").closest("[data-nav-index]")!),
"plex.playlist.removeFromPlaylist", "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)))); await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
expect(screen.getByText("Movie 1")).toBeTruthy(); expect(screen.getByText("Movie 1")).toBeTruthy();
}); });
@@ -119,7 +120,7 @@ describe("the Plex playlist page", () => {
const field = await screen.findByDisplayValue("Films"); const field = await screen.findByDisplayValue("Films");
fireEvent.change(field, { target: { value: "Movies" } }); fireEvent.change(field, { target: { value: "Movies" } });
fireEvent.keyDown(field, { key: "Enter" }); 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 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(screen.getByLabelText("playlists.addToPlaylist"));
fireEvent.click(await screen.findByRole("menuitemcheckbox", { hidden: true })); 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)); 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")); fireEvent.click(await screen.findByText("Gone"));
// The server's own localized reason wins over the caller's fallback when the detail is // 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. // 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)); await waitFor(() => expect(lists.mock.calls.length).toBeGreaterThan(1));
}); });
}); });
@@ -300,7 +301,7 @@ describe("the tag manager", () => {
</QueryClientProvider>, </QueryClientProvider>,
); );
fireEvent.click(await screen.findByLabelText("tagManager.delete")); 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)); await waitFor(() => expect(tags.mock.calls.length).toBeGreaterThan(1));
}); });
}); });
@@ -19,7 +19,7 @@ vi.mock("react-i18next", async (importOriginal) => {
}); });
vi.mock("./components/ui/confirmContext", () => ({ useConfirm: () => async () => true })); vi.mock("./components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
const { notify } = vi.hoisted(() => ({ 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) => ({ vi.mock("./lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("./lib/notifications")>()), ...(await importOriginal<typeof import("./lib/notifications")>()),
@@ -30,6 +30,7 @@ import PlexCollectionEditor from "./modules/plex/PlexCollectionEditor";
import UploadDialog from "./modules/files/UploadDialog"; import UploadDialog from "./modules/files/UploadDialog";
import DownloadDialog from "./features/downloads/DownloadDialog"; import DownloadDialog from "./features/downloads/DownloadDialog";
import { api, HttpError } from "./lib/api"; import { api, HttpError } from "./lib/api";
import { msg, type NotifText } from "./lib/notifications";
beforeAll(() => { beforeAll(() => {
class Stub { class Stub {
@@ -87,7 +88,7 @@ describe("the Plex collection editor", () => {
); );
mount(); mount();
fireEvent.click(await screen.findByText("Marathon")); 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 () => { it("says so when enabling editing is refused", async () => {
@@ -97,7 +98,7 @@ describe("the Plex collection editor", () => {
); );
mount(); mount();
fireEvent.click(await screen.findByText("plex.collEditor.makeEditable")); 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 () => { it("says so when a CONFIRMED delete does not happen", async () => {
@@ -108,7 +109,7 @@ describe("the Plex collection editor", () => {
); );
mount(); mount();
fireEvent.click(await screen.findByText("plex.collEditor.delete")); 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(); mount();
pick(); pick();
fireEvent.click(screen.getByText("downloads.upload.submit")); 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 () => { it("says nothing when the user cancelled it themselves", async () => {
@@ -172,6 +173,6 @@ describe("the download dialog", () => {
); );
await screen.findByText("Best"); await screen.findByText("Best");
fireEvent.click(screen.getByText("downloads.dialog.submit")); fireEvent.click(screen.getByText("downloads.dialog.submit"));
await waitFor(() => expect(errors()).toEqual(["errors.writeFailed.enqueueDownload"])); await waitFor(() => expect(errors()).toEqual([msg("errors.writeFailed.enqueueDownload")]));
}); });
}); });
@@ -21,7 +21,7 @@ vi.mock("react-i18next", async (importOriginal) => {
vi.mock("../../lib/history", () => ({ useBackToClose: () => {} })); vi.mock("../../lib/history", () => ({ useBackToClose: () => {} }));
import Modal from "./Modal"; 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 { pushLayer, removeLayer, useLayer } from "../../lib/layerStack";
import { createPortal, flushSync } from "react-dom"; import { createPortal, flushSync } from "react-dom";
@@ -121,7 +121,7 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
<Body /> <Body />
</Modal>, </Modal>,
); );
act(() => void notify({ level: "info", message: "Saved" })); act(() => void notify({ level: "info", message: raw("Saved") }));
try { try {
const dismissToast = screen.getByTitle("notifications.dismiss"); 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. // 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')", () => {
<Body /> <Body />
</Modal>, </Modal>,
); );
act(() => void notify({ level: "error", message: "Nope" })); act(() => void notify({ level: "error", message: raw("Nope") }));
const dismissToast = screen.getByTitle("notifications.dismiss"); const dismissToast = screen.getByTitle("notifications.dismiss");
dismissToast.focus(); dismissToast.focus();
expect(document.activeElement).toBe(dismissToast); // the scenario is actually being driven expect(document.activeElement).toBe(dismissToast); // the scenario is actually being driven
@@ -455,7 +455,7 @@ describe("Modal — toasts raised while it is open", () => {
<Body /> <Body />
</Modal>, </Modal>,
); );
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."); expect(screen.getByRole("dialog").textContent).toContain("Couldn't share it.");
}); });
}); });
+15 -15
View File
@@ -14,7 +14,7 @@ vi.mock("react-i18next", async (importOriginal) => {
}); });
import Toaster from "./Toaster"; import Toaster from "./Toaster";
import { clearAll, notify } from "../../lib/notifications"; import { clearAll, notify, raw } from "../../lib/notifications";
import { dockUnderToasts } from "../../lib/toastDock"; import { dockUnderToasts } from "../../lib/toastDock";
afterEach(() => { afterEach(() => {
@@ -31,7 +31,7 @@ describe("Toaster — one host shows the toasts", () => {
<Toaster /> <Toaster />
</div>, </div>,
); );
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."); expect(screen.getByTestId("app").textContent).toContain("Couldn't remove the video.");
const overlay = render( 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", () => { it("lets a click through where there is no toast and nothing docked, and not where there is", () => {
const Docked = () => <button>dock</button>; const Docked = () => <button>dock</button>;
const stop = dockUnderToasts(Docked); const stop = dockUnderToasts(Docked);
notify({ level: "info", message: "hello" }); notify({ level: "info", message: raw("hello") });
render(<Toaster />); render(<Toaster />);
const region = document.querySelector<HTMLElement>("[data-toast-region]")!; const region = document.querySelector<HTMLElement>("[data-toast-region]")!;
// The empty part of the region (beside and above a docked control) must not swallow the page's // 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", () => {
<Toaster overlay /> <Toaster overlay />
</div>, </div>,
); );
act(() => void notify({ level: "error", message: "boom" })); act(() => void notify({ level: "error", message: raw("boom") }));
fireEvent.click(screen.getByText("boom")); fireEvent.click(screen.getByText("boom"));
expect(backdrop).not.toHaveBeenCalled(); 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 // 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. // assertively, each time a player (or any dialog) opened or closed.
render(<Toaster />); render(<Toaster />);
act(() => void notify({ level: "error", message: "old news" })); act(() => void notify({ level: "error", message: raw("old news") }));
render(<Toaster overlay />); render(<Toaster overlay />);
const old = screen.getByText("old news").closest(".toast-card")!; const old = screen.getByText("old news").closest(".toast-card")!;
expect(old.getAttribute("role")).toBeNull(); 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"); 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 // 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). // "second" arrived (its own screen had become the handover by then).
render(<Toaster />); render(<Toaster />);
act(() => void notify({ level: "error", message: "first" })); act(() => void notify({ level: "error", message: raw("first") }));
act(() => void notify({ level: "error", message: "second" })); act(() => void notify({ level: "error", message: raw("second") }));
const card = (text: string) => screen.getByText(text).closest(".toast-card")!; const card = (text: string) => screen.getByText(text).closest(".toast-card")!;
expect(card("first").getAttribute("role")).toBe("alert"); expect(card("first").getAttribute("role")).toBe("alert");
expect(card("second").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 // 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. // arming has to wait for the next one — a screen reader reads the region's state after the task.
render(<Toaster />); render(<Toaster />);
act(() => void notify({ level: "info", message: "saved" })); act(() => void notify({ level: "info", message: raw("saved") }));
const overlay = render(<Toaster overlay />); const overlay = render(<Toaster overlay />);
const region = overlay.container.querySelector("[aria-live]")!; const region = overlay.container.querySelector("[aria-live]")!;
expect(region.textContent).toContain("saved"); // it took the toast over… expect(region.textContent).toContain("saved"); // it took the toast over…
@@ -135,7 +135,7 @@ describe("Toaster — handing the toasts to another host", () => {
<Toaster overlay /> <Toaster overlay />
<button <button
onClick={() => { onClick={() => {
notify({ level: "success", message: "upload queued" }); notify({ level: "success", message: raw("upload queued") });
setOpen(false); setOpen(false);
}} }}
> >
@@ -147,7 +147,7 @@ describe("Toaster — handing the toasts to another host", () => {
); );
} }
render(<Stage />); render(<Stage />);
act(() => void notify({ level: "error", message: "old news" })); act(() => void notify({ level: "error", message: raw("old news") }));
fireEvent.click(screen.getByText("upload")); fireEvent.click(screen.getByText("upload"));
const card = (text: string) => screen.getByText(text).closest(".toast-card")!; const card = (text: string) => screen.getByText(text).closest(".toast-card")!;
@@ -168,7 +168,7 @@ describe("Toaster — handing the toasts to another host", () => {
<Toaster overlay order={2} /> <Toaster overlay order={2} />
</div>, </div>,
); );
act(() => void notify({ level: "error", message: "boom" })); act(() => void notify({ level: "error", message: raw("boom") }));
expect(screen.getByTestId("dialog").textContent).toContain("boom"); expect(screen.getByTestId("dialog").textContent).toContain("boom");
// The player swaps its host (leaving true fullscreen) while the dialog stays open. // The player swaps its host (leaving true fullscreen) while the dialog stays open.
player.rerender( player.rerender(
@@ -188,7 +188,7 @@ describe("Toaster — handing the toasts to another host", () => {
const t0 = Date.now(); const t0 = Date.now();
const now = vi.spyOn(Date, "now").mockReturnValue(t0); const now = vi.spyOn(Date, "now").mockReturnValue(t0);
render(<Toaster />); render(<Toaster />);
act(() => void notify({ level: "info", message: "saved" })); act(() => void notify({ level: "info", message: raw("saved") }));
now.mockReturnValue(t0 + 4000); now.mockReturnValue(t0 + 4000);
render(<Toaster overlay />); render(<Toaster overlay />);
const bar = screen.getByText("saved").closest(".toast-card")!.lastElementChild as HTMLElement; const bar = screen.getByText("saved").closest(".toast-card")!.lastElementChild as HTMLElement;
@@ -199,7 +199,7 @@ describe("Toaster — handing the toasts to another host", () => {
// At the app's own spot (bottom-4) the stack sat on the Plex transport row and PlayerModal's // At the app's own spot (bottom-4) the stack sat on the Plex transport row and PlayerModal's
// docked bar, above them at z-overlay, and took their clicks for as long as a toast was up. // docked bar, above them at z-overlay, and took their clicks for as long as a toast was up.
render(<Toaster overlay />); render(<Toaster overlay />);
act(() => void notify({ level: "error", message: "boom" })); act(() => void notify({ level: "error", message: raw("boom") }));
const host = hosts()[0]!.closest("[data-toast-region]")!; const host = hosts()[0]!.closest("[data-toast-region]")!;
expect(host.className).toContain("bottom-24"); expect(host.className).toContain("bottom-24");
expect(host.className).toContain("pointer-events-none"); expect(host.className).toContain("pointer-events-none");
@@ -218,7 +218,7 @@ describe("Toaster — a toast's actions", () => {
() => () =>
void notify({ void notify({
level: "success", level: "success",
message: "Downloaded", message: raw("Downloaded"),
action: { label: "Copy link", onClick: copy }, action: { label: "Copy link", onClick: copy },
secondaryAction: { label: "Open", onClick: open }, secondaryAction: { label: "Open", onClick: open },
}), }),
@@ -0,0 +1,73 @@
// @vitest-environment jsdom
//
// A notice is said in the reader's language when it is shown, not in the one it was raised in. The
// text used to be translated by the caller and kept so: after a language switch every toast and every
// row of the notification center stayed in the old language, and so did the history after a reload.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import i18n from "../../i18n";
import Toaster from "./Toaster";
import { clearAll, msg, notify, raw, sayNotif } from "../../lib/notifications";
beforeEach(async () => {
await i18n.changeLanguage("en");
});
afterEach(async () => {
cleanup();
act(() => clearAll());
await i18n.changeLanguage("en");
});
describe("a notice speaks the reader's language of the moment", () => {
it("a toast raised in English reads Hungarian after the switch", async () => {
render(<Toaster />);
act(() => void notify({ level: "success", message: msg("common.copy.done") }));
const en = i18n.getFixedT("en")("common.copy.done");
const hu = i18n.getFixedT("hu")("common.copy.done");
expect(en).not.toBe(hu);
expect(screen.getByText(en)).toBeTruthy();
await act(async () => void (await i18n.changeLanguage("hu")));
expect(screen.getByText(hu)).toBeTruthy();
expect(screen.queryByText(en)).toBeNull();
});
it("a value inside the text is said again too, and a number is grouped the reader's way", async () => {
const text = msg("settings.takeout.read", {
name: raw("watch-history.json"),
count: 12345,
n: { number: 12345 },
});
const en = sayNotif(text);
await i18n.changeLanguage("hu");
const hu = sayNotif(text);
expect(en).toContain("12,345");
expect(hu).not.toContain("12,345");
expect(hu).toContain("watch-history.json");
expect(hu).not.toBe(en);
});
});
describe("the history kept from before", () => {
beforeEach(() => {
localStorage.clear();
vi.resetModules();
});
it("shows a notice stored as text as it was stored", async () => {
localStorage.setItem("siftlode.owner", "5");
localStorage.setItem(
"siftlode.notifications",
JSON.stringify([
{ id: 1, level: "info", title: "Old title", message: "Old text", ts: 1, read: true },
]),
);
const notifications = await import("../../lib/notifications");
const { claimLocalData } = await import("../../lib/storage");
claimLocalData(5);
const n = notifications.getNotifications()[0]!;
expect(n.message).toEqual(raw("Old text"));
expect(n.title).toEqual(raw("Old title"));
expect(notifications.sayNotif(n.message)).toBe("Old text");
});
});
@@ -23,7 +23,7 @@ vi.mock("react-i18next", async (importOriginal) => {
import Toaster from "./Toaster"; import Toaster from "./Toaster";
import { useOverlayOrder } from "./overlayOrder"; import { useOverlayOrder } from "./overlayOrder";
import { clearAll, notify } from "../../lib/notifications"; import { clearAll, notify, raw } from "../../lib/notifications";
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
@@ -37,7 +37,7 @@ function Harness() {
<> <>
<button <button
onClick={() => { onClick={() => {
notify({ level: "success", message: "Uploaded" }); notify({ level: "success", message: raw("Uploaded") });
setOpen(false); // …in the SAME handler: one batch, one commit setOpen(false); // …in the SAME handler: one batch, one commit
}} }}
> >
+5 -3
View File
@@ -1,7 +1,7 @@
import { useEffect, useId, useLayoutEffect, useState, useSyncExternalStore } from "react"; import { useEffect, useId, useLayoutEffect, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { dismiss, getActiveToasts, subscribe } from "../../lib/notifications"; import { dismiss, getActiveToasts, sayNotif, subscribe } from "../../lib/notifications";
import { createStore } from "../../lib/store"; import { createStore } from "../../lib/store";
import { useToastDock } from "../../lib/toastDock"; import { useToastDock } from "../../lib/toastDock";
import { useOverlayOrder } from "./overlayOrder"; import { useOverlayOrder } from "./overlayOrder";
@@ -162,9 +162,11 @@ export default function Toaster({
> >
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} /> <Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{toast.title && <div className="text-sm font-semibold">{toast.title}</div>} {toast.title && (
<div className="text-sm font-semibold">{sayNotif(toast.title, t)}</div>
)}
<div className="text-sm wrap-break-word"> <div className="text-sm wrap-break-word">
{toast.message} {sayNotif(toast.message, t)}
{toast.repeat > 1 && ( {toast.repeat > 1 && (
<span className="ml-1.5 align-middle text-2xs font-semibold text-muted tabular-nums"> <span className="ml-1.5 align-middle text-2xs font-semibold text-muted tabular-nums">
×{toast.repeat} ×{toast.repeat}
+5 -5
View File
@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
import { Copy, Link2 } from "lucide-react"; import { Copy, Link2 } from "lucide-react";
import { copyText } from "../../lib/clipboard"; import { copyText } from "../../lib/clipboard";
import { notify } from "../../lib/notifications"; import { msg, type NotifText, notify } from "../../lib/notifications";
import { type ActionMenuItem } from "./ActionMenu"; import { type ActionMenuItem } from "./ActionMenu";
// "Copy title" and "Copy link", the two rows every object's menu ends its middle group with // "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() { export function useCopyItems() {
const { t } = useTranslation(); const { t } = useTranslation();
const copy = (text: string, done: string) => { const copy = (text: string, done: NotifText) => {
void copyText(text).then((ok) => void copyText(text).then((ok) =>
notify( notify(
ok ok
? { level: "success", message: done } ? { 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"), label: t(named === "title" ? "common.copy.title" : "common.copy.name"),
icon: <Copy className="w-4 h-4" />, icon: <Copy className="w-4 h-4" />,
inGesture: true, 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"), label: linkLabel ?? t("common.copy.link"),
icon: <Link2 className="w-4 h-4" />, icon: <Link2 className="w-4 h-4" />,
inGesture: true, inGesture: true,
onSelect: () => copy(link, t("common.copy.linkDone")), onSelect: () => copy(link, msg("common.copy.linkDone")),
}, },
] ]
: []), : []),
@@ -11,8 +11,12 @@ vi.mock("react-i18next", async (importOriginal) => ({
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
})); }));
vi.mock("../../lib/clipboard", () => ({ copyText: h.copyText })); vi.mock("../../lib/clipboard", () => ({ copyText: h.copyText }));
vi.mock("../../lib/notifications", () => ({ notify: h.notify })); vi.mock("../../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../../lib/notifications")>()),
notify: h.notify,
}));
import { msg } from "../../lib/notifications";
import { useCopyFeedback } from "./useCopyFeedback"; import { useCopyFeedback } from "./useCopyFeedback";
afterEach(() => { afterEach(() => {
@@ -27,7 +31,7 @@ describe("useCopyFeedback", () => {
await act(() => result.current.copy("sfl_x")); await act(() => result.current.copy("sfl_x"));
expect(h.copyText).toHaveBeenCalledWith("sfl_x"); expect(h.copyText).toHaveBeenCalledWith("sfl_x");
expect(result.current.copied).toBe(true); 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)); act(() => void vi.advanceTimersByTime(2000));
expect(result.current.copied).toBe(false); expect(result.current.copied).toBe(false);
}); });
@@ -49,6 +53,6 @@ describe("useCopyFeedback", () => {
const { result } = renderHook(() => useCopyFeedback()); const { result } = renderHook(() => useCopyFeedback());
await act(() => result.current.copy("sfl_x")); await act(() => result.current.copy("sfl_x"));
expect(result.current.copied).toBe(false); 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") });
}); });
}); });
@@ -1,8 +1,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { copyText } from "../../lib/clipboard"; 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. // How long a Copy button reads "Copied" after it worked.
const COPIED_MS = 2000; 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). */ * A feature file, not `ui/`: it speaks the app's notifications (as `copyItems` does). */
export function useCopyFeedback(): { copied: boolean; copy: (value: string) => Promise<void> } { export function useCopyFeedback(): { copied: boolean; copy: (value: string) => Promise<void> } {
const { t } = useTranslation();
const [copied, setCopied] = useState(false); 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. // One reset at a time: a copy again within the moment starts it over, not cut short by the last.
const timer = useRef<number | undefined>(undefined); const timer = useRef<number | undefined>(undefined);
@@ -23,12 +21,12 @@ export function useCopyFeedback(): { copied: boolean; copy: (value: string) => P
const copy = async (value: string) => { const copy = async (value: string) => {
if (await copyText(value)) { if (await copyText(value)) {
setCopied(true); setCopied(true);
notify({ level: "success", message: t("common.copy.done") }); notify({ level: "success", message: msg("common.copy.done") });
window.clearTimeout(timer.current); window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setCopied(false), COPIED_MS); timer.current = window.setTimeout(() => setCopied(false), COPIED_MS);
return; return;
} }
notify({ level: "warning", message: t("common.copy.failed") }); notify({ level: "warning", message: msg("common.copy.failed") });
}; };
return { copied, copy }; return { copied, copy };
} }
+5 -5
View File
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { Plus, Trash2 } from "lucide-react"; import { Plus, Trash2 } from "lucide-react";
import { api, type Tag } from "../../lib/api"; import { api, type Tag } from "../../lib/api";
import { notify } from "../../lib/notifications"; import { msg, type NotifText, notify } from "../../lib/notifications";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
import { useScrollFade } from "../../lib/useScrollFade"; import { useScrollFade } from "../../lib/useScrollFade";
import { Button } from "../../components/ui/Button"; 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 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. // (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); notifyActionError(e, fallback);
invalidate(); invalidate();
}; };
const rename = useMutation({ const rename = useMutation({
mutationFn: (v: { id: number; name: string }) => api.updateTag(v.id, { name: v.name }), mutationFn: (v: { id: number; name: string }) => api.updateTag(v.id, { name: v.name }),
onSuccess: invalidate, onSuccess: invalidate,
onError: (e) => onFailed(e, t("tagManager.renameFailed")), onError: (e) => onFailed(e, msg("tagManager.renameFailed")),
}); });
const del = useMutation({ const del = useMutation({
mutationFn: (v: { id: number; name: string }) => api.deleteTag(v.id), mutationFn: (v: { id: number; name: string }) => api.deleteTag(v.id),
onSuccess: (_d, v) => { onSuccess: (_d, v) => {
invalidate(); invalidate();
notify({ level: "success", message: t("tagManager.deleted", { name: v.name }) }); notify({ level: "success", message: msg("tagManager.deleted", { name: 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. // Picking a channel from a tag's popover: close the dialog, then focus it in the manager.
+7 -7
View File
@@ -6,7 +6,7 @@ import { Loader2, Pin, PinOff } from "lucide-react";
import { plexLang } from "../../i18n"; import { plexLang } from "../../i18n";
import { api, type DiscoverCard, type PinKind } from "../../lib/api"; import { api, type DiscoverCard, type PinKind } from "../../lib/api";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { type ActionMenuItem } from "../../components/ui/ActionMenu"; import { type ActionMenuItem } from "../../components/ui/ActionMenu";
@@ -139,13 +139,13 @@ export function useComfortPin(open: boolean): ComfortPin {
await api.addComfortPin(target.kind, target.ref); await api.addComfortPin(target.kind, target.ref);
// The shelf lives on another page, so without this the click has no visible consequence at // 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. // 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) { } catch (e) {
notifyActionError(e, t("home.pin.addFailed")); notifyActionError(e, msg("home.pin.addFailed"));
} }
invalidate(); invalidate();
}, },
[invalidate, t], [invalidate],
); );
const remove = useCallback( const remove = useCallback(
@@ -153,14 +153,14 @@ export function useComfortPin(open: boolean): ComfortPin {
void (async () => { void (async () => {
try { try {
await api.removeComfortPin(pinId); await api.removeComfortPin(pinId);
notify({ level: "success", message: t("home.pin.removed", { title }) }); notify({ level: "success", message: msg("home.pin.removed", { title }) });
} catch (e) { } catch (e) {
notifyActionError(e, t("home.pin.removeFailed")); notifyActionError(e, msg("home.pin.removeFailed"));
} }
invalidate(); invalidate();
})(); })();
}, },
[invalidate, t], [invalidate],
); );
const toggle = useCallback( const toggle = useCallback(
@@ -18,7 +18,7 @@ import {
} from "../../lib/debugRecorder"; } from "../../lib/debugRecorder";
import { formatDateTimeSec, formatDuration } from "../../lib/format"; import { formatDateTimeSec, formatDuration } from "../../lib/format";
import { useLayer } from "../../lib/layerStack"; import { useLayer } from "../../lib/layerStack";
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { dockUnderToasts } from "../../lib/toastDock"; import { dockUnderToasts } from "../../lib/toastDock";
import { useMe } from "../../lib/useMe"; import { useMe } from "../../lib/useMe";
@@ -73,7 +73,7 @@ export default function DebugRecorder() {
if (!id || !until) return; if (!id || !until) return;
const wait = Math.min(Date.parse(until) - Date.now(), 2 ** 31 - 1); const wait = Math.min(Date.parse(until) - Date.now(), 2 ** 31 - 1);
const timer = window.setTimeout(() => { 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); openComposer(id);
void qc.invalidateQueries({ queryKey: qk.me() }); void qc.invalidateQueries({ queryKey: qk.me() });
}, wait); }, wait);
@@ -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 { api, type DebugScreenshot, type DebugSession, type ServerLogRow } from "../../lib/api";
import { closeComposer } from "../../lib/debugRecorder"; import { closeComposer } from "../../lib/debugRecorder";
import { formatBytes, formatDateTimeSec, formatUnit } from "../../lib/format"; import { formatBytes, formatDateTimeSec, formatUnit } from "../../lib/format";
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { useMe } from "../../lib/useMe"; import { useMe } from "../../lib/useMe";
import { import {
draftSteps, draftSteps,
@@ -217,7 +217,7 @@ function ComposerBody({
meta: { failTitle: "recorder.composer.sendFailed" }, meta: { failTitle: "recorder.composer.sendFailed" },
onSuccess: (r) => { onSuccess: (r) => {
qc.setQueryData(qk.debugSession(session.id), r.session); 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. // A failure half-way still created the issue: show what the server now has.
onSettled: async () => { onSettled: async () => {
@@ -1,6 +1,5 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query"; import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { api, type DebugSession, type DebugSessionLevel } from "../../lib/api"; import { api, type DebugSession, type DebugSessionLevel } from "../../lib/api";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
@@ -14,7 +13,7 @@ import {
} from "../../lib/debugRecorder"; } from "../../lib/debugRecorder";
import { flushNow, liveSession } from "../../lib/diagnostics"; import { flushNow, liveSession } from "../../lib/diagnostics";
import { log } from "../../lib/log"; import { log } from "../../lib/log";
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { updateMeCache, useMe } from "../../lib/useMe"; 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 * 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. */ * answer, so a reload, another tab or the cap all show the same thing. */
export function useDebugRecording() { export function useDebugRecording() {
const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const me = useMe(); const me = useMe();
const session = liveSession(me?.debug_session); const session = liveSession(me?.debug_session);
@@ -54,7 +52,7 @@ export function useDebugRecording() {
meta: { failTitle: "recorder.pill.stopFailed" }, meta: { failTitle: "recorder.pill.stopFailed" },
onSuccess: (r) => { onSuccess: (r) => {
setSession(null); setSession(null);
notify({ level: "success", message: t("recorder.pill.stopped") }); notify({ level: "success", message: msg("recorder.pill.stopped") });
openComposer(r.session.id); 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. // 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 }); 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 n = nextStep(sessionId);
const text = label.trim(); const text = label.trim();
log.info("debug.step", text || `step ${n}`, { n, ...(text ? { label: text } : {}) }); 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 /** 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))); await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
image = await captureTab(); image = await captureTab();
} catch (err) { } catch (err) {
notifyActionError(err, t("recorder.pill.shotFailed")); notifyActionError(err, msg("recorder.pill.shotFailed"));
} }
hide(false); hide(false);
if (image) shoot.mutate({ id: session.id, image }); if (image) shoot.mutate({ id: session.id, image });
@@ -9,7 +9,7 @@ import { invalidateDownloads } from "../../lib/downloads";
// Lazy: the full profile editor only opens from the dialog's "manage presets" affordance. // Lazy: the full profile editor only opens from the dialog's "manage presets" affordance.
const ProfileEditor = lazy(() => import("../../modules/settings/ProfileEditor")); const ProfileEditor = lazy(() => import("../../modules/settings/ProfileEditor"));
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
import { showInTray } from "../../lib/downloadQueue"; import { showInTray } from "../../lib/downloadQueue";
import { profileName } from "../../lib/vocab"; import { profileName } from "../../lib/vocab";
@@ -63,7 +63,7 @@ export default function DownloadDialog({
setLastProfile(chosen); setLastProfile(chosen);
notify({ notify({
level: "success", level: "success",
message: t("downloads.dialog.added"), message: msg("downloads.dialog.added"),
action: { label: t("downloads.dialog.view"), onClick: () => showInTray(job.id) }, action: { label: t("downloads.dialog.view"), onClick: () => showInTray(job.id) },
}); });
onClose(); onClose();
@@ -71,7 +71,7 @@ export default function DownloadDialog({
// A quota or validation refusal (422/409) is already in the global error dialog, which the // 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 // reporter skips; a 403/404/429 or a lost connection is not, and used to leave the dialog
// open with nothing said. // open with nothing said.
notifyActionError(e, t("errors.writeFailed.enqueueDownload")); notifyActionError(e, msg("errors.writeFailed.enqueueDownload"));
setBusy(false); setBusy(false);
} }
}; };
@@ -70,6 +70,7 @@ vi.mock("../../lib/notifications", async (importOriginal) => ({
})); }));
import DownloadTray from "./DownloadTray"; import DownloadTray from "./DownloadTray";
import { msg, type NotifText } from "../../lib/notifications";
import { noteQueue, resetQueueWatch, showInTray } from "../../lib/downloadQueue"; import { noteQueue, resetQueueWatch, showInTray } from "../../lib/downloadQueue";
import { pushLayer, removeLayer } from "../../lib/layerStack"; import { pushLayer, removeLayer } from "../../lib/layerStack";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
@@ -199,13 +200,13 @@ describe("DownloadTray", () => {
poll([job(1, "done")], view); poll([job(1, "done")], view);
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1)); await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
const notice = h.notify.mock.calls[0]![0] as { const notice = h.notify.mock.calls[0]![0] as {
title: string; title: NotifText;
message: string; message: NotifText;
action: { label: string; onClick: () => void }; action: { label: string; onClick: () => void };
secondaryAction: { label: string; onClick: () => void }; secondaryAction: { label: string; onClick: () => void };
}; };
expect(notice.title).toBe("tray.done.title:Clip 1"); expect(notice.title).toEqual(msg("tray.done.title", { title: "Clip 1" }));
expect(notice.message).toBe("tray.done.phone"); expect(notice.message).toEqual(msg("tray.done.phone"));
expect(notice.action.label).toBe("tray.done.copyLink"); expect(notice.action.label).toBe("tray.done.copyLink");
notice.action.onClick(); notice.action.onClick();
expect(h.share).toHaveBeenCalled(); expect(h.share).toHaveBeenCalled();
@@ -225,9 +226,9 @@ describe("DownloadTray", () => {
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1)); await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
poll([job(1, "done"), job(2, "done")], view); poll([job(1, "done"), job(2, "done")], view);
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(2)); await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(2));
expect(h.notify.mock.calls.map((c) => (c[0] as { title: string }).title)).toEqual([ expect(h.notify.mock.calls.map((c) => (c[0] as { title: NotifText }).title)).toEqual([
"tray.done.title:Clip 1", msg("tray.done.title", { title: "Clip 1" }),
"tray.done.title:Clip 2", msg("tray.done.title", { title: "Clip 2" }),
]); ]);
}); });
@@ -236,7 +237,9 @@ describe("DownloadTray", () => {
const view = mount(); const view = mount();
poll([job(1, "done", { asset: { media_kind: "file" } as never })], view); poll([job(1, "done", { asset: { media_kind: "file" } as never })], view);
await waitFor(() => 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 +260,7 @@ describe("DownloadTray", () => {
poll([job(1, "done", { source_key: "upload" })], view); poll([job(1, "done", { source_key: "upload" })], view);
await waitFor(() => await waitFor(() =>
expect(h.notify).toHaveBeenCalledWith( expect(h.notify).toHaveBeenCalledWith(
expect.objectContaining({ title: "tray.done.uploadedTitle:Clip 1" }), expect.objectContaining({ title: msg("tray.done.uploadedTitle", { title: "Clip 1" }) }),
), ),
); );
}); });
@@ -30,7 +30,7 @@ import {
} from "../../lib/downloadQueue"; } from "../../lib/downloadQueue";
import { anyBlockingLayer } from "../../lib/layerStack"; import { anyBlockingLayer } from "../../lib/layerStack";
import { focusNavItem, navItemAttrs } from "../../lib/listNav"; import { focusNavItem, navItemAttrs } from "../../lib/listNav";
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { LS } from "../../lib/storage"; import { LS } from "../../lib/storage";
import { useLiveQuery } from "../../lib/useLiveQuery"; import { useLiveQuery } from "../../lib/useLiveQuery";
@@ -191,10 +191,10 @@ export default function DownloadTray() {
notify({ notify({
level: "success", level: "success",
// An upload the worker had to convert first is done here too — it was never downloaded. // 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: msg(job.source_key === "upload" ? "tray.done.uploadedTitle" : "tray.done.title", {
title, 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: { action: {
label: t("tray.done.copyLink"), label: t("tray.done.copyLink"),
onClick: () => void quickShareLink(qc, { kind: "file", id: job.id }, t), onClick: () => void quickShareLink(qc, { kind: "file", id: job.id }, t),
@@ -9,6 +9,7 @@ import { useCopyItems } from "../../components/ui/copyItems";
import { jobTitle } from "./downloadTitle"; import { jobTitle } from "./downloadTitle";
import { type ActionMenuItem } from "../../components/ui/ActionMenu"; import { type ActionMenuItem } from "../../components/ui/ActionMenu";
import { menuGroups } from "../../components/ui/menuGroups"; 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 // 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 // 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.resumeDownload(id)
: api.cancelDownload(id), : api.cancelDownload(id),
onSuccess: () => invalidateDownloads(qc), 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) => { const confirmThen = async (title: string, body: string, action: string, fn: () => void) => {
@@ -8,6 +8,7 @@ import { notifyActionError } from "../../lib/actionErrors";
import { ownKey } from "../../lib/storage"; import { ownKey } from "../../lib/storage";
import { useConfirm } from "../../components/ui/confirmContext"; import { useConfirm } from "../../components/ui/confirmContext";
import type { SavedViewsContext, ViewSource } from "./savedViewsContext"; 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 // 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 // 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 isDirty = displayView != null && activeView == null;
const refresh = () => void qc.invalidateQueries({ queryKey: qk.savedViews(source) }); const refresh = () => void qc.invalidateQueries({ queryKey: qk.savedViews(source) });
async function runViewMutation(fn: () => Promise<unknown>, failMsg: string): Promise<boolean> { async function runViewMutation(fn: () => Promise<unknown>, failMsg: NotifText): Promise<boolean> {
let ok = true; let ok = true;
try { try {
await fn(); await fn();
@@ -146,28 +147,28 @@ export function useSavedViewsStore(cfg: SavedViewsConfig): SavedViewsContext {
if (!clean) return false; if (!clean) return false;
return runViewMutation( return runViewMutation(
() => api.createView(clean, cfg.stripForSave(cfg.filters), source), () => api.createView(clean, cfg.stripForSave(cfg.filters), source),
t("views.actionFailed"), msg("views.actionFailed"),
); );
}, },
rename: async (id, name) => { rename: async (id, name) => {
const next = name.trim(); const next = name.trim();
if (!next) return false; 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) => updateFilters: (id, filters) =>
runViewMutation(() => api.updateView(id, { filters }), t("views.actionFailed")), runViewMutation(() => api.updateView(id, { filters }), msg("views.actionFailed")),
saveCurrent: async (id) => { saveCurrent: async (id) => {
const filters = cfg.stripForSave(cfg.filters); const filters = cfg.stripForSave(cfg.filters);
const ok = await runViewMutation( const ok = await runViewMutation(
() => api.updateView(id, { filters }), () => api.updateView(id, { filters }),
t("views.actionFailed"), msg("views.actionFailed"),
); );
return ok ? filters : null; return ok ? filters : null;
}, },
toggleDefault: (v) => toggleDefault: (v) =>
runViewMutation( runViewMutation(
() => api.updateView(v.id, { is_default: !v.is_default }), () => api.updateView(v.id, { is_default: !v.is_default }),
t("views.actionFailed"), msg("views.actionFailed"),
).then(() => undefined), ).then(() => undefined),
remove: async (v) => { remove: async (v) => {
const ok = await confirm({ const ok = await confirm({
@@ -177,14 +178,14 @@ export function useSavedViewsStore(cfg: SavedViewsConfig): SavedViewsContext {
}); });
if (!ok) return false; if (!ok) return false;
if (stickyId === v.id) setStickyId(null); 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) => { reorder: async (nextIds) => {
qc.setQueryData<SavedView[]>( qc.setQueryData<SavedView[]>(
qk.savedViews(source), qk.savedViews(source),
nextIds.map((id) => views.find((v) => v.id === id)!).filter(Boolean), 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"));
}, },
}; };
} }
@@ -30,10 +30,11 @@ vi.mock("../../lib/downloadQueue", () => ({ showInTray: (id: number) => showInTr
const notifyActionError = vi.fn(); const notifyActionError = vi.fn();
vi.mock("../../lib/actionErrors", async (importOriginal) => { vi.mock("../../lib/actionErrors", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/actionErrors")>(); const actual = await importOriginal<typeof import("../../lib/actionErrors")>();
return { ...actual, notifyActionError: (e: unknown, t: string) => notifyActionError(e, t) }; return { ...actual, notifyActionError: (e: unknown, t: NotifText) => notifyActionError(e, t) };
}); });
import ClipPanel, { type ClipCrop, type ClipSource } from "./ClipPanel"; import ClipPanel, { type ClipCrop, type ClipSource } from "./ClipPanel";
import { msg, type NotifText } from "../../lib/notifications";
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
@@ -188,7 +189,7 @@ describe("ClipPanel", () => {
display_name: "The bit", display_name: "The bit",
}); });
expect(notify).toHaveBeenCalledWith( 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. // Its View opens the tray on the new clip.
( (
@@ -214,7 +215,7 @@ describe("ClipPanel", () => {
fireEvent.click(screen.getByRole("button", { name: "clip.cut" })); fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
await waitFor(() => expect(notifyActionError).toHaveBeenCalledTimes(1)); await waitFor(() => expect(notifyActionError).toHaveBeenCalledTimes(1));
expect(notifyActionError.mock.calls[0]![0]).toBe(err); 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(onClose).not.toHaveBeenCalled();
expect((screen.getByRole("button", { name: "clip.cut" }) as HTMLButtonElement).disabled).toBe( expect((screen.getByRole("button", { name: "clip.cut" }) as HTMLButtonElement).disabled).toBe(
false, false,
+3 -3
View File
@@ -5,7 +5,7 @@ import { Crop, Crosshair, Maximize, Minus, Plus, Repeat, Scissors, X } from "luc
import { api, type ClipRequest } from "../../lib/api"; import { api, type ClipRequest } from "../../lib/api";
import { DISPLAY_NAME_MAX } from "../../lib/api/downloads"; import { DISPLAY_NAME_MAX } from "../../lib/api/downloads";
import { invalidateDownloads } from "../../lib/downloads"; import { invalidateDownloads } from "../../lib/downloads";
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
import { showInTray } from "../../lib/downloadQueue"; import { showInTray } from "../../lib/downloadQueue";
import { log } from "../../lib/log"; 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) }); log.info("clip.queued", "clip queued", { source: src.source, seconds: Math.round(length) });
notify({ notify({
level: "success", level: "success",
message: t("clip.queued"), message: msg("clip.queued"),
action: { label: t("clip.view"), onClick: () => showInTray(job.id) }, action: { label: t("clip.view"), onClick: () => showInTray(job.id) },
}); });
onClose(); onClose();
} catch (err) { } catch (err) {
// A refusal the server explains (too long, quota) is already in the error dialog; a lost // 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. // 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); setBusy(false);
} }
}; };
@@ -1,5 +1,4 @@
import { lazy, Suspense, useState } from "react"; import { lazy, Suspense, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { type Video, type VideoStatus } from "../../lib/api"; import { type Video, type VideoStatus } from "../../lib/api";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
@@ -7,6 +6,7 @@ import { notifyActionError } from "../../lib/actionErrors";
import { useStableCallback } from "../../lib/useLatest"; import { useStableCallback } from "../../lib/useLatest";
import { writeVideoState } from "../video/videoWrites"; import { writeVideoState } from "../video/videoWrites";
import { useOpenLinkedPlayer } from "./useOpenLinkedPlayer"; import { useOpenLinkedPlayer } from "./useOpenLinkedPlayer";
import { msg } from "../../lib/notifications";
const PlayerModal = lazy(() => import("./PlayerModal")); 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 * 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. */ * alone. Decided at boot, once — `hosted` is whether the page on screen then opens it itself. */
export function LinkedPlayer({ hosted }: { hosted: boolean }) { export function LinkedPlayer({ hosted }: { hosted: boolean }) {
const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const [own] = useState(!hosted); const [own] = useState(!hosted);
const [video, setVideo] = useState<Video | null>(null); const [video, setVideo] = useState<Video | null>(null);
@@ -27,7 +26,7 @@ export function LinkedPlayer({ hosted }: { hosted: boolean }) {
const write = writeVideoState(qc, id, status); const write = writeVideoState(qc, id, status);
write write
.then(() => qc.invalidateQueries({ queryKey: qk.feed() })) .then(() => qc.invalidateQueries({ queryKey: qk.feed() }))
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState"))); .catch((e: unknown) => notifyActionError(e, msg("errors.writeFailed.videoState")));
return write; return write;
}); });
const close = useStableCallback(() => setVideo(null)); const close = useStableCallback(() => setVideo(null));
@@ -40,7 +40,7 @@ import { closeKeyboardHelp, toggleKeyboardHelp } from "../../lib/keymap";
import { ConfirmProvider } from "../../components/ui/ConfirmProvider"; import { ConfirmProvider } from "../../components/ui/ConfirmProvider";
import { useConfirm } from "../../components/ui/confirmContext"; import { useConfirm } from "../../components/ui/confirmContext";
import { api, type Me, type Video, type VideoStatus } from "../../lib/api"; import { api, type Me, type Video, type VideoStatus } from "../../lib/api";
import { clearAll, notify } from "../../lib/notifications"; import { clearAll, notify, raw } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { writeVideoState } from "../video/videoWrites"; import { writeVideoState } from "../video/videoWrites";
@@ -314,7 +314,7 @@ describe("PlayerModal — toasts raised while it is open", () => {
onState={() => Promise.resolve()} onState={() => Promise.resolve()}
/>, />,
); );
act(() => void notify({ level: "error", message: "Couldn't remove the video." })); act(() => void notify({ level: "error", message: raw("Couldn't remove the video.") }));
expect(screen.getByTestId("player-modal").textContent).toContain("Couldn't remove the video."); expect(screen.getByTestId("player-modal").textContent).toContain("Couldn't remove the video.");
}); });
@@ -336,7 +336,7 @@ describe("PlayerModal — toasts raised while it is open", () => {
Object.defineProperty(document, "fullscreenElement", { configurable: true, get: () => stage }); Object.defineProperty(document, "fullscreenElement", { configurable: true, get: () => stage });
try { try {
act(() => void document.dispatchEvent(new Event("fullscreenchange"))); act(() => void document.dispatchEvent(new Event("fullscreenchange")));
act(() => void notify({ level: "error", message: "Connection lost" })); act(() => void notify({ level: "error", message: raw("Connection lost") }));
await waitFor(() => expect(stage.textContent).toContain("Connection lost")); await waitFor(() => expect(stage.textContent).toContain("Connection lost"));
} finally { } finally {
delete (document as { fullscreenElement?: unknown }).fullscreenElement; delete (document as { fullscreenElement?: unknown }).fullscreenElement;
@@ -26,6 +26,7 @@ import VideoCard from "../video/VideoCard";
import { VideoMenu } from "../video/VideoMenu"; import { VideoMenu } from "../video/VideoMenu";
import { announceVideoState } from "../video/stateNotice"; import { announceVideoState } from "../video/stateNotice";
import { writeVideoState, writeWatchLater } from "../video/videoWrites"; import { writeVideoState, writeWatchLater } from "../video/videoWrites";
import { msg } from "../../lib/notifications";
/** Which list YouTube fills in — on the video's topic or with what trends in Hungary — is a mood, /** Which list YouTube fills in — on the video's topic or with what trends in Hungary — is a mood,
* decided at the list, and the account keeps the last one (the owner's pick). */ * decided at the list, and the account keeps the last one (the owner's pick). */
@@ -100,7 +101,7 @@ export function RelatedVideos({
announceVideoState(id, status, v, () => onState(id, "new")); announceVideoState(id, status, v, () => onState(id, "new"));
}) })
.catch((e: unknown) => { .catch((e: unknown) => {
notifyActionError(e, t("errors.writeFailed.videoState")); notifyActionError(e, msg("errors.writeFailed.videoState"));
settle(seq); settle(seq);
}); });
}; };
@@ -118,7 +119,7 @@ export function RelatedVideos({
.catch((e: unknown) => { .catch((e: unknown) => {
notifyActionError( notifyActionError(
e, e,
t(saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove"), msg(saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove"),
); );
settle(seq); settle(seq);
}); });
@@ -26,6 +26,7 @@ import { useOpenLinkedPlayer } from "./useOpenLinkedPlayer";
import { LinkedPlayer } from "./LinkedPlayer"; import { LinkedPlayer } from "./LinkedPlayer";
import { api, type Video } from "../../lib/api"; import { api, type Video } from "../../lib/api";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
import { msg } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
const vid = (id: string) => ({ id, title: id }) as unknown as Video; const vid = (id: string) => ({ id, title: id }) as unknown as Video;
@@ -76,7 +77,7 @@ describe("useOpenLinkedPlayer", () => {
const open = vi.fn(); const open = vi.fn();
renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper }); renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper });
await waitFor(() => await waitFor(() =>
expect(notifyActionError).toHaveBeenCalledWith(gone, "player.linkedFailed"), expect(notifyActionError).toHaveBeenCalledWith(gone, msg("player.linkedFailed")),
); );
expect(open).not.toHaveBeenCalled(); expect(open).not.toHaveBeenCalled();
// Said once: the address no longer names it, so a reload or a copied link does not say it again. // Said once: the address no longer names it, so a reload or a copied link does not say it again.
@@ -1,11 +1,11 @@
import { useEffect, useEffectEvent, useRef, useState } from "react"; import { useEffect, useEffectEvent, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { api, type Video } from "../../lib/api"; import { api, type Video } from "../../lib/api";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { linkedPlayer, PLAYER_KEYS } from "../../lib/playerUrlState"; import { linkedPlayer, PLAYER_KEYS } from "../../lib/playerUrlState";
import { clearParams } from "../../lib/urlState"; import { clearParams } from "../../lib/urlState";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
import { msg } from "../../lib/notifications";
/** Opens the player an address names (a reload, a bookmark — lib/playerUrlState) on the page that /** Opens the player an address names (a reload, a bookmark — lib/playerUrlState) on the page that
* hosts it, once: read as the host mounts, opened once its list is in (`ready`). From the list when the * hosts it, once: read as the host mounts, opened once its list is in (`ready`). From the list when the
@@ -22,7 +22,6 @@ export function useOpenLinkedPlayer(
open: (video: Video, inList: boolean) => void, open: (video: Video, inList: boolean) => void,
scope: string | number | null = null, scope: string | number | null = null,
): void { ): void {
const { t } = useTranslation();
const [linked] = useState(() => linkedPlayer()?.watch ?? null); const [linked] = useState(() => linkedPlayer()?.watch ?? null);
const [linkedScope] = useState(scope); const [linkedScope] = useState(scope);
// Opened, said to be gone or given up, once. A ref for the effect (a StrictMode re-run of it must // Opened, said to be gone or given up, once. A ref for the effect (a StrictMode re-run of it must
@@ -40,7 +39,7 @@ export function useOpenLinkedPlayer(
}); });
const openIt = useEffectEvent(open); const openIt = useEffectEvent(open);
const failed = useEffectEvent((err: unknown) => { const failed = useEffectEvent((err: unknown) => {
notifyActionError(err, t("player.linkedFailed")); notifyActionError(err, msg("player.linkedFailed"));
clearParams(...PLAYER_KEYS); clearParams(...PLAYER_KEYS);
}); });
useEffect(() => { useEffect(() => {
@@ -8,6 +8,7 @@ import { PopoverSurface } from "../../components/ui/AnchoredPopover";
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks"; import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
import { MembershipMenu } from "../../components/ui/MembershipMenu"; import { MembershipMenu } from "../../components/ui/MembershipMenu";
import { SURFACE } from "../../components/ui/surfaces"; import { SURFACE } from "../../components/ui/surfaces";
import { msg } from "../../lib/notifications";
// "Add to collection…" from a movie or show card's menu: a panel beside the card that ticks the title // "Add to collection…" from a movie or show card's menu: a panel beside the card that ticks the title
// into or out of its library's manual collections, and names a new one — created with this title in // into or out of its library's manual collections, and names a new one — created with this title in
@@ -67,7 +68,7 @@ export default function PlexCollectionAddAt({
else await api.plexCollectionAddItem(c.id, ratingKey); else await api.plexCollectionAddItem(c.id, ratingKey);
qc.setQueryData<typeof list.data>(forKey, (old) => flipContains(old, c.id)); qc.setQueryData<typeof list.data>(forKey, (old) => flipContains(old, c.id));
} catch (e) { } catch (e) {
notifyActionError(e, t("plex.collection.editFailed")); notifyActionError(e, msg("plex.collection.editFailed"));
} }
refresh(); refresh();
setBusy(false); setBusy(false);
@@ -83,7 +84,7 @@ export default function PlexCollectionAddAt({
made = true; made = true;
} catch (e) { } catch (e) {
made = madeOnPlex(e); made = madeOnPlex(e);
notifyActionError(e, t("plex.collection.editFailed")); notifyActionError(e, msg("plex.collection.editFailed"));
} }
refresh(); refresh();
setBusy(false); setBusy(false);
@@ -12,6 +12,7 @@ import Modal from "../../components/ui/Modal";
import { Pressable } from "../../components/ui/Pressable"; import { Pressable } from "../../components/ui/Pressable";
import { Input } from "../../components/ui/Input"; import { Input } from "../../components/ui/Input";
import { SURFACE } from "../../components/ui/surfaces"; import { SURFACE } from "../../components/ui/surfaces";
import { msg } from "../../lib/notifications";
// What we're adding: a single leaf (movie/episode) or a whole group (a season or an entire show, as a // What we're adding: a single leaf (movie/episode) or a whole group (a season or an entire show, as a
// set of episode rating_keys). Groups get a tri-state (none / some / all) per playlist. // set of episode rating_keys). Groups get a tri-state (none / some / all) per playlist.
@@ -80,7 +81,7 @@ export default function PlexPlaylistAdd({
} catch (e) { } catch (e) {
notifyActionError( notifyActionError(
e, e,
t(allIn ? "plex.playlistAdd.removeFailed" : "plex.playlistAdd.addFailed"), msg(allIn ? "plex.playlistAdd.removeFailed" : "plex.playlistAdd.addFailed"),
); );
} }
refresh(); refresh();
@@ -104,7 +105,7 @@ export default function PlexPlaylistAdd({
} catch (e) { } catch (e) {
// The create may already have landed when the add fails — `refresh()` in `finally` is what // The create may already have landed when the add fails — `refresh()` in `finally` is what
// makes that half-done state visible instead of leaving a playlist the dialog never lists. // makes that half-done state visible instead of leaving a playlist the dialog never lists.
notifyActionError(e, t("plex.playlistAdd.createFailed")); notifyActionError(e, msg("plex.playlistAdd.createFailed"));
} }
refresh(); refresh();
setBusy(false); setBusy(false);
@@ -13,6 +13,7 @@ import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
import { IconButton } from "../../components/ui/IconButton"; import { IconButton } from "../../components/ui/IconButton";
import { MembershipMenu } from "../../components/ui/MembershipMenu"; import { MembershipMenu } from "../../components/ui/MembershipMenu";
import { SURFACE } from "../../components/ui/surfaces"; import { SURFACE } from "../../components/ui/surfaces";
import { msg } from "../../lib/notifications";
// A small anchored menu for toggling a video's membership in the user's playlists, with inline // A small anchored menu for toggling a video's membership in the user's playlists, with inline
// "new playlist" creation. Its body is the shared MembershipMenu (as the channel tag editor's). // "new playlist" creation. Its body is the shared MembershipMenu (as the channel tag editor's).
@@ -122,7 +123,7 @@ function MembershipPanel({
old?.map((p) => (p.id === pl.id ? { ...p, has_video: !p.has_video } : p)), old?.map((p) => (p.id === pl.id ? { ...p, has_video: !p.has_video } : p)),
); );
} catch (e) { } catch (e) {
notifyActionError(e, t(pl.has_video ? "playlists.removeFailed" : "playlists.addFailed")); notifyActionError(e, msg(pl.has_video ? "playlists.removeFailed" : "playlists.addFailed"));
} }
refresh(); refresh();
setBusy(false); setBusy(false);
@@ -137,7 +138,7 @@ function MembershipPanel({
await addPlaylistItem(qc, pl, videoId); await addPlaylistItem(qc, pl, videoId);
made = true; made = true;
} catch (e) { } catch (e) {
notifyActionError(e, t("playlists.addFailed")); notifyActionError(e, msg("playlists.addFailed"));
} }
refresh(); refresh();
setBusy(false); setBusy(false);
+3 -3
View File
@@ -1,5 +1,5 @@
import i18n from "../../i18n"; import i18n from "../../i18n";
import { notify, resolveVideo } from "../../lib/notifications"; import { msg, notify, resolveVideo } from "../../lib/notifications";
import type { Video, VideoStatus } from "../../lib/api"; import type { Video, VideoStatus } from "../../lib/api";
/** What a notice names the video by; any of it may be unknown to the surface that marked it. */ /** What a notice names the video by; any of it may be unknown to the surface that marked it. */
@@ -24,8 +24,8 @@ export function announceVideoState(
const hidden = status === "hidden"; const hidden = status === "hidden";
notify({ notify({
message: video?.title message: video?.title
? i18n.t(hidden ? "feed.hiddenNamed" : "feed.markedWatchedNamed", { title: video.title }) ? msg(hidden ? "feed.hiddenNamed" : "feed.markedWatchedNamed", { title: video.title })
: i18n.t(hidden ? "feed.hidden" : "feed.markedWatched"), : msg(hidden ? "feed.hidden" : "feed.markedWatched"),
action: { label: i18n.t(hidden ? "feed.undo" : "feed.unwatch"), onClick: undo }, action: { label: i18n.t(hidden ? "feed.undo" : "feed.unwatch"), onClick: undo },
meta: { meta: {
kind: hidden ? "video-hidden" : "video-watched", kind: hidden ? "video-hidden" : "video-watched",
@@ -23,7 +23,7 @@ vi.mock("../../lib/onboarding", () => ({ beginGrant: vi.fn(), endOnboarding: vi.
import OnboardingWizard from "./OnboardingWizard"; import OnboardingWizard from "./OnboardingWizard";
import { api, type Me } from "../../lib/api"; import { api, type Me } from "../../lib/api";
import { clearAll, notify } from "../../lib/notifications"; import { clearAll, notify, raw } from "../../lib/notifications";
const me = { id: 1, email: "a@b", can_read: false, can_write: false } as unknown as Me; const me = { id: 1, email: "a@b", can_read: false, can_write: false } as unknown as Me;
@@ -110,7 +110,7 @@ describe("the onboarding wizard as a dialog", () => {
document.body.appendChild(outside); document.body.appendChild(outside);
outside.focus(); outside.focus();
const { unmount } = mount(); const { unmount } = mount();
act(() => void notify({ level: "error", message: "offline" })); act(() => void notify({ level: "error", message: raw("offline") }));
const dismissToast = screen.getByTitle("notifications.dismiss"); const dismissToast = screen.getByTitle("notifications.dismiss");
dismissToast.focus(); dismissToast.focus();
expect(document.activeElement).toBe(dismissToast); expect(document.activeElement).toBe(dismissToast);
@@ -126,7 +126,7 @@ describe("the onboarding wizard as a dialog", () => {
// unreachable from the keyboard. `ui/Modal` spans the whole dialog for exactly this reason. // unreachable from the keyboard. `ui/Modal` spans the whole dialog for exactly this reason.
mount(); mount();
const panel = screen.getByRole("dialog"); const panel = screen.getByRole("dialog");
act(() => void notify({ level: "error", message: "offline" })); act(() => void notify({ level: "error", message: raw("offline") }));
try { try {
const dismissToast = screen.getByTitle("notifications.dismiss"); const dismissToast = screen.getByTitle("notifications.dismiss");
expect(panel.contains(dismissToast)).toBe(false); expect(panel.contains(dismissToast)).toBe(false);
@@ -89,7 +89,7 @@ const quotaActions = enumValues("quota.py");
// ruff wraps over several lines still matches. // ruff wraps over several lines still matches.
const adminCodes = [ const adminCodes = [
...( ...(
readFileSync(new URL("../lib/api/core.ts", import.meta.url), "utf-8").match( readFileSync(new URL("../lib/api/refusalText.ts", import.meta.url), "utf-8").match(
/ADMIN_REFUSALS = new Set\(\[([^\]]*)\]/, /ADMIN_REFUSALS = new Set\(\[([^\]]*)\]/,
)?.[1] ?? "" )?.[1] ?? ""
).matchAll(/"(\w+)"/g), ).matchAll(/"(\w+)"/g),
+46 -24
View File
@@ -4,12 +4,16 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
// Spy on the toast sink so we can assert exactly what each error maps to. // Spy on the toast sink so we can assert exactly what each error maps to.
vi.mock("./notifications", () => ({ notify: vi.fn(() => 0) })); vi.mock("./notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("./notifications")>()),
notify: vi.fn(() => 0),
}));
vi.mock("./errorDialog", () => ({ reportError: vi.fn() })); vi.mock("./errorDialog", () => ({ reportError: vi.fn() }));
import { HttpError, api } from "./api"; import { HttpError, api } from "./api";
import { alreadyReported } from "./api/core"; import { alreadyReported } from "./api/core";
import { notify } from "./notifications"; import { msg, notify, raw, sayNotif } from "./notifications";
import { localizeDetail } from "./api/refusalText";
import { reportError } from "./errorDialog"; import { reportError } from "./errorDialog";
import { failureReason, notifyActionError, notifyKnownFailure } from "./actionErrors"; import { failureReason, notifyActionError, notifyKnownFailure } from "./actionErrors";
import i18n from "../i18n"; import i18n from "../i18n";
@@ -32,10 +36,19 @@ describe("a Plex collection name that is already taken", () => {
); );
const err = await api.plexCreateCollection("1", "Marvel", "m1").catch((e: unknown) => e); const err = await api.plexCreateCollection("1", "Marvel", "m1").catch((e: unknown) => e);
notifyMock.mockClear(); notifyMock.mockClear();
notifyActionError(err, "EDIT_FAILED"); notifyActionError(err, raw("EDIT_FAILED"));
expect(notifyMock.mock.calls).toEqual([ expect(notifyMock.mock.calls).toEqual([
[{ level: "error", title: "EDIT_FAILED", message: i18n.t("plex.collection.nameTaken") }], [
{
level: "error",
title: raw("EDIT_FAILED"),
message: { detail: { code: "collection", reason: "name_taken" } },
},
],
]); ]);
expect(sayNotif(notifyMock.mock.calls[0]![0].message)).toBe(
i18n.t("plex.collection.nameTaken"),
);
expect(reportError).not.toHaveBeenCalled(); expect(reportError).not.toHaveBeenCalled();
}); });
}); });
@@ -52,26 +65,26 @@ describe("notifyActionError", () => {
); );
const err = await api.plexSetState("1", "watched").catch((e: unknown) => e); const err = await api.plexSetState("1", "watched").catch((e: unknown) => e);
notifyMock.mockClear(); notifyMock.mockClear();
notifyActionError(err, "FAILED"); notifyActionError(err, raw("FAILED"));
expect(notifyMock).not.toHaveBeenCalled(); expect(notifyMock).not.toHaveBeenCalled();
}); });
it("does toast one the caller was left to handle", () => { it("does toast one the caller was left to handle", () => {
notifyActionError(new HttpError(404, "gone"), "FAILED"); notifyActionError(new HttpError(404, "gone"), raw("FAILED"));
expect(notifyMock).toHaveBeenCalled(); expect(notifyMock).toHaveBeenCalled();
}); });
it("reports one failure once, however many callers try", () => { it("reports one failure once, however many callers try", () => {
// What keeps the app-wide mutation reporter quiet for a mutation whose own onError already spoke. // What keeps the app-wide mutation reporter quiet for a mutation whose own onError already spoke.
const err = new HttpError(404, "gone"); const err = new HttpError(404, "gone");
notifyActionError(err, "FIRST"); notifyActionError(err, raw("FIRST"));
notifyActionError(err, "SECOND"); notifyActionError(err, raw("SECOND"));
expect(notifyMock).toHaveBeenCalledTimes(1); expect(notifyMock).toHaveBeenCalledTimes(1);
expect(alreadyReported(err)).toBe(true); expect(alreadyReported(err)).toBe(true);
}); });
it("says nothing about an upload the user cancelled", () => { it("says nothing about an upload the user cancelled", () => {
notifyActionError(new HttpError(0, "aborted"), "FAILED"); notifyActionError(new HttpError(0, "aborted"), raw("FAILED"));
expect(notifyMock).not.toHaveBeenCalled(); expect(notifyMock).not.toHaveBeenCalled();
}); });
@@ -79,27 +92,32 @@ describe("notifyActionError", () => {
// `reportFailure` leaves 403/404/429 to the caller, so for exactly those statuses whatever // `reportFailure` leaves 403/404/429 to the caller, so for exactly those statuses whatever
// English a route hand-wrote went straight into the toast — "No user with that email." in an // English a route hand-wrote went straight into the toast — "No user with that email." in an
// otherwise Hungarian UI, while the caller's translated fallback sat unused. // otherwise Hungarian UI, while the caller's translated fallback sat unused.
notifyActionError(new HttpError(404, "No user with that email."), "TRANSLATED"); notifyActionError(new HttpError(404, "No user with that email."), raw("TRANSLATED"));
expect(notifyMock).toHaveBeenCalledWith({ expect(notifyMock).toHaveBeenCalledWith({
level: "error", level: "error",
title: "TRANSLATED", title: raw("TRANSLATED"),
message: i18n.t("errors.notFound.generic"), message: msg("errors.notFound.generic"),
}); });
}); });
it("still prefers a STRUCTURED reason, which the request layer has already localized", () => { it("still prefers a STRUCTURED reason, kept as the refusal and said in the reader's language", () => {
// `req` runs `{code, reason}` through localizeDetail, so `detail` is in the reader's language // The notice keeps `{code, reason}` and says it through localizeDetail when shown: in the
// AND more specific than anything a status can say — quota numbers, which row is gone. // reader's language AND more specific than anything a status can say — quota numbers, which row
// is gone.
const err = new HttpError(404, "Ez a csatorna már nincs a könyvtárban.", { const err = new HttpError(404, "Ez a csatorna már nincs a könyvtárban.", {
code: "not_found", code: "not_found",
reason: "channel", reason: "channel",
}); });
notifyActionError(err, "TRANSLATED FALLBACK"); notifyActionError(err, raw("TRANSLATED FALLBACK"));
expect(notifyMock).toHaveBeenCalledWith({ expect(notifyMock).toHaveBeenCalledWith({
level: "error", level: "error",
title: "TRANSLATED FALLBACK", title: raw("TRANSLATED FALLBACK"),
message: "Ez a csatorna már nincs a könyvtárban.", message: { detail: { code: "not_found", reason: "channel" } },
}); });
// Said at display through localizeDetail, in the language of that moment.
expect(sayNotif(notifyMock.mock.calls[0]![0].message)).toBe(
localizeDetail({ code: "not_found", reason: "channel" }),
);
}); });
}); });
@@ -114,17 +132,21 @@ describe("failureReason — why a write failed, when the server did not say", ()
[0, "errors.reason.unreachable"], // uploadReq's network failure [0, "errors.reason.unreachable"], // uploadReq's network failure
[500, "errors.generic"], [500, "errors.generic"],
])("a %s reads %s", (status, key) => { ])("a %s reads %s", (status, key) => {
expect(failureReason(new HttpError(status, "hand-written English"))).toBe(i18n.t(key)); const reason = failureReason(new HttpError(status, "hand-written English"));
expect(reason).toEqual(msg(key));
expect(sayNotif(reason)).toBe(i18n.t(key));
}); });
it("a request that never reached the server reads as unreachable", () => { it("a request that never reached the server reads as unreachable", () => {
expect(failureReason(new TypeError("Failed to fetch"))).toBe( expect(sayNotif(failureReason(new TypeError("Failed to fetch")))).toBe(
i18n.t("errors.reason.unreachable"), i18n.t("errors.reason.unreachable"),
); );
}); });
it("anything else is the app's own fault, not the server's", () => { it("anything else is the app's own fault, not the server's", () => {
expect(failureReason(new Error("key import failed"))).toBe(i18n.t("errors.reason.unexpected")); expect(sayNotif(failureReason(new Error("key import failed")))).toBe(
i18n.t("errors.reason.unexpected"),
);
}); });
it("every reason exists in both languages", () => { it("every reason exists in both languages", () => {
@@ -142,9 +164,9 @@ describe("failureReason — why a write failed, when the server did not say", ()
describe("notifyKnownFailure", () => { describe("notifyKnownFailure", () => {
it("shows the caller's specific sentence and marks the failure reported", () => { it("shows the caller's specific sentence and marks the failure reported", () => {
const err = new HttpError(409, "x", { code: "resend", reason: "email_off" }); const err = new HttpError(409, "x", { code: "resend", reason: "email_off" });
notifyKnownFailure(err, "EMAIL IS OFF"); notifyKnownFailure(err, raw("EMAIL IS OFF"));
expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: "EMAIL IS OFF" }); expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: raw("EMAIL IS OFF") });
notifyActionError(err, "GENERIC"); notifyActionError(err, raw("GENERIC"));
expect(notifyMock).toHaveBeenCalledTimes(1); expect(notifyMock).toHaveBeenCalledTimes(1);
}); });
}); });
+9 -11
View File
@@ -1,26 +1,24 @@
// From the core, not the `./api` barrel: these are pure (no request), and a test that mocks the // From the core, not the `./api` barrel: these are pure (no request), and a test that mocks the
// barrel wholesale to stub its requests must not take the error path's own plumbing with it. // barrel wholesale to stub its requests must not take the error path's own plumbing with it.
import { alreadyReported, HttpError, isAborted, markReported } from "./api/core"; import { alreadyReported, HttpError, isAborted, markReported } from "./api/core";
import { notify } from "./notifications"; import { msg, notify, type NotifText } from "./notifications";
import i18n from "../i18n";
const GATEWAY = new Set([502, 503, 504]); const GATEWAY = new Set([502, 503, 504]);
/** /**
* WHY a write failed, in the reader's language — the line under the toast's title. * WHY a write failed — the line under the toast's title, said in the reader's language when shown.
* *
* The server's own reason wins: `req` has already run the structured detail through * The server's own reason wins: the structured detail, which `localizeDetail` says, and it is the most specific thing there is to say (quota numbers, which row is
* `localizeDetail`, and it is the most specific thing there is to say (quota numbers, which row is
* gone). Every refusal the app answers is structured now (backend `app/refusal.py`), and a string * gone). Every refusal the app answers is structured now (backend `app/refusal.py`), and a string
* detail never reaches `HttpError.detail` (see there) — the hand-written English that once put * detail never reaches `HttpError.detail` (see there) — the hand-written English that once put
* "No user with that email." into a Hungarian UI has nowhere left to come from. `detailData` still * "No user with that email." into a Hungarian UI has nowhere left to come from. `detailData` still
* gates it: status-0 errors carry a local sentinel in `detail`, not a reason. Otherwise the status * gates it: status-0 errors carry a local sentinel in `detail`, not a reason. Otherwise the status
* says it. * says it.
*/ */
export function failureReason(err: unknown): string { export function failureReason(err: unknown): NotifText {
const t = i18n.t.bind(i18n); const t = msg;
if (err instanceof HttpError) { if (err instanceof HttpError) {
if (err.detailData != null && err.detail) return err.detail; if (err.detailData != null && err.detail) return { detail: err.detailData };
// 0: `uploadReq`'s network failure. 502–504: the gateway could not reach the app. Either way the // 0: `uploadReq`'s network failure. 502–504: the gateway could not reach the app. Either way the
// change did not go through, which the connection notice alone does not say. // change did not go through, which the connection notice alone does not say.
if (err.status === 0 || GATEWAY.has(err.status)) return t("errors.reason.unreachable"); if (err.status === 0 || GATEWAY.has(err.status)) return t("errors.reason.unreachable");
@@ -36,13 +34,13 @@ export function failureReason(err: unknown): string {
/** /**
* Surface a failed write as an error toast instead of the click silently doing nothing: the title * Surface a failed write as an error toast instead of the click silently doing nothing: the title
* names WHAT failed (the caller's translated "Couldn't hide the channel"), the line under it says * names WHAT failed (the caller's "Couldn't hide the channel"), the line under it says
* WHY (`failureReason`). Domain-agnostic — the one way every action failure is reported, including * WHY (`failureReason`). Domain-agnostic — the one way every action failure is reported, including
* the app-wide mutation reporter in lib/queryClient. * the app-wide mutation reporter in lib/queryClient.
*/ */
export function notifyActionError( export function notifyActionError(
err: unknown, err: unknown,
title: string, title: NotifText,
level: "error" | "warning" = "error", level: "error" | "warning" = "error",
): void { ): void {
// Already on screen (`req` opened the error dialog, the tab is being signed out, or a caller's own // Already on screen (`req` opened the error dialog, the tab is being signed out, or a caller's own
@@ -57,7 +55,7 @@ export function notifyActionError(
* instance cannot send email", "someone registered on that address") — shown as it is, and marked, * instance cannot send email", "someone registered on that address") — shown as it is, and marked,
* so the app-wide mutation reporter does not put a second, generic toast next to it. * so the app-wide mutation reporter does not put a second, generic toast next to it.
*/ */
export function notifyKnownFailure(err: unknown, message: string): void { export function notifyKnownFailure(err: unknown, message: NotifText): void {
if (alreadyReported(err)) return; if (alreadyReported(err)) return;
notify({ level: "error", message }); notify({ level: "error", message });
markReported(err); markReported(err);
+6 -1
View File
@@ -14,7 +14,12 @@ const { reportError, notify, remove, getActiveToasts } = vi.hoisted(() => ({
})); }));
vi.mock("../errorDialog", () => ({ reportError })); vi.mock("../errorDialog", () => ({ reportError }));
vi.mock("../notifications", () => ({ notify, remove, getActiveToasts })); vi.mock("../notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../notifications")>()),
notify,
remove,
getActiveToasts,
}));
vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" } })); vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" } }));
import { setDebugSession } from "../log"; import { setDebugSession } from "../log";
+4 -119
View File
@@ -2,9 +2,9 @@
// error, structured-error localization, the connectivity toast, the identity headers, // error, structured-error localization, the connectivity toast, the identity headers,
// and the keepalive beacon. Domain modules import from here; nothing here imports a domain module, // and the keepalive beacon. Domain modules import from here; nothing here imports a domain module,
// so the runtime graph is a clean barrel → domain → core with no cycles. // so the runtime graph is a clean barrel → domain → core with no cycles.
import { getActiveToasts, notify, remove } from "../notifications"; import { getActiveToasts, msg, notify, remove } from "../notifications";
import i18n from "../../i18n"; import i18n from "../../i18n";
import { formatBytes, formatDecimal } from "../format"; import { localizeDetail } from "./refusalText";
import { reportError } from "../errorDialog"; import { reportError } from "../errorDialog";
import { logRequest, QUIET_HEADER, REQUEST_ID_HEADER, ROUTE_HEADER, send } from "../http"; import { logRequest, QUIET_HEADER, REQUEST_ID_HEADER, ROUTE_HEADER, send } from "../http";
import { currentDebugSession, newRequestId } from "../log"; import { currentDebugSession, newRequestId } from "../log";
@@ -71,121 +71,6 @@ export const refusalReason = (err: unknown): string | undefined =>
? (err.detailData as { reason?: string } | undefined)?.reason ? (err.detailData as { reason?: string } | undefined)?.reason
: undefined; : undefined;
// Localize a STRUCTURED server error ({ code, reason, ... }) into the user's language. FastAPI
// usually returns a plain string `detail`, but some routes (e.g. download quota/edit) return a
// structured object with an i18n reason key + numbers so HU/DE users don't get hardcoded English
// with a dot-decimal separator. Falls back to a generic message for unknown shapes.
// The refusal codes of the admin's account and invite actions (routes/admin.py). Their reasons share
// one namespace, `errors.admin.<reason>`, which i18n/backendIds.contract checks against the backend.
const ADMIN_REFUSALS = new Set(["activate", "resend", "invite_send"]);
function localizeDetail(d: unknown): string {
const t = i18n.t.bind(i18n);
// FastAPI request-validation errors: `detail` is an ARRAY of { loc, msg, type }. The UI sends
// well-formed requests, so this only fires on genuinely malformed input. The per-field `msg` and
// `loc` field names are English, snake_case internal tokens — surface a clean, fully localized
// "check your input" message rather than leaking them into a translated sentence.
if (Array.isArray(d)) return t("errors.validation");
const o = d as
| { code?: string; reason?: string; limit?: number; limit_s?: number; min?: number }
| null
| undefined;
if (o?.code === "quota") {
if (o.reason === "max_bytes") {
const gb = formatDecimal((o.limit || 0) / 1_073_741_824, i18n.language, 1, 0);
return t("downloads.errors.quota.max_bytes", { size: gb });
}
if (o.reason === "max_jobs") return t("downloads.errors.quota.max_jobs", { limit: o.limit });
return t("downloads.errors.quota.generic");
}
if (o?.code === "edit")
return t(`downloads.errors.edit.${o.reason}`, t("downloads.errors.edit.generic"));
// A clip the server will not cut (downloads/clip): the range, the frame, the source. An unknown
// reason reads as the plain failure rather than a key; a cap comes with the refusal (`limit_s`).
if (o?.code === "clip")
return t(`clip.errors.${o.reason}`, {
defaultValue: t("clip.failed"),
minutes: o.limit_s == null ? undefined : formatDecimal(o.limit_s / 60, i18n.language, 1, 0),
});
if (o?.code === "cookies")
return t(`downloads.errors.cookies.${o.reason}`, t("downloads.errors.cookies.generic"));
// Password sign-in refusals. The card renders the account-shaped ones as a full panel
// off `detailData`; this is the one-line form every other consumer (and the card's own fallback)
// gets. A reason this build doesn't know falls through to the generic message rather than showing
// a raw key, which is what an i18n miss would otherwise put on screen.
if (o?.code === "login")
return t(`welcome.auth.blocked.${o.reason}.short`, t("welcome.auth.genericError"));
// The two email-link confirmations. Their failure is one a user meets by simply clicking an old
// link, so it is the last place that may answer in hardcoded English under a bilingual UI.
if (o?.code === "verify") return t("welcome.auth.verifyInvalid");
if (o?.code === "reset")
return o.reason === "weak_password"
? t("welcome.auth.weakPassword", { min: o.limit ?? o.min })
: t("welcome.auth.deadLink.short");
// Local-upload ingest: the per-file cap carries the limit in bytes; empty is a bare code.
if (o?.code === "upload_too_large")
return t("downloads.errors.upload.tooLarge", { size: formatBytes(o.limit, i18n.language) });
if (o?.code === "upload_empty") return t("downloads.errors.upload.empty");
// The EDGE ceiling's refusal (`bodylimit.TOO_LARGE_BODY`), shared by every route and carrying no
// number. Not a theoretical case: on a DEFAULT install it is what an over-cap upload gets,
// because the ceiling and the route's own cap are the same figure and the edge answers first —
// the route's numbered `upload_too_large` above is the answer only once an admin has LOWERED the
// knob (see bodylimit.ROUTE_MAX_BODY_BYTES). Without this branch that refusal fell through to
// the generic "couldn't carry out that action", the one message no retry can fix.
//
// A BACKSTOP, not the plan. The middleware answers without draining, so whether the browser gets
// to read this at all depends on the client having finished sending: measured readable up to
// 200 MiB over loopback, and measured as a bare `onerror` through a dev proxy. That is why the
// upload dialog checks the file against `upload_max_bytes` BEFORE sending — this branch is what
// happens when that check could not run.
//
// `errors.tooLarge` stays PAYLOAD-NEUTRAL for that reason. It is also what a JSON form gets
// (`debug_sessions` refuses an over-size draft with the same bare code), so wording it as advice
// about a file told the issue composer's user to pick a smaller one — of nothing. The
// file-specific sentence is `downloads.errors.upload.tooLarge`, one branch up, which has a number.
if (o?.code === "too_large") return t("errors.tooLarge");
// SEC-12: a download URL the server refuses to fetch. The reasons are deliberately distinct —
// "we could not look that host up" is a different thing to tell someone than "we will not fetch
// from that address" — so an unknown reason falls back to the generic refusal rather than a key.
if (o?.code === "url_blocked")
return t(`downloads.errors.urlBlocked.${o.reason}`, t("downloads.errors.urlBlocked.generic"));
// The one-step "move to another list". Both reasons mean the same thing to a reader —
// the list moved under you — but they are different enough to be worth naming separately, and an
// unknown reason falls back to the plain failure rather than printing a key.
if (o?.code === "playlist_move")
return t(`playlists.moveErrors.${o.reason}`, t("playlists.moveFailed"));
// A Plex collection name already taken in that library (the one collection-edit refusal).
if (o?.code === "collection")
return o.reason === "name_taken"
? t("plex.collection.nameTaken")
: t("plex.collection.editFailed");
// An action YouTube refused (routes/_common.youtube_refusal): what the reader can do about it —
// wait for the budget, reconnect, or retry. An unknown reason reads as the retryable one.
if (o?.code === "youtube")
return t(`errors.youtube.${o.reason}`, t("errors.youtube.unavailable"));
// Every other refusal (backend `app/refusal.py`): one family, the reason naming the sentence and
// the rest of the detail its values (`{{min}}`, `{{name}}`, …). A reason this build does not know
// reads as the generic failure rather than a key.
if (o?.code === "refused")
return t(`errors.refused.${o.reason}`, { ...o, defaultValue: t("errors.generic") });
// A playlist action the server refused (Watch later can't be deleted, YouTube said no, …).
if (o?.code === "playlist") return t(`playlists.errors.${o.reason}`, t("errors.generic"));
// An admin action on an account or an invite that the server refused: activating an address that
// is no longer approved, a confirmation link or invitation with nothing to go to or no mail to go
// by. The toast's title already names the button; this is the WHY, and it used to be the generic
// "try again", which no retry could fix.
if (ADMIN_REFUSALS.has(o?.code ?? "")) return t(`errors.admin.${o?.reason}`, t("errors.generic"));
// Any row that is gone — deleted in another tab or device, or never the caller's (a stranger's
// row 404s the same way). `reason` names WHAT is gone; one this build doesn't know says it
// generically rather than printing a key.
if (o?.code === "not_found")
return t(`errors.notFound.${o.reason}`, t("errors.notFound.generic"));
// SEC-14: a manual sync trigger on cooldown. Not an error the user caused so much as a pace
// limit, and the copy says so.
if (o?.code === "sync_cooldown") return t("errors.syncCooldown");
return t("errors.generic");
}
// A single, self-resolving "connection lost" status: while the server is unreachable we // A single, self-resolving "connection lost" status: while the server is unreachable we
// show one sticky, transient (non-persisted) notice; the moment any request reaches the // show one sticky, transient (non-persisted) notice; the moment any request reaches the
// server again we remove it. This makes good on its "clears once it's back" copy and keeps // server again we remove it. This makes good on its "clears once it's back" copy and keeps
@@ -199,8 +84,8 @@ function markConnectivityLost(): void {
if (id !== null) remove(id); if (id !== null) remove(id);
connectivityNotifId = notify({ connectivityNotifId = notify({
level: "error", level: "error",
title: i18n.t("errors.offline.title"), title: msg("errors.offline.title"),
message: i18n.t("errors.offline.body"), message: msg("errors.offline.body"),
transient: true, transient: true,
}); });
} }
+2 -1
View File
@@ -7,7 +7,8 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../errorDialog", () => ({ reportError: vi.fn() })); vi.mock("../errorDialog", () => ({ reportError: vi.fn() }));
vi.mock("../notifications", () => ({ vi.mock("../notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../notifications")>()),
notify: vi.fn(() => 1), notify: vi.fn(() => 1),
remove: vi.fn(), remove: vi.fn(),
getActiveToasts: vi.fn((): { id: number }[] => []), getActiveToasts: vi.fn((): { id: number }[] => []),
+120
View File
@@ -0,0 +1,120 @@
// A server refusal ({ code, reason, … }) in the reader's language. Its own module, not the request
// core's: a notice keeps the refusal it reports and says it again at display, in the language of the
// moment, and the notice store cannot import the request core that imports it.
import i18n from "../../i18n";
import { formatBytes, formatDecimal } from "../format";
// Localize a STRUCTURED server error ({ code, reason, ... }) into the user's language. FastAPI
// usually returns a plain string `detail`, but some routes (e.g. download quota/edit) return a
// structured object with an i18n reason key + numbers so HU/DE users don't get hardcoded English
// with a dot-decimal separator. Falls back to a generic message for unknown shapes.
// The refusal codes of the admin's account and invite actions (routes/admin.py). Their reasons share
// one namespace, `errors.admin.<reason>`, which i18n/backendIds.contract checks against the backend.
const ADMIN_REFUSALS = new Set(["activate", "resend", "invite_send"]);
export function localizeDetail(d: unknown): string {
const t = i18n.t.bind(i18n);
// FastAPI request-validation errors: `detail` is an ARRAY of { loc, msg, type }. The UI sends
// well-formed requests, so this only fires on genuinely malformed input. The per-field `msg` and
// `loc` field names are English, snake_case internal tokens — surface a clean, fully localized
// "check your input" message rather than leaking them into a translated sentence.
if (Array.isArray(d)) return t("errors.validation");
const o = d as
| { code?: string; reason?: string; limit?: number; limit_s?: number; min?: number }
| null
| undefined;
if (o?.code === "quota") {
if (o.reason === "max_bytes") {
const gb = formatDecimal((o.limit || 0) / 1_073_741_824, i18n.language, 1, 0);
return t("downloads.errors.quota.max_bytes", { size: gb });
}
if (o.reason === "max_jobs") return t("downloads.errors.quota.max_jobs", { limit: o.limit });
return t("downloads.errors.quota.generic");
}
if (o?.code === "edit")
return t(`downloads.errors.edit.${o.reason}`, t("downloads.errors.edit.generic"));
// A clip the server will not cut (downloads/clip): the range, the frame, the source. An unknown
// reason reads as the plain failure rather than a key; a cap comes with the refusal (`limit_s`).
if (o?.code === "clip")
return t(`clip.errors.${o.reason}`, {
defaultValue: t("clip.failed"),
minutes: o.limit_s == null ? undefined : formatDecimal(o.limit_s / 60, i18n.language, 1, 0),
});
if (o?.code === "cookies")
return t(`downloads.errors.cookies.${o.reason}`, t("downloads.errors.cookies.generic"));
// Password sign-in refusals. The card renders the account-shaped ones as a full panel
// off `detailData`; this is the one-line form every other consumer (and the card's own fallback)
// gets. A reason this build doesn't know falls through to the generic message rather than showing
// a raw key, which is what an i18n miss would otherwise put on screen.
if (o?.code === "login")
return t(`welcome.auth.blocked.${o.reason}.short`, t("welcome.auth.genericError"));
// The two email-link confirmations. Their failure is one a user meets by simply clicking an old
// link, so it is the last place that may answer in hardcoded English under a bilingual UI.
if (o?.code === "verify") return t("welcome.auth.verifyInvalid");
if (o?.code === "reset")
return o.reason === "weak_password"
? t("welcome.auth.weakPassword", { min: o.limit ?? o.min })
: t("welcome.auth.deadLink.short");
// Local-upload ingest: the per-file cap carries the limit in bytes; empty is a bare code.
if (o?.code === "upload_too_large")
return t("downloads.errors.upload.tooLarge", { size: formatBytes(o.limit, i18n.language) });
if (o?.code === "upload_empty") return t("downloads.errors.upload.empty");
// The EDGE ceiling's refusal (`bodylimit.TOO_LARGE_BODY`), shared by every route and carrying no
// number. Not a theoretical case: on a DEFAULT install it is what an over-cap upload gets,
// because the ceiling and the route's own cap are the same figure and the edge answers first —
// the route's numbered `upload_too_large` above is the answer only once an admin has LOWERED the
// knob (see bodylimit.ROUTE_MAX_BODY_BYTES). Without this branch that refusal fell through to
// the generic "couldn't carry out that action", the one message no retry can fix.
//
// A BACKSTOP, not the plan. The middleware answers without draining, so whether the browser gets
// to read this at all depends on the client having finished sending: measured readable up to
// 200 MiB over loopback, and measured as a bare `onerror` through a dev proxy. That is why the
// upload dialog checks the file against `upload_max_bytes` BEFORE sending — this branch is what
// happens when that check could not run.
//
// `errors.tooLarge` stays PAYLOAD-NEUTRAL for that reason. It is also what a JSON form gets
// (`debug_sessions` refuses an over-size draft with the same bare code), so wording it as advice
// about a file told the issue composer's user to pick a smaller one — of nothing. The
// file-specific sentence is `downloads.errors.upload.tooLarge`, one branch up, which has a number.
if (o?.code === "too_large") return t("errors.tooLarge");
// SEC-12: a download URL the server refuses to fetch. The reasons are deliberately distinct —
// "we could not look that host up" is a different thing to tell someone than "we will not fetch
// from that address" — so an unknown reason falls back to the generic refusal rather than a key.
if (o?.code === "url_blocked")
return t(`downloads.errors.urlBlocked.${o.reason}`, t("downloads.errors.urlBlocked.generic"));
// The one-step "move to another list". Both reasons mean the same thing to a reader —
// the list moved under you — but they are different enough to be worth naming separately, and an
// unknown reason falls back to the plain failure rather than printing a key.
if (o?.code === "playlist_move")
return t(`playlists.moveErrors.${o.reason}`, t("playlists.moveFailed"));
// A Plex collection name already taken in that library (the one collection-edit refusal).
if (o?.code === "collection")
return o.reason === "name_taken"
? t("plex.collection.nameTaken")
: t("plex.collection.editFailed");
// An action YouTube refused (routes/_common.youtube_refusal): what the reader can do about it —
// wait for the budget, reconnect, or retry. An unknown reason reads as the retryable one.
if (o?.code === "youtube")
return t(`errors.youtube.${o.reason}`, t("errors.youtube.unavailable"));
// Every other refusal (backend `app/refusal.py`): one family, the reason naming the sentence and
// the rest of the detail its values (`{{min}}`, `{{name}}`, …). A reason this build does not know
// reads as the generic failure rather than a key.
if (o?.code === "refused")
return t(`errors.refused.${o.reason}`, { ...o, defaultValue: t("errors.generic") });
// A playlist action the server refused (Watch later can't be deleted, YouTube said no, …).
if (o?.code === "playlist") return t(`playlists.errors.${o.reason}`, t("errors.generic"));
// An admin action on an account or an invite that the server refused: activating an address that
// is no longer approved, a confirmation link or invitation with nothing to go to or no mail to go
// by. The toast's title already names the button; this is the WHY, and it used to be the generic
// "try again", which no retry could fix.
if (ADMIN_REFUSALS.has(o?.code ?? "")) return t(`errors.admin.${o?.reason}`, t("errors.generic"));
// Any row that is gone — deleted in another tab or device, or never the caller's (a stranger's
// row 404s the same way). `reason` names WHAT is gone; one this build doesn't know says it
// generically rather than printing a key.
if (o?.code === "not_found")
return t(`errors.notFound.${o.reason}`, t("errors.notFound.generic"));
// SEC-14: a manual sync trigger on cooldown. Not an error the user caused so much as a pace
// limit, and the copy says so.
if (o?.code === "sync_cooldown") return t("errors.syncCooldown");
return t("errors.generic");
}
@@ -9,13 +9,15 @@ import type { ReactNode } from "react";
import { act, renderHook, waitFor } from "@testing-library/react"; import { act, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("./notifications", () => ({ notify: vi.fn(() => 0) })); vi.mock("./notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("./notifications")>()),
notify: vi.fn(() => 0),
}));
import { api, HttpError } from "./api"; import { api, HttpError } from "./api";
import { notify } from "./notifications"; import { msg, notify } from "./notifications";
import { useChannelTagActions } from "./useChannelTagActions"; import { useChannelTagActions } from "./useChannelTagActions";
import { useChannelPriority } from "./useChannelPriority"; import { useChannelPriority } from "./useChannelPriority";
import i18n from "../i18n";
const notifyMock = vi.mocked(notify); const notifyMock = vi.mocked(notify);
beforeEach(() => notifyMock.mockClear()); beforeEach(() => notifyMock.mockClear());
@@ -37,8 +39,8 @@ describe("useChannelTagActions", () => {
await waitFor(() => expect(onError).toHaveBeenCalledWith("UC1")); await waitFor(() => expect(onError).toHaveBeenCalledWith("UC1"));
expect(notifyMock).toHaveBeenCalledWith({ expect(notifyMock).toHaveBeenCalledWith({
level: "error", level: "error",
title: i18n.t("channels.tags.updateFailed"), title: msg("channels.tags.updateFailed"),
message: i18n.t("errors.reason.forbidden"), message: msg("errors.reason.forbidden"),
}); });
}); });
@@ -52,8 +54,8 @@ describe("useChannelTagActions", () => {
await waitFor(() => await waitFor(() =>
expect(notifyMock).toHaveBeenCalledWith({ expect(notifyMock).toHaveBeenCalledWith({
level: "error", level: "error",
title: i18n.t("channels.tags.createFailed"), title: msg("channels.tags.createFailed"),
message: i18n.t("errors.reason.rateLimited"), message: msg("errors.reason.rateLimited"),
}), }),
); );
}); });
@@ -76,8 +78,8 @@ describe("useChannelPriority", () => {
await waitFor(() => expect(onError).toHaveBeenCalledWith("UC1")); await waitFor(() => expect(onError).toHaveBeenCalledWith("UC1"));
expect(notifyMock).toHaveBeenCalledWith({ expect(notifyMock).toHaveBeenCalledWith({
level: "error", level: "error",
title: i18n.t("errors.writeFailed.channelPriority"), title: msg("errors.writeFailed.channelPriority"),
message: i18n.t("errors.reason.unreachable"), message: msg("errors.reason.unreachable"),
}); });
}); });
}); });
+7 -6
View File
@@ -5,11 +5,12 @@
// notification store used to keep its empty, nobody's history, and the tab's first notice replaced the // notification store used to keep its empty, nobody's history, and the tab's first notice replaced the
// account's stored history with it. // account's stored history with it.
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { raw, sayNotif, type NotifText } from "./notifications";
const entry = (id: number, message: string) => ({ const entry = (id: number, message: string) => ({
id, id,
level: "info", level: "info",
message, message: raw(message),
ts: 1, ts: 1,
read: true, read: true,
dismissed: true, dismissed: true,
@@ -20,9 +21,9 @@ const entry = (id: number, message: string) => ({
const storedMessages = () => const storedMessages = () =>
( (
JSON.parse(localStorage.getItem("siftlode.notifications") ?? "[]") as { JSON.parse(localStorage.getItem("siftlode.notifications") ?? "[]") as {
message: string; message: NotifText;
}[] }[]
).map((n) => n.message); ).map((n) => sayNotif(n.message));
// Warm the transform cache once (see verifyEntry.dom.test.tsx): the first cold import costs ~0.8 s // Warm the transform cache once (see verifyEntry.dom.test.tsx): the first cold import costs ~0.8 s
// alone and outran the 5 s case timeout under a full coverage run. Transformed code outlives // alone and outran the 5 s case timeout under a full coverage run. Transformed code outlives
@@ -52,9 +53,9 @@ describe("stores read at import, before the account is known", { timeout: CASE_B
const notifications = await import("./notifications"); const notifications = await import("./notifications");
const { claimLocalData } = await import("./storage"); const { claimLocalData } = await import("./storage");
notifications.notify({ message: "before the claim" }); // e.g. a failed boot probe notifications.notify({ message: raw("before the claim") }); // e.g. a failed boot probe
claimLocalData(5); claimLocalData(5);
notifications.notify({ message: "after the claim" }); notifications.notify({ message: raw("after the claim") });
expect(storedMessages()).toEqual(["older", "before the claim", "after the claim"]); expect(storedMessages()).toEqual(["older", "before the claim", "after the claim"]);
expect(notifications.getNotifSettings().durationMs).toBe(0); expect(notifications.getNotifSettings().durationMs).toBe(0);
@@ -80,7 +81,7 @@ describe("a browser that held another account's state", { timeout: CASE_BUDGET }
const { claimLocalData } = await import("./storage"); const { claimLocalData } = await import("./storage");
claimLocalData(5); claimLocalData(5);
notifications.notify({ message: "as five" }); notifications.notify({ message: raw("as five") });
expect(storedMessages()).toEqual(["as five"]); expect(storedMessages()).toEqual(["as five"]);
}); });
+9 -5
View File
@@ -21,17 +21,21 @@ vi.mock("./api", () => ({
api: { downloadLinks, createDownloadLink, folderLinks, createFolderLink }, api: { downloadLinks, createDownloadLink, folderLinks, createFolderLink },
})); }));
vi.mock("./clipboard", () => ({ copyText })); vi.mock("./clipboard", () => ({ copyText }));
vi.mock("./notifications", () => ({ notify })); vi.mock("./notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("./notifications")>()),
notify,
}));
import { quickShareLink } from "./downloads"; import { quickShareLink } from "./downloads";
import { qk } from "./queryKeys"; import { qk } from "./queryKeys";
import { msg, type NotifText } from "./notifications";
const t = (k: string) => k; const t = (k: string) => k;
const origin = window.location.origin; const origin = window.location.origin;
type ToastArg = { type ToastArg = {
level: string; level: string;
message: string; message: NotifText;
action?: { label: string; onClick: () => void }; action?: { label: string; onClick: () => void };
}; };
const firstToast = () => notify.mock.calls[0]![0] as ToastArg; const firstToast = () => notify.mock.calls[0]![0] as ToastArg;
@@ -63,7 +67,7 @@ describe("quickShareLink", () => {
expect(copyText).toHaveBeenCalledWith(`${origin}/watch/tok7`); expect(copyText).toHaveBeenCalledWith(`${origin}/watch/tok7`);
const arg = firstToast(); const arg = firstToast();
expect(arg.level).toBe("success"); expect(arg.level).toBe("success");
expect(arg.message).toBe("downloads.share.linkCopied"); expect(arg.message).toEqual(msg("downloads.share.linkCopied"));
expect(arg.action!.label).toBe("downloads.share.openNewTab"); expect(arg.action!.label).toBe("downloads.share.openNewTab");
arg.action!.onClick(); arg.action!.onClick();
expect(openSpy).toHaveBeenCalledWith(`${origin}/watch/tok7`, "_blank", "noopener,noreferrer"); expect(openSpy).toHaveBeenCalledWith(`${origin}/watch/tok7`, "_blank", "noopener,noreferrer");
@@ -134,7 +138,7 @@ describe("quickShareLink", () => {
expect(url).toBe(`${origin}/watch/x`); // the link is never a dead end expect(url).toBe(`${origin}/watch/x`); // the link is never a dead end
const arg = firstToast(); const arg = firstToast();
expect(arg.level).toBe("warning"); expect(arg.level).toBe("warning");
expect(arg.message).toBe("downloads.share.copyFailed"); expect(arg.message).toEqual(msg("downloads.share.copyFailed"));
expect(arg.action!.label).toBe("downloads.share.openNewTab"); expect(arg.action!.label).toBe("downloads.share.openNewTab");
}); });
@@ -148,7 +152,7 @@ describe("quickShareLink", () => {
expect(copyText).not.toHaveBeenCalled(); expect(copyText).not.toHaveBeenCalled();
// Titled with what failed, and carrying why (notifyActionError), not a bare message. // Titled with what failed, and carrying why (notifyActionError), not a bare message.
expect(notify).toHaveBeenCalledWith( expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "error", title: "downloads.share.linkFailed" }), expect.objectContaining({ level: "error", title: msg("downloads.share.linkFailed") }),
); );
}); });
}); });
+4 -4
View File
@@ -2,7 +2,7 @@ import type { QueryClient } from "@tanstack/react-query";
import { qk } from "./queryKeys"; import { qk } from "./queryKeys";
import { api, type NewShareLink } from "./api"; import { api, type NewShareLink } from "./api";
import { copyText } from "./clipboard"; import { copyText } from "./clipboard";
import { notify } from "./notifications"; import { msg, notify } from "./notifications";
import { notifyActionError } from "./actionErrors"; import { notifyActionError } from "./actionErrors";
// The download queue, the per-card download index, the library (its listings and folder counts) and // The download queue, the per-card download index, the library (its listings and folder counts) and
@@ -65,7 +65,7 @@ export async function quickShareLink(
} }
if (!path) throw new Error("the new link came back without its address"); if (!path) throw new Error("the new link came back without its address");
} catch (e) { } catch (e) {
notifyActionError(e, t("downloads.share.linkFailed")); notifyActionError(e, msg("downloads.share.linkFailed"));
return null; return null;
} }
const url = window.location.origin + path; const url = window.location.origin + path;
@@ -75,8 +75,8 @@ export async function quickShareLink(
}; };
notify( notify(
(await copyText(url)) (await copyText(url))
? { level: "success", message: t("downloads.share.linkCopied"), action } ? { level: "success", message: msg("downloads.share.linkCopied"), action }
: { level: "warning", message: t("downloads.share.copyFailed"), action }, : { level: "warning", message: msg("downloads.share.copyFailed"), action },
); );
return url; return url;
} }
@@ -10,6 +10,7 @@ import {
configureNotifications, configureNotifications,
getActiveToasts, getActiveToasts,
notify, notify,
raw,
} from "./notifications"; } from "./notifications";
beforeEach(() => vi.useFakeTimers()); beforeEach(() => vi.useFakeTimers());
@@ -25,8 +26,8 @@ const shown = (id: number) => getActiveToasts().some((n) => n.id === id);
describe("an action toast's lifetime", () => { describe("an action toast's lifetime", () => {
it("stays for the floor when the reader's toasts last 3 s", () => { it("stays for the floor when the reader's toasts last 3 s", () => {
configureNotifications({ durationMs: 3000 }); configureNotifications({ durationMs: 3000 });
const plain = notify({ message: "plain" }); const plain = notify({ message: raw("plain") });
const withUndo = notify({ message: "removed", action: undo }); const withUndo = notify({ message: raw("removed"), action: undo });
vi.advanceTimersByTime(3001); vi.advanceTimersByTime(3001);
expect(shown(plain)).toBe(false); expect(shown(plain)).toBe(false);
expect(shown(withUndo)).toBe(true); expect(shown(withUndo)).toBe(true);
@@ -36,7 +37,7 @@ describe("an action toast's lifetime", () => {
it("keeps a longer setting", () => { it("keeps a longer setting", () => {
configureNotifications({ durationMs: 12000 }); configureNotifications({ durationMs: 12000 });
const id = notify({ message: "removed", action: undo }); const id = notify({ message: raw("removed"), action: undo });
vi.advanceTimersByTime(ACTION_TTL_MIN + 1); vi.advanceTimersByTime(ACTION_TTL_MIN + 1);
expect(shown(id)).toBe(true); expect(shown(id)).toBe(true);
vi.advanceTimersByTime(12000 - ACTION_TTL_MIN); vi.advanceTimersByTime(12000 - ACTION_TTL_MIN);
@@ -45,7 +46,7 @@ describe("an action toast's lifetime", () => {
it("never dismisses when the reader chose never", () => { it("never dismisses when the reader chose never", () => {
configureNotifications({ durationMs: 0 }); configureNotifications({ durationMs: 0 });
const id = notify({ message: "removed", action: undo }); const id = notify({ message: raw("removed"), action: undo });
vi.advanceTimersByTime(60000); vi.advanceTimersByTime(60000);
expect(shown(id)).toBe(true); expect(shown(id)).toBe(true);
}); });
+63 -11
View File
@@ -2,9 +2,56 @@
// the Notification Center shows (info events, actions awaiting interaction, and app // the Notification Center shows (info events, actions awaiting interaction, and app
// errors). History is persisted to localStorage so it survives reloads; action // errors). History is persisted to localStorage so it survives reloads; action
// callbacks are live-only and dropped on reload. // callbacks are live-only and dropped on reload.
import type { TFunction } from "i18next";
import i18n from "../i18n";
import { localizeDetail } from "./api/refusalText";
import { formatBytes, formatNumber } from "./format";
import { LS, onLocalDataClaimed, readAccount, readAccountMerged, writeAccount } from "./storage"; import { LS, onLocalDataClaimed, readAccount, readAccountMerged, writeAccount } from "./storage";
import { log, type Level } from "./log"; 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, so a pre-translated string does not type-check.
// A value is formatted when shown too: `{ number }` / `{ bytes }` group digits the reader's way.
export type NotifValue = string | number | NotifText | { number: number } | { bytes: number };
export type NotifText =
| { key: string; values?: Readonly<Record<string, NotifValue>> }
| { raw: string }
| { detail: unknown };
/** The notice text for `t(key, values)`, said when it is shown. */
export const msg = (key: string, values?: Record<string, NotifValue>): NotifText =>
values ? { key, values } : { key };
/** Text with no translation of its own (a title, a name), shown as it is. */
export const raw = (text: string): NotifText => ({ raw: text });
/** A notice text in the language `t` speaks — the reader's, from a component's `useTranslation`. */
export function sayNotif(text: NotifText, t: TFunction = i18n.t.bind(i18n)): string {
if ("raw" in text) return text.raw;
if ("detail" in text) return localizeDetail(text.detail);
if (!text.values) return t(text.key);
const values = Object.fromEntries(
Object.entries(text.values).map(([k, v]) => [k, sayValue(v, t)]),
);
return t(text.key, values);
}
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);
return sayNotif(v, t);
}
const sameText = (a: NotifText | undefined, b: NotifText | undefined): boolean =>
JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
// A notice the history kept from before it said keys: its text, in the language it was raised in.
const asText = (v: unknown): NotifText | undefined =>
typeof v === "string" ? raw(v) : (v as NotifText | undefined);
// A notification's level in the client log: what the user was TOLD is part of the story a log tells. // A notification's level in the client log: what the user was TOLD is part of the story a log tells.
const LOG_LEVEL: Record<NotifLevel, Level> = { const LOG_LEVEL: Record<NotifLevel, Level> = {
info: "INFO", info: "INFO",
@@ -56,8 +103,8 @@ export type NotifMeta =
export interface Notification { export interface Notification {
id: number; id: number;
level: NotifLevel; level: NotifLevel;
title?: string; title?: NotifText;
message: string; message: NotifText;
action?: NotifAction; action?: NotifAction;
// A second, lesser action beside it (a done download's "Open" after its "Copy link"). // A second, lesser action beside it (a done download's "Open" after its "Copy link").
secondaryAction?: NotifAction; secondaryAction?: NotifAction;
@@ -79,9 +126,9 @@ export interface Notification {
} }
export interface NotifyInput { export interface NotifyInput {
message: string; message: NotifText;
level?: NotifLevel; level?: NotifLevel;
title?: string; title?: NotifText;
action?: NotifAction; action?: NotifAction;
secondaryAction?: NotifAction; secondaryAction?: NotifAction;
meta?: NotifMeta; meta?: NotifMeta;
@@ -175,17 +222,19 @@ let cachedUnread = 0;
function load(): Notification[] { function load(): Notification[] {
try { try {
const raw = readAccount<unknown>(HISTORY_KEY, []); const stored = readAccount<unknown>(HISTORY_KEY, []);
if (!Array.isArray(raw)) return []; if (!Array.isArray(stored)) return [];
// Restored entries are history only. Mark them dismissed so they don't // Restored entries are history only. Mark them dismissed so they don't
// resurrect as active toasts on reload — their auto-dismiss timers aren't // resurrect as active toasts on reload — their auto-dismiss timers aren't
// re-armed across a reload, so otherwise they'd stick forever. Also drop the // re-armed across a reload, so otherwise they'd stick forever. Also drop the
// live `action` callback, which can't survive serialization. // live `action` callback, which can't survive serialization.
return raw.map( return stored.map(
(n) => (n) =>
({ ({
repeat: 1, repeat: 1,
...(n as object), ...(n as object),
title: asText((n as { title?: unknown }).title),
message: asText((n as { message?: unknown }).message) ?? raw(""),
action: undefined, action: undefined,
secondaryAction: undefined, secondaryAction: undefined,
dismissed: true, dismissed: true,
@@ -261,9 +310,12 @@ const COALESCE_WINDOW_MS = 5000;
export function notify(input: NotifyInput): number { export function notify(input: NotifyInput): number {
const requiresInteraction = input.requiresInteraction ?? false; const requiresInteraction = input.requiresInteraction ?? false;
const level = input.level ?? "info"; const level = input.level ?? "info";
log.at(LOG_LEVEL[level], "ui.notify", input.message, { // In English, whatever the reader's language (a server refusal aside: it says itself in the
// reader's), so one log reads the same for every reader.
const en = i18n.getFixedT("en");
log.at(LOG_LEVEL[level], "ui.notify", sayNotif(input.message, en), {
level, level,
...(input.title ? { title: input.title } : {}), ...(input.title ? { title: sayNotif(input.title, en) } : {}),
...(input.meta ? { kind: input.meta.kind } : {}), ...(input.meta ? { kind: input.meta.kind } : {}),
}); });
const now = Date.now(); const now = Date.now();
@@ -294,8 +346,8 @@ export function notify(input: NotifyInput): number {
!n.action && !n.action &&
now - n.ts <= COALESCE_WINDOW_MS && now - n.ts <= COALESCE_WINDOW_MS &&
n.level === level && n.level === level &&
(n.title ?? "") === (input.title ?? "") && sameText(n.title, input.title) &&
n.message === input.message, sameText(n.message, input.message),
); );
if (prior) { if (prior) {
// Re-surface the existing toast (undismiss + unread) and re-arm its auto-dismiss timer. // Re-surface the existing toast (undismiss + unread) and re-arm its auto-dismiss timer.
+6
View File
@@ -1,7 +1,13 @@
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import { msg, raw, type NotifText } from "./notifications";
/** Watch later is a built-in playlist whose stored `name` is not translatable — show the localized /** Watch later is a built-in playlist whose stored `name` is not translatable — show the localized
* label instead. Was hand-inlined in Playlists, PlaylistsRail and AddToPlaylist; one copy now. */ * label instead. Was hand-inlined in Playlists, PlaylistsRail and AddToPlaylist; one copy now. */
export function playlistName(p: { kind: string; name: string }, t: TFunction): string { export function playlistName(p: { kind: string; name: string }, t: TFunction): string {
return p.kind === "watch_later" ? t("playlists.watchLater") : p.name; return p.kind === "watch_later" ? t("playlists.watchLater") : p.name;
} }
/** The same name in a notice, said in the reader's language when it is shown. */
export function playlistNameText(p: { kind: string; name: string }): NotifText {
return p.kind === "watch_later" ? msg("playlists.watchLater") : raw(p.name);
}
+2 -3
View File
@@ -1,6 +1,6 @@
import type { TFunction } from "i18next";
import { api, type PlexCard } from "./api"; import { api, type PlexCard } from "./api";
import { notifyActionError } from "./actionErrors"; import { notifyActionError } from "./actionErrors";
import { msg } from "./notifications";
// Persist a watch-state toggle from any Plex surface: flip via the caller's `optimistic` cache // Persist a watch-state toggle from any Plex surface: flip via the caller's `optimistic` cache
// updater, call the API, surface a failure (the click used to look dead on 403/404/429), then // updater, call the API, surface a failure (the click used to look dead on 403/404/429), then
@@ -12,7 +12,6 @@ import { notifyActionError } from "./actionErrors";
// (react-query keeps the same `data`), or a cache whose refetch lands later than the click's answer. // (react-query keeps the same `data`), or a cache whose refetch lands later than the click's answer.
export async function toggleWatchState( export async function toggleWatchState(
card: PlexCard, card: PlexCard,
t: TFunction,
handlers: { optimistic: (next: string) => void; resync: () => void; revert?: () => void }, handlers: { optimistic: (next: string) => void; resync: () => void; revert?: () => void },
) { ) {
const next = card.status === "watched" ? "new" : "watched"; const next = card.status === "watched" ? "new" : "watched";
@@ -21,7 +20,7 @@ export async function toggleWatchState(
await api.plexSetState(card.id, next); await api.plexSetState(card.id, next);
} catch (e) { } catch (e) {
handlers.revert?.(); handlers.revert?.();
notifyActionError(e, t("plex.watchedFailed")); notifyActionError(e, msg("plex.watchedFailed"));
} }
handlers.resync(); handlers.resync();
} }
+12 -10
View File
@@ -5,16 +5,18 @@
// silence on a 403/404/429 or a lost connection — 34 of them. // silence on a 403/404/429 or a lost connection — 34 of them.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("./notifications", () => ({ notify: vi.fn(() => 0) })); vi.mock("./notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("./notifications")>()),
notify: vi.fn(() => 0),
}));
vi.mock("./errorDialog", () => ({ reportError: vi.fn() })); vi.mock("./errorDialog", () => ({ reportError: vi.fn() }));
import { MutationObserver, type MutationObserverOptions } from "@tanstack/react-query"; import { MutationObserver, type MutationObserverOptions } from "@tanstack/react-query";
import { createQueryClient } from "./queryClient"; import { createQueryClient } from "./queryClient";
import { HttpError, req } from "./api/core"; import { HttpError, req } from "./api/core";
import { notify } from "./notifications"; import { msg, notify, raw } from "./notifications";
import { reportError } from "./errorDialog"; import { reportError } from "./errorDialog";
import { notifyActionError } from "./actionErrors"; import { notifyActionError } from "./actionErrors";
import i18n from "../i18n";
const notifyMock = vi.mocked(notify); const notifyMock = vi.mocked(notify);
beforeEach(() => { beforeEach(() => {
@@ -39,7 +41,7 @@ describe("a failed mutation", () => {
meta: { failTitle: TITLE }, meta: { failTitle: TITLE },
}); });
expect(notifyMock.mock.calls).toEqual([ expect(notifyMock.mock.calls).toEqual([
[{ level: "error", title: i18n.t(TITLE), message: i18n.t("errors.notFound.generic") }], [{ level: "error", title: msg(TITLE), message: msg("errors.notFound.generic") }],
]); ]);
}); });
@@ -47,8 +49,8 @@ describe("a failed mutation", () => {
await fail({ mutationFn: () => Promise.reject(new HttpError(429, "slow down")) }); await fail({ mutationFn: () => Promise.reject(new HttpError(429, "slow down")) });
expect(notifyMock).toHaveBeenCalledWith({ expect(notifyMock).toHaveBeenCalledWith({
level: "error", level: "error",
title: i18n.t("errors.title"), title: msg("errors.title"),
message: i18n.t("errors.reason.rateLimited"), message: msg("errors.reason.rateLimited"),
}); });
}); });
@@ -58,7 +60,7 @@ describe("a failed mutation", () => {
meta: { failTitle: TITLE }, meta: { failTitle: TITLE },
}); });
expect(notifyMock).toHaveBeenCalledWith( expect(notifyMock).toHaveBeenCalledWith(
expect.objectContaining({ message: i18n.t("errors.reason.unreachable") }), expect.objectContaining({ message: msg("errors.reason.unreachable") }),
); );
}); });
@@ -66,9 +68,9 @@ describe("a failed mutation", () => {
await fail({ await fail({
mutationFn: () => Promise.reject(new HttpError(404, "gone")), mutationFn: () => Promise.reject(new HttpError(404, "gone")),
meta: { failTitle: TITLE }, meta: { failTitle: TITLE },
onError: (e) => notifyActionError(e, "OWN TITLE"), onError: (e) => notifyActionError(e, raw("OWN TITLE")),
}); });
expect(notifyMock.mock.calls.map(([n]) => n.title)).toEqual(["OWN TITLE"]); expect(notifyMock.mock.calls.map(([n]) => n.title)).toEqual([raw("OWN TITLE")]);
}); });
it("is still reported when the mutation's onError only rolls back", async () => { it("is still reported when the mutation's onError only rolls back", async () => {
@@ -79,7 +81,7 @@ describe("a failed mutation", () => {
onError: rollback, onError: rollback,
}); });
expect(rollback).toHaveBeenCalled(); expect(rollback).toHaveBeenCalled();
expect(notifyMock.mock.calls.map(([n]) => n.title)).toEqual([i18n.t(TITLE)]); expect(notifyMock.mock.calls.map(([n]) => n.title)).toEqual([msg(TITLE)]);
}); });
it("gets no toast when the page shows the failure itself", async () => { it("gets no toast when the page shows the failure itself", async () => {
+2 -2
View File
@@ -1,7 +1,7 @@
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query"; import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
import i18n from "../i18n";
import { notifyActionError } from "./actionErrors"; import { notifyActionError } from "./actionErrors";
import { log } from "./log"; import { log } from "./log";
import { msg } from "./notifications";
/** A failed query or mutation, for the client log. Only the key's HEAD (the resource, `"videos"`): /** A failed query or mutation, for the client log. Only the key's HEAD (the resource, `"videos"`):
* the rest of a key is filter state, which the base level does not keep. The status is what * the rest of a key is filter state, which the base level does not keep. The status is what
@@ -42,7 +42,7 @@ type FailMeta = { failTitle?: string; errorShownInline?: boolean } | undefined;
*/ */
function reportMutationFailure(err: unknown, meta: FailMeta): void { function reportMutationFailure(err: unknown, meta: FailMeta): void {
if (meta?.errorShownInline) return; if (meta?.errorShownInline) return;
setTimeout(() => notifyActionError(err, i18n.t(meta?.failTitle ?? "errors.title")), 0); setTimeout(() => notifyActionError(err, msg(meta?.failTitle ?? "errors.title")), 0);
} }
export function createQueryClient(): QueryClient { export function createQueryClient(): QueryClient {
@@ -28,6 +28,7 @@ vi.mock("./entryUrl", async (importOriginal) => {
return { ...actual, accountSwitchNotice: () => notice() }; return { ...actual, accountSwitchNotice: () => notice() };
}); });
const { useAccountSwitchNotice } = await import("./useAccountSwitchNotice"); const { useAccountSwitchNotice } = await import("./useAccountSwitchNotice");
const { msg } = await import("./notifications");
afterEach(() => { afterEach(() => {
notifyMock.mockClear(); notifyMock.mockClear();
@@ -53,7 +54,7 @@ describe("the shell's word on a switch of account", () => {
expect(notifyMock).toHaveBeenCalledTimes(1); expect(notifyMock).toHaveBeenCalledTimes(1);
expect(notifyMock).toHaveBeenCalledWith({ expect(notifyMock).toHaveBeenCalledWith({
level: "info", level: "info",
message: 'header.account.switchRequested {"email":"b@example.com"}', message: msg("header.account.switchRequested", { email: "b@example.com" }),
}); });
}); });
+2 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { accountSwitchNotice } from "./entryUrl"; import { accountSwitchNotice } from "./entryUrl";
import { notify } from "./notifications"; import { msg, notify } from "./notifications";
/** Say, once per load, what the entry URL tells the account signed in here about a switch of /** Say, once per load, what the entry URL tells the account signed in here about a switch of
* account (`accountSwitchNotice`): a sign-in as someone else the server refused, or a tab that * account (`accountSwitchNotice`): a sign-in as someone else the server refused, or a tab that
@@ -14,6 +14,6 @@ export function useAccountSwitchNotice(signedIn: boolean): void {
if (!signedIn || said.current) return; if (!signedIn || said.current) return;
said.current = true; said.current = true;
const notice = accountSwitchNotice(); const notice = accountSwitchNotice();
if (notice) notify({ level: notice.level, message: t(notice.key, notice.values) }); if (notice) notify({ level: notice.level, message: msg(notice.key, notice.values) });
}, [signedIn, t]); }, [signedIn, t]);
} }
+2 -2
View File
@@ -1,8 +1,8 @@
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef } from "react";
import { api } from "./api"; import { api } from "./api";
import { notifyActionError } from "./actionErrors"; import { notifyActionError } from "./actionErrors";
import i18n from "../i18n";
import { useLatest } from "./useLatest"; import { useLatest } from "./useLatest";
import { msg } from "./notifications";
/** The shared feed-priority flow used by both the channel manager and the channel detail page: /** The shared feed-priority flow used by both the channel manager and the channel detail page:
* an optimistic bump/set with a debounced server flush (~400ms after the last notch) so a * an optimistic bump/set with a debounced server flush (~400ms after the last notch) so a
@@ -40,7 +40,7 @@ export function useChannelPriority(opts: {
.catch((err: unknown) => { .catch((err: unknown) => {
// Said here, once, for both callers: their `onError` only refetches, so a refused priority // Said here, once, for both callers: their `onError` only refetches, so a refused priority
// used to just snap back — often after the stepper had already been let go. // used to just snap back — often after the stepper had already been let go.
notifyActionError(err, i18n.t("errors.writeFailed.channelPriority")); notifyActionError(err, msg("errors.writeFailed.channelPriority"));
optsRef.current.onError(channelId); optsRef.current.onError(channelId);
}); });
}, },
+3 -4
View File
@@ -1,8 +1,8 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { api } from "./api"; import { api } from "./api";
import { qk } from "./queryKeys"; import { qk } from "./queryKeys";
import { notifyActionError } from "./actionErrors"; import { notifyActionError } from "./actionErrors";
import { msg } from "./notifications";
/** The shared channel-tag mutation flow used by both the channel manager and the channel detail /** The shared channel-tag mutation flow used by both the channel manager and the channel detail
* page: optimistic attach/detach + a create-then-attach shortcut, with the cache-shape-specific * page: optimistic attach/detach + a create-then-attach shortcut, with the cache-shape-specific
@@ -21,7 +21,6 @@ export function useChannelTagActions(opts: {
onError: (channelId: string) => void; onError: (channelId: string) => void;
}) { }) {
const qc = useQueryClient(); const qc = useQueryClient();
const { t } = useTranslation();
const toggleTag = (channelId: string, tagId: number, hasTag: boolean) => { const toggleTag = (channelId: string, tagId: number, hasTag: boolean) => {
opts.optimisticToggle(channelId, tagId, !hasTag); opts.optimisticToggle(channelId, tagId, !hasTag);
@@ -31,7 +30,7 @@ export function useChannelTagActions(opts: {
call call
.then(() => opts.onSuccess?.(channelId)) .then(() => opts.onSuccess?.(channelId))
.catch((err: unknown) => { .catch((err: unknown) => {
notifyActionError(err, t("channels.tags.updateFailed")); notifyActionError(err, msg("channels.tags.updateFailed"));
opts.onError(channelId); opts.onError(channelId);
}); });
}; };
@@ -45,7 +44,7 @@ export function useChannelTagActions(opts: {
qc.invalidateQueries({ queryKey: qk.tags() }); qc.invalidateQueries({ queryKey: qk.tags() });
toggleTag(channelId, tag.id, false); toggleTag(channelId, tag.id, false);
}) })
.catch((err: unknown) => notifyActionError(err, t("channels.tags.createFailed"))); .catch((err: unknown) => notifyActionError(err, msg("channels.tags.createFailed")));
}; };
return { toggleTag, createAndAttachTag }; return { toggleTag, createAndAttachTag };
@@ -17,7 +17,7 @@ vi.mock("./api", () => ({ api: { savePrefs, me: vi.fn() } }));
vi.mock("./actionErrors", () => ({ notifyActionError })); vi.mock("./actionErrors", () => ({ notifyActionError }));
import { usePref, useSavePref } from "./usePref"; import { usePref, useSavePref } from "./usePref";
import i18n from "../i18n"; import { msg } from "./notifications";
afterEach(() => { afterEach(() => {
savePrefs.mockReset(); savePrefs.mockReset();
@@ -34,7 +34,7 @@ describe("a refused preference save", () => {
const { result } = renderHook(() => usePref<boolean>("hintsOn", true), { wrapper }); const { result } = renderHook(() => usePref<boolean>("hintsOn", true), { wrapper });
act(() => result.current[1](false)); act(() => result.current[1](false));
await waitFor(() => await waitFor(() =>
expect(notifyActionError).toHaveBeenCalledWith(refusal, i18n.t("errors.writeFailed.prefs")), expect(notifyActionError).toHaveBeenCalledWith(refusal, msg("errors.writeFailed.prefs")),
); );
expect(result.current[0]).toBe(false); // the local value stays; the toast says it did not save expect(result.current[0]).toBe(false); // the local value stays; the toast says it did not save
}); });
@@ -45,7 +45,7 @@ describe("a refused preference save", () => {
const { result } = renderHook(() => useSavePref(), { wrapper }); const { result } = renderHook(() => useSavePref(), { wrapper });
act(() => result.current({ stripSources: { c1: false } })); act(() => result.current({ stripSources: { c1: false } }));
await waitFor(() => await waitFor(() =>
expect(notifyActionError).toHaveBeenCalledWith(refusal, i18n.t("errors.writeFailed.prefs")), expect(notifyActionError).toHaveBeenCalledWith(refusal, msg("errors.writeFailed.prefs")),
); );
}); });
}); });
+2 -2
View File
@@ -6,13 +6,13 @@ import type { Me } from "./api/account";
import { getAccountRaw, writeAccount } from "./storage"; import { getAccountRaw, writeAccount } from "./storage";
import { notifyActionError } from "./actionErrors"; import { notifyActionError } from "./actionErrors";
import { updateMeCache } from "./useMe"; import { updateMeCache } from "./useMe";
import i18n from "../i18n"; import { msg } from "./notifications";
// The local state + cache already show the new value, so a refused save is otherwise invisible until // The local state + cache already show the new value, so a refused save is otherwise invisible until
// the next load quietly puts the old one back. `req` reports none of 403/404/429 and only the // the next load quietly puts the old one back. `req` reports none of 403/404/429 and only the
// connection notice for a lost connection — say it. // connection notice for a lost connection — say it.
const reportPrefSaveFailure = (err: unknown) => const reportPrefSaveFailure = (err: unknown) =>
notifyActionError(err, i18n.t("errors.writeFailed.prefs")); notifyActionError(err, msg("errors.writeFailed.prefs"));
// The ONE per-account + server preference hook. A preference (unlike an ephemeral filter) persists to // The ONE per-account + server preference hook. A preference (unlike an ephemeral filter) persists to
// the account on the server (PUT /api/me/preferences, which MERGES the partial payload) and follows the // the account on the server (PUT /api/me/preferences, which MERGES the partial payload) and follows the
+2 -3
View File
@@ -1,6 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { api, type Me } from "./api"; import { api, type Me } from "./api";
import { qk } from "./queryKeys"; import { qk } from "./queryKeys";
import { notifyActionError } from "./actionErrors"; import { notifyActionError } from "./actionErrors";
@@ -15,6 +14,7 @@ import {
type PanelId, type PanelId,
type PanelLayout, type PanelLayout,
} from "./panelLayout"; } from "./panelLayout";
import { msg } from "./notifications";
// A side rail's own state: its group cards' layout and whether it is collapsed. Each rail reads it // A side rail's own state: its group cards' layout and whether it is collapsed. Each rail reads it
// here rather than being handed it, so the shell only places the rail. // here rather than being handed it, so the shell only places the rail.
@@ -24,7 +24,6 @@ import {
// it. The server keeps the copy that follows the account, this browser a local one for an account // it. The server keeps the copy that follows the account, this browser a local one for an account
// whose server copy has none. // whose server copy has none.
export function usePanelLayout(panel: PanelId): [PanelLayout, (next: PanelLayout) => void] { export function usePanelLayout(panel: PanelId): [PanelLayout, (next: PanelLayout) => void] {
const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const [layout, setState] = useState(() => const [layout, setState] = useState(() =>
seedLayout(panel, qc.getQueryData<Me | null>(qk.me())?.preferences), seedLayout(panel, qc.getQueryData<Me | null>(qk.me())?.preferences),
@@ -38,7 +37,7 @@ export function usePanelLayout(panel: PanelId): [PanelLayout, (next: PanelLayout
})); }));
api api
.savePrefs({ [PREF_KEY[panel]]: next }) .savePrefs({ [PREF_KEY[panel]]: next })
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.prefs"))); .catch((e: unknown) => notifyActionError(e, msg("errors.writeFailed.prefs")));
}; };
return [layout, setLayout]; return [layout, setLayout];
} }
+4 -4
View File
@@ -1,16 +1,16 @@
import { HttpError } from "./api"; import { HttpError } from "./api";
import { markReported } from "./api/core"; import { markReported } from "./api/core";
import { notify } from "./notifications"; import { msg, notify, type NotifText } from "./notifications";
import { notifyActionError } from "./actionErrors"; import { notifyActionError } from "./actionErrors";
import i18n from "../i18n"; import i18n from "../i18n";
// A YouTube-gated action (subscribe, sync, backfill, unsubscribe) that 403s means the user hasn't // 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 // granted the write scope — surface a "Connect" affordance instead of a vague failure. Pass
// `onConnect` to offer the connect-wizard button (callers that have no wizard handle, e.g. the // `onConnect` to offer the connect-wizard button (callers that have no wizard handle, e.g. the
// channel page, just get the message). `fallbackMessage` is the already-translated non-403 error. // channel page, just get the message). `fallbackMessage` is what a non-403 error says.
export function notifyYouTubeActionError( export function notifyYouTubeActionError(
err: unknown, err: unknown,
fallbackMessage: string, fallbackMessage: NotifText,
onConnect?: () => void, onConnect?: () => void,
): void { ): void {
const httpErr = err instanceof HttpError ? err : null; const httpErr = err instanceof HttpError ? err : null;
@@ -18,7 +18,7 @@ export function notifyYouTubeActionError(
// A 403 on a YouTube write action means the write scope isn't granted — offer to connect. // A 403 on a YouTube write action means the write scope isn't granted — offer to connect.
notify({ notify({
level: "error", level: "error",
message: i18n.t("channels.notify.needYouTube"), message: msg("channels.notify.needYouTube"),
action: onConnect action: onConnect
? { label: i18n.t("channels.notify.connect"), onClick: onConnect } ? { label: i18n.t("channels.notify.connect"), onClick: onConnect }
: undefined, : undefined,
@@ -8,7 +8,7 @@ import { notifyActionError } from "../../lib/actionErrors";
import { sinceOf } from "../../lib/activityUrlState"; import { sinceOf } from "../../lib/activityUrlState";
import { auditActionLabel, auditText } from "../../lib/auditText"; import { auditActionLabel, auditText } from "../../lib/auditText";
import { formatDateTimeSec } from "../../lib/format"; import { formatDateTimeSec } from "../../lib/format";
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { useHistoryList } from "../../lib/useHistoryList"; import { useHistoryList } from "../../lib/useHistoryList";
import type { Shared } from "./ActivityRuns"; import type { Shared } from "./ActivityRuns";
@@ -70,9 +70,9 @@ export default function ActivityAudit({
onSuccess: (r) => { onSuccess: (r) => {
void qc.invalidateQueries({ queryKey: qk.adminAudit() }); void qc.invalidateQueries({ queryKey: qk.adminAudit() });
void qc.invalidateQueries({ queryKey: qk.adminAuditFacets() }); void qc.invalidateQueries({ queryKey: qk.adminAuditFacets() });
notify({ level: "success", message: t("audit.cleared", { count: r.cleared }) }); notify({ level: "success", message: msg("audit.cleared", { count: r.cleared }) });
}, },
onError: (e) => notifyActionError(e, t("audit.clearFailed")), onError: (e) => notifyActionError(e, msg("audit.clearFailed")),
}); });
const onClear = async () => { const onClear = async () => {
if ( if (
+3 -3
View File
@@ -15,7 +15,7 @@ import { nextPageOnClick } from "../../lib/nextPage";
import { LEVEL_GROUPS, type LevelGroup } from "../../lib/log"; import { LEVEL_GROUPS, type LevelGroup } from "../../lib/log";
import { sinceOf } from "../../lib/activityUrlState"; import { sinceOf } from "../../lib/activityUrlState";
import { logsParamsToState, logsParamUpdates, type LogsUrlState } from "../../lib/logsUrlState"; import { logsParamsToState, logsParamUpdates, type LogsUrlState } from "../../lib/logsUrlState";
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { useUrlMirror } from "../../lib/useUrlMirror"; import { useUrlMirror } from "../../lib/useUrlMirror";
import { HistoryFrame } from "./HistoryList"; import { HistoryFrame } from "./HistoryList";
@@ -187,8 +187,8 @@ export default function AdminLogs({
const ok = await copyText(text); const ok = await copyText(text);
notify( notify(
ok ok
? { level: "success", message: t("adminLogs.copied", { count: rows.length }) } ? { level: "success", message: msg("adminLogs.copied", { count: rows.length }) }
: { level: "error", message: t("adminLogs.copyFailed") }, : { level: "error", message: msg("adminLogs.copyFailed") },
); );
}; };
@@ -11,7 +11,7 @@ import { hydrateSort, sortRows, type SortState } from "../../lib/columnSort";
import { usePref } from "../../lib/usePref"; import { usePref } from "../../lib/usePref";
import { useCardPager } from "../../lib/useCardPager"; import { useCardPager } from "../../lib/useCardPager";
import { subsColumn } from "./channelColumns"; import { subsColumn } from "./channelColumns";
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { notifyYouTubeActionError } from "../../lib/youtubeErrors"; import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
import Tooltip from "../../components/ui/Tooltip"; import Tooltip from "../../components/ui/Tooltip";
import ChannelLink from "./ChannelLink"; import ChannelLink from "./ChannelLink";
@@ -70,13 +70,13 @@ export default function ChannelDiscovery({
// callbacks are gone (see ChannelSubscribedMeta). // callbacks are gone (see ChannelSubscribedMeta).
notify({ notify({
level: "success", level: "success",
title: t("channels.discovery.subscribedTitle"), title: msg("channels.discovery.subscribedTitle"),
message: t("channels.discovery.subscribedBody", { name }), message: msg("channels.discovery.subscribedBody", { name }),
meta: { kind: "channel-subscribed", channelId: c.id, channelName: name }, meta: { kind: "channel-subscribed", channelId: c.id, channelName: name },
}); });
}, },
onError: (err: unknown) => onError: (err: unknown) =>
notifyYouTubeActionError(err, t("channels.discovery.subscribeFailed"), onOpenWizard), notifyYouTubeActionError(err, msg("channels.discovery.subscribeFailed"), onOpenWizard),
}); });
// Subscribing changes the user's real YouTube account and spends a little quota — confirm // Subscribing changes the user's real YouTube account and spends a little quota — confirm
+7 -7
View File
@@ -18,7 +18,7 @@ import { Sparkles } from "lucide-react";
import { notifyYouTubeActionError } from "../../lib/youtubeErrors"; import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
import { LS, ownKey } from "../../lib/storage"; import { LS, ownKey } from "../../lib/storage";
import { buildChannelColumns, applySyncFilter, tagFilterChips } from "./channelColumns"; import { buildChannelColumns, applySyncFilter, tagFilterChips } from "./channelColumns";
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { Badge } from "../../components/ui/Badge"; import { Badge } from "../../components/ui/Badge";
import { Button } from "../../components/ui/Button"; import { Button } from "../../components/ui/Button";
import Tooltip from "../../components/ui/Tooltip"; import Tooltip from "../../components/ui/Tooltip";
@@ -168,9 +168,9 @@ export default function Channels() {
(tagId: number, name: string) => { (tagId: number, name: string) => {
setFeedFilters({ ...feedFilters, tags: [tagId], channelIds: [] }); setFeedFilters({ ...feedFilters, tags: [tagId], channelIds: [] });
setPage("feed"); setPage("feed");
notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) }); notify({ level: "info", message: msg("channels.notify.filteredByTag", { name }) });
}, },
[setFeedFilters, feedFilters, setPage, t], [setFeedFilters, feedFilters, setPage],
); );
const channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels }); const channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels });
@@ -294,10 +294,10 @@ export default function Channels() {
invalidate(); invalidate();
notify({ notify({
level: "success", level: "success",
message: t("channels.notify.synced", { count: r.subscriptions ?? 0 }), message: msg("channels.notify.synced", { count: r.subscriptions ?? 0 }),
}); });
}, },
onError: (e) => notifyYouTubeActionError(e, t("channels.notify.syncFailed"), onOpenWizard), onError: (e) => notifyYouTubeActionError(e, msg("channels.notify.syncFailed"), onOpenWizard),
}); });
const deepAll = useMutation({ const deepAll = useMutation({
mutationFn: () => api.deepAll(true), mutationFn: () => api.deepAll(true),
@@ -306,11 +306,11 @@ export default function Channels() {
qc.invalidateQueries({ queryKey: qk.myStatus() }); qc.invalidateQueries({ queryKey: qk.myStatus() });
notify({ notify({
level: "success", level: "success",
message: t("channels.notify.fullHistoryRequested", { count: r.updated ?? 0 }), message: msg("channels.notify.fullHistoryRequested", { count: r.updated ?? 0 }),
}); });
}, },
onError: (e) => onError: (e) =>
notifyYouTubeActionError(e, t("channels.notify.fullHistoryFailed"), onOpenWizard), notifyYouTubeActionError(e, msg("channels.notify.fullHistoryFailed"), onOpenWizard),
}); });
// The tag-chip row is a controlled view over the Tags COLUMN filter (`filters.tags`, a string[] of // The tag-chip row is a controlled view over the Tags COLUMN filter (`filters.tags`, a string[] of
+2 -1
View File
@@ -12,6 +12,7 @@ import { Button } from "../../components/ui/Button";
import { IconButton } from "../../components/ui/IconButton"; import { IconButton } from "../../components/ui/IconButton";
import { Pressable } from "../../components/ui/Pressable"; import { Pressable } from "../../components/ui/Pressable";
import { SURFACE } from "../../components/ui/surfaces"; import { SURFACE } from "../../components/ui/surfaces";
import { msg } from "../../lib/notifications";
// Per-user status (not the global catalog): shows the number of videos available to *this* // Per-user status (not the global catalog): shows the number of videos available to *this*
// user, how many of *their* channels are still being fetched, and how many lack full // user, how many of *their* channels are still being fetched, and how many lack full
@@ -64,7 +65,7 @@ export default function SyncStatus({
onError: (e) => onError: (e) =>
notifyActionError( notifyActionError(
e, e,
t(data?.paused ? "errors.writeFailed.syncResume" : "errors.writeFailed.syncPause"), msg(data?.paused ? "errors.writeFailed.syncResume" : "errors.writeFailed.syncPause"),
), ),
}); });
@@ -16,6 +16,7 @@ import { Pressable } from "../../components/ui/Pressable";
import RemoteImage from "../../components/ui/RemoteImage"; import RemoteImage from "../../components/ui/RemoteImage";
import Tooltip from "../../components/ui/Tooltip"; import Tooltip from "../../components/ui/Tooltip";
import type { WatchedChannelActions } from "./useWatchedChannelActions"; import type { WatchedChannelActions } from "./useWatchedChannelActions";
import { msg } from "../../lib/notifications";
const PlayerModal = lazy(() => import("../../features/player/PlayerModal")); const PlayerModal = lazy(() => import("../../features/player/PlayerModal"));
@@ -67,7 +68,7 @@ export default function WatchedVideosDialog({
); );
void qc.invalidateQueries({ queryKey: qk.feed() }); void qc.invalidateQueries({ queryKey: qk.feed() });
}) })
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState"))); .catch((e: unknown) => notifyActionError(e, msg("errors.writeFailed.videoState")));
return write; return write;
}); });
const closePlayer = useStableCallback(() => setPlaying(null)); const closePlayer = useStableCallback(() => setPlaying(null));
@@ -33,6 +33,7 @@ vi.mock("../../lib/useMe", () => ({ useMe: () => ({ role: h.role, can_write: h.c
import { useChannelActions } from "./useChannelActions"; import { useChannelActions } from "./useChannelActions";
import { api, type ManagedChannel, type Tag } from "../../lib/api"; import { api, type ManagedChannel, type Tag } from "../../lib/api";
import type { ActionMenuItem } from "../../components/ui/ActionMenu"; import type { ActionMenuItem } from "../../components/ui/ActionMenu";
import { msg, type NotifText } from "../../lib/notifications";
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
@@ -195,10 +196,10 @@ describe("useChannelActions — hide and unsubscribe", () => {
expect(h.confirm).not.toHaveBeenCalled(); expect(h.confirm).not.toHaveBeenCalled();
expect(upd).toHaveBeenCalledWith("UC1", { hidden: true }); expect(upd).toHaveBeenCalledWith("UC1", { hidden: true });
const toast = h.notify.mock.calls[0]![0] as { const toast = h.notify.mock.calls[0]![0] as {
message: string; message: NotifText;
action: { onClick: () => void }; action: { onClick: () => void };
}; };
expect(toast.message).toBe("channels.notify.hidden"); expect(toast.message).toEqual(msg("channels.notify.hidden", { name: "Chan" }));
act(() => toast.action.onClick()); act(() => toast.action.onClick());
await waitFor(() => expect(upd).toHaveBeenCalledWith("UC1", { hidden: false })); await waitFor(() => expect(upd).toHaveBeenCalledWith("UC1", { hidden: false }));
}); });
@@ -7,7 +7,7 @@ import { api, type ManagedChannel, type Tag } from "../../lib/api";
import { priorityChoices } from "../../lib/channelOrder"; import { priorityChoices } from "../../lib/channelOrder";
import { compareText } from "../../lib/collate"; import { compareText } from "../../lib/collate";
import { channelYouTubeUrl } from "../../lib/format"; import { channelYouTubeUrl } from "../../lib/format";
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { notifyYouTubeActionError } from "../../lib/youtubeErrors"; import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
import { useMe } from "../../lib/useMe"; import { useMe } from "../../lib/useMe";
import { useConfirm } from "../../components/ui/confirmContext"; import { useConfirm } from "../../components/ui/confirmContext";
@@ -70,7 +70,7 @@ export function useChannelActions({
refresh(); refresh();
notify({ notify({
level: "success", level: "success",
message: t("channels.notify.hidden", { name: nameOf(c) }), message: msg("channels.notify.hidden", { name: nameOf(c) }),
action: { action: {
label: t("common.undo"), label: t("common.undo"),
onClick: () => patch.mutate({ id: c.id, body: { hidden: false } }), onClick: () => patch.mutate({ id: c.id, body: { hidden: false } }),
@@ -82,19 +82,22 @@ export function useChannelActions({
mutationFn: (c: ManagedChannel) => api.unsubscribeChannel(c.id), mutationFn: (c: ManagedChannel) => api.unsubscribeChannel(c.id),
onSuccess: (_d, c) => { onSuccess: (_d, c) => {
invalidateSubscription(qc, c.id); invalidateSubscription(qc, c.id);
notify({ level: "success", message: t("channels.notify.unsubscribed", { name: nameOf(c) }) }); notify({
level: "success",
message: msg("channels.notify.unsubscribed", { name: nameOf(c) }),
});
}, },
onError: (e) => onError: (e) =>
notifyYouTubeActionError(e, t("channels.notify.unsubscribeFailed"), onOpenWizard), notifyYouTubeActionError(e, msg("channels.notify.unsubscribeFailed"), onOpenWizard),
}); });
const resetBackfill = useMutation({ const resetBackfill = useMutation({
mutationFn: (c: ManagedChannel) => api.resetChannelBackfill(c.id), mutationFn: (c: ManagedChannel) => api.resetChannelBackfill(c.id),
onSuccess: (_d, c) => { onSuccess: (_d, c) => {
qc.invalidateQueries({ queryKey: qk.channels() }); qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.myStatus() }); qc.invalidateQueries({ queryKey: qk.myStatus() });
notify({ level: "success", message: t("channels.notify.resetDone", { name: nameOf(c) }) }); notify({ level: "success", message: msg("channels.notify.resetDone", { name: nameOf(c) }) });
}, },
onError: (e) => notifyYouTubeActionError(e, t("channels.notify.resetFailed"), onOpenWizard), onError: (e) => notifyYouTubeActionError(e, msg("channels.notify.resetFailed"), onOpenWizard),
}); });
async function onUnsubscribe(c: ManagedChannel) { async function onUnsubscribe(c: ManagedChannel) {
@@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { invalidateSubscription } from "../../lib/subscriptionCache"; import { invalidateSubscription } from "../../lib/subscriptionCache";
import { api, type WatchedChannel } from "../../lib/api"; import { api, type WatchedChannel } from "../../lib/api";
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { notifyYouTubeActionError } from "../../lib/youtubeErrors"; import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
import { useConfirm } from "../../components/ui/confirmContext"; import { useConfirm } from "../../components/ui/confirmContext";
@@ -21,13 +21,13 @@ export function useWatchedChannelActions(onOpenWizard: () => void) {
invalidateSubscription(qc, c.id); invalidateSubscription(qc, c.id);
notify({ notify({
level: "success", level: "success",
title: t("channels.discovery.subscribedTitle"), title: msg("channels.discovery.subscribedTitle"),
message: t("channels.discovery.subscribedBody", { name: nameOf(c) }), message: msg("channels.discovery.subscribedBody", { name: nameOf(c) }),
meta: { kind: "channel-subscribed", channelId: c.id, channelName: nameOf(c) }, meta: { kind: "channel-subscribed", channelId: c.id, channelName: nameOf(c) },
}); });
}, },
onError: (err: unknown) => onError: (err: unknown) =>
notifyYouTubeActionError(err, t("channels.discovery.subscribeFailed"), onOpenWizard), notifyYouTubeActionError(err, msg("channels.discovery.subscribeFailed"), onOpenWizard),
}); });
const restore = useMutation({ const restore = useMutation({
mutationFn: (c: WatchedChannel) => api.restoreWatchedChannel(c.id), mutationFn: (c: WatchedChannel) => api.restoreWatchedChannel(c.id),
@@ -43,7 +43,7 @@ export function useWatchedChannelActions(onOpenWizard: () => void) {
void qc.invalidateQueries({ queryKey: qk.watchedChannels() }); void qc.invalidateQueries({ queryKey: qk.watchedChannels() });
notify({ notify({
level: "success", level: "success",
message: t("channels.watched.dismissed", { name: nameOf(c) }), message: msg("channels.watched.dismissed", { name: nameOf(c) }),
action: { label: t("common.undo"), onClick: () => restore.mutate(c) }, action: { label: t("common.undo"), onClick: () => restore.mutate(c) },
}); });
}, },
+8 -8
View File
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plug, RotateCcw, Send } from "lucide-react"; import { Plug, RotateCcw, Send } from "lucide-react";
import { api, type ConfigItem, type PlexTestResult } from "../../lib/api"; import { api, type ConfigItem, type PlexTestResult } from "../../lib/api";
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import Tooltip from "../../components/ui/Tooltip"; import Tooltip from "../../components/ui/Tooltip";
import InlineNotice from "../../components/ui/InlineNotice"; import InlineNotice from "../../components/ui/InlineNotice";
import { useMe } from "../../lib/useMe"; import { useMe } from "../../lib/useMe";
@@ -130,7 +130,7 @@ export default function ConfigPanel() {
setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 2500); setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 2500);
} catch (e) { } catch (e) {
setSaveState("error"); setSaveState("error");
notifyActionError(e, t("config.saveFailed")); notifyActionError(e, msg("config.saveFailed"));
} }
} }
@@ -142,8 +142,8 @@ export default function ConfigPanel() {
const testEmail = useMutation({ const testEmail = useMutation({
mutationFn: () => api.testEmail(), mutationFn: () => api.testEmail(),
onSuccess: (r) => notify({ level: "success", message: t("config.testSent", { to: r.to }) }), onSuccess: (r) => notify({ level: "success", message: msg("config.testSent", { to: r.to }) }),
onError: (e) => notifyActionError(e, t("config.testFailed")), onError: (e) => notifyActionError(e, msg("config.testFailed")),
}); });
// Plex "Test connection": verifies the server URL + token and returns the library sections, // Plex "Test connection": verifies the server URL + token and returns the library sections,
@@ -154,19 +154,19 @@ export default function ConfigPanel() {
mutationFn: () => api.testPlex(), mutationFn: () => api.testPlex(),
onSuccess: (r) => { onSuccess: (r) => {
setPlexResult(r); setPlexResult(r);
notify({ level: "success", message: t("config.plexTestOk", { name: r.server_name }) }); notify({ level: "success", message: msg("config.plexTestOk", { name: r.server_name }) });
}, },
onError: (e) => { onError: (e) => {
setPlexResult(null); setPlexResult(null);
notifyActionError(e, t("config.plexTestFailed")); notifyActionError(e, msg("config.plexTestFailed"));
}, },
}); });
// Bug reports' "Test connection": the repository answers, takes issues, is private. // Bug reports' "Test connection": the repository answers, takes issues, is private.
const testIssues = useMutation({ const testIssues = useMutation({
mutationFn: () => api.testIssues(), mutationFn: () => api.testIssues(),
onSuccess: (r) => onSuccess: (r) =>
notify({ level: "success", message: t("config.issuesTestOk", { repo: r.repo }) }), notify({ level: "success", message: msg("config.issuesTestOk", { repo: r.repo }) }),
onError: (e) => notifyActionError(e, t("config.issuesTestFailed")), onError: (e) => notifyActionError(e, msg("config.issuesTestFailed")),
}); });
const plexLibs = (draft["plex_libraries"] ?? "") const plexLibs = (draft["plex_libraries"] ?? "")
+8 -8
View File
@@ -42,7 +42,7 @@ import {
} from "../../lib/channelPageUrlState"; } from "../../lib/channelPageUrlState";
import { LS } from "../../lib/storage"; import { LS } from "../../lib/storage";
import { useConfirm } from "../../components/ui/confirmContext"; import { useConfirm } from "../../components/ui/confirmContext";
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { notifyYouTubeActionError } from "../../lib/youtubeErrors"; import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
import { import {
@@ -152,11 +152,11 @@ export default function ChannelPage({
}) })
// Both the automatic first-visit explore and "load more" used to fail in silence: an empty // Both the automatic first-visit explore and "load more" used to fail in silence: an empty
// channel page that looked like a channel with no videos. // channel page that looked like a channel with no videos.
.catch((err: unknown) => notifyActionError(err, t("errors.writeFailed.exploreChannel"))) .catch((err: unknown) => notifyActionError(err, msg("errors.writeFailed.exploreChannel")))
.finally(() => setExploring(false)) .finally(() => setExploring(false))
); );
}, },
[channelId, qc, t], [channelId, qc],
); );
// First visit of an un-subscribed channel → ingest its recent uploads. Skipped when videos are // First visit of an un-subscribed channel → ingest its recent uploads. Skipped when videos are
@@ -201,16 +201,16 @@ export default function ChannelPage({
mutationFn: () => api.subscribeChannel(channelId), mutationFn: () => api.subscribeChannel(channelId),
onSuccess: () => { onSuccess: () => {
invalidateSubscription(qc, channelId); invalidateSubscription(qc, channelId);
notify({ level: "info", message: t("channel.subscribed", { name: ch?.title ?? "" }) }); notify({ level: "info", message: msg("channel.subscribed", { name: ch?.title ?? "" }) });
}, },
// A 403 (missing write scope) otherwise fails silently on the channel page (no wizard handle // A 403 (missing write scope) otherwise fails silently on the channel page (no wizard handle
// here, so no Connect button — but the user at least sees why it didn't work). // here, so no Connect button — but the user at least sees why it didn't work).
onError: (err) => notifyYouTubeActionError(err, t("channels.discovery.subscribeFailed")), onError: (err) => notifyYouTubeActionError(err, msg("channels.discovery.subscribeFailed")),
}); });
const unsubscribe = useMutation({ const unsubscribe = useMutation({
mutationFn: () => api.unsubscribeChannel(channelId), mutationFn: () => api.unsubscribeChannel(channelId),
onSuccess: () => invalidateSubscription(qc, channelId), onSuccess: () => invalidateSubscription(qc, channelId),
onError: (err) => notifyYouTubeActionError(err, t("channels.notify.unsubscribeFailed")), onError: (err) => notifyYouTubeActionError(err, msg("channels.notify.unsubscribeFailed")),
}); });
// "Get full history": the catalog only holds the recent-backfill/RSS subset of a channel's // "Get full history": the catalog only holds the recent-backfill/RSS subset of a channel's
// uploads (video_count is YouTube's raw total), so flipping deep_requested queues the full // uploads (video_count is YouTube's raw total), so flipping deep_requested queues the full
@@ -222,9 +222,9 @@ export default function ChannelPage({
qc.invalidateQueries({ queryKey: qk.feed() }); qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: qk.channels() }); qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.myStatus() }); qc.invalidateQueries({ queryKey: qk.myStatus() });
notify({ level: "info", message: t("channel.fullHistoryQueued") }); notify({ level: "info", message: msg("channel.fullHistoryQueued") });
}, },
onError: (err) => notifyYouTubeActionError(err, t("channel.fullHistoryFailed")), onError: (err) => notifyYouTubeActionError(err, msg("channel.fullHistoryFailed")),
}); });
// Feed priority, editable here with the same stepper + debounced-flush the manager uses (shared // Feed priority, editable here with the same stepper + debounced-flush the manager uses (shared
+6 -11
View File
@@ -31,7 +31,6 @@ import {
type Video, type Video,
type VideoStatus, type VideoStatus,
} from "../../lib/api"; } from "../../lib/api";
import i18n from "../../i18n";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
import { announceVideoState, type NoticeVideo } from "../../features/video/stateNotice"; import { announceVideoState, type NoticeVideo } from "../../features/video/stateNotice";
import { import {
@@ -39,7 +38,7 @@ import {
writeVideoState, writeVideoState,
writeWatchLater, writeWatchLater,
} from "../../features/video/videoWrites"; } from "../../features/video/videoWrites";
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { useMe } from "../../lib/useMe"; import { useMe } from "../../lib/useMe";
import { YT_COUNTS } from "../../lib/ytSearchUrlState"; import { YT_COUNTS } from "../../lib/ytSearchUrlState";
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext"; import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
@@ -419,7 +418,7 @@ export default function Feed({
// Server rejected the change — drop the optimistic override so the card // Server rejected the change — drop the optimistic override so the card
// reverts to the real (unchanged) state instead of staying phantom-hidden, and say so: // reverts to the real (unchanged) state instead of staying phantom-hidden, and say so:
// a card that silently snaps back reads as a misclick. // a card that silently snaps back reads as a misclick.
notifyActionError(e, i18n.t("errors.writeFailed.videoState")); notifyActionError(e, msg("errors.writeFailed.videoState"));
setOverrides((o) => withoutWrite(o, id, seq)); setOverrides((o) => withoutWrite(o, id, seq));
}); });
return write; return write;
@@ -447,7 +446,7 @@ export default function Feed({
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" }); qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
}) })
.catch((e: unknown) => { .catch((e: unknown) => {
notifyActionError(e, i18n.t("errors.writeFailed.videoReset")); notifyActionError(e, msg("errors.writeFailed.videoReset"));
setOverrides((o) => withoutWrite(o, id, seq)); setOverrides((o) => withoutWrite(o, id, seq));
if (resets.current.get(id) !== seq) return; if (resets.current.get(id) !== seq) return;
resets.current.delete(id); resets.current.delete(id);
@@ -471,9 +470,7 @@ export default function Feed({
.catch((e: unknown) => { .catch((e: unknown) => {
notifyActionError( notifyActionError(
e, e,
i18n.t( msg(saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove"),
saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove",
),
); );
setSavedOverrides((o) => withoutWrite(o, id, seq)); setSavedOverrides((o) => withoutWrite(o, id, seq));
}); });
@@ -619,11 +616,9 @@ export default function Feed({
const ids = ytItems.map((v) => v.id); const ids = ytItems.map((v) => v.id);
api api
.clearSearch(ids) .clearSearch(ids)
.then((r) => .then((r) => notify({ message: msg("feed.yt.cleared", { count: r.removed }) }))
notify({ message: i18n.t("feed.yt.cleared", { count: r.removed }) }),
)
.catch((e: unknown) => .catch((e: unknown) =>
notifyActionError(e, i18n.t("errors.writeFailed.clearSearch")), notifyActionError(e, msg("errors.writeFailed.clearSearch")),
); );
setFilters({ ...filters, librarySource: "organic", sort: FEED_DEFAULT_SORT }); setFilters({ ...filters, librarySource: "organic", sort: FEED_DEFAULT_SORT });
onExitYtSearch(); onExitYtSearch();
@@ -116,6 +116,7 @@ vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
})); }));
import Files from "./Files"; import Files from "./Files";
import { msg } from "../../lib/notifications";
import { FILES_LIST_CAP, type QueueJob } from "../../lib/api"; import { FILES_LIST_CAP, type QueueJob } from "../../lib/api";
import { noteQueue, resetQueueWatch, useFinished } from "../../lib/downloadQueue"; import { noteQueue, resetQueueWatch, useFinished } from "../../lib/downloadQueue";
@@ -589,10 +590,13 @@ describe("Files · a library past the list's cap", { timeout: 30_000 }, () => {
await waitFor(() => await waitFor(() =>
expect(mocks.notify).toHaveBeenCalledWith({ expect(mocks.notify).toHaveBeenCalledWith({
level: "info", level: "info",
message: "files.arrival.notListed", message: msg("files.arrival.notListed"),
}), }),
); );
expect(mocks.notify).not.toHaveBeenCalledWith({ level: "info", message: "files.arrival.gone" }); expect(mocks.notify).not.toHaveBeenCalledWith({
level: "info",
message: msg("files.arrival.gone"),
});
}); });
it("an arrival's cleared search is asked of the server at once, not after the typing pause", () => { it("an arrival's cleared search is asked of the server at once, not after the typing pause", () => {
@@ -805,7 +809,7 @@ describe("Files · a file the address names", () => {
mount(); mount();
await waitFor(() => await waitFor(() =>
expect(mocks.notify).toHaveBeenCalledWith( expect(mocks.notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "warning", message: "files.linkedGone" }), expect.objectContaining({ level: "warning", message: msg("files.linkedGone") }),
), ),
); );
expect(screen.queryByRole("dialog")).toBeNull(); expect(screen.queryByRole("dialog")).toBeNull();
@@ -822,11 +826,11 @@ describe("Files · a file the address names", () => {
mount(); mount();
await waitFor(() => await waitFor(() =>
expect(mocks.notify).toHaveBeenCalledWith( expect(mocks.notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "info", message: "files.linkedNotListed" }), expect.objectContaining({ level: "info", message: msg("files.linkedNotListed") }),
), ),
); );
expect(mocks.notify).not.toHaveBeenCalledWith( expect(mocks.notify).not.toHaveBeenCalledWith(
expect.objectContaining({ message: "files.linkedGone" }), expect.objectContaining({ message: msg("files.linkedGone") }),
); );
expect(here().get("fview")).toBe("9999"); expect(here().get("fview")).toBe("9999");
}, 30_000); // a full page of FILES_LIST_CAP rows }, 30_000); // a full page of FILES_LIST_CAP rows
@@ -839,11 +843,11 @@ describe("Files · a file the address names", () => {
mount(); mount();
await waitFor(() => await waitFor(() =>
expect(mocks.notify).toHaveBeenCalledWith( expect(mocks.notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "info", message: "files.linkedNotListed" }), expect.objectContaining({ level: "info", message: msg("files.linkedNotListed") }),
), ),
); );
expect(mocks.notify).not.toHaveBeenCalledWith( expect(mocks.notify).not.toHaveBeenCalledWith(
expect.objectContaining({ message: "files.linkedGone" }), expect.objectContaining({ message: msg("files.linkedGone") }),
); );
expect(here().get("fview")).toBe("9999"); expect(here().get("fview")).toBe("9999");
}, 30_000); // a full page of FILES_LIST_CAP rows }, 30_000); // a full page of FILES_LIST_CAP rows
@@ -1344,7 +1348,10 @@ describe("Files · when what it needs is missing", () => {
mocks.folders = [YT]; mocks.folders = [YT];
mount(ALL_FILES, vi.fn(), "", { focus: { id: 9, token: 1 } }); mount(ALL_FILES, vi.fn(), "", { focus: { id: 9, token: 1 } });
await waitFor(() => await waitFor(() =>
expect(mocks.notify).toHaveBeenCalledWith({ level: "info", message: "files.arrival.gone" }), expect(mocks.notify).toHaveBeenCalledWith({
level: "info",
message: msg("files.arrival.gone"),
}),
); );
}); });
}); });
+8 -5
View File
@@ -19,7 +19,7 @@ import {
import { libraryRowActions, sharedRowActions } from "../../lib/downloadActions"; import { libraryRowActions, sharedRowActions } from "../../lib/downloadActions";
import { formatBytes, formatDateTime, relativeTime } from "../../lib/format"; import { formatBytes, formatDateTime, relativeTime } from "../../lib/format";
import { focusNavItem } from "../../lib/listNav"; import { focusNavItem } from "../../lib/listNav";
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { LS, ownKey } from "../../lib/storage"; import { LS, ownKey } from "../../lib/storage";
import { useCardPager } from "../../lib/useCardPager"; import { useCardPager } from "../../lib/useCardPager";
@@ -296,9 +296,9 @@ export default function Files() {
const openLinked = useEffectEvent((file: LibraryFile | undefined) => { const openLinked = useEffectEvent((file: LibraryFile | undefined) => {
// Looked for in the list it was shown in: a cut one cannot tell (the newest files, or the first by // Looked for in the list it was shown in: a cut one cannot tell (the newest files, or the first by
// its sort or search), as the arrival decides it. // its sort or search), as the arrival decides it.
if (!file && cut) notify({ level: "info", message: t("files.linkedNotListed") }); if (!file && cut) notify({ level: "info", message: msg("files.linkedNotListed") });
else if (!file) { else if (!file) {
notify({ level: "warning", message: t("files.linkedGone") }); notify({ level: "warning", message: msg("files.linkedGone") });
clearParams(FILES_VIEW_KEY, FILES_PLAY_KEY); clearParams(FILES_VIEW_KEY, FILES_PLAY_KEY);
} else if (linkedFile?.open === "play") play(file); } else if (linkedFile?.open === "play") play(file);
else setViewing(file.id); else setViewing(file.id);
@@ -387,7 +387,10 @@ export default function Files() {
consumeFocus(null); consumeFocus(null);
// Said, not swallowed. A list the cap cut cannot tell a deleted file from one beyond its end; // Said, not swallowed. A list the cap cut cannot tell a deleted file from one beyond its end;
// a whole list can: an old notice's file may have expired or been deleted since. // a whole list can: an old notice's file may have expired or been deleted since.
notify({ level: "info", message: t(cut ? "files.arrival.notListed" : "files.arrival.gone") }); notify({
level: "info",
message: msg(cut ? "files.arrival.notListed" : "files.arrival.gone"),
});
} }
}, [here, missing, lookEverywhere, cut, viewKey, setView, consumeFocus, t]); }, [here, missing, lookEverywhere, cut, viewKey, setView, consumeFocus, t]);
const arrival = arrived && arrived.view === viewKey ? arrived : null; const arrival = arrived && arrived.view === viewKey ? arrived : null;
@@ -434,7 +437,7 @@ export default function Files() {
const dropped = e.dataTransfer.files; const dropped = e.dataTransfer.files;
const first = dropped.item(0); const first = dropped.item(0);
if (first) setUpload({ file: first }); if (first) setUpload({ file: first });
if (dropped.length > 1) notify({ level: "info", message: t("files.drop.oneAtATime") }); if (dropped.length > 1) notify({ level: "info", message: msg("files.drop.oneAtATime") });
} }
setDragging(dragDepth.current > 0); setDragging(dragDepth.current > 0);
}); });
+3 -3
View File
@@ -5,7 +5,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Copy, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react"; import { Check, Copy, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react";
import Modal from "../../components/ui/Modal"; import Modal from "../../components/ui/Modal";
import { useConfirm } from "../../components/ui/confirmContext"; import { useConfirm } from "../../components/ui/confirmContext";
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
import { formatDate } from "../../lib/format"; import { formatDate } from "../../lib/format";
import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../../lib/api"; import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../../lib/api";
@@ -40,9 +40,9 @@ function UserShare({ job }: { job: DownloadJob }) {
const share = useMutation({ const share = useMutation({
mutationFn: (email: string) => api.shareDownload(job.id, email), mutationFn: (email: string) => api.shareDownload(job.id, email),
onSuccess: (r) => { onSuccess: (r) => {
notify({ level: "success", message: t("downloads.share.done", { email: r.shared_with }) }); notify({ level: "success", message: msg("downloads.share.done", { email: r.shared_with }) });
}, },
onError: (e) => notifyActionError(e, t("downloads.share.failed")), onError: (e) => notifyActionError(e, msg("downloads.share.failed")),
}); });
return ( return (
@@ -45,6 +45,7 @@ vi.mock("../../lib/downloadQueue", () => ({
import UploadDialog from "./UploadDialog"; import UploadDialog from "./UploadDialog";
import { FilesActionsContext } from "./filesContext"; import { FilesActionsContext } from "./filesContext";
import { msg, type NotifText } from "../../lib/notifications";
const wrap = ( const wrap = (
onClose = vi.fn(), onClose = vi.fn(),
@@ -101,7 +102,9 @@ describe("UploadDialog", () => {
expect(passedFile).toBe(f); expect(passedFile).toBe(f);
expect((opts as { displayName?: string }).displayName).toBe("My notes"); expect((opts as { displayName?: string }).displayName).toBe("My notes");
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1)); await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
expect(h.notify.mock.calls[0]![0]).toMatchObject({ message: "downloads.upload.added" }); expect((h.notify.mock.calls[0]![0] as { message: NotifText }).message).toEqual(
msg("downloads.upload.added"),
);
await waitFor(() => expect(onClose).toHaveBeenCalled()); await waitFor(() => expect(onClose).toHaveBeenCalled());
}); });
@@ -115,9 +118,9 @@ describe("UploadDialog", () => {
}); });
fireEvent.click(screen.getByText("downloads.upload.submit")); fireEvent.click(screen.getByText("downloads.upload.submit"));
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1)); await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
expect(h.notify.mock.calls[0]![0]).toMatchObject({ expect((h.notify.mock.calls[0]![0] as { message: NotifText }).message).toEqual(
message: "downloads.upload.addedConverting", msg("downloads.upload.addedConverting"),
}); );
// Its finish is still to come, and the tray's to announce. // Its finish is still to come, and the tray's to announce.
expect(h.uploadEnded).toHaveBeenCalledWith(null); expect(h.uploadEnded).toHaveBeenCalledWith(null);
}); });
+3 -3
View File
@@ -9,7 +9,7 @@ import { qk } from "../../lib/queryKeys";
import { isAborted } from "../../lib/api/core"; import { isAborted } from "../../lib/api/core";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
import { invalidateDownloads } from "../../lib/downloads"; import { invalidateDownloads } from "../../lib/downloads";
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { showInTray, uploadEnded, uploadStarted } from "../../lib/downloadQueue"; import { showInTray, uploadEnded, uploadStarted } from "../../lib/downloadQueue";
import type { DownloadJob } from "../../lib/api"; import type { DownloadJob } from "../../lib/api";
import { useFilesActions } from "./filesContext"; import { useFilesActions } from "./filesContext";
@@ -105,7 +105,7 @@ export default function UploadDialog({
invalidateDownloads(qc); invalidateDownloads(qc);
notify({ notify({
level: "success", level: "success",
message: t(uploadedKey(job.status)), message: msg(uploadedKey(job.status)),
action: { action: {
label: t("downloads.dialog.view"), label: t("downloads.dialog.view"),
onClick: () => openUploaded(job), onClick: () => openUploaded(job),
@@ -120,7 +120,7 @@ export default function UploadDialog({
// named and no job in the list. The comment here claimed the global error modal had it; it // named and no job in the list. The comment here claimed the global error modal had it; it
// had not since the shared `reportFailure` landed. // had not since the shared `reportFailure` landed.
uploadEnded(null); uploadEnded(null);
if (!isAborted(e)) notifyActionError(e, t("downloads.upload.failed")); if (!isAborted(e)) notifyActionError(e, msg("downloads.upload.failed"));
// Re-arm the form so the user can retry or pick again. // Re-arm the form so the user can retry or pick again.
setPct(null); setPct(null);
abortRef.current = null; abortRef.current = null;
@@ -22,6 +22,7 @@ vi.mock("../../lib/notifications", async (importOriginal) => ({
import VideoEditor from "./VideoEditor"; import VideoEditor from "./VideoEditor";
import { api, HttpError, type DownloadJob } from "../../lib/api"; import { api, HttpError, type DownloadJob } from "../../lib/api";
import { markReported } from "../../lib/api/core"; import { markReported } from "../../lib/api/core";
import { msg } from "../../lib/notifications";
beforeAll(() => { beforeAll(() => {
class Stub { class Stub {
@@ -88,7 +89,10 @@ describe("VideoEditor — a split export that fails part-way", () => {
await act(async () => refuse(refused)); await act(async () => refuse(refused));
await waitFor(() => await waitFor(() =>
expect(notify).toHaveBeenCalledWith( expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "error", title: "editor.partlyCreated" }), expect.objectContaining({
level: "error",
title: msg("editor.partlyCreated", { done: 1, total: 2 }),
}),
), ),
); );
expect(enqueue.mock.calls[0]![0].display_name).toBe("Talk (1/2)"); expect(enqueue.mock.calls[0]![0].display_name).toBe("Talk (1/2)");
+4 -4
View File
@@ -6,7 +6,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Crop, Eye, EyeOff, Pause, Play, Scissors, SplitSquareHorizontal, X } from "lucide-react"; import { Crop, Eye, EyeOff, Pause, Play, Scissors, SplitSquareHorizontal, X } from "lucide-react";
import clsx from "clsx"; import clsx from "clsx";
import Modal, { ModalFooter } from "../../components/ui/Modal"; import Modal, { ModalFooter } from "../../components/ui/Modal";
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { api, type DownloadJob, type EditSpec } from "../../lib/api"; import { api, type DownloadJob, type EditSpec } from "../../lib/api";
import { cropToSourcePx, type Frac } from "../../lib/cropGeometry"; import { cropToSourcePx, type Frac } from "../../lib/cropGeometry";
import { CropFrame } from "../../components/ui/CropFrame"; import { CropFrame } from "../../components/ui/CropFrame";
@@ -288,7 +288,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
// what the dialog does not tell. // what the dialog does not tell.
notify({ notify({
level: "error", level: "error",
title: t("editor.partlyCreated", { done: done.length, total: N }), title: msg("editor.partlyCreated", { done: done.length, total: N }),
message: failureReason(e), message: failureReason(e),
}); });
markReported(e); markReported(e);
@@ -300,10 +300,10 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
onSuccess: ({ files }) => { onSuccess: ({ files }) => {
// The whole library moves with it: an edit the user already made is done at once, and lists. // The whole library moves with it: an edit the user already made is done at once, and lists.
invalidateDownloads(qc); invalidateDownloads(qc);
notify({ level: "success", message: t("editor.created", { count: files }) }); notify({ level: "success", message: msg("editor.created", { count: files }) });
onClose(); onClose();
}, },
onError: (e) => notifyActionError(e, t("editor.failed")), onError: (e) => notifyActionError(e, msg("editor.failed")),
onSettled: () => { onSettled: () => {
creating.current = false; creating.current = false;
}, },
@@ -28,6 +28,7 @@ import { jobTitle } from "../../features/downloads/downloadTitle";
import { folderLabel } from "../../features/folders/fileFolders"; import { folderLabel } from "../../features/folders/fileFolders";
import { type ActionMenuItem } from "../../components/ui/ActionMenu"; import { type ActionMenuItem } from "../../components/ui/ActionMenu";
import { menuGroups } from "../../components/ui/menuGroups"; import { menuGroups } from "../../components/ui/menuGroups";
import { msg } from "../../lib/notifications";
// The Files rows' ONE menu, and the actions the rows show as icons (the same ones, so a row's every // The Files rows' ONE menu, and the actions the rows show as icons (the same ones, so a row's every
// action is reachable by right click — DESIGN.md "Library list columns"). My own files and the ones // action is reachable by right click — DESIGN.md "Library list columns"). My own files and the ones
@@ -79,7 +80,7 @@ export function useFileActions({
const update = useMutation({ const update = useMutation({
mutationFn: ({ id, patch }: { id: number; patch: Patch }) => api.updateFile(id, patch), mutationFn: ({ id, patch }: { id: number; patch: Patch }) => api.updateFile(id, patch),
onSuccess: () => invalidateDownloads(qc), onSuccess: () => invalidateDownloads(qc),
onError: (e, v) => notifyActionError(e, t(patchFail(v.patch))), onError: (e, v) => notifyActionError(e, msg(patchFail(v.patch))),
}); });
const remove = useMutation({ const remove = useMutation({
mutationFn: (id: number) => api.deleteFile(id), mutationFn: (id: number) => api.deleteFile(id),
@@ -9,6 +9,7 @@ import { useConfirm } from "../../components/ui/confirmContext";
import { folderLabel } from "../../features/folders/fileFolders"; import { folderLabel } from "../../features/folders/fileFolders";
import { type ActionMenuItem } from "../../components/ui/ActionMenu"; import { type ActionMenuItem } from "../../components/ui/ActionMenu";
import { menuGroups } from "../../components/ui/menuGroups"; import { menuGroups } from "../../components/ui/menuGroups";
import { msg } from "../../lib/notifications";
// A library folder's ONE menu — the rail entry's right click and the Files page's "…" beside an open // A library folder's ONE menu — the rail entry's right click and the Files page's "…" beside an open
// folder's name. An own folder is shared by link (its link opens whatever is in it), renamed (inline, // folder's name. An own folder is shared by link (its link opens whatever is in it), renamed (inline,
@@ -43,7 +44,7 @@ export function useFolderActions({ onRename }: { onRename: (folder: FileFolder)
const update = useMutation({ const update = useMutation({
mutationFn: ({ id, patch }: { id: number; patch: FolderPatch }) => api.updateFolder(id, patch), mutationFn: ({ id, patch }: { id: number; patch: FolderPatch }) => api.updateFolder(id, patch),
onSuccess: () => invalidateDownloads(qc), onSuccess: () => invalidateDownloads(qc),
onError: (e, v) => notifyActionError(e, t(patchFail(v.patch))), onError: (e, v) => notifyActionError(e, msg(patchFail(v.patch))),
}); });
const remove = useMutation({ const remove = useMutation({
mutationFn: (id: number) => api.deleteFolder(id), mutationFn: (id: number) => api.deleteFolder(id),
+9 -8
View File
@@ -81,6 +81,7 @@ import RemoteImage from "../../components/ui/RemoteImage";
import { useOpenLinkedPlayer } from "../../features/player/useOpenLinkedPlayer"; import { useOpenLinkedPlayer } from "../../features/player/useOpenLinkedPlayer";
import { budgetFrom, budgetParams, DEFAULT_BUDGET } from "../../lib/homeUrlState"; import { budgetFrom, budgetParams, DEFAULT_BUDGET } from "../../lib/homeUrlState";
import { useUrlMirror } from "../../lib/useUrlMirror"; import { useUrlMirror } from "../../lib/useUrlMirror";
import { msg } from "../../lib/notifications";
// The Home surface — "Mit nézzek most?". // The Home surface — "Mit nézzek most?".
// //
@@ -868,10 +869,10 @@ export default function Home() {
// click raises a bare fetch TypeError, so there is no server reason to prefer over it. It // click raises a bare fetch TypeError, so there is no server reason to prefer over it. It
// would describe a state the user can see is untrue, over a fully populated page whose // would describe a state the user can see is untrue, over a fully populated page whose
// invalidations never even ran (they sit inside `.then`). // invalidations never even ran (they sit inside `.then`).
.catch((e: unknown) => notifyActionError(e, t("home.actionFailed"))); .catch((e: unknown) => notifyActionError(e, msg("home.actionFailed")));
return write; return write;
}, },
[qc, t], [qc],
); );
const onToggleSave = useCallback( const onToggleSave = useCallback(
@@ -887,9 +888,9 @@ export default function Home() {
qc.invalidateQueries({ queryKey: qk.discover() }); qc.invalidateQueries({ queryKey: qk.discover() });
}) })
// The write key, for the same reason as `onState` above. // The write key, for the same reason as `onState` above.
.catch((e: unknown) => notifyActionError(e, t("home.actionFailed"))); .catch((e: unknown) => notifyActionError(e, msg("home.actionFailed")));
}, },
[qc, t], [qc],
); );
// One click plays. A shelf tile resolves through its `next` (a show → the episode that would // One click plays. A shelf tile resolves through its `next` (a show → the episode that would
@@ -940,11 +941,11 @@ export default function Home() {
// re-asks the server rather than trying to predict a cross-rail consequence. // re-asks the server rather than trying to predict a cross-rail consequence.
const onToggleWatched = useCallback( const onToggleWatched = useCallback(
(card: PlexCard) => (card: PlexCard) =>
void toggleWatchState(card, t, { void toggleWatchState(card, {
optimistic: () => {}, optimistic: () => {},
resync: () => qc.invalidateQueries({ queryKey: qk.discover() }), resync: () => qc.invalidateQueries({ queryKey: qk.discover() }),
}), }),
[qc, t], [qc],
); );
// Shelf reorder, from the comfort rail's menu. Optimistic on THIS budget's cached payload so the // Shelf reorder, from the comfort rail's menu. Optimistic on THIS budget's cached payload so the
@@ -986,7 +987,7 @@ export default function Home() {
.reorderComfortPins(ids) .reorderComfortPins(ids)
.catch((e: unknown) => { .catch((e: unknown) => {
qc.setQueryData<DiscoverPayload>(key, (old) => (old ? { ...old, comfort: cards } : old)); qc.setQueryData<DiscoverPayload>(key, (old) => (old ? { ...old, comfort: cards } : old));
notifyActionError(e, t("home.shelf.moveFailed")); notifyActionError(e, msg("home.shelf.moveFailed"));
}) })
.finally(() => { .finally(() => {
shelfSavingRef.current = false; shelfSavingRef.current = false;
@@ -1000,7 +1001,7 @@ export default function Home() {
void qc.invalidateQueries({ queryKey: qk.discover() }); void qc.invalidateQueries({ queryKey: qk.discover() });
}); });
}, },
[budget, lang, qc, t], [budget, lang, qc],
); );
const reroll = useCallback( const reroll = useCallback(
@@ -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 { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { api, type AppNotification } from "../../lib/api"; import { api, type AppNotification } from "../../lib/api";
import type { Notification as ClientNotif } from "../../lib/notifications"; import { raw, type Notification as ClientNotif } from "../../lib/notifications";
const h = vi.hoisted(() => ({ const h = vi.hoisted(() => ({
server: [] as unknown[], server: [] as unknown[],
@@ -116,7 +116,7 @@ const client = (id: number, extra: Partial<ClientNotif> = {}) =>
({ ({
id, id,
level: "success", level: "success",
message: `Activity ${id}`, message: raw(`Activity ${id}`),
requiresInteraction: false, requiresInteraction: false,
ts: Date.now(), ts: Date.now(),
read: true, read: true,
@@ -50,6 +50,7 @@ vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
import NotificationsPanel from "./NotificationsPanel"; import NotificationsPanel from "./NotificationsPanel";
import { focusNavItem, restoreNavPosition } from "../../lib/listNav"; import { focusNavItem, restoreNavPosition } from "../../lib/listNav";
import { raw } from "../../lib/notifications";
// The server list goes through the real live query, so an optimistic patch of its cache is seen. // The server list goes through the real live query, so an optimistic patch of its cache is seen.
beforeEach(() => { beforeEach(() => {
@@ -80,7 +81,7 @@ const client = (id: number, ageMs: number, extra: Partial<ClientNotif> = {}) =>
({ ({
id, id,
level: "success", level: "success",
message: `Activity ${id}.`, message: raw(`Activity ${id}.`),
requiresInteraction: false, requiresInteraction: false,
ts: Date.now() - ageMs, ts: Date.now() - ageMs,
read: true, read: true,
@@ -122,8 +123,8 @@ describe("NotificationsPanel · order", () => {
h.client = [ h.client = [
client(9, 5 * 60 * 1000, { client(9, 5 * 60 * 1000, {
level: "warning", level: "warning",
title: "common.accessRequestsTitle", title: raw("common.accessRequestsTitle"),
message: "1 pending", message: raw("1 pending"),
meta: { kind: "access-requests" }, meta: { kind: "access-requests" },
}), }),
client(8, 2 * D), client(8, 2 * D),
@@ -366,8 +367,8 @@ describe("NotificationsPanel · order", () => {
h.client = [ h.client = [
client(9, 5 * 60 * 1000, { client(9, 5 * 60 * 1000, {
level: "warning", level: "warning",
title: "common.accessRequestsTitle", title: raw("common.accessRequestsTitle"),
message: "1 pending", message: raw("1 pending"),
meta: { kind: "access-requests" }, meta: { kind: "access-requests" },
}), }),
]; ];
@@ -465,7 +466,9 @@ describe("NotificationsPanel · order", () => {
// A screen reader hears the name while the keyboard is on the row, and Back finds the row it // A screen reader hears the name while the keyboard is on the row, and Back finds the row it
// left by it: "Download ready" alone was every download's name. // left by it: "Download ready" alone was every download's name.
h.server = [server(1, 1 * H, true), server(2, 2 * H, true)]; h.server = [server(1, 1 * H, true), server(2, 2 * H, true)];
h.client = [client(3, 3 * H, { title: "Something broke", message: "Unexpected error." })]; h.client = [
client(3, 3 * H, { title: raw("Something broke"), message: raw("Unexpected error.") }),
];
mount(); mount();
await loaded(); await loaded();
expect( expect(
@@ -29,16 +29,18 @@ import { useLiveQuery } from "../../lib/useLiveQuery";
import { focusAccessRequestsTab } from "../../lib/adminUsersTab"; import { focusAccessRequestsTab } from "../../lib/adminUsersTab";
import { focusSettingsTab } from "../../lib/settingsTab"; import { focusSettingsTab } from "../../lib/settingsTab";
import { import {
type ChannelSubscribedMeta,
clearAll as clearClient, clearAll as clearClient,
dismiss as dismissClient, dismiss as dismissClient,
getNotifications, getNotifications,
getUnreadCount, getUnreadCount,
markAllRead as markAllClientRead, markAllRead as markAllClientRead,
msg,
type Notification as ClientNotif,
remove as removeClient, remove as removeClient,
resolveVideo, resolveVideo,
sayNotif,
subscribe, subscribe,
type Notification as ClientNotif,
type ChannelSubscribedMeta,
type VideoHiddenMeta, type VideoHiddenMeta,
type VideoWatchedMeta, type VideoWatchedMeta,
} from "../../lib/notifications"; } from "../../lib/notifications";
@@ -250,7 +252,7 @@ export default function NotificationsPanel() {
// Quietly resolve the now-stale notice (no confirmation toast — the change is visible). // Quietly resolve the now-stale notice (no confirmation toast — the change is visible).
resolveVideo(meta.videoId); resolveVideo(meta.videoId);
}) })
.catch((e) => notifyActionError(e, t("inbox.revertFailed"))); .catch((e) => notifyActionError(e, msg("inbox.revertFailed")));
} }
// --- The rows' ONE menu ------------------------------------------------------------- // --- The rows' ONE menu -------------------------------------------------------------
@@ -707,10 +709,12 @@ function serverRowText(
* Trimmed as Back saves it (`lib/listNav`), or a title ending in a space never matches its row. */ * Trimmed as Back saves it (`lib/listNav`), or a title ending in a space never matches its row. */
const rowName = (title: string, body: string | null | undefined): string => const rowName = (title: string, body: string | null | undefined): string =>
(body ? `${title}: ${body}` : title).trim(); (body ? `${title}: ${body}` : title).trim();
const clientRowName = (n: ClientNotif): string => const clientRowName = (n: ClientNotif, t: TFunction): string =>
n.title ? rowName(n.title, n.message) : rowName(n.message, null); n.title
? rowName(sayNotif(n.title, t), sayNotif(n.message, t))
: rowName(sayNotif(n.message, t), null);
const entryName = (e: InboxEntry, t: TFunction, lang: string): string => { const entryName = (e: InboxEntry, t: TFunction, lang: string): string => {
if (e.src === "client") return clientRowName(e.n); if (e.src === "client") return clientRowName(e.n, t);
const { title, body } = serverRowText(e.n, t, lang); const { title, body } = serverRowText(e.n, t, lang);
return rowName(title, body); return rowName(title, body);
}; };
@@ -854,15 +858,15 @@ function ClientActivityRow({
const access = n.meta?.kind === "access-requests" ? n.meta : null; const access = n.meta?.kind === "access-requests" ? n.meta : null;
return ( return (
<div <div
{...navItemAttrs(navIndex, clientRowName(n))} {...navItemAttrs(navIndex, clientRowName(n, t))}
{...rowProps} {...rowProps}
className={`${SURFACE.card} group p-3.5 flex items-start gap-3 ${needsAction ? "ring-1 ring-accent/30" : ""}`} className={`${SURFACE.card} group p-3.5 flex items-start gap-3 ${needsAction ? "ring-1 ring-accent/30" : ""}`}
> >
<Icon className={`w-4 h-4 shrink-0 mt-0.5 ${color}`} /> <Icon className={`w-4 h-4 shrink-0 mt-0.5 ${color}`} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{n.title && <div className="text-sm font-semibold">{n.title}</div>} {n.title && <div className="text-sm font-semibold">{sayNotif(n.title, t)}</div>}
<div className="text-sm wrap-break-word"> <div className="text-sm wrap-break-word">
{n.message} {sayNotif(n.message, t)}
{n.repeat > 1 && ( {n.repeat > 1 && (
<span className="ml-1.5 align-middle text-2xs font-semibold text-muted tabular-nums"> <span className="ml-1.5 align-middle text-2xs font-semibold text-muted tabular-nums">
×{n.repeat} ×{n.repeat}
@@ -94,6 +94,7 @@ import { PageMenuSlot, type PageMenuOpener } from "../../components/ui/pageMenuS
import { api, HttpError, type Video } from "../../lib/api"; import { api, HttpError, type Video } from "../../lib/api";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { patchCachedProgress } from "../../lib/progressCache"; import { patchCachedProgress } from "../../lib/progressCache";
import { msg, type NotifText } from "../../lib/notifications";
beforeAll(() => { beforeAll(() => {
class Stub { class Stub {
@@ -644,7 +645,7 @@ describe("Playlists — a change the server refused is taken back on screen", ()
await act(async () => void (await new Promise((r) => setTimeout(r, 20)))); await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
expect(rowsOnScreen()).toEqual(["Video V", "Video W"]); expect(rowsOnScreen()).toEqual(["Video V", "Video W"]);
expect(h.notify).toHaveBeenCalledWith( expect(h.notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "error", title: "playlists.removeFailed" }), expect.objectContaining({ level: "error", title: msg("playlists.removeFailed") }),
); );
}); });
}); });
@@ -662,8 +663,8 @@ describe("Playlists — removing a row, and undoing it", () => {
// removal may notify too. // removal may notify too.
const lastToast = () => const lastToast = () =>
h.notify.mock.calls h.notify.mock.calls
.map((c) => c[0] as { message: string; action?: { label: string; onClick: () => void } }) .map((c) => c[0] as { message: NotifText; action?: { label: string; onClick: () => void } })
.filter((n) => n.message?.startsWith("playlists.removedToast")) .filter((n) => "key" in n.message && n.message.key === "playlists.removedToast")
.pop()!; .pop()!;
// The list's keys are live once its effect has registered it (`data-nav-list`), a passive effect that // The list's keys are live once its effect has registered it (`data-nav-list`), a passive effect that
// can run AFTER the rows are on screen: a key pressed on a slow machine in between did nothing (CI flake). // can run AFTER the rows are on screen: a key pressed on a slow machine in between did nothing (CI flake).
@@ -686,7 +687,9 @@ describe("Playlists — removing a row, and undoing it", () => {
expect(rowsOnScreen()).toEqual(["Video A", "Video C"]); expect(rowsOnScreen()).toEqual(["Video A", "Video C"]);
// No confirm: the toast's Undo is the safety net. // No confirm: the toast's Undo is the safety net.
expect(h.confirm).not.toHaveBeenCalled(); expect(h.confirm).not.toHaveBeenCalled();
await waitFor(() => expect(lastToast().message).toBe("playlists.removedToast:Video B")); await waitFor(() =>
expect(lastToast().message).toEqual(msg("playlists.removedToast", { title: "Video B" })),
);
expect(lastToast().action?.label).toBe("common.undo"); expect(lastToast().action?.label).toBe("common.undo");
act(() => lastToast().action!.onClick()); act(() => lastToast().action!.onClick());
// Index 1 of the STORED order: where it was, not the end of the list. // Index 1 of the STORED order: where it was, not the end of the list.
@@ -733,7 +736,7 @@ describe("Playlists — removing a row, and undoing it", () => {
act(() => lastToast().action!.onClick()); act(() => lastToast().action!.onClick());
await waitFor(() => await waitFor(() =>
expect(h.notify).toHaveBeenCalledWith( expect(h.notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "error", title: "playlists.undoRemoveFailed" }), expect.objectContaining({ level: "error", title: msg("playlists.undoRemoveFailed") }),
), ),
); );
}); });
+11 -9
View File
@@ -34,10 +34,10 @@ import {
type Video, type Video,
type VideoStatus, type VideoStatus,
} from "../../lib/api"; } from "../../lib/api";
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
import { notifyYouTubeActionError } from "../../lib/youtubeErrors"; import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
import { playlistName } from "../../lib/playlistName"; import { playlistName, playlistNameText } from "../../lib/playlistName";
import { reinsertAt } from "../../lib/reinsert"; import { reinsertAt } from "../../lib/reinsert";
import { useUndoable } from "../../lib/useUndoable"; import { useUndoable } from "../../lib/useUndoable";
import { cycleSort, type SortState } from "../../lib/columnSort"; import { cycleSort, type SortState } from "../../lib/columnSort";
@@ -231,7 +231,7 @@ function PlaylistDetail() {
// Was a bare .then(): a failed reorder left the UI showing an order the server had // Was a bare .then(): a failed reorder left the UI showing an order the server had
// rejected, and produced an unhandled rejection. Say so, and put back the order the server // rejected, and produced an unhandled rejection. Say so, and put back the order the server
// still has — or the rejected one becomes the base for the next drag. // still has — or the rejected one becomes the base for the next drag.
notifyYouTubeActionError(e, t("playlists.reorderFailed")); notifyYouTubeActionError(e, msg("playlists.reorderFailed"));
restoreFromCache(listId); restoreFromCache(listId);
qc.invalidateQueries({ queryKey: qk.playlist(listId) }); qc.invalidateQueries({ queryKey: qk.playlist(listId) });
}); });
@@ -535,7 +535,7 @@ function PlaylistDetail() {
// Said here, as moveItemTo does, or a removal fails in silence (inside the full-screen player // Said here, as moveItemTo does, or a removal fails in silence (inside the full-screen player
// there is not even a row snapping back to show it) — unless `req` already put it in its error // there is not even a row snapping back to show it) — unless `req` already put it in its error
// dialog, which `notifyActionError` checks. // dialog, which `notifyActionError` checks.
notifyActionError(e, t("playlists.removeFailed")); notifyActionError(e, msg("playlists.removeFailed"));
// …and the row goes back NOW, not when the resync lands: a removal that failed because the // …and the row goes back NOW, not when the resync lands: a removal that failed because the
// server was unreachable has a resync that fails too, and the list kept showing the row as // server was unreachable has a resync that fails too, and the list kept showing the row as
// removed while it was still in the playlist. // removed while it was still in the playlist.
@@ -545,7 +545,7 @@ function PlaylistDetail() {
} }
notify({ notify({
level: "success", level: "success",
message: t("playlists.removedToast", { title: video.title }), message: msg("playlists.removedToast", { title: video.title ?? "" }),
action: { action: {
label: t("common.undo"), label: t("common.undo"),
// ONE request that puts it back at its index (the server renumbers around it): an append // ONE request that puts it back at its index (the server renumbers around it): an append
@@ -567,7 +567,7 @@ function PlaylistDetail() {
if (back.keys.length) if (back.keys.length)
return addPlaylistItem(qc, listRef(listId), video.id, back.positions[0]); return addPlaylistItem(qc, listRef(listId), video.id, back.positions[0]);
}) })
.catch((e: unknown) => notifyActionError(e, t("playlists.undoRemoveFailed"))) .catch((e: unknown) => notifyActionError(e, msg("playlists.undoRemoveFailed")))
.finally(() => { .finally(() => {
refreshAll(listId); refreshAll(listId);
qc.invalidateQueries({ queryKey: qk.playlistMembership(video.id) }); qc.invalidateQueries({ queryKey: qk.playlistMembership(video.id) });
@@ -619,6 +619,8 @@ function PlaylistDetail() {
const next = items.filter((v) => v.id !== video.id); const next = items.filter((v) => v.id !== video.id);
order.rebase((list) => list.filter((v) => v.id !== video.id)); order.rebase((list) => list.filter((v) => v.id !== video.id));
lastSetRef.current = `${selectedId}:${idsKey(next)}`; lastSetRef.current = `${selectedId}:${idsKey(next)}`;
// Outside the `try`: a `??` inside it makes the React Compiler skip the component.
const title = video.title ?? "";
try { try {
// ONE request. The two-call version (add there, remove here) leaves the video in BOTH lists // ONE request. The two-call version (add there, remove here) leaves the video in BOTH lists
// whenever the second call fails — see the endpoint's docstring. // whenever the second call fails — see the endpoint's docstring.
@@ -628,7 +630,7 @@ function PlaylistDetail() {
setLastMoveTargetId(target.id); setLastMoveTargetId(target.id);
notify({ notify({
level: "success", level: "success",
message: t("playlists.movedToast", { title: video.title, name: plName(target) }), message: msg("playlists.movedToast", { title, name: playlistNameText(target) }),
}); });
} catch (e) { } catch (e) {
// NOT a silent revert. `req` deliberately leaves 401/403/404 to the caller as control flow, and // NOT a silent revert. `req` deliberately leaves 401/403/404 to the caller as control flow, and
@@ -637,7 +639,7 @@ function PlaylistDetail() {
// explanation and the shipped moveErrors strings were unreachable. // explanation and the shipped moveErrors strings were unreachable.
// A fault `req` already put in its error dialog is not toasted again — `notifyActionError` // A fault `req` already put in its error dialog is not toasted again — `notifyActionError`
// checks (a review found it reported twice). // checks (a review found it reported twice).
notifyActionError(e, t("playlists.moveFailed")); notifyActionError(e, msg("playlists.moveFailed"));
restoreFromCache(selectedId); // the row is still in this list restoreFromCache(selectedId); // the row is still in this list
} }
// BOTH sides changed, and so did this video's membership. `qk.playlist(selectedId)` is an exact // BOTH sides changed, and so did this video's membership. `qk.playlist(selectedId)` is an exact
@@ -677,7 +679,7 @@ function PlaylistDetail() {
qc.invalidateQueries({ queryKey: qk.feed() }); qc.invalidateQueries({ queryKey: qk.feed() });
}) })
// No catch used to mean an unhandled rejection and nothing said. // No catch used to mean an unhandled rejection and nothing said.
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState"))); .catch((e: unknown) => notifyActionError(e, msg("errors.writeFailed.videoState")));
return write; return write;
} }
@@ -7,7 +7,7 @@ import { YouTubeIcon } from "../../lib/brandIcons";
import { api, type Playlist } from "../../lib/api"; import { api, type Playlist } from "../../lib/api";
import { compareText } from "../../lib/collate"; import { compareText } from "../../lib/collate";
import { formatRuntime } from "../../lib/format"; import { formatRuntime } from "../../lib/format";
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { notifyYouTubeActionError } from "../../lib/youtubeErrors"; import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
import { LS } from "../../lib/storage"; import { LS } from "../../lib/storage";
import { usePref } from "../../lib/usePref"; import { usePref } from "../../lib/usePref";
@@ -155,9 +155,9 @@ export default function PlaylistsRail() {
const r = await api.syncYoutubePlaylists(); const r = await api.syncYoutubePlaylists();
qc.invalidateQueries({ queryKey: qk.playlists() }); qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: qk.playlist() }); qc.invalidateQueries({ queryKey: qk.playlist() });
notify({ level: "success", message: t("playlists.syncedToast", { count: r.synced }) }); notify({ level: "success", message: msg("playlists.syncedToast", { count: r.synced }) });
} catch (e) { } catch (e) {
notifyYouTubeActionError(e, t("playlists.syncFailed")); notifyYouTubeActionError(e, msg("playlists.syncFailed"));
} }
setSyncing(false); setSyncing(false);
} }
@@ -31,6 +31,7 @@ vi.mock("../../lib/useMe", () => ({ useMe: () => ({ can_write: h.canWrite }) }))
import { usePlaylistActions } from "./usePlaylistActions"; import { usePlaylistActions } from "./usePlaylistActions";
import { HttpError, api } from "../../lib/api"; import { HttpError, api } from "../../lib/api";
import { msg, sayNotif, type NotifText } from "../../lib/notifications";
type Menuable = Parameters<ReturnType<typeof usePlaylistActions>["items"]>[0]; type Menuable = Parameters<ReturnType<typeof usePlaylistActions>["items"]>[0];
@@ -73,21 +74,21 @@ describe("usePlaylistActions — deleting a list", () => {
const { result } = mount(); const { result } = mount();
await act(() => result.current.remove(linked)); await act(() => result.current.remove(linked));
expect(h.notify).toHaveBeenCalledWith( expect(h.notify).toHaveBeenCalledWith(
expect.objectContaining({ title: "playlists.deleteYtFailed" }), expect.objectContaining({ title: msg("playlists.deleteYtFailed") }),
);
expect(h.notify).not.toHaveBeenCalledWith(
expect.objectContaining({ message: "Unknown playlist" }),
); );
expect(
h.notify.mock.calls.map(([n]) => sayNotif((n as { message: NotifText }).message)),
).not.toContain("Unknown playlist");
}); });
it("prefers a STRUCTURED reason, which the request layer has already localized", async () => { it("prefers a STRUCTURED reason, kept as the refusal to say in the reader's language", async () => {
vi.spyOn(api, "deletePlaylist").mockRejectedValue( vi.spyOn(api, "deletePlaylist").mockRejectedValue(
new HttpError(404, "Ez a lista már nem létezik.", { code: "not_found", reason: "playlist" }), new HttpError(404, "Ez a lista már nem létezik.", { code: "not_found", reason: "playlist" }),
); );
const { result } = mount(); const { result } = mount();
await act(() => result.current.remove(linked)); await act(() => result.current.remove(linked));
expect(h.notify).toHaveBeenCalledWith( expect(h.notify).toHaveBeenCalledWith(
expect.objectContaining({ message: "Ez a lista már nem létezik." }), expect.objectContaining({ message: { detail: { code: "not_found", reason: "playlist" } } }),
); );
}); });
}); });
@@ -4,10 +4,10 @@ import { ExternalLink, Pencil, RotateCcw, Trash2, Upload } from "lucide-react";
import { YouTubeIcon } from "../../lib/brandIcons"; import { YouTubeIcon } from "../../lib/brandIcons";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { api, type Playlist } from "../../lib/api"; import { api, type Playlist } from "../../lib/api";
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
import { notifyYouTubeActionError } from "../../lib/youtubeErrors"; import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
import { playlistName } from "../../lib/playlistName"; import { playlistName, playlistNameText } from "../../lib/playlistName";
import { useChoice, useConfirm } from "../../components/ui/confirmContext"; import { useChoice, useConfirm } from "../../components/ui/confirmContext";
import { usePlaylists, usePlaylistsActions } from "./playlistsContext"; import { usePlaylists, usePlaylistsActions } from "./playlistsContext";
import { useMe } from "../../lib/useMe"; import { useMe } from "../../lib/useMe";
@@ -72,7 +72,7 @@ export function usePlaylistActions() {
try { try {
await api.renamePlaylist(id, next); await api.renamePlaylist(id, next);
} catch (e) { } catch (e) {
notifyYouTubeActionError(e, t("playlists.renameFailed")); notifyYouTubeActionError(e, msg("playlists.renameFailed"));
} }
refresh(id); refresh(id);
} }
@@ -112,7 +112,7 @@ export function usePlaylistActions() {
// `req` already put it in its error dialog: not twice (notifyActionError checks). // `req` already put it in its error dialog: not twice (notifyActionError checks).
notifyActionError( notifyActionError(
err, err,
onYoutube ? t("playlists.deleteYtFailed") : t("playlists.deleteFailed"), msg(onYoutube ? "playlists.deleteYtFailed" : "playlists.deleteFailed"),
"warning", "warning",
); );
} }
@@ -136,29 +136,28 @@ export function usePlaylistActions() {
refresh(pl.id); refresh(pl.id);
notify({ notify({
level: "success", level: "success",
message: t("playlists.revertDone", { name: playlistName(pl, t) }), message: msg("playlists.revertDone", { name: playlistNameText(pl) }),
}); });
} catch (e) { } catch (e) {
notifyYouTubeActionError(e, t("playlists.revertFailed")); notifyYouTubeActionError(e, msg("playlists.revertFailed"));
} }
mark("reverting", pl.id, false); mark("reverting", pl.id, false);
} }
async function push(pl: Menuable) { async function push(pl: Menuable) {
if (syncing(pl.id)) return; if (syncing(pl.id)) return;
const name = playlistName(pl, t);
mark("pushing", pl.id, true); mark("pushing", pl.id, true);
try { try {
await pushSteps(pl, name); await pushSteps(pl);
} catch (e) { } catch (e) {
notifyYouTubeActionError(e, t("playlists.pushFailed")); notifyYouTubeActionError(e, msg("playlists.pushFailed"));
} }
mark("pushing", pl.id, false); mark("pushing", pl.id, false);
} }
/** push()'s steps, apart so its "pushing" mark clears on every way out without a try/finally /** push()'s steps, apart so its "pushing" mark clears on every way out without a try/finally
* (the React Compiler cannot lower one yet, and skips the whole hook). */ * (the React Compiler cannot lower one yet, and skips the whole hook). */
async function pushSteps(pl: Menuable, name: string) { async function pushSteps(pl: Menuable) {
const plan = await api.playlistPushPlan(pl.id); const plan = await api.playlistPushPlan(pl.id);
const nothingToDo = const nothingToDo =
plan.action === "update" && plan.action === "update" &&
@@ -169,13 +168,16 @@ export function usePlaylistActions() {
!plan.to_reorder && !plan.to_reorder &&
!plan.rename; !plan.rename;
if (nothingToDo) { if (nothingToDo) {
notify({ level: "info", message: t("playlists.pushUpToDate", { name }) }); notify({
level: "info",
message: msg("playlists.pushUpToDate", { name: playlistNameText(pl) }),
});
return; return;
} }
if (!plan.affordable) { if (!plan.affordable) {
notify({ notify({
level: "warning", level: "warning",
message: t("playlists.pushNoQuota", { message: msg("playlists.pushNoQuota", {
left: plan.remaining_today, left: plan.remaining_today,
units: plan.units_estimate, units: plan.units_estimate,
}), }),
@@ -224,10 +226,13 @@ export function usePlaylistActions() {
if (r.failures.length) { if (r.failures.length) {
notify({ notify({
level: "warning", level: "warning",
message: t("playlists.pushPartial", { count: r.failures.length }), message: msg("playlists.pushPartial", { count: r.failures.length }),
}); });
} else { } else {
notify({ level: "success", message: t("playlists.pushDone", { name }) }); notify({
level: "success",
message: msg("playlists.pushDone", { name: playlistNameText(pl) }),
});
} }
} }
+1 -1
View File
@@ -393,7 +393,7 @@ function PlexBrowseView({ sub }: { sub: ReturnType<typeof useHistorySubview<Sub>
); );
} }
const toggleWatched = (card: PlexCard) => const toggleWatched = (card: PlexCard) =>
toggleWatchState(card, t, { toggleWatchState(card, {
optimistic: (next) => optimisticStatus(card.id, next), optimistic: (next) => optimisticStatus(card.id, next),
resync: () => qc.invalidateQueries({ queryKey: qk.plexLibrary() }), resync: () => qc.invalidateQueries({ queryKey: qk.plexLibrary() }),
}); });
@@ -13,6 +13,7 @@ import { useConfirm } from "../../components/ui/confirmContext";
import { Pressable } from "../../components/ui/Pressable"; import { Pressable } from "../../components/ui/Pressable";
import { Input } from "../../components/ui/Input"; import { Input } from "../../components/ui/Input";
import { SURFACE } from "../../components/ui/surfaces"; import { SURFACE } from "../../components/ui/surfaces";
import { msg } from "../../lib/notifications";
// Admin-only "manage this movie's collections" dialog. Lists the library's EDITABLE // Admin-only "manage this movie's collections" dialog. Lists the library's EDITABLE
// collections with an in/out toggle (add/remove the movie, written back to Plex), plus a field to // collections with an in/out toggle (add/remove the movie, written back to Plex), plus a field to
@@ -85,7 +86,7 @@ export default function PlexCollectionEditor({
// 403/404 are left to the caller by `req` (see reportFailure), so without this the tick // 403/404 are left to the caller by `req` (see reportFailure), so without this the tick
// simply never appeared and the user clicked again — a collection deleted on the Plex server // simply never appeared and the user clicked again — a collection deleted on the Plex server
// or flipped read-only failed in silence. // or flipped read-only failed in silence.
notifyActionError(e, t("plex.collection.editFailed")); notifyActionError(e, msg("plex.collection.editFailed"));
} }
setBusy(null); setBusy(null);
} }
@@ -97,7 +98,7 @@ export default function PlexCollectionEditor({
await api.plexSetCollectionEditable(id, true); await api.plexSetCollectionEditable(id, true);
refresh(); refresh();
} catch (e) { } catch (e) {
notifyActionError(e, t("plex.collection.editFailed")); notifyActionError(e, msg("plex.collection.editFailed"));
} }
setBusy(null); setBusy(null);
} }
@@ -114,7 +115,7 @@ export default function PlexCollectionEditor({
} catch (e) { } catch (e) {
// The backend rejects a duplicate name with 409 (Plex conflates same names), structured — so // The backend rejects a duplicate name with 409 (Plex conflates same names), structured — so
// it reads in the reader's language (see localizeDetail). // it reads in the reader's language (see localizeDetail).
notifyActionError(e, t("plex.collection.editFailed")); notifyActionError(e, msg("plex.collection.editFailed"));
} }
setBusy(null); setBusy(null);
} }
@@ -134,7 +135,7 @@ export default function PlexCollectionEditor({
} catch (e) { } catch (e) {
// The worst of the three to lose: the user has just confirmed a destructive dialog, and the // The worst of the three to lose: the user has just confirmed a destructive dialog, and the
// row stayed where it was with nothing said. // row stayed where it was with nothing said.
notifyActionError(e, t("plex.collection.deleteFailed")); notifyActionError(e, msg("plex.collection.deleteFailed"));
} }
setBusy(null); setBusy(null);
} }
@@ -44,6 +44,7 @@ vi.mock("../../lib/notifications", async (importOriginal) => ({
})); }));
import PlexCollectionView from "./PlexCollectionView"; import PlexCollectionView from "./PlexCollectionView";
import { msg } from "../../lib/notifications";
import { import {
api, api,
HttpError, HttpError,
@@ -135,7 +136,7 @@ describe("PlexCollectionView — Play-all", () => {
fireEvent.click(await screen.findByText("plex.collection.playAll")); fireEvent.click(await screen.findByText("plex.collection.playAll"));
await waitFor(() => await waitFor(() =>
expect(h.notify).toHaveBeenCalledWith( expect(h.notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "info", message: "plex.collection.playEmpty" }), expect.objectContaining({ level: "info", message: msg("plex.collection.playEmpty") }),
), ),
); );
expect(onPlay).not.toHaveBeenCalled(); expect(onPlay).not.toHaveBeenCalled();
@@ -8,7 +8,7 @@ import { plexLang } from "../../i18n";
import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollectionDetail } from "../../lib/api"; import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollectionDetail } from "../../lib/api";
import { formatRuntime } from "../../lib/format"; import { formatRuntime } from "../../lib/format";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { showEpisodeKeys } from "../../lib/plexQueue"; import { showEpisodeKeys } from "../../lib/plexQueue";
import { healPlexSort, plexSortOptions } from "../../lib/plexSort"; import { healPlexSort, plexSortOptions } from "../../lib/plexSort";
import { usePref } from "../../lib/usePref"; import { usePref } from "../../lib/usePref";
@@ -154,9 +154,9 @@ export default function PlexCollectionView({
// canPlay gates on "has a show member", but a show can expand to zero episodes (unmatched / // canPlay gates on "has a show member", but a show can expand to zero episodes (unmatched /
// empty). An enabled Play-all that silently no-ops is a dishonest state, so say so plainly. // empty). An enabled Play-all that silently no-ops is a dishonest state, so say so plainly.
if (queue.length > 0) onPlay(queue[0]!, queue); if (queue.length > 0) onPlay(queue[0]!, queue);
else notify({ level: "info", message: t("plex.collection.playEmpty") }); else notify({ level: "info", message: msg("plex.collection.playEmpty") });
} catch (e) { } catch (e) {
notifyActionError(e, t("plex.collection.playFailed")); notifyActionError(e, msg("plex.collection.playFailed"));
} }
setQueueBusy(false); setQueueBusy(false);
} }
@@ -168,7 +168,7 @@ export default function PlexCollectionView({
const readOnly = !d?.can_edit && !canEnableEdit; // external / smart auto-list const readOnly = !d?.can_edit && !canEnableEdit; // external / smart auto-list
const toggleWatched = (card: PlexCard) => const toggleWatched = (card: PlexCard) =>
toggleWatchState(card, t, { toggleWatchState(card, {
optimistic: (next) => { optimistic: (next) => {
qc.setQueryData<PlexCollectionDetail>(detailKey, (old) => qc.setQueryData<PlexCollectionDetail>(detailKey, (old) =>
old old
@@ -211,7 +211,7 @@ export default function PlexCollectionView({
setRenaming(false); setRenaming(false);
} catch (e) { } catch (e) {
// The backend enforces per-library name uniqueness — a taken name comes back localized. // The backend enforces per-library name uniqueness — a taken name comes back localized.
notifyActionError(e, t("plex.collection.editFailed")); notifyActionError(e, msg("plex.collection.editFailed"));
} }
setBusy(false); setBusy(false);
} }
@@ -226,7 +226,7 @@ export default function PlexCollectionView({
invalidate(); invalidate();
onBack(); onBack();
} catch (e) { } catch (e) {
notifyActionError(e, t("plex.collection.editFailed")); notifyActionError(e, msg("plex.collection.editFailed"));
setBusy(false); setBusy(false);
} }
} }
@@ -240,7 +240,7 @@ export default function PlexCollectionView({
await api.plexCollectionRemoveItem(collectionId, card.id); await api.plexCollectionRemoveItem(collectionId, card.id);
invalidate(); invalidate();
} catch (e) { } catch (e) {
notifyActionError(e, t("plex.collection.editFailed")); notifyActionError(e, msg("plex.collection.editFailed"));
} }
setBusy(false); setBusy(false);
} }
@@ -251,7 +251,7 @@ export default function PlexCollectionView({
await api.plexSetCollectionEditable(collectionId, true); await api.plexSetCollectionEditable(collectionId, true);
invalidate(); invalidate();
} catch (e) { } catch (e) {
notifyActionError(e, t("plex.collection.editFailed")); notifyActionError(e, msg("plex.collection.editFailed"));
} }
setBusy(false); setBusy(false);
} }
@@ -495,7 +495,7 @@ function AddItemPanel({
setAdded((s) => new Set(s).add(card.id)); setAdded((s) => new Set(s).add(card.id));
onAdded(); onAdded();
} catch (e) { } catch (e) {
notifyActionError(e, t("plex.collection.editFailed")); notifyActionError(e, msg("plex.collection.editFailed"));
} }
} }
+1 -1
View File
@@ -172,7 +172,7 @@ export default function PlexDiscover({
: old, : old,
); );
const toggleWatched = (card: PlexCard) => const toggleWatched = (card: PlexCard) =>
toggleWatchState(card, t, { toggleWatchState(card, {
optimistic: (next) => { optimistic: (next) => {
const flip = (cards: PlexCard[]) => const flip = (cards: PlexCard[]) =>
cards.map((c) => (c.id === card.id ? { ...c, status: next } : c)); cards.map((c) => (c.id === card.id ? { ...c, status: next } : c));
+3 -2
View File
@@ -36,6 +36,7 @@ import { usePlexCardActions } from "../../features/plexCards/usePlexCardActions"
import { toggleWatchState } from "../../lib/plexWatch"; import { toggleWatchState } from "../../lib/plexWatch";
import { vocab } from "../../lib/vocab"; import { vocab } from "../../lib/vocab";
import RemoteImage from "../../components/ui/RemoteImage"; import RemoteImage from "../../components/ui/RemoteImage";
import { msg } from "../../lib/notifications";
// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating, // The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating,
// genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from // genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from
@@ -254,7 +255,7 @@ export default function PlexInfo({
try { try {
await api.plexSetState(detail.id, status); await api.plexSetState(detail.id, status);
} catch (e) { } catch (e) {
notifyActionError(e, t("plex.watchedFailed")); notifyActionError(e, msg("plex.watchedFailed"));
} }
qc.invalidateQueries({ queryKey: qk.plexItem(detail.id) }); qc.invalidateQueries({ queryKey: qk.plexItem(detail.id) });
qc.invalidateQueries({ queryKey: qk.plexLibrary() }); qc.invalidateQueries({ queryKey: qk.plexLibrary() });
@@ -267,7 +268,7 @@ export default function PlexInfo({
// is; the resync corrects it to the truth either way. // is; the resync corrects it to the truth either way.
const colMenu = useContextMenu<PlexCard>(); const colMenu = useContextMenu<PlexCard>();
const toggleColWatched = (card: PlexCard) => const toggleColWatched = (card: PlexCard) =>
toggleWatchState(card, t, { toggleWatchState(card, {
optimistic: (next) => optimistic: (next) =>
qc.setQueriesData<PlexItemDetail>({ queryKey: qk.plexItem(detail.id) }, (old) => qc.setQueriesData<PlexItemDetail>({ queryKey: qk.plexItem(detail.id) }, (old) =>
old old
@@ -20,7 +20,7 @@ vi.mock("react-i18next", async (importOriginal) => {
import PlexPlayer from "./PlexPlayer"; import PlexPlayer from "./PlexPlayer";
import { api, HttpError, type PlexItemDetail } from "../../lib/api"; import { api, HttpError, type PlexItemDetail } from "../../lib/api";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { notify } from "../../lib/notifications"; import { notify, raw } from "../../lib/notifications";
import { claimLocalData } from "../../lib/storage"; import { claimLocalData } from "../../lib/storage";
type Media = HTMLMediaElement & { _t?: number }; type Media = HTMLMediaElement & { _t?: number };
@@ -285,7 +285,7 @@ it("shows a toast raised while it is open inside itself, not under its scrim", a
mount({ details: { A: item("A") }, sessions: { A: () => new Promise(() => {}) } }); mount({ details: { A: item("A") }, sessions: { A: () => new Promise(() => {}) } });
await screen.findByText("Title A"); await screen.findByText("Title A");
act(() => { act(() => {
notify({ level: "error", message: "Couldn't mark it watched." }); notify({ level: "error", message: raw("Couldn't mark it watched.") });
}); });
const toast = await screen.findByText("Couldn't mark it watched."); const toast = await screen.findByText("Couldn't mark it watched.");
let host: Element | null = video(); let host: Element | null = video();
+3 -2
View File
@@ -103,6 +103,7 @@ import { closeThenAct, useAnchoredPopover } from "../../components/ui/anchoredPo
import { Slider } from "../../components/ui/NativeControls"; import { Slider } from "../../components/ui/NativeControls";
import { formatUnit } from "../../lib/format"; import { formatUnit } from "../../lib/format";
import { trackLabel } from "../../lib/vocab"; import { trackLabel } from "../../lib/vocab";
import { msg } from "../../lib/notifications";
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page. // The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
const PlexInfo = lazy(() => import("./PlexInfo")); const PlexInfo = lazy(() => import("./PlexInfo"));
@@ -716,8 +717,8 @@ export default function PlexPlayer({
}); });
const reportWatchedError = useCallback( const reportWatchedError = useCallback(
(e: unknown) => notifyActionError(e, t("plex.watchedFailed")), (e: unknown) => notifyActionError(e, msg("plex.watchedFailed")),
[t], [],
); );
const go = useCallback( const go = useCallback(
@@ -29,6 +29,7 @@ vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => h.confi
import PlexPlaylistView from "./PlexPlaylistView"; import PlexPlaylistView from "./PlexPlaylistView";
import { api, type PlexCard } from "../../lib/api"; import { api, type PlexCard } from "../../lib/api";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { msg, type NotifText } from "../../lib/notifications";
beforeAll(() => { beforeAll(() => {
class Stub { class Stub {
@@ -98,8 +99,8 @@ const moreOf = (text: string) =>
rowOf(text).querySelector<HTMLElement>('button[aria-label="plex.playlist.rowMenu"]')!; rowOf(text).querySelector<HTMLElement>('button[aria-label="plex.playlist.rowMenu"]')!;
const toast = () => const toast = () =>
h.notify.mock.calls h.notify.mock.calls
.map((c) => c[0] as { message: string; action?: { label: string; onClick: () => void } }) .map((c) => c[0] as { message: NotifText; action?: { label: string; onClick: () => void } })
.filter((n) => n.message?.startsWith("plex.playlist.removedToast")) .filter((n) => "key" in n.message && n.message.key === "plex.playlist.removedToast")
.pop(); .pop();
// The list's keys are live once its effect has registered it (`data-nav-list`), a passive effect that // The list's keys are live once its effect has registered it (`data-nav-list`), a passive effect that
// can run AFTER the rows are on screen: a key pressed on a slow machine in between did nothing (CI flake). // can run AFTER the rows are on screen: a key pressed on a slow machine in between did nothing (CI flake).
@@ -181,7 +182,9 @@ describe("PlexPlaylistView · a row's menu", () => {
await pick(() => fireEvent.contextMenu(rowOf("Movie 2")), "plex.playlist.removeFromPlaylist"); await pick(() => fireEvent.contextMenu(rowOf("Movie 2")), "plex.playlist.removeFromPlaylist");
await waitFor(() => expect(api.plexPlaylistRemoveItem).toHaveBeenCalledWith(5, "2")); await waitFor(() => expect(api.plexPlaylistRemoveItem).toHaveBeenCalledWith(5, "2"));
expect(h.confirm).not.toHaveBeenCalled(); expect(h.confirm).not.toHaveBeenCalled();
await waitFor(() => expect(toast()?.message).toBe("plex.playlist.removedToast:Movie 2")); await waitFor(() =>
expect(toast()?.message).toEqual(msg("plex.playlist.removedToast", { title: "Movie 2" })),
);
act(() => toast()!.action!.onClick()); act(() => toast()!.action!.onClick());
await waitFor(() => expect(add).toHaveBeenCalledWith(5, ["2"], [1])); await waitFor(() => expect(add).toHaveBeenCalledWith(5, ["2"], [1]));
}); });
@@ -208,7 +211,9 @@ describe("PlexPlaylistView · a row's menu", () => {
const bulk = vi.spyOn(api, "plexPlaylistRemoveBulk").mockResolvedValue({} as never); const bulk = vi.spyOn(api, "plexPlaylistRemoveBulk").mockResolvedValue({} as never);
await pick(() => fireEvent.click(moreOf("The Show")), "plex.playlist.removeShow"); await pick(() => fireEvent.click(moreOf("The Show")), "plex.playlist.removeShow");
await waitFor(() => expect(bulk).toHaveBeenCalledWith(5, ["e1", "e2"])); await waitFor(() => expect(bulk).toHaveBeenCalledWith(5, ["e1", "e2"]));
await waitFor(() => expect(toast()?.message).toBe("plex.playlist.removedToast:The Show")); await waitFor(() =>
expect(toast()?.message).toEqual(msg("plex.playlist.removedToast", { title: "The Show" })),
);
}); });
it("removes one season from its label's menu", async () => { it("removes one season from its label's menu", async () => {
@@ -349,7 +354,7 @@ describe("PlexPlaylistView · a failed watched toggle", () => {
await pick(() => fireEvent.contextMenu(rowOf("Movie 2")), "plex.markWatched"); await pick(() => fireEvent.contextMenu(rowOf("Movie 2")), "plex.markWatched");
await waitFor(() => await waitFor(() =>
expect(h.notify).toHaveBeenCalledWith( expect(h.notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "error", title: "plex.watchedFailed" }), expect.objectContaining({ level: "error", title: msg("plex.watchedFailed") }),
), ),
); );
await act(async () => {}); await act(async () => {});
@@ -58,6 +58,7 @@ import { MEDIA_BADGE } from "../../components/ui/mediaCardStyles";
import { seasonTitle } from "../../lib/plexDetail"; import { seasonTitle } from "../../lib/plexDetail";
import { cardImage } from "../../lib/plexImage"; import { cardImage } from "../../lib/plexImage";
import RemoteImage from "../../components/ui/RemoteImage"; import RemoteImage from "../../components/ui/RemoteImage";
import { msg } from "../../lib/notifications";
// A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the // A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the
// same show collapses into a season/show group so a whole series doesn't sprawl into an endless flat // same show collapses into a season/show group so a whole series doesn't sprawl into an endless flat
@@ -459,7 +460,7 @@ export default function PlexPlaylistView({
try { try {
await api.plexRenamePlaylist(playlistId, title); await api.plexRenamePlaylist(playlistId, title);
} catch (e) { } catch (e) {
notifyActionError(e, t("plex.playlist.renameFailed")); // the field stays open to try again notifyActionError(e, msg("plex.playlist.renameFailed")); // the field stays open to try again
return; return;
} }
setRenaming(false); setRenaming(false);
@@ -479,7 +480,7 @@ export default function PlexPlaylistView({
// Gone already (deleted on another device): what was asked for is true — leave the dead page. // Gone already (deleted on another device): what was asked for is true — leave the dead page.
// Anything else is a real failure, and the page stays. // Anything else is a real failure, and the page stays.
if (!(e instanceof HttpError && e.status === 404)) { if (!(e instanceof HttpError && e.status === 404)) {
notifyActionError(e, t("plex.playlist.deleteFailed")); notifyActionError(e, msg("plex.playlist.deleteFailed"));
return; return;
} }
} }
@@ -569,7 +570,7 @@ export default function PlexPlaylistView({
onRestart: (c) => onPlay(c.id, order, true), onRestart: (c) => onPlay(c.id, order, true),
onInfo: onInfoCard, onInfo: onInfoCard,
onToggleWatched: (c) => onToggleWatched: (c) =>
void toggleWatchState(c, t, { void toggleWatchState(c, {
optimistic: (next) => setStatus(c.id, next), optimistic: (next) => setStatus(c.id, next),
// The flip is on the view's LOCAL copy: a failed write's resync refetches an identical // The flip is on the view's LOCAL copy: a failed write's resync refetches an identical
// list, react-query keeps the same `data`, and the copy is never re-seeded from it. // list, react-query keeps the same `data`, and the copy is never re-seeded from it.
+2 -1
View File
@@ -22,6 +22,7 @@ import { usePlex, usePlexActions } from "./plexContext";
import { DismissibleChip } from "../../components/ui/Chip"; import { DismissibleChip } from "../../components/ui/Chip";
import { Input } from "../../components/ui/Input"; import { Input } from "../../components/ui/Input";
import { useRail } from "../../lib/useRail"; import { useRail } from "../../lib/useRail";
import { msg } from "../../lib/notifications";
// The Plex module's left filter column — now the shared floating SidePanel with its filter groups // The Plex module's left filter column — now the shared floating SidePanel with its filter groups
// as reorderable "islands" (same system as the feed rail). Movie libraries get the full metadata // as reorderable "islands" (same system as the feed rail). Movie libraries get the full metadata
@@ -350,7 +351,7 @@ export function PlaylistPicker() {
onOpenPlaylist(pl.id); onOpenPlaylist(pl.id);
} catch (e) { } catch (e) {
// Called without an await from the picker, so a refusal was an unhandled rejection. // Called without an await from the picker, so a refusal was an unhandled rejection.
notifyActionError(e, t("plex.playlistAdd.createFailed")); notifyActionError(e, msg("plex.playlistAdd.createFailed"));
} }
} }
@@ -16,6 +16,7 @@ import { Button } from "../../components/ui/Button";
import { IconButton } from "../../components/ui/IconButton"; import { IconButton } from "../../components/ui/IconButton";
import { Input } from "../../components/ui/Input"; import { Input } from "../../components/ui/Input";
import { SURFACE } from "../../components/ui/surfaces"; import { SURFACE } from "../../components/ui/surfaces";
import { msg } from "../../lib/notifications";
// A collection tile's sub-line: item count · total runtime (each dropped when unknown, e.g. show-only // A collection tile's sub-line: item count · total runtime (each dropped when unknown, e.g. show-only
// collections have no movie runtime). // collections have no movie runtime).
@@ -103,7 +104,7 @@ function NewCollectionPanel({
} catch (e) { } catch (e) {
// The backend is authoritative on per-library name uniqueness — a taken name comes back // The backend is authoritative on per-library name uniqueness — a taken name comes back
// structured, and so localized (see localizeDetail). // structured, and so localized (see localizeDetail).
notifyActionError(e, t("plex.collection.editFailed")); notifyActionError(e, msg("plex.collection.editFailed"));
setBusy(false); setBusy(false);
} }
} }
+6 -5
View File
@@ -69,6 +69,7 @@ import { PageHeading } from "../../components/ui/Headings";
import { vocab } from "../../lib/vocab"; import { vocab } from "../../lib/vocab";
import { formatDate, formatDecimal } from "../../lib/format"; import { formatDate, formatDecimal } from "../../lib/format";
import RemoteImage from "../../components/ui/RemoteImage"; import RemoteImage from "../../components/ui/RemoteImage";
import { msg } from "../../lib/notifications";
// The Plex sub-router's view branches, split out of PlexBrowse. Each is invoked by PlexBrowse with // The Plex sub-router's view branches, split out of PlexBrowse. Each is invoked by PlexBrowse with
// shell callbacks as props (onBack/onPlay/onFilter/…) — no view reaches back into the shell's state, // shell callbacks as props (onBack/onPlay/onFilter/…) — no view reaches back into the shell's state,
@@ -209,7 +210,7 @@ export function PlexPersonView({
// (both the movies and shows arrays — an id matches only one), then persist + resync via the shared // (both the movies and shows arrays — an id matches only one), then persist + resync via the shared
// helper; also invalidate the library so its grid agrees. // helper; also invalidate the library so its grid agrees.
const toggle = (card: PlexCard) => const toggle = (card: PlexCard) =>
toggleWatchState(card, t, { toggleWatchState(card, {
optimistic: (next) => optimistic: (next) =>
qc.setQueryData<PlexPersonDetail>(personKey, (old) => qc.setQueryData<PlexPersonDetail>(personKey, (old) =>
old old
@@ -500,7 +501,7 @@ export function PlexShowView({
try { try {
await api.plexShowState(showId, w); await api.plexShowState(showId, w);
} catch (e) { } catch (e) {
notifyActionError(e, t("plex.watchedFailed")); notifyActionError(e, msg("plex.watchedFailed"));
} }
setBusy(false); setBusy(false);
qc.invalidateQueries({ queryKey: qk.plexShow(showId) }); qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
@@ -510,7 +511,7 @@ export function PlexShowView({
try { try {
await api.plexSeasonState(seasonRk, w); await api.plexSeasonState(seasonRk, w);
} catch (e) { } catch (e) {
notifyActionError(e, t("plex.watchedFailed")); notifyActionError(e, msg("plex.watchedFailed"));
} }
qc.invalidateQueries({ queryKey: qk.plexShow(showId) }); qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
qc.invalidateQueries({ queryKey: qk.plexLibrary() }); qc.invalidateQueries({ queryKey: qk.plexLibrary() });
@@ -836,7 +837,7 @@ export function PlexSeasonView({
try { try {
await api.plexSeasonState(seasonId, w); await api.plexSeasonState(seasonId, w);
} catch (e) { } catch (e) {
notifyActionError(e, t("plex.watchedFailed")); notifyActionError(e, msg("plex.watchedFailed"));
} }
setBusy(false); setBusy(false);
qc.invalidateQueries({ queryKey: qk.plexShow(showId) }); qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
@@ -847,7 +848,7 @@ export function PlexSeasonView({
try { try {
await api.plexSetState(ep.id, next); await api.plexSetState(ep.id, next);
} catch (e) { } catch (e) {
notifyActionError(e, t("plex.watchedFailed")); notifyActionError(e, msg("plex.watchedFailed"));
} }
qc.invalidateQueries({ queryKey: qk.plexShow(showId) }); qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
qc.invalidateQueries({ queryKey: qk.plexLibrary() }); qc.invalidateQueries({ queryKey: qk.plexLibrary() });

Some files were not shown because too many files have changed in this diff Show More