fix(notifications): a notice keeps what to say and says it in the reader's language when shown
This commit is contained in:
@@ -22,7 +22,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
});
|
||||
vi.mock("./components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
|
||||
const { notify } = vi.hoisted(() => ({
|
||||
notify: vi.fn((_n: { level: string; message: string }) => 0),
|
||||
notify: vi.fn((_n: { level: string; message: NotifText }) => 0),
|
||||
}));
|
||||
vi.mock("./lib/notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./lib/notifications")>()),
|
||||
@@ -35,6 +35,7 @@ import PlexPlaylistAdd from "./features/plexCards/PlexPlaylistAdd";
|
||||
import TagManager from "./features/channel/TagManager";
|
||||
import { api, HttpError, type PlexCard } from "./lib/api";
|
||||
import { qk } from "./lib/queryKeys";
|
||||
import type { NotifText } from "./lib/notifications";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
@@ -98,7 +99,7 @@ describe("the Plex playlist page", () => {
|
||||
() => fireEvent.contextMenu(screen.getByText("Movie 1").closest("[data-nav-index]")!),
|
||||
"plex.playlist.removeFromPlaylist",
|
||||
);
|
||||
await waitFor(() => expect(errors()).toEqual(["gone playlist"]));
|
||||
await waitFor(() => expect(errors()).toEqual([{ detail: gone("playlist").detailData }]));
|
||||
await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
|
||||
expect(screen.getByText("Movie 1")).toBeTruthy();
|
||||
});
|
||||
@@ -119,7 +120,7 @@ describe("the Plex playlist page", () => {
|
||||
const field = await screen.findByDisplayValue("Films");
|
||||
fireEvent.change(field, { target: { value: "Movies" } });
|
||||
fireEvent.keyDown(field, { key: "Enter" });
|
||||
await waitFor(() => expect(errors()).toEqual(["gone playlist"]));
|
||||
await waitFor(() => expect(errors()).toEqual([{ detail: gone("playlist").detailData }]));
|
||||
expect(screen.getByDisplayValue("Movies")).toBeTruthy(); // still open to try again
|
||||
});
|
||||
|
||||
@@ -154,7 +155,7 @@ describe("the add-to-playlist popover", () => {
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText("playlists.addToPlaylist"));
|
||||
fireEvent.click(await screen.findByRole("menuitemcheckbox", { hidden: true }));
|
||||
await waitFor(() => expect(errors()).toEqual(["gone playlist"]));
|
||||
await waitFor(() => expect(errors()).toEqual([{ detail: gone("playlist").detailData }]));
|
||||
await waitFor(() => expect(lists.mock.calls.length).toBeGreaterThan(1));
|
||||
});
|
||||
|
||||
@@ -282,7 +283,7 @@ describe("the add-to-playlist popover", () => {
|
||||
fireEvent.click(await screen.findByText("Gone"));
|
||||
// The server's own localized reason wins over the caller's fallback when the detail is
|
||||
// STRUCTURED — same as the twin above. What is under test is that anything is said at all.
|
||||
await waitFor(() => expect(errors()).toEqual(["gone playlist"]));
|
||||
await waitFor(() => expect(errors()).toEqual([{ detail: gone("playlist").detailData }]));
|
||||
await waitFor(() => expect(lists.mock.calls.length).toBeGreaterThan(1));
|
||||
});
|
||||
});
|
||||
@@ -300,7 +301,7 @@ describe("the tag manager", () => {
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
});
|
||||
vi.mock("./components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
|
||||
const { notify } = vi.hoisted(() => ({
|
||||
notify: vi.fn((_n: { level: string; title?: string; message: string }) => 0),
|
||||
notify: vi.fn((_n: { level: string; title?: NotifText; message: NotifText }) => 0),
|
||||
}));
|
||||
vi.mock("./lib/notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./lib/notifications")>()),
|
||||
@@ -30,6 +30,7 @@ import PlexCollectionEditor from "./modules/plex/PlexCollectionEditor";
|
||||
import UploadDialog from "./modules/files/UploadDialog";
|
||||
import DownloadDialog from "./features/downloads/DownloadDialog";
|
||||
import { api, HttpError } from "./lib/api";
|
||||
import { msg, type NotifText } from "./lib/notifications";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
@@ -87,7 +88,7 @@ describe("the Plex collection editor", () => {
|
||||
);
|
||||
mount();
|
||||
fireEvent.click(await screen.findByText("Marathon"));
|
||||
await waitFor(() => expect(errors()).toEqual(["plex.collection.editFailed"]));
|
||||
await waitFor(() => expect(errors()).toEqual([msg("plex.collection.editFailed")]));
|
||||
});
|
||||
|
||||
it("says so when enabling editing is refused", async () => {
|
||||
@@ -97,7 +98,7 @@ describe("the Plex collection editor", () => {
|
||||
);
|
||||
mount();
|
||||
fireEvent.click(await screen.findByText("plex.collEditor.makeEditable"));
|
||||
await waitFor(() => expect(errors()).toEqual(["plex.collection.editFailed"]));
|
||||
await waitFor(() => expect(errors()).toEqual([msg("plex.collection.editFailed")]));
|
||||
});
|
||||
|
||||
it("says so when a CONFIRMED delete does not happen", async () => {
|
||||
@@ -108,7 +109,7 @@ describe("the Plex collection editor", () => {
|
||||
);
|
||||
mount();
|
||||
fireEvent.click(await screen.findByText("plex.collEditor.delete"));
|
||||
await waitFor(() => expect(errors()).toEqual(["plex.collection.deleteFailed"]));
|
||||
await waitFor(() => expect(errors()).toEqual([msg("plex.collection.deleteFailed")]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -144,7 +145,7 @@ describe("the upload dialog", () => {
|
||||
mount();
|
||||
pick();
|
||||
fireEvent.click(screen.getByText("downloads.upload.submit"));
|
||||
await waitFor(() => expect(errors()).toEqual(["downloads.upload.failed"]));
|
||||
await waitFor(() => expect(errors()).toEqual([msg("downloads.upload.failed")]));
|
||||
});
|
||||
|
||||
it("says nothing when the user cancelled it themselves", async () => {
|
||||
@@ -172,6 +173,6 @@ describe("the download dialog", () => {
|
||||
);
|
||||
await screen.findByText("Best");
|
||||
fireEvent.click(screen.getByText("downloads.dialog.submit"));
|
||||
await waitFor(() => expect(errors()).toEqual(["errors.writeFailed.enqueueDownload"]));
|
||||
await waitFor(() => expect(errors()).toEqual([msg("errors.writeFailed.enqueueDownload")]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
vi.mock("../../lib/history", () => ({ useBackToClose: () => {} }));
|
||||
|
||||
import Modal from "./Modal";
|
||||
import { clearAll, notify } from "../../lib/notifications";
|
||||
import { clearAll, notify, raw } from "../../lib/notifications";
|
||||
import { pushLayer, removeLayer, useLayer } from "../../lib/layerStack";
|
||||
import { createPortal, flushSync } from "react-dom";
|
||||
|
||||
@@ -121,7 +121,7 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
|
||||
<Body />
|
||||
</Modal>,
|
||||
);
|
||||
act(() => void notify({ level: "info", message: "Saved" }));
|
||||
act(() => void notify({ level: "info", message: raw("Saved") }));
|
||||
try {
|
||||
const dismissToast = screen.getByTitle("notifications.dismiss");
|
||||
// The panel's last control is no longer the trap's edge: Tab carries on (natively) to the toast.
|
||||
@@ -234,7 +234,7 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
|
||||
<Body />
|
||||
</Modal>,
|
||||
);
|
||||
act(() => void notify({ level: "error", message: "Nope" }));
|
||||
act(() => void notify({ level: "error", message: raw("Nope") }));
|
||||
const dismissToast = screen.getByTitle("notifications.dismiss");
|
||||
dismissToast.focus();
|
||||
expect(document.activeElement).toBe(dismissToast); // the scenario is actually being driven
|
||||
@@ -455,7 +455,7 @@ describe("Modal — toasts raised while it is open", () => {
|
||||
<Body />
|
||||
</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.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
});
|
||||
|
||||
import Toaster from "./Toaster";
|
||||
import { clearAll, notify } from "../../lib/notifications";
|
||||
import { clearAll, notify, raw } from "../../lib/notifications";
|
||||
import { dockUnderToasts } from "../../lib/toastDock";
|
||||
|
||||
afterEach(() => {
|
||||
@@ -31,7 +31,7 @@ describe("Toaster — one host shows the toasts", () => {
|
||||
<Toaster />
|
||||
</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.");
|
||||
|
||||
const overlay = render(
|
||||
@@ -54,7 +54,7 @@ describe("Toaster — one host shows the toasts", () => {
|
||||
it("lets a click through where there is no toast and nothing docked, and not where there is", () => {
|
||||
const Docked = () => <button>dock</button>;
|
||||
const stop = dockUnderToasts(Docked);
|
||||
notify({ level: "info", message: "hello" });
|
||||
notify({ level: "info", message: raw("hello") });
|
||||
render(<Toaster />);
|
||||
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
|
||||
@@ -75,7 +75,7 @@ describe("Toaster — one host shows the toasts", () => {
|
||||
<Toaster overlay />
|
||||
</div>,
|
||||
);
|
||||
act(() => void notify({ level: "error", message: "boom" }));
|
||||
act(() => void notify({ level: "error", message: raw("boom") }));
|
||||
fireEvent.click(screen.getByText("boom"));
|
||||
expect(backdrop).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -86,11 +86,11 @@ describe("Toaster — handing the toasts to another host", () => {
|
||||
// The new host gets them as NEW nodes: a screen reader read every one out again, an error
|
||||
// assertively, each time a player (or any dialog) opened or closed.
|
||||
render(<Toaster />);
|
||||
act(() => void notify({ level: "error", message: "old news" }));
|
||||
act(() => void notify({ level: "error", message: raw("old news") }));
|
||||
render(<Toaster overlay />);
|
||||
const old = screen.getByText("old news").closest(".toast-card")!;
|
||||
expect(old.getAttribute("role")).toBeNull();
|
||||
act(() => void notify({ level: "error", message: "fresh" }));
|
||||
act(() => void notify({ level: "error", message: raw("fresh") }));
|
||||
expect(screen.getByText("fresh").closest(".toast-card")!.getAttribute("role")).toBe("alert");
|
||||
});
|
||||
|
||||
@@ -99,8 +99,8 @@ describe("Toaster — handing the toasts to another host", () => {
|
||||
// host last showed — re-read on every render, this host's own "first" turned silent the moment
|
||||
// "second" arrived (its own screen had become the handover by then).
|
||||
render(<Toaster />);
|
||||
act(() => void notify({ level: "error", message: "first" }));
|
||||
act(() => void notify({ level: "error", message: "second" }));
|
||||
act(() => void notify({ level: "error", message: raw("first") }));
|
||||
act(() => void notify({ level: "error", message: raw("second") }));
|
||||
const card = (text: string) => screen.getByText(text).closest(".toast-card")!;
|
||||
expect(card("first").getAttribute("role")).toBe("alert");
|
||||
expect(card("second").getAttribute("role")).toBe("alert");
|
||||
@@ -111,7 +111,7 @@ describe("Toaster — handing the toasts to another host", () => {
|
||||
// inserted announces them anyway. React flushes effects in the SAME task as that render, so the
|
||||
// arming has to wait for the next one — a screen reader reads the region's state after the task.
|
||||
render(<Toaster />);
|
||||
act(() => void notify({ level: "info", message: "saved" }));
|
||||
act(() => void notify({ level: "info", message: raw("saved") }));
|
||||
const overlay = render(<Toaster overlay />);
|
||||
const region = overlay.container.querySelector("[aria-live]")!;
|
||||
expect(region.textContent).toContain("saved"); // it took the toast over…
|
||||
@@ -135,7 +135,7 @@ describe("Toaster — handing the toasts to another host", () => {
|
||||
<Toaster overlay />
|
||||
<button
|
||||
onClick={() => {
|
||||
notify({ level: "success", message: "upload queued" });
|
||||
notify({ level: "success", message: raw("upload queued") });
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
@@ -147,7 +147,7 @@ describe("Toaster — handing the toasts to another host", () => {
|
||||
);
|
||||
}
|
||||
render(<Stage />);
|
||||
act(() => void notify({ level: "error", message: "old news" }));
|
||||
act(() => void notify({ level: "error", message: raw("old news") }));
|
||||
fireEvent.click(screen.getByText("upload"));
|
||||
|
||||
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} />
|
||||
</div>,
|
||||
);
|
||||
act(() => void notify({ level: "error", message: "boom" }));
|
||||
act(() => void notify({ level: "error", message: raw("boom") }));
|
||||
expect(screen.getByTestId("dialog").textContent).toContain("boom");
|
||||
// The player swaps its host (leaving true fullscreen) while the dialog stays open.
|
||||
player.rerender(
|
||||
@@ -188,7 +188,7 @@ describe("Toaster — handing the toasts to another host", () => {
|
||||
const t0 = Date.now();
|
||||
const now = vi.spyOn(Date, "now").mockReturnValue(t0);
|
||||
render(<Toaster />);
|
||||
act(() => void notify({ level: "info", message: "saved" }));
|
||||
act(() => void notify({ level: "info", message: raw("saved") }));
|
||||
now.mockReturnValue(t0 + 4000);
|
||||
render(<Toaster overlay />);
|
||||
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
|
||||
// docked bar, above them at z-overlay, and took their clicks for as long as a toast was up.
|
||||
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]")!;
|
||||
expect(host.className).toContain("bottom-24");
|
||||
expect(host.className).toContain("pointer-events-none");
|
||||
@@ -218,7 +218,7 @@ describe("Toaster — a toast's actions", () => {
|
||||
() =>
|
||||
void notify({
|
||||
level: "success",
|
||||
message: "Downloaded",
|
||||
message: raw("Downloaded"),
|
||||
action: { label: "Copy link", onClick: copy },
|
||||
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 { useOverlayOrder } from "./overlayOrder";
|
||||
import { clearAll, notify } from "../../lib/notifications";
|
||||
import { clearAll, notify, raw } from "../../lib/notifications";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -37,7 +37,7 @@ function Harness() {
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
notify({ level: "success", message: "Uploaded" });
|
||||
notify({ level: "success", message: raw("Uploaded") });
|
||||
setOpen(false); // …in the SAME handler: one batch, one commit
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useId, useLayoutEffect, useState, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 { useToastDock } from "../../lib/toastDock";
|
||||
import { useOverlayOrder } from "./overlayOrder";
|
||||
@@ -162,9 +162,11 @@ export default function Toaster({
|
||||
>
|
||||
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
|
||||
<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">
|
||||
{toast.message}
|
||||
{sayNotif(toast.message, t)}
|
||||
{toast.repeat > 1 && (
|
||||
<span className="ml-1.5 align-middle text-2xs font-semibold text-muted tabular-nums">
|
||||
×{toast.repeat}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Copy, Link2 } from "lucide-react";
|
||||
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, type NotifText, notify } from "../../lib/notifications";
|
||||
import { type ActionMenuItem } from "./ActionMenu";
|
||||
|
||||
// "Copy title" and "Copy link", the two rows every object's menu ends its middle group with
|
||||
@@ -17,12 +17,12 @@ type Named = "title" | "name";
|
||||
|
||||
export function useCopyItems() {
|
||||
const { t } = useTranslation();
|
||||
const copy = (text: string, done: string) => {
|
||||
const copy = (text: string, done: NotifText) => {
|
||||
void copyText(text).then((ok) =>
|
||||
notify(
|
||||
ok
|
||||
? { level: "success", message: done }
|
||||
: { level: "error", message: t("common.copy.failed") },
|
||||
: { level: "error", message: msg("common.copy.failed") },
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -43,7 +43,7 @@ export function useCopyItems() {
|
||||
label: t(named === "title" ? "common.copy.title" : "common.copy.name"),
|
||||
icon: <Copy className="w-4 h-4" />,
|
||||
inGesture: true,
|
||||
onSelect: () => copy(text, t("common.copy.done")),
|
||||
onSelect: () => copy(text, msg("common.copy.done")),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -54,7 +54,7 @@ export function useCopyItems() {
|
||||
label: linkLabel ?? t("common.copy.link"),
|
||||
icon: <Link2 className="w-4 h-4" />,
|
||||
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 }),
|
||||
}));
|
||||
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";
|
||||
|
||||
afterEach(() => {
|
||||
@@ -27,7 +31,7 @@ describe("useCopyFeedback", () => {
|
||||
await act(() => result.current.copy("sfl_x"));
|
||||
expect(h.copyText).toHaveBeenCalledWith("sfl_x");
|
||||
expect(result.current.copied).toBe(true);
|
||||
expect(h.notify).toHaveBeenCalledWith({ level: "success", message: "common.copy.done" });
|
||||
expect(h.notify).toHaveBeenCalledWith({ level: "success", message: msg("common.copy.done") });
|
||||
act(() => void vi.advanceTimersByTime(2000));
|
||||
expect(result.current.copied).toBe(false);
|
||||
});
|
||||
@@ -49,6 +53,6 @@ describe("useCopyFeedback", () => {
|
||||
const { result } = renderHook(() => useCopyFeedback());
|
||||
await act(() => result.current.copy("sfl_x"));
|
||||
expect(result.current.copied).toBe(false);
|
||||
expect(h.notify).toHaveBeenCalledWith({ level: "warning", message: "common.copy.failed" });
|
||||
expect(h.notify).toHaveBeenCalledWith({ level: "warning", message: msg("common.copy.failed") });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, notify } from "../../lib/notifications";
|
||||
|
||||
// How long a Copy button reads "Copied" after it worked.
|
||||
const COPIED_MS = 2000;
|
||||
@@ -15,7 +14,6 @@ const COPIED_MS = 2000;
|
||||
*
|
||||
* A feature file, not `ui/`: it speaks the app's notifications (as `copyItems` does). */
|
||||
export function useCopyFeedback(): { copied: boolean; copy: (value: string) => Promise<void> } {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
// One reset at a time: a copy again within the moment starts it over, not cut short by the last.
|
||||
const timer = useRef<number | undefined>(undefined);
|
||||
@@ -23,12 +21,12 @@ export function useCopyFeedback(): { copied: boolean; copy: (value: string) => P
|
||||
const copy = async (value: string) => {
|
||||
if (await copyText(value)) {
|
||||
setCopied(true);
|
||||
notify({ level: "success", message: t("common.copy.done") });
|
||||
notify({ level: "success", message: msg("common.copy.done") });
|
||||
window.clearTimeout(timer.current);
|
||||
timer.current = window.setTimeout(() => setCopied(false), COPIED_MS);
|
||||
return;
|
||||
}
|
||||
notify({ level: "warning", message: t("common.copy.failed") });
|
||||
notify({ level: "warning", message: msg("common.copy.failed") });
|
||||
};
|
||||
return { copied, copy };
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { api, type Tag } from "../../lib/api";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, type NotifText, notify } from "../../lib/notifications";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { useScrollFade } from "../../lib/useScrollFade";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
@@ -137,22 +137,22 @@ export default function TagManager({
|
||||
});
|
||||
// A failure is said and the list refetched — a tag deleted in another tab used to fail in silence
|
||||
// (a 404 `not_found/tag` gets no global dialog) and stay listed.
|
||||
const onFailed = (e: unknown, fallback: string) => {
|
||||
const onFailed = (e: unknown, fallback: NotifText) => {
|
||||
notifyActionError(e, fallback);
|
||||
invalidate();
|
||||
};
|
||||
const rename = useMutation({
|
||||
mutationFn: (v: { id: number; name: string }) => api.updateTag(v.id, { name: v.name }),
|
||||
onSuccess: invalidate,
|
||||
onError: (e) => onFailed(e, t("tagManager.renameFailed")),
|
||||
onError: (e) => onFailed(e, msg("tagManager.renameFailed")),
|
||||
});
|
||||
const del = useMutation({
|
||||
mutationFn: (v: { id: number; name: string }) => api.deleteTag(v.id),
|
||||
onSuccess: (_d, v) => {
|
||||
invalidate();
|
||||
notify({ level: "success", message: t("tagManager.deleted", { name: v.name }) });
|
||||
notify({ level: "success", message: msg("tagManager.deleted", { name: 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.
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Loader2, Pin, PinOff } from "lucide-react";
|
||||
import { plexLang } from "../../i18n";
|
||||
import { api, type DiscoverCard, type PinKind } from "../../lib/api";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, notify } from "../../lib/notifications";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||
|
||||
@@ -139,13 +139,13 @@ export function useComfortPin(open: boolean): ComfortPin {
|
||||
await api.addComfortPin(target.kind, target.ref);
|
||||
// The shelf lives on another page, so without this the click has no visible consequence at
|
||||
// all from the feed — the one place a toast genuinely carries the whole feedback.
|
||||
notify({ level: "success", message: t("home.pin.added", { title }) });
|
||||
notify({ level: "success", message: msg("home.pin.added", { title }) });
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("home.pin.addFailed"));
|
||||
notifyActionError(e, msg("home.pin.addFailed"));
|
||||
}
|
||||
invalidate();
|
||||
},
|
||||
[invalidate, t],
|
||||
[invalidate],
|
||||
);
|
||||
|
||||
const remove = useCallback(
|
||||
@@ -153,14 +153,14 @@ export function useComfortPin(open: boolean): ComfortPin {
|
||||
void (async () => {
|
||||
try {
|
||||
await api.removeComfortPin(pinId);
|
||||
notify({ level: "success", message: t("home.pin.removed", { title }) });
|
||||
notify({ level: "success", message: msg("home.pin.removed", { title }) });
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("home.pin.removeFailed"));
|
||||
notifyActionError(e, msg("home.pin.removeFailed"));
|
||||
}
|
||||
invalidate();
|
||||
})();
|
||||
},
|
||||
[invalidate, t],
|
||||
[invalidate],
|
||||
);
|
||||
|
||||
const toggle = useCallback(
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from "../../lib/debugRecorder";
|
||||
import { formatDateTimeSec, formatDuration } from "../../lib/format";
|
||||
import { useLayer } from "../../lib/layerStack";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, notify } from "../../lib/notifications";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { dockUnderToasts } from "../../lib/toastDock";
|
||||
import { useMe } from "../../lib/useMe";
|
||||
@@ -73,7 +73,7 @@ export default function DebugRecorder() {
|
||||
if (!id || !until) return;
|
||||
const wait = Math.min(Date.parse(until) - Date.now(), 2 ** 31 - 1);
|
||||
const timer = window.setTimeout(() => {
|
||||
notify({ level: "warning", message: t("recorder.pill.expired", { count: minutes ?? 0 }) });
|
||||
notify({ level: "warning", message: msg("recorder.pill.expired", { count: minutes ?? 0 }) });
|
||||
openComposer(id);
|
||||
void qc.invalidateQueries({ queryKey: qk.me() });
|
||||
}, wait);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ExternalLink, FileText, Plus, Server, X } from "lucide-react";
|
||||
import { api, type DebugScreenshot, type DebugSession, type ServerLogRow } from "../../lib/api";
|
||||
import { closeComposer } from "../../lib/debugRecorder";
|
||||
import { formatBytes, formatDateTimeSec, formatUnit } from "../../lib/format";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, notify } from "../../lib/notifications";
|
||||
import { useMe } from "../../lib/useMe";
|
||||
import {
|
||||
draftSteps,
|
||||
@@ -217,7 +217,7 @@ function ComposerBody({
|
||||
meta: { failTitle: "recorder.composer.sendFailed" },
|
||||
onSuccess: (r) => {
|
||||
qc.setQueryData(qk.debugSession(session.id), r.session);
|
||||
notify({ level: "success", message: t("recorder.composer.sentOk") });
|
||||
notify({ level: "success", message: msg("recorder.composer.sentOk") });
|
||||
},
|
||||
// A failure half-way still created the issue: show what the server now has.
|
||||
onSettled: async () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback } from "react";
|
||||
import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { api, type DebugSession, type DebugSessionLevel } from "../../lib/api";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
@@ -14,7 +13,7 @@ import {
|
||||
} from "../../lib/debugRecorder";
|
||||
import { flushNow, liveSession } from "../../lib/diagnostics";
|
||||
import { log } from "../../lib/log";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, notify } from "../../lib/notifications";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { updateMeCache, useMe } from "../../lib/useMe";
|
||||
|
||||
@@ -22,7 +21,6 @@ import { updateMeCache, useMe } from "../../lib/useMe";
|
||||
* Diagnosztika and the command palette. The session itself is `me.debug_session` — the server's
|
||||
* answer, so a reload, another tab or the cap all show the same thing. */
|
||||
export function useDebugRecording() {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const me = useMe();
|
||||
const session = liveSession(me?.debug_session);
|
||||
@@ -54,7 +52,7 @@ export function useDebugRecording() {
|
||||
meta: { failTitle: "recorder.pill.stopFailed" },
|
||||
onSuccess: (r) => {
|
||||
setSession(null);
|
||||
notify({ level: "success", message: t("recorder.pill.stopped") });
|
||||
notify({ level: "success", message: msg("recorder.pill.stopped") });
|
||||
openComposer(r.session.id);
|
||||
},
|
||||
});
|
||||
@@ -78,7 +76,7 @@ export function useDebugRecording() {
|
||||
);
|
||||
// In the evidence too, where the step list is drafted from: which step it belongs to.
|
||||
log.info("debug.screenshot", "screenshot taken", { screenshot: r.screenshot.id });
|
||||
notify({ level: "success", message: t("recorder.pill.shotSaved") });
|
||||
notify({ level: "success", message: msg("recorder.pill.shotSaved") });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -95,9 +93,9 @@ export function useDebugRecording() {
|
||||
const n = nextStep(sessionId);
|
||||
const text = label.trim();
|
||||
log.info("debug.step", text || `step ${n}`, { n, ...(text ? { label: text } : {}) });
|
||||
notify({ level: "info", message: t("recorder.pill.stepMarked", { n }) });
|
||||
notify({ level: "info", message: msg("recorder.pill.stepMarked", { n }) });
|
||||
},
|
||||
[sessionId, t],
|
||||
[sessionId],
|
||||
);
|
||||
|
||||
/** Capture this tab and attach it. `hide` runs around the capture so the pill is not in the
|
||||
@@ -111,7 +109,7 @@ export function useDebugRecording() {
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
image = await captureTab();
|
||||
} catch (err) {
|
||||
notifyActionError(err, t("recorder.pill.shotFailed"));
|
||||
notifyActionError(err, msg("recorder.pill.shotFailed"));
|
||||
}
|
||||
hide(false);
|
||||
if (image) shoot.mutate({ id: session.id, image });
|
||||
|
||||
@@ -9,7 +9,7 @@ import { invalidateDownloads } from "../../lib/downloads";
|
||||
|
||||
// Lazy: the full profile editor only opens from the dialog's "manage presets" affordance.
|
||||
const ProfileEditor = lazy(() => import("../../modules/settings/ProfileEditor"));
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, notify } from "../../lib/notifications";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { showInTray } from "../../lib/downloadQueue";
|
||||
import { profileName } from "../../lib/vocab";
|
||||
@@ -63,7 +63,7 @@ export default function DownloadDialog({
|
||||
setLastProfile(chosen);
|
||||
notify({
|
||||
level: "success",
|
||||
message: t("downloads.dialog.added"),
|
||||
message: msg("downloads.dialog.added"),
|
||||
action: { label: t("downloads.dialog.view"), onClick: () => showInTray(job.id) },
|
||||
});
|
||||
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
|
||||
// reporter skips; a 403/404/429 or a lost connection is not, and used to leave the dialog
|
||||
// open with nothing said.
|
||||
notifyActionError(e, t("errors.writeFailed.enqueueDownload"));
|
||||
notifyActionError(e, msg("errors.writeFailed.enqueueDownload"));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -70,6 +70,7 @@ vi.mock("../../lib/notifications", async (importOriginal) => ({
|
||||
}));
|
||||
|
||||
import DownloadTray from "./DownloadTray";
|
||||
import { msg, type NotifText } from "../../lib/notifications";
|
||||
import { noteQueue, resetQueueWatch, showInTray } from "../../lib/downloadQueue";
|
||||
import { pushLayer, removeLayer } from "../../lib/layerStack";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
@@ -199,13 +200,13 @@ describe("DownloadTray", () => {
|
||||
poll([job(1, "done")], view);
|
||||
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
|
||||
const notice = h.notify.mock.calls[0]![0] as {
|
||||
title: string;
|
||||
message: string;
|
||||
title: NotifText;
|
||||
message: NotifText;
|
||||
action: { label: string; onClick: () => void };
|
||||
secondaryAction: { label: string; onClick: () => void };
|
||||
};
|
||||
expect(notice.title).toBe("tray.done.title:Clip 1");
|
||||
expect(notice.message).toBe("tray.done.phone");
|
||||
expect(notice.title).toEqual(msg("tray.done.title", { title: "Clip 1" }));
|
||||
expect(notice.message).toEqual(msg("tray.done.phone"));
|
||||
expect(notice.action.label).toBe("tray.done.copyLink");
|
||||
notice.action.onClick();
|
||||
expect(h.share).toHaveBeenCalled();
|
||||
@@ -225,9 +226,9 @@ describe("DownloadTray", () => {
|
||||
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
|
||||
poll([job(1, "done"), job(2, "done")], view);
|
||||
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(2));
|
||||
expect(h.notify.mock.calls.map((c) => (c[0] as { title: string }).title)).toEqual([
|
||||
"tray.done.title:Clip 1",
|
||||
"tray.done.title:Clip 2",
|
||||
expect(h.notify.mock.calls.map((c) => (c[0] as { title: NotifText }).title)).toEqual([
|
||||
msg("tray.done.title", { title: "Clip 1" }),
|
||||
msg("tray.done.title", { title: "Clip 2" }),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -236,7 +237,9 @@ describe("DownloadTray", () => {
|
||||
const view = mount();
|
||||
poll([job(1, "done", { asset: { media_kind: "file" } as never })], view);
|
||||
await waitFor(() =>
|
||||
expect(h.notify).toHaveBeenCalledWith(expect.objectContaining({ message: "tray.done.file" })),
|
||||
expect(h.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: msg("tray.done.file") }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -257,7 +260,7 @@ describe("DownloadTray", () => {
|
||||
poll([job(1, "done", { source_key: "upload" })], view);
|
||||
await waitFor(() =>
|
||||
expect(h.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: "tray.done.uploadedTitle:Clip 1" }),
|
||||
expect.objectContaining({ title: msg("tray.done.uploadedTitle", { title: "Clip 1" }) }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
} from "../../lib/downloadQueue";
|
||||
import { anyBlockingLayer } from "../../lib/layerStack";
|
||||
import { focusNavItem, navItemAttrs } from "../../lib/listNav";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, notify } from "../../lib/notifications";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { LS } from "../../lib/storage";
|
||||
import { useLiveQuery } from "../../lib/useLiveQuery";
|
||||
@@ -191,10 +191,10 @@ export default function DownloadTray() {
|
||||
notify({
|
||||
level: "success",
|
||||
// An upload the worker had to convert first is done here too — it was never downloaded.
|
||||
title: t(job.source_key === "upload" ? "tray.done.uploadedTitle" : "tray.done.title", {
|
||||
title: msg(job.source_key === "upload" ? "tray.done.uploadedTitle" : "tray.done.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: {
|
||||
label: t("tray.done.copyLink"),
|
||||
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 { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||
import { menuGroups } from "../../components/ui/menuGroups";
|
||||
import { msg } from "../../lib/notifications";
|
||||
|
||||
// A queued download's ONE action list: the row's "…", its right-click, long-press and Shift+F10. The
|
||||
// row keeps its primary action (pause / resume / retry) and its state (the error and its details) in
|
||||
@@ -56,7 +57,7 @@ export function useDownloadActions() {
|
||||
? api.resumeDownload(id)
|
||||
: api.cancelDownload(id),
|
||||
onSuccess: () => invalidateDownloads(qc),
|
||||
onError: (e, v) => notifyActionError(e, t(`errors.writeFailed.job.${v.op}`)),
|
||||
onError: (e, v) => notifyActionError(e, msg(`errors.writeFailed.job.${v.op}`)),
|
||||
});
|
||||
|
||||
const confirmThen = async (title: string, body: string, action: string, fn: () => void) => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { ownKey } from "../../lib/storage";
|
||||
import { useConfirm } from "../../components/ui/confirmContext";
|
||||
import type { SavedViewsContext, ViewSource } from "./savedViewsContext";
|
||||
import { msg, type NotifText } from "../../lib/notifications";
|
||||
|
||||
// Saved "smart views" serve two modules — the feed and Plex — and each owns one store of them in its
|
||||
// own folder, built on this hook. The two never switch into each other: a visit to Plex leaves the
|
||||
@@ -114,7 +115,7 @@ export function useSavedViewsStore(cfg: SavedViewsConfig): SavedViewsContext {
|
||||
const isDirty = displayView != null && activeView == null;
|
||||
|
||||
const refresh = () => void qc.invalidateQueries({ queryKey: qk.savedViews(source) });
|
||||
async function runViewMutation(fn: () => Promise<unknown>, failMsg: string): Promise<boolean> {
|
||||
async function runViewMutation(fn: () => Promise<unknown>, failMsg: NotifText): Promise<boolean> {
|
||||
let ok = true;
|
||||
try {
|
||||
await fn();
|
||||
@@ -146,28 +147,28 @@ export function useSavedViewsStore(cfg: SavedViewsConfig): SavedViewsContext {
|
||||
if (!clean) return false;
|
||||
return runViewMutation(
|
||||
() => api.createView(clean, cfg.stripForSave(cfg.filters), source),
|
||||
t("views.actionFailed"),
|
||||
msg("views.actionFailed"),
|
||||
);
|
||||
},
|
||||
rename: async (id, name) => {
|
||||
const next = name.trim();
|
||||
if (!next) return false;
|
||||
return runViewMutation(() => api.updateView(id, { name: next }), t("views.actionFailed"));
|
||||
return runViewMutation(() => api.updateView(id, { name: next }), msg("views.actionFailed"));
|
||||
},
|
||||
updateFilters: (id, filters) =>
|
||||
runViewMutation(() => api.updateView(id, { filters }), t("views.actionFailed")),
|
||||
runViewMutation(() => api.updateView(id, { filters }), msg("views.actionFailed")),
|
||||
saveCurrent: async (id) => {
|
||||
const filters = cfg.stripForSave(cfg.filters);
|
||||
const ok = await runViewMutation(
|
||||
() => api.updateView(id, { filters }),
|
||||
t("views.actionFailed"),
|
||||
msg("views.actionFailed"),
|
||||
);
|
||||
return ok ? filters : null;
|
||||
},
|
||||
toggleDefault: (v) =>
|
||||
runViewMutation(
|
||||
() => api.updateView(v.id, { is_default: !v.is_default }),
|
||||
t("views.actionFailed"),
|
||||
msg("views.actionFailed"),
|
||||
).then(() => undefined),
|
||||
remove: async (v) => {
|
||||
const ok = await confirm({
|
||||
@@ -177,14 +178,14 @@ export function useSavedViewsStore(cfg: SavedViewsConfig): SavedViewsContext {
|
||||
});
|
||||
if (!ok) return false;
|
||||
if (stickyId === v.id) setStickyId(null);
|
||||
return runViewMutation(() => api.deleteView(v.id), t("views.actionFailed"));
|
||||
return runViewMutation(() => api.deleteView(v.id), msg("views.actionFailed"));
|
||||
},
|
||||
reorder: async (nextIds) => {
|
||||
qc.setQueryData<SavedView[]>(
|
||||
qk.savedViews(source),
|
||||
nextIds.map((id) => views.find((v) => v.id === id)!).filter(Boolean),
|
||||
);
|
||||
await runViewMutation(() => api.reorderViews(nextIds), t("views.reorderFailed"));
|
||||
await runViewMutation(() => api.reorderViews(nextIds), msg("views.reorderFailed"));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,10 +30,11 @@ vi.mock("../../lib/downloadQueue", () => ({ showInTray: (id: number) => showInTr
|
||||
const notifyActionError = vi.fn();
|
||||
vi.mock("../../lib/actionErrors", async (importOriginal) => {
|
||||
const actual = await importOriginal<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 { msg, type NotifText } from "../../lib/notifications";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -188,7 +189,7 @@ describe("ClipPanel", () => {
|
||||
display_name: "The bit",
|
||||
});
|
||||
expect(notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: "success", message: "clip.queued" }),
|
||||
expect.objectContaining({ level: "success", message: msg("clip.queued") }),
|
||||
);
|
||||
// Its View opens the tray on the new clip.
|
||||
(
|
||||
@@ -214,7 +215,7 @@ describe("ClipPanel", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
|
||||
await waitFor(() => expect(notifyActionError).toHaveBeenCalledTimes(1));
|
||||
expect(notifyActionError.mock.calls[0]![0]).toBe(err);
|
||||
expect(notifyActionError.mock.calls[0]![1]).toBe("clip.failed");
|
||||
expect(notifyActionError.mock.calls[0]![1]).toEqual(msg("clip.failed"));
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect((screen.getByRole("button", { name: "clip.cut" }) as HTMLButtonElement).disabled).toBe(
|
||||
false,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Crop, Crosshair, Maximize, Minus, Plus, Repeat, Scissors, X } from "luc
|
||||
import { api, type ClipRequest } from "../../lib/api";
|
||||
import { DISPLAY_NAME_MAX } from "../../lib/api/downloads";
|
||||
import { invalidateDownloads } from "../../lib/downloads";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, notify } from "../../lib/notifications";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { showInTray } from "../../lib/downloadQueue";
|
||||
import { log } from "../../lib/log";
|
||||
@@ -197,14 +197,14 @@ export default function ClipPanel({
|
||||
log.info("clip.queued", "clip queued", { source: src.source, seconds: Math.round(length) });
|
||||
notify({
|
||||
level: "success",
|
||||
message: t("clip.queued"),
|
||||
message: msg("clip.queued"),
|
||||
action: { label: t("clip.view"), onClick: () => showInTray(job.id) },
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
// A refusal the server explains (too long, quota) is already in the error dialog; a lost
|
||||
// connection or a 404 is not, and must not leave the panel looking as if nothing happened.
|
||||
notifyActionError(err, t("clip.failed"));
|
||||
notifyActionError(err, msg("clip.failed"));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { lazy, Suspense, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { type Video, type VideoStatus } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
@@ -7,6 +6,7 @@ import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { useStableCallback } from "../../lib/useLatest";
|
||||
import { writeVideoState } from "../video/videoWrites";
|
||||
import { useOpenLinkedPlayer } from "./useOpenLinkedPlayer";
|
||||
import { msg } from "../../lib/notifications";
|
||||
|
||||
const PlayerModal = lazy(() => import("./PlayerModal"));
|
||||
|
||||
@@ -14,7 +14,6 @@ const PlayerModal = lazy(() => import("./PlayerModal"));
|
||||
* channel manager, where the watched-videos dialog played it; any page a link names): the video
|
||||
* alone. Decided at boot, once — `hosted` is whether the page on screen then opens it itself. */
|
||||
export function LinkedPlayer({ hosted }: { hosted: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const [own] = useState(!hosted);
|
||||
const [video, setVideo] = useState<Video | null>(null);
|
||||
@@ -27,7 +26,7 @@ export function LinkedPlayer({ hosted }: { hosted: boolean }) {
|
||||
const write = writeVideoState(qc, id, status);
|
||||
write
|
||||
.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;
|
||||
});
|
||||
const close = useStableCallback(() => setVideo(null));
|
||||
|
||||
@@ -40,7 +40,7 @@ import { closeKeyboardHelp, toggleKeyboardHelp } from "../../lib/keymap";
|
||||
import { ConfirmProvider } from "../../components/ui/ConfirmProvider";
|
||||
import { useConfirm } from "../../components/ui/confirmContext";
|
||||
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 { writeVideoState } from "../video/videoWrites";
|
||||
|
||||
@@ -314,7 +314,7 @@ describe("PlayerModal — toasts raised while it is open", () => {
|
||||
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.");
|
||||
});
|
||||
|
||||
@@ -336,7 +336,7 @@ describe("PlayerModal — toasts raised while it is open", () => {
|
||||
Object.defineProperty(document, "fullscreenElement", { configurable: true, get: () => stage });
|
||||
try {
|
||||
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"));
|
||||
} finally {
|
||||
delete (document as { fullscreenElement?: unknown }).fullscreenElement;
|
||||
|
||||
@@ -26,6 +26,7 @@ import VideoCard from "../video/VideoCard";
|
||||
import { VideoMenu } from "../video/VideoMenu";
|
||||
import { announceVideoState } from "../video/stateNotice";
|
||||
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,
|
||||
* 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"));
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
notifyActionError(e, t("errors.writeFailed.videoState"));
|
||||
notifyActionError(e, msg("errors.writeFailed.videoState"));
|
||||
settle(seq);
|
||||
});
|
||||
};
|
||||
@@ -118,7 +119,7 @@ export function RelatedVideos({
|
||||
.catch((e: unknown) => {
|
||||
notifyActionError(
|
||||
e,
|
||||
t(saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove"),
|
||||
msg(saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove"),
|
||||
);
|
||||
settle(seq);
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useOpenLinkedPlayer } from "./useOpenLinkedPlayer";
|
||||
import { LinkedPlayer } from "./LinkedPlayer";
|
||||
import { api, type Video } from "../../lib/api";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { msg } from "../../lib/notifications";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
|
||||
const vid = (id: string) => ({ id, title: id }) as unknown as Video;
|
||||
@@ -76,7 +77,7 @@ describe("useOpenLinkedPlayer", () => {
|
||||
const open = vi.fn();
|
||||
renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper });
|
||||
await waitFor(() =>
|
||||
expect(notifyActionError).toHaveBeenCalledWith(gone, "player.linkedFailed"),
|
||||
expect(notifyActionError).toHaveBeenCalledWith(gone, msg("player.linkedFailed")),
|
||||
);
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
// 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 { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api, type Video } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { linkedPlayer, PLAYER_KEYS } from "../../lib/playerUrlState";
|
||||
import { clearParams } from "../../lib/urlState";
|
||||
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
|
||||
* 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,
|
||||
scope: string | number | null = null,
|
||||
): void {
|
||||
const { t } = useTranslation();
|
||||
const [linked] = useState(() => linkedPlayer()?.watch ?? null);
|
||||
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
|
||||
@@ -40,7 +39,7 @@ export function useOpenLinkedPlayer(
|
||||
});
|
||||
const openIt = useEffectEvent(open);
|
||||
const failed = useEffectEvent((err: unknown) => {
|
||||
notifyActionError(err, t("player.linkedFailed"));
|
||||
notifyActionError(err, msg("player.linkedFailed"));
|
||||
clearParams(...PLAYER_KEYS);
|
||||
});
|
||||
useEffect(() => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { PopoverSurface } from "../../components/ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
||||
import { MembershipMenu } from "../../components/ui/MembershipMenu";
|
||||
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
|
||||
// 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);
|
||||
qc.setQueryData<typeof list.data>(forKey, (old) => flipContains(old, c.id));
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
notifyActionError(e, msg("plex.collection.editFailed"));
|
||||
}
|
||||
refresh();
|
||||
setBusy(false);
|
||||
@@ -83,7 +84,7 @@ export default function PlexCollectionAddAt({
|
||||
made = true;
|
||||
} catch (e) {
|
||||
made = madeOnPlex(e);
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
notifyActionError(e, msg("plex.collection.editFailed"));
|
||||
}
|
||||
refresh();
|
||||
setBusy(false);
|
||||
|
||||
@@ -12,6 +12,7 @@ import Modal from "../../components/ui/Modal";
|
||||
import { Pressable } from "../../components/ui/Pressable";
|
||||
import { Input } from "../../components/ui/Input";
|
||||
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
|
||||
// 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) {
|
||||
notifyActionError(
|
||||
e,
|
||||
t(allIn ? "plex.playlistAdd.removeFailed" : "plex.playlistAdd.addFailed"),
|
||||
msg(allIn ? "plex.playlistAdd.removeFailed" : "plex.playlistAdd.addFailed"),
|
||||
);
|
||||
}
|
||||
refresh();
|
||||
@@ -104,7 +105,7 @@ export default function PlexPlaylistAdd({
|
||||
} catch (e) {
|
||||
// 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.
|
||||
notifyActionError(e, t("plex.playlistAdd.createFailed"));
|
||||
notifyActionError(e, msg("plex.playlistAdd.createFailed"));
|
||||
}
|
||||
refresh();
|
||||
setBusy(false);
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
||||
import { IconButton } from "../../components/ui/IconButton";
|
||||
import { MembershipMenu } from "../../components/ui/MembershipMenu";
|
||||
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
|
||||
// "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)),
|
||||
);
|
||||
} catch (e) {
|
||||
notifyActionError(e, t(pl.has_video ? "playlists.removeFailed" : "playlists.addFailed"));
|
||||
notifyActionError(e, msg(pl.has_video ? "playlists.removeFailed" : "playlists.addFailed"));
|
||||
}
|
||||
refresh();
|
||||
setBusy(false);
|
||||
@@ -137,7 +138,7 @@ function MembershipPanel({
|
||||
await addPlaylistItem(qc, pl, videoId);
|
||||
made = true;
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("playlists.addFailed"));
|
||||
notifyActionError(e, msg("playlists.addFailed"));
|
||||
}
|
||||
refresh();
|
||||
setBusy(false);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import i18n from "../../i18n";
|
||||
import { notify, resolveVideo } from "../../lib/notifications";
|
||||
import { msg, notify, resolveVideo } from "../../lib/notifications";
|
||||
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. */
|
||||
@@ -24,8 +24,8 @@ export function announceVideoState(
|
||||
const hidden = status === "hidden";
|
||||
notify({
|
||||
message: video?.title
|
||||
? i18n.t(hidden ? "feed.hiddenNamed" : "feed.markedWatchedNamed", { title: video.title })
|
||||
: i18n.t(hidden ? "feed.hidden" : "feed.markedWatched"),
|
||||
? msg(hidden ? "feed.hiddenNamed" : "feed.markedWatchedNamed", { title: video.title })
|
||||
: msg(hidden ? "feed.hidden" : "feed.markedWatched"),
|
||||
action: { label: i18n.t(hidden ? "feed.undo" : "feed.unwatch"), onClick: undo },
|
||||
meta: {
|
||||
kind: hidden ? "video-hidden" : "video-watched",
|
||||
|
||||
@@ -23,7 +23,7 @@ vi.mock("../../lib/onboarding", () => ({ beginGrant: vi.fn(), endOnboarding: vi.
|
||||
|
||||
import OnboardingWizard from "./OnboardingWizard";
|
||||
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;
|
||||
|
||||
@@ -110,7 +110,7 @@ describe("the onboarding wizard as a dialog", () => {
|
||||
document.body.appendChild(outside);
|
||||
outside.focus();
|
||||
const { unmount } = mount();
|
||||
act(() => void notify({ level: "error", message: "offline" }));
|
||||
act(() => void notify({ level: "error", message: raw("offline") }));
|
||||
const dismissToast = screen.getByTitle("notifications.dismiss");
|
||||
dismissToast.focus();
|
||||
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.
|
||||
mount();
|
||||
const panel = screen.getByRole("dialog");
|
||||
act(() => void notify({ level: "error", message: "offline" }));
|
||||
act(() => void notify({ level: "error", message: raw("offline") }));
|
||||
try {
|
||||
const dismissToast = screen.getByTitle("notifications.dismiss");
|
||||
expect(panel.contains(dismissToast)).toBe(false);
|
||||
|
||||
@@ -89,7 +89,7 @@ const quotaActions = enumValues("quota.py");
|
||||
// ruff wraps over several lines still matches.
|
||||
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\(\[([^\]]*)\]/,
|
||||
)?.[1] ?? ""
|
||||
).matchAll(/"(\w+)"/g),
|
||||
|
||||
@@ -4,12 +4,16 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// 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() }));
|
||||
|
||||
import { HttpError, api } from "./api";
|
||||
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 { failureReason, notifyActionError, notifyKnownFailure } from "./actionErrors";
|
||||
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);
|
||||
notifyMock.mockClear();
|
||||
notifyActionError(err, "EDIT_FAILED");
|
||||
notifyActionError(err, raw("EDIT_FAILED"));
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -52,26 +65,26 @@ describe("notifyActionError", () => {
|
||||
);
|
||||
const err = await api.plexSetState("1", "watched").catch((e: unknown) => e);
|
||||
notifyMock.mockClear();
|
||||
notifyActionError(err, "FAILED");
|
||||
notifyActionError(err, raw("FAILED"));
|
||||
expect(notifyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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.
|
||||
const err = new HttpError(404, "gone");
|
||||
notifyActionError(err, "FIRST");
|
||||
notifyActionError(err, "SECOND");
|
||||
notifyActionError(err, raw("FIRST"));
|
||||
notifyActionError(err, raw("SECOND"));
|
||||
expect(notifyMock).toHaveBeenCalledTimes(1);
|
||||
expect(alreadyReported(err)).toBe(true);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -79,27 +92,32 @@ describe("notifyActionError", () => {
|
||||
// `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
|
||||
// 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({
|
||||
level: "error",
|
||||
title: "TRANSLATED",
|
||||
message: i18n.t("errors.notFound.generic"),
|
||||
title: raw("TRANSLATED"),
|
||||
message: msg("errors.notFound.generic"),
|
||||
});
|
||||
});
|
||||
|
||||
it("still prefers a STRUCTURED reason, which the request layer has already localized", () => {
|
||||
// `req` runs `{code, reason}` through localizeDetail, so `detail` is in the reader's language
|
||||
// AND more specific than anything a status can say — quota numbers, which row is gone.
|
||||
it("still prefers a STRUCTURED reason, kept as the refusal and said in the reader's language", () => {
|
||||
// The notice keeps `{code, reason}` and says it through localizeDetail when shown: in the
|
||||
// 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.", {
|
||||
code: "not_found",
|
||||
reason: "channel",
|
||||
});
|
||||
notifyActionError(err, "TRANSLATED FALLBACK");
|
||||
notifyActionError(err, raw("TRANSLATED FALLBACK"));
|
||||
expect(notifyMock).toHaveBeenCalledWith({
|
||||
level: "error",
|
||||
title: "TRANSLATED FALLBACK",
|
||||
message: "Ez a csatorna már nincs a könyvtárban.",
|
||||
title: raw("TRANSLATED FALLBACK"),
|
||||
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
|
||||
[500, "errors.generic"],
|
||||
])("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", () => {
|
||||
expect(failureReason(new TypeError("Failed to fetch"))).toBe(
|
||||
expect(sayNotif(failureReason(new TypeError("Failed to fetch")))).toBe(
|
||||
i18n.t("errors.reason.unreachable"),
|
||||
);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
@@ -142,9 +164,9 @@ describe("failureReason — why a write failed, when the server did not say", ()
|
||||
describe("notifyKnownFailure", () => {
|
||||
it("shows the caller's specific sentence and marks the failure reported", () => {
|
||||
const err = new HttpError(409, "x", { code: "resend", reason: "email_off" });
|
||||
notifyKnownFailure(err, "EMAIL IS OFF");
|
||||
expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: "EMAIL IS OFF" });
|
||||
notifyActionError(err, "GENERIC");
|
||||
notifyKnownFailure(err, raw("EMAIL IS OFF"));
|
||||
expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: raw("EMAIL IS OFF") });
|
||||
notifyActionError(err, raw("GENERIC"));
|
||||
expect(notifyMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
// 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.
|
||||
import { alreadyReported, HttpError, isAborted, markReported } from "./api/core";
|
||||
import { notify } from "./notifications";
|
||||
import i18n from "../i18n";
|
||||
import { msg, notify, type NotifText } from "./notifications";
|
||||
|
||||
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
|
||||
* `localizeDetail`, and it is the most specific thing there is to say (quota numbers, which row is
|
||||
* 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
|
||||
* 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
|
||||
* "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
|
||||
* says it.
|
||||
*/
|
||||
export function failureReason(err: unknown): string {
|
||||
const t = i18n.t.bind(i18n);
|
||||
export function failureReason(err: unknown): NotifText {
|
||||
const t = msg;
|
||||
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
|
||||
// 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");
|
||||
@@ -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
|
||||
* 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
|
||||
* the app-wide mutation reporter in lib/queryClient.
|
||||
*/
|
||||
export function notifyActionError(
|
||||
err: unknown,
|
||||
title: string,
|
||||
title: NotifText,
|
||||
level: "error" | "warning" = "error",
|
||||
): void {
|
||||
// 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,
|
||||
* 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;
|
||||
notify({ level: "error", message });
|
||||
markReported(err);
|
||||
|
||||
@@ -14,7 +14,12 @@ const { reportError, notify, remove, getActiveToasts } = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
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" } }));
|
||||
|
||||
import { setDebugSession } from "../log";
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
// 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,
|
||||
// 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 { formatBytes, formatDecimal } from "../format";
|
||||
import { localizeDetail } from "./refusalText";
|
||||
import { reportError } from "../errorDialog";
|
||||
import { logRequest, QUIET_HEADER, REQUEST_ID_HEADER, ROUTE_HEADER, send } from "../http";
|
||||
import { currentDebugSession, newRequestId } from "../log";
|
||||
@@ -71,121 +71,6 @@ export const refusalReason = (err: unknown): string | undefined =>
|
||||
? (err.detailData as { reason?: string } | undefined)?.reason
|
||||
: 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
|
||||
// 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
|
||||
@@ -199,8 +84,8 @@ function markConnectivityLost(): void {
|
||||
if (id !== null) remove(id);
|
||||
connectivityNotifId = notify({
|
||||
level: "error",
|
||||
title: i18n.t("errors.offline.title"),
|
||||
message: i18n.t("errors.offline.body"),
|
||||
title: msg("errors.offline.title"),
|
||||
message: msg("errors.offline.body"),
|
||||
transient: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("../errorDialog", () => ({ reportError: vi.fn() }));
|
||||
vi.mock("../notifications", () => ({
|
||||
vi.mock("../notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../notifications")>()),
|
||||
notify: vi.fn(() => 1),
|
||||
remove: vi.fn(),
|
||||
getActiveToasts: vi.fn((): { id: number }[] => []),
|
||||
|
||||
@@ -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 { 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 { notify } from "./notifications";
|
||||
import { msg, notify } from "./notifications";
|
||||
import { useChannelTagActions } from "./useChannelTagActions";
|
||||
import { useChannelPriority } from "./useChannelPriority";
|
||||
import i18n from "../i18n";
|
||||
|
||||
const notifyMock = vi.mocked(notify);
|
||||
beforeEach(() => notifyMock.mockClear());
|
||||
@@ -37,8 +39,8 @@ describe("useChannelTagActions", () => {
|
||||
await waitFor(() => expect(onError).toHaveBeenCalledWith("UC1"));
|
||||
expect(notifyMock).toHaveBeenCalledWith({
|
||||
level: "error",
|
||||
title: i18n.t("channels.tags.updateFailed"),
|
||||
message: i18n.t("errors.reason.forbidden"),
|
||||
title: msg("channels.tags.updateFailed"),
|
||||
message: msg("errors.reason.forbidden"),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,8 +54,8 @@ describe("useChannelTagActions", () => {
|
||||
await waitFor(() =>
|
||||
expect(notifyMock).toHaveBeenCalledWith({
|
||||
level: "error",
|
||||
title: i18n.t("channels.tags.createFailed"),
|
||||
message: i18n.t("errors.reason.rateLimited"),
|
||||
title: msg("channels.tags.createFailed"),
|
||||
message: msg("errors.reason.rateLimited"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -76,8 +78,8 @@ describe("useChannelPriority", () => {
|
||||
await waitFor(() => expect(onError).toHaveBeenCalledWith("UC1"));
|
||||
expect(notifyMock).toHaveBeenCalledWith({
|
||||
level: "error",
|
||||
title: i18n.t("errors.writeFailed.channelPriority"),
|
||||
message: i18n.t("errors.reason.unreachable"),
|
||||
title: msg("errors.writeFailed.channelPriority"),
|
||||
message: msg("errors.reason.unreachable"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,11 +5,12 @@
|
||||
// notification store used to keep its empty, nobody's history, and the tab's first notice replaced the
|
||||
// account's stored history with it.
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { raw, sayNotif, type NotifText } from "./notifications";
|
||||
|
||||
const entry = (id: number, message: string) => ({
|
||||
id,
|
||||
level: "info",
|
||||
message,
|
||||
message: raw(message),
|
||||
ts: 1,
|
||||
read: true,
|
||||
dismissed: true,
|
||||
@@ -20,9 +21,9 @@ const entry = (id: number, message: string) => ({
|
||||
const storedMessages = () =>
|
||||
(
|
||||
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
|
||||
// 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 { 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);
|
||||
notifications.notify({ message: "after the claim" });
|
||||
notifications.notify({ message: raw("after the claim") });
|
||||
|
||||
expect(storedMessages()).toEqual(["older", "before the claim", "after the claim"]);
|
||||
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");
|
||||
|
||||
claimLocalData(5);
|
||||
notifications.notify({ message: "as five" });
|
||||
notifications.notify({ message: raw("as five") });
|
||||
|
||||
expect(storedMessages()).toEqual(["as five"]);
|
||||
});
|
||||
|
||||
@@ -21,17 +21,21 @@ vi.mock("./api", () => ({
|
||||
api: { downloadLinks, createDownloadLink, folderLinks, createFolderLink },
|
||||
}));
|
||||
vi.mock("./clipboard", () => ({ copyText }));
|
||||
vi.mock("./notifications", () => ({ notify }));
|
||||
vi.mock("./notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./notifications")>()),
|
||||
notify,
|
||||
}));
|
||||
|
||||
import { quickShareLink } from "./downloads";
|
||||
import { qk } from "./queryKeys";
|
||||
import { msg, type NotifText } from "./notifications";
|
||||
|
||||
const t = (k: string) => k;
|
||||
const origin = window.location.origin;
|
||||
|
||||
type ToastArg = {
|
||||
level: string;
|
||||
message: string;
|
||||
message: NotifText;
|
||||
action?: { label: string; onClick: () => void };
|
||||
};
|
||||
const firstToast = () => notify.mock.calls[0]![0] as ToastArg;
|
||||
@@ -63,7 +67,7 @@ describe("quickShareLink", () => {
|
||||
expect(copyText).toHaveBeenCalledWith(`${origin}/watch/tok7`);
|
||||
const arg = firstToast();
|
||||
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");
|
||||
arg.action!.onClick();
|
||||
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
|
||||
const arg = firstToast();
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -148,7 +152,7 @@ describe("quickShareLink", () => {
|
||||
expect(copyText).not.toHaveBeenCalled();
|
||||
// Titled with what failed, and carrying why (notifyActionError), not a bare message.
|
||||
expect(notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: "error", title: "downloads.share.linkFailed" }),
|
||||
expect.objectContaining({ level: "error", title: msg("downloads.share.linkFailed") }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { QueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "./queryKeys";
|
||||
import { api, type NewShareLink } from "./api";
|
||||
import { copyText } from "./clipboard";
|
||||
import { notify } from "./notifications";
|
||||
import { msg, notify } from "./notifications";
|
||||
import { notifyActionError } from "./actionErrors";
|
||||
|
||||
// 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");
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("downloads.share.linkFailed"));
|
||||
notifyActionError(e, msg("downloads.share.linkFailed"));
|
||||
return null;
|
||||
}
|
||||
const url = window.location.origin + path;
|
||||
@@ -75,8 +75,8 @@ export async function quickShareLink(
|
||||
};
|
||||
notify(
|
||||
(await copyText(url))
|
||||
? { level: "success", message: t("downloads.share.linkCopied"), action }
|
||||
: { level: "warning", message: t("downloads.share.copyFailed"), action },
|
||||
? { level: "success", message: msg("downloads.share.linkCopied"), action }
|
||||
: { level: "warning", message: msg("downloads.share.copyFailed"), action },
|
||||
);
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
configureNotifications,
|
||||
getActiveToasts,
|
||||
notify,
|
||||
raw,
|
||||
} from "./notifications";
|
||||
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
@@ -25,8 +26,8 @@ const shown = (id: number) => getActiveToasts().some((n) => n.id === id);
|
||||
describe("an action toast's lifetime", () => {
|
||||
it("stays for the floor when the reader's toasts last 3 s", () => {
|
||||
configureNotifications({ durationMs: 3000 });
|
||||
const plain = notify({ message: "plain" });
|
||||
const withUndo = notify({ message: "removed", action: undo });
|
||||
const plain = notify({ message: raw("plain") });
|
||||
const withUndo = notify({ message: raw("removed"), action: undo });
|
||||
vi.advanceTimersByTime(3001);
|
||||
expect(shown(plain)).toBe(false);
|
||||
expect(shown(withUndo)).toBe(true);
|
||||
@@ -36,7 +37,7 @@ describe("an action toast's lifetime", () => {
|
||||
|
||||
it("keeps a longer setting", () => {
|
||||
configureNotifications({ durationMs: 12000 });
|
||||
const id = notify({ message: "removed", action: undo });
|
||||
const id = notify({ message: raw("removed"), action: undo });
|
||||
vi.advanceTimersByTime(ACTION_TTL_MIN + 1);
|
||||
expect(shown(id)).toBe(true);
|
||||
vi.advanceTimersByTime(12000 - ACTION_TTL_MIN);
|
||||
@@ -45,7 +46,7 @@ describe("an action toast's lifetime", () => {
|
||||
|
||||
it("never dismisses when the reader chose never", () => {
|
||||
configureNotifications({ durationMs: 0 });
|
||||
const id = notify({ message: "removed", action: undo });
|
||||
const id = notify({ message: raw("removed"), action: undo });
|
||||
vi.advanceTimersByTime(60000);
|
||||
expect(shown(id)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -2,9 +2,56 @@
|
||||
// the Notification Center shows (info events, actions awaiting interaction, and app
|
||||
// errors). History is persisted to localStorage so it survives reloads; action
|
||||
// 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 { 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.
|
||||
const LOG_LEVEL: Record<NotifLevel, Level> = {
|
||||
info: "INFO",
|
||||
@@ -56,8 +103,8 @@ export type NotifMeta =
|
||||
export interface Notification {
|
||||
id: number;
|
||||
level: NotifLevel;
|
||||
title?: string;
|
||||
message: string;
|
||||
title?: NotifText;
|
||||
message: NotifText;
|
||||
action?: NotifAction;
|
||||
// A second, lesser action beside it (a done download's "Open" after its "Copy link").
|
||||
secondaryAction?: NotifAction;
|
||||
@@ -79,9 +126,9 @@ export interface Notification {
|
||||
}
|
||||
|
||||
export interface NotifyInput {
|
||||
message: string;
|
||||
message: NotifText;
|
||||
level?: NotifLevel;
|
||||
title?: string;
|
||||
title?: NotifText;
|
||||
action?: NotifAction;
|
||||
secondaryAction?: NotifAction;
|
||||
meta?: NotifMeta;
|
||||
@@ -175,17 +222,19 @@ let cachedUnread = 0;
|
||||
|
||||
function load(): Notification[] {
|
||||
try {
|
||||
const raw = readAccount<unknown>(HISTORY_KEY, []);
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const stored = readAccount<unknown>(HISTORY_KEY, []);
|
||||
if (!Array.isArray(stored)) return [];
|
||||
// Restored entries are history only. Mark them dismissed so they don'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
|
||||
// live `action` callback, which can't survive serialization.
|
||||
return raw.map(
|
||||
return stored.map(
|
||||
(n) =>
|
||||
({
|
||||
repeat: 1,
|
||||
...(n as object),
|
||||
title: asText((n as { title?: unknown }).title),
|
||||
message: asText((n as { message?: unknown }).message) ?? raw(""),
|
||||
action: undefined,
|
||||
secondaryAction: undefined,
|
||||
dismissed: true,
|
||||
@@ -261,9 +310,12 @@ const COALESCE_WINDOW_MS = 5000;
|
||||
export function notify(input: NotifyInput): number {
|
||||
const requiresInteraction = input.requiresInteraction ?? false;
|
||||
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,
|
||||
...(input.title ? { title: input.title } : {}),
|
||||
...(input.title ? { title: sayNotif(input.title, en) } : {}),
|
||||
...(input.meta ? { kind: input.meta.kind } : {}),
|
||||
});
|
||||
const now = Date.now();
|
||||
@@ -294,8 +346,8 @@ export function notify(input: NotifyInput): number {
|
||||
!n.action &&
|
||||
now - n.ts <= COALESCE_WINDOW_MS &&
|
||||
n.level === level &&
|
||||
(n.title ?? "") === (input.title ?? "") &&
|
||||
n.message === input.message,
|
||||
sameText(n.title, input.title) &&
|
||||
sameText(n.message, input.message),
|
||||
);
|
||||
if (prior) {
|
||||
// Re-surface the existing toast (undismiss + unread) and re-arm its auto-dismiss timer.
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
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
|
||||
* label instead. Was hand-inlined in Playlists, PlaylistsRail and AddToPlaylist; one copy now. */
|
||||
export function playlistName(p: { kind: string; name: string }, t: TFunction): string {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { api, type PlexCard } from "./api";
|
||||
import { notifyActionError } from "./actionErrors";
|
||||
import { msg } from "./notifications";
|
||||
|
||||
// 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
|
||||
@@ -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.
|
||||
export async function toggleWatchState(
|
||||
card: PlexCard,
|
||||
t: TFunction,
|
||||
handlers: { optimistic: (next: string) => void; resync: () => void; revert?: () => void },
|
||||
) {
|
||||
const next = card.status === "watched" ? "new" : "watched";
|
||||
@@ -21,7 +20,7 @@ export async function toggleWatchState(
|
||||
await api.plexSetState(card.id, next);
|
||||
} catch (e) {
|
||||
handlers.revert?.();
|
||||
notifyActionError(e, t("plex.watchedFailed"));
|
||||
notifyActionError(e, msg("plex.watchedFailed"));
|
||||
}
|
||||
handlers.resync();
|
||||
}
|
||||
|
||||
@@ -5,16 +5,18 @@
|
||||
// silence on a 403/404/429 or a lost connection — 34 of them.
|
||||
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() }));
|
||||
|
||||
import { MutationObserver, type MutationObserverOptions } from "@tanstack/react-query";
|
||||
import { createQueryClient } from "./queryClient";
|
||||
import { HttpError, req } from "./api/core";
|
||||
import { notify } from "./notifications";
|
||||
import { msg, notify, raw } from "./notifications";
|
||||
import { reportError } from "./errorDialog";
|
||||
import { notifyActionError } from "./actionErrors";
|
||||
import i18n from "../i18n";
|
||||
|
||||
const notifyMock = vi.mocked(notify);
|
||||
beforeEach(() => {
|
||||
@@ -39,7 +41,7 @@ describe("a failed mutation", () => {
|
||||
meta: { failTitle: TITLE },
|
||||
});
|
||||
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")) });
|
||||
expect(notifyMock).toHaveBeenCalledWith({
|
||||
level: "error",
|
||||
title: i18n.t("errors.title"),
|
||||
message: i18n.t("errors.reason.rateLimited"),
|
||||
title: msg("errors.title"),
|
||||
message: msg("errors.reason.rateLimited"),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,7 +60,7 @@ describe("a failed mutation", () => {
|
||||
meta: { failTitle: TITLE },
|
||||
});
|
||||
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({
|
||||
mutationFn: () => Promise.reject(new HttpError(404, "gone")),
|
||||
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 () => {
|
||||
@@ -79,7 +81,7 @@ describe("a failed mutation", () => {
|
||||
onError: rollback,
|
||||
});
|
||||
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 () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
|
||||
import i18n from "../i18n";
|
||||
import { notifyActionError } from "./actionErrors";
|
||||
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"`):
|
||||
* 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 {
|
||||
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 {
|
||||
|
||||
@@ -28,6 +28,7 @@ vi.mock("./entryUrl", async (importOriginal) => {
|
||||
return { ...actual, accountSwitchNotice: () => notice() };
|
||||
});
|
||||
const { useAccountSwitchNotice } = await import("./useAccountSwitchNotice");
|
||||
const { msg } = await import("./notifications");
|
||||
|
||||
afterEach(() => {
|
||||
notifyMock.mockClear();
|
||||
@@ -53,7 +54,7 @@ describe("the shell's word on a switch of account", () => {
|
||||
expect(notifyMock).toHaveBeenCalledTimes(1);
|
||||
expect(notifyMock).toHaveBeenCalledWith({
|
||||
level: "info",
|
||||
message: 'header.account.switchRequested {"email":"b@example.com"}',
|
||||
message: msg("header.account.switchRequested", { email: "b@example.com" }),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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
|
||||
* 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;
|
||||
said.current = true;
|
||||
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]);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { api } from "./api";
|
||||
import { notifyActionError } from "./actionErrors";
|
||||
import i18n from "../i18n";
|
||||
import { useLatest } from "./useLatest";
|
||||
import { msg } from "./notifications";
|
||||
|
||||
/** 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
|
||||
@@ -40,7 +40,7 @@ export function useChannelPriority(opts: {
|
||||
.catch((err: unknown) => {
|
||||
// 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.
|
||||
notifyActionError(err, i18n.t("errors.writeFailed.channelPriority"));
|
||||
notifyActionError(err, msg("errors.writeFailed.channelPriority"));
|
||||
optsRef.current.onError(channelId);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api } from "./api";
|
||||
import { qk } from "./queryKeys";
|
||||
import { notifyActionError } from "./actionErrors";
|
||||
import { msg } from "./notifications";
|
||||
|
||||
/** 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
|
||||
@@ -21,7 +21,6 @@ export function useChannelTagActions(opts: {
|
||||
onError: (channelId: string) => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const toggleTag = (channelId: string, tagId: number, hasTag: boolean) => {
|
||||
opts.optimisticToggle(channelId, tagId, !hasTag);
|
||||
@@ -31,7 +30,7 @@ export function useChannelTagActions(opts: {
|
||||
call
|
||||
.then(() => opts.onSuccess?.(channelId))
|
||||
.catch((err: unknown) => {
|
||||
notifyActionError(err, t("channels.tags.updateFailed"));
|
||||
notifyActionError(err, msg("channels.tags.updateFailed"));
|
||||
opts.onError(channelId);
|
||||
});
|
||||
};
|
||||
@@ -45,7 +44,7 @@ export function useChannelTagActions(opts: {
|
||||
qc.invalidateQueries({ queryKey: qk.tags() });
|
||||
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 };
|
||||
|
||||
@@ -17,7 +17,7 @@ vi.mock("./api", () => ({ api: { savePrefs, me: vi.fn() } }));
|
||||
vi.mock("./actionErrors", () => ({ notifyActionError }));
|
||||
|
||||
import { usePref, useSavePref } from "./usePref";
|
||||
import i18n from "../i18n";
|
||||
import { msg } from "./notifications";
|
||||
|
||||
afterEach(() => {
|
||||
savePrefs.mockReset();
|
||||
@@ -34,7 +34,7 @@ describe("a refused preference save", () => {
|
||||
const { result } = renderHook(() => usePref<boolean>("hintsOn", true), { wrapper });
|
||||
act(() => result.current[1](false));
|
||||
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
|
||||
});
|
||||
@@ -45,7 +45,7 @@ describe("a refused preference save", () => {
|
||||
const { result } = renderHook(() => useSavePref(), { wrapper });
|
||||
act(() => result.current({ stripSources: { c1: false } }));
|
||||
await waitFor(() =>
|
||||
expect(notifyActionError).toHaveBeenCalledWith(refusal, i18n.t("errors.writeFailed.prefs")),
|
||||
expect(notifyActionError).toHaveBeenCalledWith(refusal, msg("errors.writeFailed.prefs")),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,13 +6,13 @@ import type { Me } from "./api/account";
|
||||
import { getAccountRaw, writeAccount } from "./storage";
|
||||
import { notifyActionError } from "./actionErrors";
|
||||
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 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.
|
||||
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 account on the server (PUT /api/me/preferences, which MERGES the partial payload) and follows the
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type Me } from "./api";
|
||||
import { qk } from "./queryKeys";
|
||||
import { notifyActionError } from "./actionErrors";
|
||||
@@ -15,6 +14,7 @@ import {
|
||||
type PanelId,
|
||||
type 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
|
||||
// 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
|
||||
// whose server copy has none.
|
||||
export function usePanelLayout(panel: PanelId): [PanelLayout, (next: PanelLayout) => void] {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const [layout, setState] = useState(() =>
|
||||
seedLayout(panel, qc.getQueryData<Me | null>(qk.me())?.preferences),
|
||||
@@ -38,7 +37,7 @@ export function usePanelLayout(panel: PanelId): [PanelLayout, (next: PanelLayout
|
||||
}));
|
||||
api
|
||||
.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];
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { HttpError } from "./api";
|
||||
import { markReported } from "./api/core";
|
||||
import { notify } from "./notifications";
|
||||
import { msg, notify, type NotifText } from "./notifications";
|
||||
import { notifyActionError } from "./actionErrors";
|
||||
import i18n from "../i18n";
|
||||
|
||||
// A YouTube-gated action (subscribe, sync, backfill, unsubscribe) that 403s means the user hasn't
|
||||
// granted the write scope — surface a "Connect" affordance instead of a vague failure. Pass
|
||||
// `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(
|
||||
err: unknown,
|
||||
fallbackMessage: string,
|
||||
fallbackMessage: NotifText,
|
||||
onConnect?: () => void,
|
||||
): void {
|
||||
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.
|
||||
notify({
|
||||
level: "error",
|
||||
message: i18n.t("channels.notify.needYouTube"),
|
||||
message: msg("channels.notify.needYouTube"),
|
||||
action: onConnect
|
||||
? { label: i18n.t("channels.notify.connect"), onClick: onConnect }
|
||||
: undefined,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { sinceOf } from "../../lib/activityUrlState";
|
||||
import { auditActionLabel, auditText } from "../../lib/auditText";
|
||||
import { formatDateTimeSec } from "../../lib/format";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, notify } from "../../lib/notifications";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { useHistoryList } from "../../lib/useHistoryList";
|
||||
import type { Shared } from "./ActivityRuns";
|
||||
@@ -70,9 +70,9 @@ export default function ActivityAudit({
|
||||
onSuccess: (r) => {
|
||||
void qc.invalidateQueries({ queryKey: qk.adminAudit() });
|
||||
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 () => {
|
||||
if (
|
||||
|
||||
@@ -15,7 +15,7 @@ import { nextPageOnClick } from "../../lib/nextPage";
|
||||
import { LEVEL_GROUPS, type LevelGroup } from "../../lib/log";
|
||||
import { sinceOf } from "../../lib/activityUrlState";
|
||||
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 { useUrlMirror } from "../../lib/useUrlMirror";
|
||||
import { HistoryFrame } from "./HistoryList";
|
||||
@@ -187,8 +187,8 @@ export default function AdminLogs({
|
||||
const ok = await copyText(text);
|
||||
notify(
|
||||
ok
|
||||
? { level: "success", message: t("adminLogs.copied", { count: rows.length }) }
|
||||
: { level: "error", message: t("adminLogs.copyFailed") },
|
||||
? { level: "success", message: msg("adminLogs.copied", { count: rows.length }) }
|
||||
: { level: "error", message: msg("adminLogs.copyFailed") },
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { hydrateSort, sortRows, type SortState } from "../../lib/columnSort";
|
||||
import { usePref } from "../../lib/usePref";
|
||||
import { useCardPager } from "../../lib/useCardPager";
|
||||
import { subsColumn } from "./channelColumns";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, notify } from "../../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
|
||||
import Tooltip from "../../components/ui/Tooltip";
|
||||
import ChannelLink from "./ChannelLink";
|
||||
@@ -70,13 +70,13 @@ export default function ChannelDiscovery({
|
||||
// callbacks are gone (see ChannelSubscribedMeta).
|
||||
notify({
|
||||
level: "success",
|
||||
title: t("channels.discovery.subscribedTitle"),
|
||||
message: t("channels.discovery.subscribedBody", { name }),
|
||||
title: msg("channels.discovery.subscribedTitle"),
|
||||
message: msg("channels.discovery.subscribedBody", { name }),
|
||||
meta: { kind: "channel-subscribed", channelId: c.id, channelName: name },
|
||||
});
|
||||
},
|
||||
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
|
||||
|
||||
@@ -18,7 +18,7 @@ import { Sparkles } from "lucide-react";
|
||||
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
|
||||
import { LS, ownKey } from "../../lib/storage";
|
||||
import { buildChannelColumns, applySyncFilter, tagFilterChips } from "./channelColumns";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, notify } from "../../lib/notifications";
|
||||
import { Badge } from "../../components/ui/Badge";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import Tooltip from "../../components/ui/Tooltip";
|
||||
@@ -168,9 +168,9 @@ export default function Channels() {
|
||||
(tagId: number, name: string) => {
|
||||
setFeedFilters({ ...feedFilters, tags: [tagId], channelIds: [] });
|
||||
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 });
|
||||
@@ -294,10 +294,10 @@ export default function Channels() {
|
||||
invalidate();
|
||||
notify({
|
||||
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({
|
||||
mutationFn: () => api.deepAll(true),
|
||||
@@ -306,11 +306,11 @@ export default function Channels() {
|
||||
qc.invalidateQueries({ queryKey: qk.myStatus() });
|
||||
notify({
|
||||
level: "success",
|
||||
message: t("channels.notify.fullHistoryRequested", { count: r.updated ?? 0 }),
|
||||
message: msg("channels.notify.fullHistoryRequested", { count: r.updated ?? 0 }),
|
||||
});
|
||||
},
|
||||
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
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Button } from "../../components/ui/Button";
|
||||
import { IconButton } from "../../components/ui/IconButton";
|
||||
import { Pressable } from "../../components/ui/Pressable";
|
||||
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*
|
||||
// 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) =>
|
||||
notifyActionError(
|
||||
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 Tooltip from "../../components/ui/Tooltip";
|
||||
import type { WatchedChannelActions } from "./useWatchedChannelActions";
|
||||
import { msg } from "../../lib/notifications";
|
||||
|
||||
const PlayerModal = lazy(() => import("../../features/player/PlayerModal"));
|
||||
|
||||
@@ -67,7 +68,7 @@ export default function WatchedVideosDialog({
|
||||
);
|
||||
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;
|
||||
});
|
||||
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 { api, type ManagedChannel, type Tag } from "../../lib/api";
|
||||
import type { ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||
import { msg, type NotifText } from "../../lib/notifications";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -195,10 +196,10 @@ describe("useChannelActions — hide and unsubscribe", () => {
|
||||
expect(h.confirm).not.toHaveBeenCalled();
|
||||
expect(upd).toHaveBeenCalledWith("UC1", { hidden: true });
|
||||
const toast = h.notify.mock.calls[0]![0] as {
|
||||
message: string;
|
||||
message: NotifText;
|
||||
action: { onClick: () => void };
|
||||
};
|
||||
expect(toast.message).toBe("channels.notify.hidden");
|
||||
expect(toast.message).toEqual(msg("channels.notify.hidden", { name: "Chan" }));
|
||||
act(() => toast.action.onClick());
|
||||
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 { compareText } from "../../lib/collate";
|
||||
import { channelYouTubeUrl } from "../../lib/format";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, notify } from "../../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
|
||||
import { useMe } from "../../lib/useMe";
|
||||
import { useConfirm } from "../../components/ui/confirmContext";
|
||||
@@ -70,7 +70,7 @@ export function useChannelActions({
|
||||
refresh();
|
||||
notify({
|
||||
level: "success",
|
||||
message: t("channels.notify.hidden", { name: nameOf(c) }),
|
||||
message: msg("channels.notify.hidden", { name: nameOf(c) }),
|
||||
action: {
|
||||
label: t("common.undo"),
|
||||
onClick: () => patch.mutate({ id: c.id, body: { hidden: false } }),
|
||||
@@ -82,19 +82,22 @@ export function useChannelActions({
|
||||
mutationFn: (c: ManagedChannel) => api.unsubscribeChannel(c.id),
|
||||
onSuccess: (_d, c) => {
|
||||
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) =>
|
||||
notifyYouTubeActionError(e, t("channels.notify.unsubscribeFailed"), onOpenWizard),
|
||||
notifyYouTubeActionError(e, msg("channels.notify.unsubscribeFailed"), onOpenWizard),
|
||||
});
|
||||
const resetBackfill = useMutation({
|
||||
mutationFn: (c: ManagedChannel) => api.resetChannelBackfill(c.id),
|
||||
onSuccess: (_d, c) => {
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
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) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { invalidateSubscription } from "../../lib/subscriptionCache";
|
||||
import { api, type WatchedChannel } from "../../lib/api";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, notify } from "../../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
|
||||
import { useConfirm } from "../../components/ui/confirmContext";
|
||||
|
||||
@@ -21,13 +21,13 @@ export function useWatchedChannelActions(onOpenWizard: () => void) {
|
||||
invalidateSubscription(qc, c.id);
|
||||
notify({
|
||||
level: "success",
|
||||
title: t("channels.discovery.subscribedTitle"),
|
||||
message: t("channels.discovery.subscribedBody", { name: nameOf(c) }),
|
||||
title: msg("channels.discovery.subscribedTitle"),
|
||||
message: msg("channels.discovery.subscribedBody", { name: nameOf(c) }),
|
||||
meta: { kind: "channel-subscribed", channelId: c.id, channelName: nameOf(c) },
|
||||
});
|
||||
},
|
||||
onError: (err: unknown) =>
|
||||
notifyYouTubeActionError(err, t("channels.discovery.subscribeFailed"), onOpenWizard),
|
||||
notifyYouTubeActionError(err, msg("channels.discovery.subscribeFailed"), onOpenWizard),
|
||||
});
|
||||
const restore = useMutation({
|
||||
mutationFn: (c: WatchedChannel) => api.restoreWatchedChannel(c.id),
|
||||
@@ -43,7 +43,7 @@ export function useWatchedChannelActions(onOpenWizard: () => void) {
|
||||
void qc.invalidateQueries({ queryKey: qk.watchedChannels() });
|
||||
notify({
|
||||
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) },
|
||||
});
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Plug, RotateCcw, Send } from "lucide-react";
|
||||
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 InlineNotice from "../../components/ui/InlineNotice";
|
||||
import { useMe } from "../../lib/useMe";
|
||||
@@ -130,7 +130,7 @@ export default function ConfigPanel() {
|
||||
setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 2500);
|
||||
} catch (e) {
|
||||
setSaveState("error");
|
||||
notifyActionError(e, t("config.saveFailed"));
|
||||
notifyActionError(e, msg("config.saveFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,8 +142,8 @@ export default function ConfigPanel() {
|
||||
|
||||
const testEmail = useMutation({
|
||||
mutationFn: () => api.testEmail(),
|
||||
onSuccess: (r) => notify({ level: "success", message: t("config.testSent", { to: r.to }) }),
|
||||
onError: (e) => notifyActionError(e, t("config.testFailed")),
|
||||
onSuccess: (r) => notify({ level: "success", message: msg("config.testSent", { to: r.to }) }),
|
||||
onError: (e) => notifyActionError(e, msg("config.testFailed")),
|
||||
});
|
||||
|
||||
// Plex "Test connection": verifies the server URL + token and returns the library sections,
|
||||
@@ -154,19 +154,19 @@ export default function ConfigPanel() {
|
||||
mutationFn: () => api.testPlex(),
|
||||
onSuccess: (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) => {
|
||||
setPlexResult(null);
|
||||
notifyActionError(e, t("config.plexTestFailed"));
|
||||
notifyActionError(e, msg("config.plexTestFailed"));
|
||||
},
|
||||
});
|
||||
// Bug reports' "Test connection": the repository answers, takes issues, is private.
|
||||
const testIssues = useMutation({
|
||||
mutationFn: () => api.testIssues(),
|
||||
onSuccess: (r) =>
|
||||
notify({ level: "success", message: t("config.issuesTestOk", { repo: r.repo }) }),
|
||||
onError: (e) => notifyActionError(e, t("config.issuesTestFailed")),
|
||||
notify({ level: "success", message: msg("config.issuesTestOk", { repo: r.repo }) }),
|
||||
onError: (e) => notifyActionError(e, msg("config.issuesTestFailed")),
|
||||
});
|
||||
|
||||
const plexLibs = (draft["plex_libraries"] ?? "")
|
||||
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
} from "../../lib/channelPageUrlState";
|
||||
import { LS } from "../../lib/storage";
|
||||
import { useConfirm } from "../../components/ui/confirmContext";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, notify } from "../../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
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
|
||||
// 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))
|
||||
);
|
||||
},
|
||||
[channelId, qc, t],
|
||||
[channelId, qc],
|
||||
);
|
||||
|
||||
// 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),
|
||||
onSuccess: () => {
|
||||
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
|
||||
// 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({
|
||||
mutationFn: () => api.unsubscribeChannel(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
|
||||
// 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.channels() });
|
||||
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
|
||||
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
type Video,
|
||||
type VideoStatus,
|
||||
} from "../../lib/api";
|
||||
import i18n from "../../i18n";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { announceVideoState, type NoticeVideo } from "../../features/video/stateNotice";
|
||||
import {
|
||||
@@ -39,7 +38,7 @@ import {
|
||||
writeVideoState,
|
||||
writeWatchLater,
|
||||
} from "../../features/video/videoWrites";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, notify } from "../../lib/notifications";
|
||||
import { useMe } from "../../lib/useMe";
|
||||
import { YT_COUNTS } from "../../lib/ytSearchUrlState";
|
||||
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
|
||||
// reverts to the real (unchanged) state instead of staying phantom-hidden, and say so:
|
||||
// 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));
|
||||
});
|
||||
return write;
|
||||
@@ -447,7 +446,7 @@ export default function Feed({
|
||||
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
notifyActionError(e, i18n.t("errors.writeFailed.videoReset"));
|
||||
notifyActionError(e, msg("errors.writeFailed.videoReset"));
|
||||
setOverrides((o) => withoutWrite(o, id, seq));
|
||||
if (resets.current.get(id) !== seq) return;
|
||||
resets.current.delete(id);
|
||||
@@ -471,9 +470,7 @@ export default function Feed({
|
||||
.catch((e: unknown) => {
|
||||
notifyActionError(
|
||||
e,
|
||||
i18n.t(
|
||||
saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove",
|
||||
),
|
||||
msg(saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove"),
|
||||
);
|
||||
setSavedOverrides((o) => withoutWrite(o, id, seq));
|
||||
});
|
||||
@@ -619,11 +616,9 @@ export default function Feed({
|
||||
const ids = ytItems.map((v) => v.id);
|
||||
api
|
||||
.clearSearch(ids)
|
||||
.then((r) =>
|
||||
notify({ message: i18n.t("feed.yt.cleared", { count: r.removed }) }),
|
||||
)
|
||||
.then((r) => notify({ message: msg("feed.yt.cleared", { count: r.removed }) }))
|
||||
.catch((e: unknown) =>
|
||||
notifyActionError(e, i18n.t("errors.writeFailed.clearSearch")),
|
||||
notifyActionError(e, msg("errors.writeFailed.clearSearch")),
|
||||
);
|
||||
setFilters({ ...filters, librarySource: "organic", sort: FEED_DEFAULT_SORT });
|
||||
onExitYtSearch();
|
||||
|
||||
@@ -116,6 +116,7 @@ vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
|
||||
}));
|
||||
|
||||
import Files from "./Files";
|
||||
import { msg } from "../../lib/notifications";
|
||||
import { FILES_LIST_CAP, type QueueJob } from "../../lib/api";
|
||||
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(() =>
|
||||
expect(mocks.notify).toHaveBeenCalledWith({
|
||||
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", () => {
|
||||
@@ -805,7 +809,7 @@ describe("Files · a file the address names", () => {
|
||||
mount();
|
||||
await waitFor(() =>
|
||||
expect(mocks.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: "warning", message: "files.linkedGone" }),
|
||||
expect.objectContaining({ level: "warning", message: msg("files.linkedGone") }),
|
||||
),
|
||||
);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
@@ -822,11 +826,11 @@ describe("Files · a file the address names", () => {
|
||||
mount();
|
||||
await waitFor(() =>
|
||||
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.objectContaining({ message: "files.linkedGone" }),
|
||||
expect.objectContaining({ message: msg("files.linkedGone") }),
|
||||
);
|
||||
expect(here().get("fview")).toBe("9999");
|
||||
}, 30_000); // a full page of FILES_LIST_CAP rows
|
||||
@@ -839,11 +843,11 @@ describe("Files · a file the address names", () => {
|
||||
mount();
|
||||
await waitFor(() =>
|
||||
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.objectContaining({ message: "files.linkedGone" }),
|
||||
expect.objectContaining({ message: msg("files.linkedGone") }),
|
||||
);
|
||||
expect(here().get("fview")).toBe("9999");
|
||||
}, 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];
|
||||
mount(ALL_FILES, vi.fn(), "", { focus: { id: 9, token: 1 } });
|
||||
await waitFor(() =>
|
||||
expect(mocks.notify).toHaveBeenCalledWith({ level: "info", message: "files.arrival.gone" }),
|
||||
expect(mocks.notify).toHaveBeenCalledWith({
|
||||
level: "info",
|
||||
message: msg("files.arrival.gone"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import { libraryRowActions, sharedRowActions } from "../../lib/downloadActions";
|
||||
import { formatBytes, formatDateTime, relativeTime } from "../../lib/format";
|
||||
import { focusNavItem } from "../../lib/listNav";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, notify } from "../../lib/notifications";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { LS, ownKey } from "../../lib/storage";
|
||||
import { useCardPager } from "../../lib/useCardPager";
|
||||
@@ -296,9 +296,9 @@ export default function Files() {
|
||||
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
|
||||
// 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) {
|
||||
notify({ level: "warning", message: t("files.linkedGone") });
|
||||
notify({ level: "warning", message: msg("files.linkedGone") });
|
||||
clearParams(FILES_VIEW_KEY, FILES_PLAY_KEY);
|
||||
} else if (linkedFile?.open === "play") play(file);
|
||||
else setViewing(file.id);
|
||||
@@ -387,7 +387,10 @@ export default function Files() {
|
||||
consumeFocus(null);
|
||||
// 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.
|
||||
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]);
|
||||
const arrival = arrived && arrived.view === viewKey ? arrived : null;
|
||||
@@ -434,7 +437,7 @@ export default function Files() {
|
||||
const dropped = e.dataTransfer.files;
|
||||
const first = dropped.item(0);
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Copy, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react";
|
||||
import Modal from "../../components/ui/Modal";
|
||||
import { useConfirm } from "../../components/ui/confirmContext";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, notify } from "../../lib/notifications";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { formatDate } from "../../lib/format";
|
||||
import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../../lib/api";
|
||||
@@ -40,9 +40,9 @@ function UserShare({ job }: { job: DownloadJob }) {
|
||||
const share = useMutation({
|
||||
mutationFn: (email: string) => api.shareDownload(job.id, email),
|
||||
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 (
|
||||
|
||||
@@ -45,6 +45,7 @@ vi.mock("../../lib/downloadQueue", () => ({
|
||||
|
||||
import UploadDialog from "./UploadDialog";
|
||||
import { FilesActionsContext } from "./filesContext";
|
||||
import { msg, type NotifText } from "../../lib/notifications";
|
||||
|
||||
const wrap = (
|
||||
onClose = vi.fn(),
|
||||
@@ -101,7 +102,9 @@ describe("UploadDialog", () => {
|
||||
expect(passedFile).toBe(f);
|
||||
expect((opts as { displayName?: string }).displayName).toBe("My notes");
|
||||
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());
|
||||
});
|
||||
|
||||
@@ -115,9 +118,9 @@ describe("UploadDialog", () => {
|
||||
});
|
||||
fireEvent.click(screen.getByText("downloads.upload.submit"));
|
||||
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
|
||||
expect(h.notify.mock.calls[0]![0]).toMatchObject({
|
||||
message: "downloads.upload.addedConverting",
|
||||
});
|
||||
expect((h.notify.mock.calls[0]![0] as { message: NotifText }).message).toEqual(
|
||||
msg("downloads.upload.addedConverting"),
|
||||
);
|
||||
// Its finish is still to come, and the tray's to announce.
|
||||
expect(h.uploadEnded).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import { qk } from "../../lib/queryKeys";
|
||||
import { isAborted } from "../../lib/api/core";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { invalidateDownloads } from "../../lib/downloads";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, notify } from "../../lib/notifications";
|
||||
import { showInTray, uploadEnded, uploadStarted } from "../../lib/downloadQueue";
|
||||
import type { DownloadJob } from "../../lib/api";
|
||||
import { useFilesActions } from "./filesContext";
|
||||
@@ -105,7 +105,7 @@ export default function UploadDialog({
|
||||
invalidateDownloads(qc);
|
||||
notify({
|
||||
level: "success",
|
||||
message: t(uploadedKey(job.status)),
|
||||
message: msg(uploadedKey(job.status)),
|
||||
action: {
|
||||
label: t("downloads.dialog.view"),
|
||||
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
|
||||
// had not since the shared `reportFailure` landed.
|
||||
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.
|
||||
setPct(null);
|
||||
abortRef.current = null;
|
||||
|
||||
@@ -22,6 +22,7 @@ vi.mock("../../lib/notifications", async (importOriginal) => ({
|
||||
import VideoEditor from "./VideoEditor";
|
||||
import { api, HttpError, type DownloadJob } from "../../lib/api";
|
||||
import { markReported } from "../../lib/api/core";
|
||||
import { msg } from "../../lib/notifications";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
@@ -88,7 +89,10 @@ describe("VideoEditor — a split export that fails part-way", () => {
|
||||
await act(async () => refuse(refused));
|
||||
await waitFor(() =>
|
||||
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)");
|
||||
|
||||
@@ -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 clsx from "clsx";
|
||||
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 { cropToSourcePx, type Frac } from "../../lib/cropGeometry";
|
||||
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.
|
||||
notify({
|
||||
level: "error",
|
||||
title: t("editor.partlyCreated", { done: done.length, total: N }),
|
||||
title: msg("editor.partlyCreated", { done: done.length, total: N }),
|
||||
message: failureReason(e),
|
||||
});
|
||||
markReported(e);
|
||||
@@ -300,10 +300,10 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
onSuccess: ({ files }) => {
|
||||
// The whole library moves with it: an edit the user already made is done at once, and lists.
|
||||
invalidateDownloads(qc);
|
||||
notify({ level: "success", message: t("editor.created", { count: files }) });
|
||||
notify({ level: "success", message: msg("editor.created", { count: files }) });
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => notifyActionError(e, t("editor.failed")),
|
||||
onError: (e) => notifyActionError(e, msg("editor.failed")),
|
||||
onSettled: () => {
|
||||
creating.current = false;
|
||||
},
|
||||
|
||||
@@ -28,6 +28,7 @@ import { jobTitle } from "../../features/downloads/downloadTitle";
|
||||
import { folderLabel } from "../../features/folders/fileFolders";
|
||||
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||
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
|
||||
// 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({
|
||||
mutationFn: ({ id, patch }: { id: number; patch: Patch }) => api.updateFile(id, patch),
|
||||
onSuccess: () => invalidateDownloads(qc),
|
||||
onError: (e, v) => notifyActionError(e, t(patchFail(v.patch))),
|
||||
onError: (e, v) => notifyActionError(e, msg(patchFail(v.patch))),
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: number) => api.deleteFile(id),
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useConfirm } from "../../components/ui/confirmContext";
|
||||
import { folderLabel } from "../../features/folders/fileFolders";
|
||||
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||
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
|
||||
// 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({
|
||||
mutationFn: ({ id, patch }: { id: number; patch: FolderPatch }) => api.updateFolder(id, patch),
|
||||
onSuccess: () => invalidateDownloads(qc),
|
||||
onError: (e, v) => notifyActionError(e, t(patchFail(v.patch))),
|
||||
onError: (e, v) => notifyActionError(e, msg(patchFail(v.patch))),
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: number) => api.deleteFolder(id),
|
||||
|
||||
@@ -81,6 +81,7 @@ import RemoteImage from "../../components/ui/RemoteImage";
|
||||
import { useOpenLinkedPlayer } from "../../features/player/useOpenLinkedPlayer";
|
||||
import { budgetFrom, budgetParams, DEFAULT_BUDGET } from "../../lib/homeUrlState";
|
||||
import { useUrlMirror } from "../../lib/useUrlMirror";
|
||||
import { msg } from "../../lib/notifications";
|
||||
|
||||
// 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
|
||||
// would describe a state the user can see is untrue, over a fully populated page whose
|
||||
// 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;
|
||||
},
|
||||
[qc, t],
|
||||
[qc],
|
||||
);
|
||||
|
||||
const onToggleSave = useCallback(
|
||||
@@ -887,9 +888,9 @@ export default function Home() {
|
||||
qc.invalidateQueries({ queryKey: qk.discover() });
|
||||
})
|
||||
// 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
|
||||
@@ -940,11 +941,11 @@ export default function Home() {
|
||||
// re-asks the server rather than trying to predict a cross-rail consequence.
|
||||
const onToggleWatched = useCallback(
|
||||
(card: PlexCard) =>
|
||||
void toggleWatchState(card, t, {
|
||||
void toggleWatchState(card, {
|
||||
optimistic: () => {},
|
||||
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
|
||||
@@ -986,7 +987,7 @@ export default function Home() {
|
||||
.reorderComfortPins(ids)
|
||||
.catch((e: unknown) => {
|
||||
qc.setQueryData<DiscoverPayload>(key, (old) => (old ? { ...old, comfort: cards } : old));
|
||||
notifyActionError(e, t("home.shelf.moveFailed"));
|
||||
notifyActionError(e, msg("home.shelf.moveFailed"));
|
||||
})
|
||||
.finally(() => {
|
||||
shelfSavingRef.current = false;
|
||||
@@ -1000,7 +1001,7 @@ export default function Home() {
|
||||
void qc.invalidateQueries({ queryKey: qk.discover() });
|
||||
});
|
||||
},
|
||||
[budget, lang, qc, t],
|
||||
[budget, lang, qc],
|
||||
);
|
||||
|
||||
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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
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(() => ({
|
||||
server: [] as unknown[],
|
||||
@@ -116,7 +116,7 @@ const client = (id: number, extra: Partial<ClientNotif> = {}) =>
|
||||
({
|
||||
id,
|
||||
level: "success",
|
||||
message: `Activity ${id}`,
|
||||
message: raw(`Activity ${id}`),
|
||||
requiresInteraction: false,
|
||||
ts: Date.now(),
|
||||
read: true,
|
||||
|
||||
@@ -50,6 +50,7 @@ vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
|
||||
|
||||
import NotificationsPanel from "./NotificationsPanel";
|
||||
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.
|
||||
beforeEach(() => {
|
||||
@@ -80,7 +81,7 @@ const client = (id: number, ageMs: number, extra: Partial<ClientNotif> = {}) =>
|
||||
({
|
||||
id,
|
||||
level: "success",
|
||||
message: `Activity ${id}.`,
|
||||
message: raw(`Activity ${id}.`),
|
||||
requiresInteraction: false,
|
||||
ts: Date.now() - ageMs,
|
||||
read: true,
|
||||
@@ -122,8 +123,8 @@ describe("NotificationsPanel · order", () => {
|
||||
h.client = [
|
||||
client(9, 5 * 60 * 1000, {
|
||||
level: "warning",
|
||||
title: "common.accessRequestsTitle",
|
||||
message: "1 pending",
|
||||
title: raw("common.accessRequestsTitle"),
|
||||
message: raw("1 pending"),
|
||||
meta: { kind: "access-requests" },
|
||||
}),
|
||||
client(8, 2 * D),
|
||||
@@ -366,8 +367,8 @@ describe("NotificationsPanel · order", () => {
|
||||
h.client = [
|
||||
client(9, 5 * 60 * 1000, {
|
||||
level: "warning",
|
||||
title: "common.accessRequestsTitle",
|
||||
message: "1 pending",
|
||||
title: raw("common.accessRequestsTitle"),
|
||||
message: raw("1 pending"),
|
||||
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
|
||||
// left by it: "Download ready" alone was every download's name.
|
||||
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();
|
||||
await loaded();
|
||||
expect(
|
||||
|
||||
@@ -29,16 +29,18 @@ import { useLiveQuery } from "../../lib/useLiveQuery";
|
||||
import { focusAccessRequestsTab } from "../../lib/adminUsersTab";
|
||||
import { focusSettingsTab } from "../../lib/settingsTab";
|
||||
import {
|
||||
type ChannelSubscribedMeta,
|
||||
clearAll as clearClient,
|
||||
dismiss as dismissClient,
|
||||
getNotifications,
|
||||
getUnreadCount,
|
||||
markAllRead as markAllClientRead,
|
||||
msg,
|
||||
type Notification as ClientNotif,
|
||||
remove as removeClient,
|
||||
resolveVideo,
|
||||
sayNotif,
|
||||
subscribe,
|
||||
type Notification as ClientNotif,
|
||||
type ChannelSubscribedMeta,
|
||||
type VideoHiddenMeta,
|
||||
type VideoWatchedMeta,
|
||||
} from "../../lib/notifications";
|
||||
@@ -250,7 +252,7 @@ export default function NotificationsPanel() {
|
||||
// Quietly resolve the now-stale notice (no confirmation toast — the change is visible).
|
||||
resolveVideo(meta.videoId);
|
||||
})
|
||||
.catch((e) => notifyActionError(e, t("inbox.revertFailed")));
|
||||
.catch((e) => notifyActionError(e, msg("inbox.revertFailed")));
|
||||
}
|
||||
|
||||
// --- 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. */
|
||||
const rowName = (title: string, body: string | null | undefined): string =>
|
||||
(body ? `${title}: ${body}` : title).trim();
|
||||
const clientRowName = (n: ClientNotif): string =>
|
||||
n.title ? rowName(n.title, n.message) : rowName(n.message, null);
|
||||
const clientRowName = (n: ClientNotif, t: TFunction): string =>
|
||||
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 => {
|
||||
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);
|
||||
return rowName(title, body);
|
||||
};
|
||||
@@ -854,15 +858,15 @@ function ClientActivityRow({
|
||||
const access = n.meta?.kind === "access-requests" ? n.meta : null;
|
||||
return (
|
||||
<div
|
||||
{...navItemAttrs(navIndex, clientRowName(n))}
|
||||
{...navItemAttrs(navIndex, clientRowName(n, t))}
|
||||
{...rowProps}
|
||||
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}`} />
|
||||
<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">
|
||||
{n.message}
|
||||
{sayNotif(n.message, t)}
|
||||
{n.repeat > 1 && (
|
||||
<span className="ml-1.5 align-middle text-2xs font-semibold text-muted tabular-nums">
|
||||
×{n.repeat}
|
||||
|
||||
@@ -94,6 +94,7 @@ import { PageMenuSlot, type PageMenuOpener } from "../../components/ui/pageMenuS
|
||||
import { api, HttpError, type Video } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { patchCachedProgress } from "../../lib/progressCache";
|
||||
import { msg, type NotifText } from "../../lib/notifications";
|
||||
|
||||
beforeAll(() => {
|
||||
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))));
|
||||
expect(rowsOnScreen()).toEqual(["Video V", "Video W"]);
|
||||
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.
|
||||
const lastToast = () =>
|
||||
h.notify.mock.calls
|
||||
.map((c) => c[0] as { message: string; action?: { label: string; onClick: () => void } })
|
||||
.filter((n) => n.message?.startsWith("playlists.removedToast"))
|
||||
.map((c) => c[0] as { message: NotifText; action?: { label: string; onClick: () => void } })
|
||||
.filter((n) => "key" in n.message && n.message.key === "playlists.removedToast")
|
||||
.pop()!;
|
||||
// The list's keys are live once its effect has registered it (`data-nav-list`), a passive effect that
|
||||
// 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"]);
|
||||
// No confirm: the toast's Undo is the safety net.
|
||||
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");
|
||||
act(() => lastToast().action!.onClick());
|
||||
// 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());
|
||||
await waitFor(() =>
|
||||
expect(h.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: "error", title: "playlists.undoRemoveFailed" }),
|
||||
expect.objectContaining({ level: "error", title: msg("playlists.undoRemoveFailed") }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -34,10 +34,10 @@ import {
|
||||
type Video,
|
||||
type VideoStatus,
|
||||
} from "../../lib/api";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, notify } from "../../lib/notifications";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
|
||||
import { playlistName } from "../../lib/playlistName";
|
||||
import { playlistName, playlistNameText } from "../../lib/playlistName";
|
||||
import { reinsertAt } from "../../lib/reinsert";
|
||||
import { useUndoable } from "../../lib/useUndoable";
|
||||
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
|
||||
// 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.
|
||||
notifyYouTubeActionError(e, t("playlists.reorderFailed"));
|
||||
notifyYouTubeActionError(e, msg("playlists.reorderFailed"));
|
||||
restoreFromCache(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
|
||||
// there is not even a row snapping back to show it) — unless `req` already put it in its error
|
||||
// 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
|
||||
// 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.
|
||||
@@ -545,7 +545,7 @@ function PlaylistDetail() {
|
||||
}
|
||||
notify({
|
||||
level: "success",
|
||||
message: t("playlists.removedToast", { title: video.title }),
|
||||
message: msg("playlists.removedToast", { title: video.title ?? "" }),
|
||||
action: {
|
||||
label: t("common.undo"),
|
||||
// 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)
|
||||
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(() => {
|
||||
refreshAll(listId);
|
||||
qc.invalidateQueries({ queryKey: qk.playlistMembership(video.id) });
|
||||
@@ -619,6 +619,8 @@ function PlaylistDetail() {
|
||||
const next = items.filter((v) => v.id !== video.id);
|
||||
order.rebase((list) => list.filter((v) => v.id !== video.id));
|
||||
lastSetRef.current = `${selectedId}:${idsKey(next)}`;
|
||||
// Outside the `try`: a `??` inside it makes the React Compiler skip the component.
|
||||
const title = video.title ?? "";
|
||||
try {
|
||||
// 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.
|
||||
@@ -628,7 +630,7 @@ function PlaylistDetail() {
|
||||
setLastMoveTargetId(target.id);
|
||||
notify({
|
||||
level: "success",
|
||||
message: t("playlists.movedToast", { title: video.title, name: plName(target) }),
|
||||
message: msg("playlists.movedToast", { title, name: playlistNameText(target) }),
|
||||
});
|
||||
} catch (e) {
|
||||
// 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.
|
||||
// A fault `req` already put in its error dialog is not toasted again — `notifyActionError`
|
||||
// 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
|
||||
}
|
||||
// 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() });
|
||||
})
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { YouTubeIcon } from "../../lib/brandIcons";
|
||||
import { api, type Playlist } from "../../lib/api";
|
||||
import { compareText } from "../../lib/collate";
|
||||
import { formatRuntime } from "../../lib/format";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, notify } from "../../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
|
||||
import { LS } from "../../lib/storage";
|
||||
import { usePref } from "../../lib/usePref";
|
||||
@@ -155,9 +155,9 @@ export default function PlaylistsRail() {
|
||||
const r = await api.syncYoutubePlaylists();
|
||||
qc.invalidateQueries({ queryKey: qk.playlists() });
|
||||
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) {
|
||||
notifyYouTubeActionError(e, t("playlists.syncFailed"));
|
||||
notifyYouTubeActionError(e, msg("playlists.syncFailed"));
|
||||
}
|
||||
setSyncing(false);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ vi.mock("../../lib/useMe", () => ({ useMe: () => ({ can_write: h.canWrite }) }))
|
||||
|
||||
import { usePlaylistActions } from "./usePlaylistActions";
|
||||
import { HttpError, api } from "../../lib/api";
|
||||
import { msg, sayNotif, type NotifText } from "../../lib/notifications";
|
||||
|
||||
type Menuable = Parameters<ReturnType<typeof usePlaylistActions>["items"]>[0];
|
||||
|
||||
@@ -73,21 +74,21 @@ describe("usePlaylistActions — deleting a list", () => {
|
||||
const { result } = mount();
|
||||
await act(() => result.current.remove(linked));
|
||||
expect(h.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: "playlists.deleteYtFailed" }),
|
||||
);
|
||||
expect(h.notify).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "Unknown playlist" }),
|
||||
expect.objectContaining({ title: msg("playlists.deleteYtFailed") }),
|
||||
);
|
||||
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(
|
||||
new HttpError(404, "Ez a lista már nem létezik.", { code: "not_found", reason: "playlist" }),
|
||||
);
|
||||
const { result } = mount();
|
||||
await act(() => result.current.remove(linked));
|
||||
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 { qk } from "../../lib/queryKeys";
|
||||
import { api, type Playlist } from "../../lib/api";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, notify } from "../../lib/notifications";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
|
||||
import { playlistName } from "../../lib/playlistName";
|
||||
import { playlistName, playlistNameText } from "../../lib/playlistName";
|
||||
import { useChoice, useConfirm } from "../../components/ui/confirmContext";
|
||||
import { usePlaylists, usePlaylistsActions } from "./playlistsContext";
|
||||
import { useMe } from "../../lib/useMe";
|
||||
@@ -72,7 +72,7 @@ export function usePlaylistActions() {
|
||||
try {
|
||||
await api.renamePlaylist(id, next);
|
||||
} catch (e) {
|
||||
notifyYouTubeActionError(e, t("playlists.renameFailed"));
|
||||
notifyYouTubeActionError(e, msg("playlists.renameFailed"));
|
||||
}
|
||||
refresh(id);
|
||||
}
|
||||
@@ -112,7 +112,7 @@ export function usePlaylistActions() {
|
||||
// `req` already put it in its error dialog: not twice (notifyActionError checks).
|
||||
notifyActionError(
|
||||
err,
|
||||
onYoutube ? t("playlists.deleteYtFailed") : t("playlists.deleteFailed"),
|
||||
msg(onYoutube ? "playlists.deleteYtFailed" : "playlists.deleteFailed"),
|
||||
"warning",
|
||||
);
|
||||
}
|
||||
@@ -136,29 +136,28 @@ export function usePlaylistActions() {
|
||||
refresh(pl.id);
|
||||
notify({
|
||||
level: "success",
|
||||
message: t("playlists.revertDone", { name: playlistName(pl, t) }),
|
||||
message: msg("playlists.revertDone", { name: playlistNameText(pl) }),
|
||||
});
|
||||
} catch (e) {
|
||||
notifyYouTubeActionError(e, t("playlists.revertFailed"));
|
||||
notifyYouTubeActionError(e, msg("playlists.revertFailed"));
|
||||
}
|
||||
mark("reverting", pl.id, false);
|
||||
}
|
||||
|
||||
async function push(pl: Menuable) {
|
||||
if (syncing(pl.id)) return;
|
||||
const name = playlistName(pl, t);
|
||||
mark("pushing", pl.id, true);
|
||||
try {
|
||||
await pushSteps(pl, name);
|
||||
await pushSteps(pl);
|
||||
} catch (e) {
|
||||
notifyYouTubeActionError(e, t("playlists.pushFailed"));
|
||||
notifyYouTubeActionError(e, msg("playlists.pushFailed"));
|
||||
}
|
||||
mark("pushing", pl.id, false);
|
||||
}
|
||||
|
||||
/** 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). */
|
||||
async function pushSteps(pl: Menuable, name: string) {
|
||||
async function pushSteps(pl: Menuable) {
|
||||
const plan = await api.playlistPushPlan(pl.id);
|
||||
const nothingToDo =
|
||||
plan.action === "update" &&
|
||||
@@ -169,13 +168,16 @@ export function usePlaylistActions() {
|
||||
!plan.to_reorder &&
|
||||
!plan.rename;
|
||||
if (nothingToDo) {
|
||||
notify({ level: "info", message: t("playlists.pushUpToDate", { name }) });
|
||||
notify({
|
||||
level: "info",
|
||||
message: msg("playlists.pushUpToDate", { name: playlistNameText(pl) }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!plan.affordable) {
|
||||
notify({
|
||||
level: "warning",
|
||||
message: t("playlists.pushNoQuota", {
|
||||
message: msg("playlists.pushNoQuota", {
|
||||
left: plan.remaining_today,
|
||||
units: plan.units_estimate,
|
||||
}),
|
||||
@@ -224,10 +226,13 @@ export function usePlaylistActions() {
|
||||
if (r.failures.length) {
|
||||
notify({
|
||||
level: "warning",
|
||||
message: t("playlists.pushPartial", { count: r.failures.length }),
|
||||
message: msg("playlists.pushPartial", { count: r.failures.length }),
|
||||
});
|
||||
} else {
|
||||
notify({ level: "success", message: t("playlists.pushDone", { name }) });
|
||||
notify({
|
||||
level: "success",
|
||||
message: msg("playlists.pushDone", { name: playlistNameText(pl) }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -393,7 +393,7 @@ function PlexBrowseView({ sub }: { sub: ReturnType<typeof useHistorySubview<Sub>
|
||||
);
|
||||
}
|
||||
const toggleWatched = (card: PlexCard) =>
|
||||
toggleWatchState(card, t, {
|
||||
toggleWatchState(card, {
|
||||
optimistic: (next) => optimisticStatus(card.id, next),
|
||||
resync: () => qc.invalidateQueries({ queryKey: qk.plexLibrary() }),
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useConfirm } from "../../components/ui/confirmContext";
|
||||
import { Pressable } from "../../components/ui/Pressable";
|
||||
import { Input } from "../../components/ui/Input";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
import { msg } from "../../lib/notifications";
|
||||
|
||||
// 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
|
||||
@@ -85,7 +86,7 @@ export default function PlexCollectionEditor({
|
||||
// 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
|
||||
// or flipped read-only failed in silence.
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
notifyActionError(e, msg("plex.collection.editFailed"));
|
||||
}
|
||||
setBusy(null);
|
||||
}
|
||||
@@ -97,7 +98,7 @@ export default function PlexCollectionEditor({
|
||||
await api.plexSetCollectionEditable(id, true);
|
||||
refresh();
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
notifyActionError(e, msg("plex.collection.editFailed"));
|
||||
}
|
||||
setBusy(null);
|
||||
}
|
||||
@@ -114,7 +115,7 @@ export default function PlexCollectionEditor({
|
||||
} catch (e) {
|
||||
// The backend rejects a duplicate name with 409 (Plex conflates same names), structured — so
|
||||
// it reads in the reader's language (see localizeDetail).
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
notifyActionError(e, msg("plex.collection.editFailed"));
|
||||
}
|
||||
setBusy(null);
|
||||
}
|
||||
@@ -134,7 +135,7 @@ export default function PlexCollectionEditor({
|
||||
} catch (e) {
|
||||
// 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.
|
||||
notifyActionError(e, t("plex.collection.deleteFailed"));
|
||||
notifyActionError(e, msg("plex.collection.deleteFailed"));
|
||||
}
|
||||
setBusy(null);
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ vi.mock("../../lib/notifications", async (importOriginal) => ({
|
||||
}));
|
||||
|
||||
import PlexCollectionView from "./PlexCollectionView";
|
||||
import { msg } from "../../lib/notifications";
|
||||
import {
|
||||
api,
|
||||
HttpError,
|
||||
@@ -135,7 +136,7 @@ describe("PlexCollectionView — Play-all", () => {
|
||||
fireEvent.click(await screen.findByText("plex.collection.playAll"));
|
||||
await waitFor(() =>
|
||||
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();
|
||||
|
||||
@@ -8,7 +8,7 @@ import { plexLang } from "../../i18n";
|
||||
import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollectionDetail } from "../../lib/api";
|
||||
import { formatRuntime } from "../../lib/format";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, notify } from "../../lib/notifications";
|
||||
import { showEpisodeKeys } from "../../lib/plexQueue";
|
||||
import { healPlexSort, plexSortOptions } from "../../lib/plexSort";
|
||||
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 /
|
||||
// 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);
|
||||
else notify({ level: "info", message: t("plex.collection.playEmpty") });
|
||||
else notify({ level: "info", message: msg("plex.collection.playEmpty") });
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.collection.playFailed"));
|
||||
notifyActionError(e, msg("plex.collection.playFailed"));
|
||||
}
|
||||
setQueueBusy(false);
|
||||
}
|
||||
@@ -168,7 +168,7 @@ export default function PlexCollectionView({
|
||||
const readOnly = !d?.can_edit && !canEnableEdit; // external / smart auto-list
|
||||
|
||||
const toggleWatched = (card: PlexCard) =>
|
||||
toggleWatchState(card, t, {
|
||||
toggleWatchState(card, {
|
||||
optimistic: (next) => {
|
||||
qc.setQueryData<PlexCollectionDetail>(detailKey, (old) =>
|
||||
old
|
||||
@@ -211,7 +211,7 @@ export default function PlexCollectionView({
|
||||
setRenaming(false);
|
||||
} catch (e) {
|
||||
// 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);
|
||||
}
|
||||
@@ -226,7 +226,7 @@ export default function PlexCollectionView({
|
||||
invalidate();
|
||||
onBack();
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
notifyActionError(e, msg("plex.collection.editFailed"));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,7 @@ export default function PlexCollectionView({
|
||||
await api.plexCollectionRemoveItem(collectionId, card.id);
|
||||
invalidate();
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
notifyActionError(e, msg("plex.collection.editFailed"));
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -251,7 +251,7 @@ export default function PlexCollectionView({
|
||||
await api.plexSetCollectionEditable(collectionId, true);
|
||||
invalidate();
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
notifyActionError(e, msg("plex.collection.editFailed"));
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -495,7 +495,7 @@ function AddItemPanel({
|
||||
setAdded((s) => new Set(s).add(card.id));
|
||||
onAdded();
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
notifyActionError(e, msg("plex.collection.editFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@ export default function PlexDiscover({
|
||||
: old,
|
||||
);
|
||||
const toggleWatched = (card: PlexCard) =>
|
||||
toggleWatchState(card, t, {
|
||||
toggleWatchState(card, {
|
||||
optimistic: (next) => {
|
||||
const flip = (cards: PlexCard[]) =>
|
||||
cards.map((c) => (c.id === card.id ? { ...c, status: next } : c));
|
||||
|
||||
@@ -36,6 +36,7 @@ import { usePlexCardActions } from "../../features/plexCards/usePlexCardActions"
|
||||
import { toggleWatchState } from "../../lib/plexWatch";
|
||||
import { vocab } from "../../lib/vocab";
|
||||
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,
|
||||
// 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 {
|
||||
await api.plexSetState(detail.id, status);
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.watchedFailed"));
|
||||
notifyActionError(e, msg("plex.watchedFailed"));
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: qk.plexItem(detail.id) });
|
||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||
@@ -267,7 +268,7 @@ export default function PlexInfo({
|
||||
// is; the resync corrects it to the truth either way.
|
||||
const colMenu = useContextMenu<PlexCard>();
|
||||
const toggleColWatched = (card: PlexCard) =>
|
||||
toggleWatchState(card, t, {
|
||||
toggleWatchState(card, {
|
||||
optimistic: (next) =>
|
||||
qc.setQueriesData<PlexItemDetail>({ queryKey: qk.plexItem(detail.id) }, (old) =>
|
||||
old
|
||||
|
||||
@@ -20,7 +20,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
import PlexPlayer from "./PlexPlayer";
|
||||
import { api, HttpError, type PlexItemDetail } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { notify, raw } from "../../lib/notifications";
|
||||
import { claimLocalData } from "../../lib/storage";
|
||||
|
||||
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(() => {}) } });
|
||||
await screen.findByText("Title A");
|
||||
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.");
|
||||
let host: Element | null = video();
|
||||
|
||||
@@ -103,6 +103,7 @@ import { closeThenAct, useAnchoredPopover } from "../../components/ui/anchoredPo
|
||||
import { Slider } from "../../components/ui/NativeControls";
|
||||
import { formatUnit } from "../../lib/format";
|
||||
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.
|
||||
const PlexInfo = lazy(() => import("./PlexInfo"));
|
||||
@@ -716,8 +717,8 @@ export default function PlexPlayer({
|
||||
});
|
||||
|
||||
const reportWatchedError = useCallback(
|
||||
(e: unknown) => notifyActionError(e, t("plex.watchedFailed")),
|
||||
[t],
|
||||
(e: unknown) => notifyActionError(e, msg("plex.watchedFailed")),
|
||||
[],
|
||||
);
|
||||
|
||||
const go = useCallback(
|
||||
|
||||
@@ -29,6 +29,7 @@ vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => h.confi
|
||||
import PlexPlaylistView from "./PlexPlaylistView";
|
||||
import { api, type PlexCard } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { msg, type NotifText } from "../../lib/notifications";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
@@ -98,8 +99,8 @@ const moreOf = (text: string) =>
|
||||
rowOf(text).querySelector<HTMLElement>('button[aria-label="plex.playlist.rowMenu"]')!;
|
||||
const toast = () =>
|
||||
h.notify.mock.calls
|
||||
.map((c) => c[0] as { message: string; action?: { label: string; onClick: () => void } })
|
||||
.filter((n) => n.message?.startsWith("plex.playlist.removedToast"))
|
||||
.map((c) => c[0] as { message: NotifText; action?: { label: string; onClick: () => void } })
|
||||
.filter((n) => "key" in n.message && n.message.key === "plex.playlist.removedToast")
|
||||
.pop();
|
||||
// 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).
|
||||
@@ -181,7 +182,9 @@ describe("PlexPlaylistView · a row's menu", () => {
|
||||
await pick(() => fireEvent.contextMenu(rowOf("Movie 2")), "plex.playlist.removeFromPlaylist");
|
||||
await waitFor(() => expect(api.plexPlaylistRemoveItem).toHaveBeenCalledWith(5, "2"));
|
||||
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());
|
||||
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);
|
||||
await pick(() => fireEvent.click(moreOf("The Show")), "plex.playlist.removeShow");
|
||||
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 () => {
|
||||
@@ -349,7 +354,7 @@ describe("PlexPlaylistView · a failed watched toggle", () => {
|
||||
await pick(() => fireEvent.contextMenu(rowOf("Movie 2")), "plex.markWatched");
|
||||
await waitFor(() =>
|
||||
expect(h.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: "error", title: "plex.watchedFailed" }),
|
||||
expect.objectContaining({ level: "error", title: msg("plex.watchedFailed") }),
|
||||
),
|
||||
);
|
||||
await act(async () => {});
|
||||
|
||||
@@ -58,6 +58,7 @@ import { MEDIA_BADGE } from "../../components/ui/mediaCardStyles";
|
||||
import { seasonTitle } from "../../lib/plexDetail";
|
||||
import { cardImage } from "../../lib/plexImage";
|
||||
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
|
||||
// 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 {
|
||||
await api.plexRenamePlaylist(playlistId, title);
|
||||
} 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;
|
||||
}
|
||||
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.
|
||||
// Anything else is a real failure, and the page stays.
|
||||
if (!(e instanceof HttpError && e.status === 404)) {
|
||||
notifyActionError(e, t("plex.playlist.deleteFailed"));
|
||||
notifyActionError(e, msg("plex.playlist.deleteFailed"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -569,7 +570,7 @@ export default function PlexPlaylistView({
|
||||
onRestart: (c) => onPlay(c.id, order, true),
|
||||
onInfo: onInfoCard,
|
||||
onToggleWatched: (c) =>
|
||||
void toggleWatchState(c, t, {
|
||||
void toggleWatchState(c, {
|
||||
optimistic: (next) => setStatus(c.id, next),
|
||||
// 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.
|
||||
|
||||
@@ -22,6 +22,7 @@ import { usePlex, usePlexActions } from "./plexContext";
|
||||
import { DismissibleChip } from "../../components/ui/Chip";
|
||||
import { Input } from "../../components/ui/Input";
|
||||
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
|
||||
// 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);
|
||||
} catch (e) {
|
||||
// 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 { Input } from "../../components/ui/Input";
|
||||
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
|
||||
// collections have no movie runtime).
|
||||
@@ -103,7 +104,7 @@ function NewCollectionPanel({
|
||||
} catch (e) {
|
||||
// The backend is authoritative on per-library name uniqueness — a taken name comes back
|
||||
// structured, and so localized (see localizeDetail).
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
notifyActionError(e, msg("plex.collection.editFailed"));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ import { PageHeading } from "../../components/ui/Headings";
|
||||
import { vocab } from "../../lib/vocab";
|
||||
import { formatDate, formatDecimal } from "../../lib/format";
|
||||
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
|
||||
// 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
|
||||
// helper; also invalidate the library so its grid agrees.
|
||||
const toggle = (card: PlexCard) =>
|
||||
toggleWatchState(card, t, {
|
||||
toggleWatchState(card, {
|
||||
optimistic: (next) =>
|
||||
qc.setQueryData<PlexPersonDetail>(personKey, (old) =>
|
||||
old
|
||||
@@ -500,7 +501,7 @@ export function PlexShowView({
|
||||
try {
|
||||
await api.plexShowState(showId, w);
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.watchedFailed"));
|
||||
notifyActionError(e, msg("plex.watchedFailed"));
|
||||
}
|
||||
setBusy(false);
|
||||
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
|
||||
@@ -510,7 +511,7 @@ export function PlexShowView({
|
||||
try {
|
||||
await api.plexSeasonState(seasonRk, w);
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.watchedFailed"));
|
||||
notifyActionError(e, msg("plex.watchedFailed"));
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
|
||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||
@@ -836,7 +837,7 @@ export function PlexSeasonView({
|
||||
try {
|
||||
await api.plexSeasonState(seasonId, w);
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.watchedFailed"));
|
||||
notifyActionError(e, msg("plex.watchedFailed"));
|
||||
}
|
||||
setBusy(false);
|
||||
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
|
||||
@@ -847,7 +848,7 @@ export function PlexSeasonView({
|
||||
try {
|
||||
await api.plexSetState(ep.id, next);
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.watchedFailed"));
|
||||
notifyActionError(e, msg("plex.watchedFailed"));
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
|
||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user