release: 0.114.1 (bug/notice-language)

This commit is contained in:
2026-10-30 03:10:42 +01:00
154 changed files with 1710 additions and 809 deletions
+6
View File
@@ -7,6 +7,12 @@ edit the release notes and re-run the generator.
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading). Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
## 0.114.1 — 2026-10-30
### Fixed
- Notifications follow a language switch: a notice on screen and every row in the notification center are said again in the new language, their buttons too.
## 0.114.0 — 2026-10-29 ## 0.114.0 — 2026-10-29
### New ### New
+1 -1
View File
@@ -1 +1 @@
0.114.0 0.114.1
+1 -1
View File
@@ -1,7 +1,7 @@
"""A request the app refuses, said in a way the UI can put in the reader's language. """A request the app refuses, said in a way the UI can put in the reader's language.
Every refusal the API answers carries a structured `detail` — `{code, reason, …params}` — and the client Every refusal the API answers carries a structured `detail` — `{code, reason, …params}` — and the client
translates it (`lib/api/core.ts` `localizeDetail`). A bare English string reached the Hungarian UI translates it (`lib/api/refusalText.ts` `localizeDetail`). A bare English string reached the Hungarian UI
verbatim: the blocking error dialog, and every inline surface that printed `HttpError.detail`, showed verbatim: the blocking error dialog, and every inline surface that printed `HttpError.detail`, showed
whatever sentence the route happened to hand-write. `refused` is the generic family whatever sentence the route happened to hand-write. `refused` is the generic family
(`errors.refused.<reason>`, the params interpolated into the sentence); the older specific codes (`errors.refused.<reason>`, the params interpolated into the sentence); the older specific codes
+1 -1
View File
@@ -139,7 +139,7 @@ def own_job_for_update(db: Session, user: User, job_id: int) -> "DownloadJob":
def quota_detail(e: quota.QuotaExceeded) -> dict: def quota_detail(e: quota.QuotaExceeded) -> dict:
"""Structured error the trilingual client localizes (reason key + numbers), instead of a """Structured error the trilingual client localizes (reason key + numbers), instead of a
pre-baked English string with a dot-decimal GB value. See lib/api.ts localizeDetail().""" pre-baked English string with a dot-decimal GB value. See lib/api/refusalText.ts localizeDetail()."""
return {"code": "quota", "reason": e.reason, "limit": e.limit, "current": e.current} return {"code": "quota", "reason": e.reason, "limit": e.limit, "current": e.current}
+2 -1
View File
@@ -128,7 +128,8 @@ def update_view(
db.commit() db.commit()
except IntegrityError: except IntegrityError:
db.rollback() db.rollback()
raise refused(409, "view_name_taken", name=view.name) from None # The name asked for: the rollback has put the view's old name back on `view`.
raise refused(409, "view_name_taken", name=name) from None
return _serialize(view) return _serialize(view)
+2 -1
View File
@@ -107,7 +107,8 @@ def update_tag(
db.commit() db.commit()
except IntegrityError: except IntegrityError:
db.rollback() db.rollback()
raise refused(409, "tag_name_taken", name=tag.name) from None # The name asked for: the rollback has put the tag's old name back on `tag`.
raise refused(409, "tag_name_taken", name=name) from None
return { return {
"ok": True, "ok": True,
"id": tag.id, "id": tag.id,
+46
View File
@@ -0,0 +1,46 @@
"""A rename onto a name already taken says WHICH name is taken: the one asked for, not the row's
old name (the rollback that follows the conflict puts the old name back on the row). DB lane.
"""
from fastapi.testclient import TestClient
import app.state as state
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.models import User
def _client(db, monkeypatch, override_dep):
u = User(email="taken@example.com")
db.add(u)
db.commit()
monkeypatch.setattr(
state, "_configured_cache", True
) # the request gate 503s an unconfigured app
override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: u)
return TestClient(app)
def test_renaming_a_view_onto_a_taken_name_names_the_taken_one(db, monkeypatch, override_dep):
c = _client(db, monkeypatch, override_dep)
gaming = c.post("/api/saved-views", json={"name": "gaming", "filters": {}, "source": "feed"})
assert gaming.status_code == 200
hh = c.post("/api/saved-views", json={"name": "hh", "filters": {}, "source": "feed"}).json()
r = c.patch(f"/api/saved-views/{hh['id']}", json={"name": "gaming"})
assert r.status_code == 409
assert r.json()["detail"] == {"code": "refused", "reason": "view_name_taken", "name": "gaming"}
def test_renaming_a_tag_onto_a_taken_name_names_the_taken_one(db, monkeypatch, override_dep):
c = _client(db, monkeypatch, override_dep)
assert c.post("/api/tags", json={"name": "music"}).status_code == 200
news = c.post("/api/tags", json={"name": "news"}).json()
r = c.patch(f"/api/tags/{news['id']}", json={"name": "music"})
assert r.status_code == 409
assert r.json()["detail"] == {"code": "refused", "reason": "tag_name_taken", "name": "music"}
+1 -1
View File
@@ -196,7 +196,7 @@ test.describe("a bookmark reopens the view it was taken of", () => {
test("a channel page's own tab, over the page it was opened from", async ({ page, browser }) => { test("a channel page's own tab, over the page it was opened from", async ({ page, browser }) => {
await openApp(page, "/?page=feed"); await openApp(page, "/?page=feed");
await expect(page.getByTestId("video-card").first()).toBeVisible(); await expect(page.getByTestId("video-card").first()).toBeVisible();
expect(await clickVisibleChannelLink(page)).toBe(true); await clickVisibleChannelLink(page);
const title = await page.getByTestId("channel-title").textContent(); const title = await page.getByTestId("channel-title").textContent();
await tab(page, "About").click(); await tab(page, "About").click();
const fresh = await bookmarked(browser, page); const fresh = await bookmarked(browser, page);
+1 -1
View File
@@ -48,7 +48,7 @@ test.describe("channel view", () => {
// `.first()` here would grab an overscan card ABOVE the viewport, and Playwright's scroll-into- // `.first()` here would grab an overscan card ABOVE the viewport, and Playwright's scroll-into-
// view would move the feed before navigating, so Back would restore that moved position, not // view would move the feed before navigating, so Back would restore that moved position, not
// the one under test. (This is what made the test fail while the app was in fact correct.) // the one under test. (This is what made the test fail while the app was in fact correct.)
expect(await clickVisibleChannelLink(page)).toBe(true); await clickVisibleChannelLink(page);
await expect(page.getByTestId("channel-title")).toBeVisible(); await expect(page.getByTestId("channel-title")).toBeVisible();
await page.getByTestId("channel-back").click(); await page.getByTestId("channel-back").click();
await expect(page.getByTestId("video-card").first()).toBeVisible(); await expect(page.getByTestId("video-card").first()).toBeVisible();
+32 -14
View File
@@ -33,23 +33,41 @@ export async function gotoPage(page: Page, id: string): Promise<void> {
* that then asserts on where the feed was. Being inside the viewport is not enough either: a card * that then asserts on where the feed was. Being inside the viewport is not enough either: a card
* can sit under the page chrome above the scroller (the update banner, the toolbar band), and the * can sit under the page chrome above the scroller (the update banner, the toolbar band), and the
* covered click makes Playwright scroll and retry — the same move (webkit failed 3 in 24). * covered click makes Playwright scroll and retry — the same move (webkit failed 3 in 24).
* So the link must be the element hit at its own centre. Returns false if none found. * So the link must be the element hit at its own centre; none within the wait fails the test, with
* the poll's own message. Looked for again until one is, not once: a fresh load is still settling
* when the first card shows (the links move down ~55px), and webkit can answer the hit test at a
* link's new centre with the card under it — every link missed in 1 of 30 runs. A link that goes
* away mid-look (its evaluate waits for it, or throws) counts as not hit, so the next look runs.
*/ */
export async function clickVisibleChannelLink(page: Page): Promise<boolean> { export async function clickVisibleChannelLink(page: Page): Promise<void> {
const links = page.getByTestId("video-card-channel").filter({ visible: true }); const links = page.getByTestId("video-card-channel").filter({ visible: true });
const count = await links.count(); let index = -1;
for (let i = 0; i < count; i++) { const findHittable = async () => {
const hittable = await links.nth(i).evaluate((el) => { const count = await links.count();
const r = el.getBoundingClientRect(); for (let i = 0; i < count; i++) {
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); const hittable = await links
return hit !== null && el.contains(hit); .nth(i)
}); .evaluate(
if (hittable) { (el) => {
await links.nth(i).click(); const r = el.getBoundingClientRect();
return true; const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
return hit !== null && el.contains(hit);
},
undefined,
{ timeout: 1000 },
)
.catch(() => false);
if (hittable) return i;
} }
} return -1;
return false; };
await expect
.poll(async () => (index = await findHittable()), {
message: "a channel link hit at its own centre",
timeout: 5000,
})
.toBeGreaterThan(-1);
await links.nth(index).click();
} }
/** /**
@@ -22,7 +22,7 @@ vi.mock("react-i18next", async (importOriginal) => {
}); });
vi.mock("./components/ui/confirmContext", () => ({ useConfirm: () => async () => true })); vi.mock("./components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
const { notify } = vi.hoisted(() => ({ const { notify } = vi.hoisted(() => ({
notify: vi.fn((_n: { level: string; message: string }) => 0), notify: vi.fn((_n: { level: string; message: NotifText }) => 0),
})); }));
vi.mock("./lib/notifications", async (importOriginal) => ({ vi.mock("./lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("./lib/notifications")>()), ...(await importOriginal<typeof import("./lib/notifications")>()),
@@ -35,6 +35,7 @@ import PlexPlaylistAdd from "./features/plexCards/PlexPlaylistAdd";
import TagManager from "./features/channel/TagManager"; import TagManager from "./features/channel/TagManager";
import { api, HttpError, type PlexCard } from "./lib/api"; import { api, HttpError, type PlexCard } from "./lib/api";
import { qk } from "./lib/queryKeys"; import { qk } from "./lib/queryKeys";
import type { NotifText } from "./lib/notifications";
beforeAll(() => { beforeAll(() => {
class Stub { class Stub {
@@ -98,7 +99,7 @@ describe("the Plex playlist page", () => {
() => fireEvent.contextMenu(screen.getByText("Movie 1").closest("[data-nav-index]")!), () => fireEvent.contextMenu(screen.getByText("Movie 1").closest("[data-nav-index]")!),
"plex.playlist.removeFromPlaylist", "plex.playlist.removeFromPlaylist",
); );
await waitFor(() => expect(errors()).toEqual(["gone playlist"])); await waitFor(() => expect(errors()).toEqual([{ detail: gone("playlist").detailData }]));
await act(async () => void (await new Promise((r) => setTimeout(r, 20)))); await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
expect(screen.getByText("Movie 1")).toBeTruthy(); expect(screen.getByText("Movie 1")).toBeTruthy();
}); });
@@ -119,7 +120,7 @@ describe("the Plex playlist page", () => {
const field = await screen.findByDisplayValue("Films"); const field = await screen.findByDisplayValue("Films");
fireEvent.change(field, { target: { value: "Movies" } }); fireEvent.change(field, { target: { value: "Movies" } });
fireEvent.keyDown(field, { key: "Enter" }); fireEvent.keyDown(field, { key: "Enter" });
await waitFor(() => expect(errors()).toEqual(["gone playlist"])); await waitFor(() => expect(errors()).toEqual([{ detail: gone("playlist").detailData }]));
expect(screen.getByDisplayValue("Movies")).toBeTruthy(); // still open to try again expect(screen.getByDisplayValue("Movies")).toBeTruthy(); // still open to try again
}); });
@@ -154,7 +155,7 @@ describe("the add-to-playlist popover", () => {
); );
fireEvent.click(screen.getByLabelText("playlists.addToPlaylist")); fireEvent.click(screen.getByLabelText("playlists.addToPlaylist"));
fireEvent.click(await screen.findByRole("menuitemcheckbox", { hidden: true })); fireEvent.click(await screen.findByRole("menuitemcheckbox", { hidden: true }));
await waitFor(() => expect(errors()).toEqual(["gone playlist"])); await waitFor(() => expect(errors()).toEqual([{ detail: gone("playlist").detailData }]));
await waitFor(() => expect(lists.mock.calls.length).toBeGreaterThan(1)); await waitFor(() => expect(lists.mock.calls.length).toBeGreaterThan(1));
}); });
@@ -282,7 +283,7 @@ describe("the add-to-playlist popover", () => {
fireEvent.click(await screen.findByText("Gone")); fireEvent.click(await screen.findByText("Gone"));
// The server's own localized reason wins over the caller's fallback when the detail is // The server's own localized reason wins over the caller's fallback when the detail is
// STRUCTURED — same as the twin above. What is under test is that anything is said at all. // STRUCTURED — same as the twin above. What is under test is that anything is said at all.
await waitFor(() => expect(errors()).toEqual(["gone playlist"])); await waitFor(() => expect(errors()).toEqual([{ detail: gone("playlist").detailData }]));
await waitFor(() => expect(lists.mock.calls.length).toBeGreaterThan(1)); await waitFor(() => expect(lists.mock.calls.length).toBeGreaterThan(1));
}); });
}); });
@@ -300,7 +301,7 @@ describe("the tag manager", () => {
</QueryClientProvider>, </QueryClientProvider>,
); );
fireEvent.click(await screen.findByLabelText("tagManager.delete")); fireEvent.click(await screen.findByLabelText("tagManager.delete"));
await waitFor(() => expect(errors()).toEqual(["gone tag"])); await waitFor(() => expect(errors()).toEqual([{ detail: gone("tag").detailData }]));
await waitFor(() => expect(tags.mock.calls.length).toBeGreaterThan(1)); await waitFor(() => expect(tags.mock.calls.length).toBeGreaterThan(1));
}); });
}); });
@@ -19,7 +19,7 @@ vi.mock("react-i18next", async (importOriginal) => {
}); });
vi.mock("./components/ui/confirmContext", () => ({ useConfirm: () => async () => true })); vi.mock("./components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
const { notify } = vi.hoisted(() => ({ const { notify } = vi.hoisted(() => ({
notify: vi.fn((_n: { level: string; title?: string; message: string }) => 0), notify: vi.fn((_n: { level: string; title?: NotifText; message: NotifText }) => 0),
})); }));
vi.mock("./lib/notifications", async (importOriginal) => ({ vi.mock("./lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("./lib/notifications")>()), ...(await importOriginal<typeof import("./lib/notifications")>()),
@@ -30,6 +30,7 @@ import PlexCollectionEditor from "./modules/plex/PlexCollectionEditor";
import UploadDialog from "./modules/files/UploadDialog"; import UploadDialog from "./modules/files/UploadDialog";
import DownloadDialog from "./features/downloads/DownloadDialog"; import DownloadDialog from "./features/downloads/DownloadDialog";
import { api, HttpError } from "./lib/api"; import { api, HttpError } from "./lib/api";
import { msg, type NotifText } from "./lib/notifications";
beforeAll(() => { beforeAll(() => {
class Stub { class Stub {
@@ -87,7 +88,7 @@ describe("the Plex collection editor", () => {
); );
mount(); mount();
fireEvent.click(await screen.findByText("Marathon")); fireEvent.click(await screen.findByText("Marathon"));
await waitFor(() => expect(errors()).toEqual(["plex.collection.editFailed"])); await waitFor(() => expect(errors()).toEqual([msg("plex.collection.editFailed")]));
}); });
it("says so when enabling editing is refused", async () => { it("says so when enabling editing is refused", async () => {
@@ -97,7 +98,7 @@ describe("the Plex collection editor", () => {
); );
mount(); mount();
fireEvent.click(await screen.findByText("plex.collEditor.makeEditable")); fireEvent.click(await screen.findByText("plex.collEditor.makeEditable"));
await waitFor(() => expect(errors()).toEqual(["plex.collection.editFailed"])); await waitFor(() => expect(errors()).toEqual([msg("plex.collection.editFailed")]));
}); });
it("says so when a CONFIRMED delete does not happen", async () => { it("says so when a CONFIRMED delete does not happen", async () => {
@@ -108,7 +109,7 @@ describe("the Plex collection editor", () => {
); );
mount(); mount();
fireEvent.click(await screen.findByText("plex.collEditor.delete")); fireEvent.click(await screen.findByText("plex.collEditor.delete"));
await waitFor(() => expect(errors()).toEqual(["plex.collection.deleteFailed"])); await waitFor(() => expect(errors()).toEqual([msg("plex.collection.deleteFailed")]));
}); });
}); });
@@ -144,7 +145,7 @@ describe("the upload dialog", () => {
mount(); mount();
pick(); pick();
fireEvent.click(screen.getByText("downloads.upload.submit")); fireEvent.click(screen.getByText("downloads.upload.submit"));
await waitFor(() => expect(errors()).toEqual(["downloads.upload.failed"])); await waitFor(() => expect(errors()).toEqual([msg("downloads.upload.failed")]));
}); });
it("says nothing when the user cancelled it themselves", async () => { it("says nothing when the user cancelled it themselves", async () => {
@@ -172,6 +173,6 @@ describe("the download dialog", () => {
); );
await screen.findByText("Best"); await screen.findByText("Best");
fireEvent.click(screen.getByText("downloads.dialog.submit")); fireEvent.click(screen.getByText("downloads.dialog.submit"));
await waitFor(() => expect(errors()).toEqual(["errors.writeFailed.enqueueDownload"])); await waitFor(() => expect(errors()).toEqual([msg("errors.writeFailed.enqueueDownload")]));
}); });
}); });
@@ -21,7 +21,7 @@ vi.mock("react-i18next", async (importOriginal) => {
vi.mock("../../lib/history", () => ({ useBackToClose: () => {} })); vi.mock("../../lib/history", () => ({ useBackToClose: () => {} }));
import Modal from "./Modal"; import Modal from "./Modal";
import { clearAll, notify } from "../../lib/notifications"; import { clearAll, notify, raw } from "../../lib/notifications";
import { pushLayer, removeLayer, useLayer } from "../../lib/layerStack"; import { pushLayer, removeLayer, useLayer } from "../../lib/layerStack";
import { createPortal, flushSync } from "react-dom"; import { createPortal, flushSync } from "react-dom";
@@ -121,7 +121,7 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
<Body /> <Body />
</Modal>, </Modal>,
); );
act(() => void notify({ level: "info", message: "Saved" })); act(() => void notify({ level: "info", message: raw("Saved") }));
try { try {
const dismissToast = screen.getByTitle("notifications.dismiss"); const dismissToast = screen.getByTitle("notifications.dismiss");
// The panel's last control is no longer the trap's edge: Tab carries on (natively) to the toast. // The panel's last control is no longer the trap's edge: Tab carries on (natively) to the toast.
@@ -234,7 +234,7 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
<Body /> <Body />
</Modal>, </Modal>,
); );
act(() => void notify({ level: "error", message: "Nope" })); act(() => void notify({ level: "error", message: raw("Nope") }));
const dismissToast = screen.getByTitle("notifications.dismiss"); const dismissToast = screen.getByTitle("notifications.dismiss");
dismissToast.focus(); dismissToast.focus();
expect(document.activeElement).toBe(dismissToast); // the scenario is actually being driven expect(document.activeElement).toBe(dismissToast); // the scenario is actually being driven
@@ -455,7 +455,7 @@ describe("Modal — toasts raised while it is open", () => {
<Body /> <Body />
</Modal>, </Modal>,
); );
act(() => void notify({ level: "error", message: "Couldn't share it." })); act(() => void notify({ level: "error", message: raw("Couldn't share it.") }));
expect(screen.getByRole("dialog").textContent).toContain("Couldn't share it."); expect(screen.getByRole("dialog").textContent).toContain("Couldn't share it.");
}); });
}); });
+17 -17
View File
@@ -14,7 +14,7 @@ vi.mock("react-i18next", async (importOriginal) => {
}); });
import Toaster from "./Toaster"; import Toaster from "./Toaster";
import { clearAll, notify } from "../../lib/notifications"; import { clearAll, notify, raw } from "../../lib/notifications";
import { dockUnderToasts } from "../../lib/toastDock"; import { dockUnderToasts } from "../../lib/toastDock";
afterEach(() => { afterEach(() => {
@@ -31,7 +31,7 @@ describe("Toaster — one host shows the toasts", () => {
<Toaster /> <Toaster />
</div>, </div>,
); );
act(() => void notify({ level: "error", message: "Couldn't remove the video." })); act(() => void notify({ level: "error", message: raw("Couldn't remove the video.") }));
expect(screen.getByTestId("app").textContent).toContain("Couldn't remove the video."); expect(screen.getByTestId("app").textContent).toContain("Couldn't remove the video.");
const overlay = render( const overlay = render(
@@ -54,7 +54,7 @@ describe("Toaster — one host shows the toasts", () => {
it("lets a click through where there is no toast and nothing docked, and not where there is", () => { it("lets a click through where there is no toast and nothing docked, and not where there is", () => {
const Docked = () => <button>dock</button>; const Docked = () => <button>dock</button>;
const stop = dockUnderToasts(Docked); const stop = dockUnderToasts(Docked);
notify({ level: "info", message: "hello" }); notify({ level: "info", message: raw("hello") });
render(<Toaster />); render(<Toaster />);
const region = document.querySelector<HTMLElement>("[data-toast-region]")!; const region = document.querySelector<HTMLElement>("[data-toast-region]")!;
// The empty part of the region (beside and above a docked control) must not swallow the page's // The empty part of the region (beside and above a docked control) must not swallow the page's
@@ -75,7 +75,7 @@ describe("Toaster — one host shows the toasts", () => {
<Toaster overlay /> <Toaster overlay />
</div>, </div>,
); );
act(() => void notify({ level: "error", message: "boom" })); act(() => void notify({ level: "error", message: raw("boom") }));
fireEvent.click(screen.getByText("boom")); fireEvent.click(screen.getByText("boom"));
expect(backdrop).not.toHaveBeenCalled(); expect(backdrop).not.toHaveBeenCalled();
}); });
@@ -86,11 +86,11 @@ describe("Toaster — handing the toasts to another host", () => {
// The new host gets them as NEW nodes: a screen reader read every one out again, an error // The new host gets them as NEW nodes: a screen reader read every one out again, an error
// assertively, each time a player (or any dialog) opened or closed. // assertively, each time a player (or any dialog) opened or closed.
render(<Toaster />); render(<Toaster />);
act(() => void notify({ level: "error", message: "old news" })); act(() => void notify({ level: "error", message: raw("old news") }));
render(<Toaster overlay />); render(<Toaster overlay />);
const old = screen.getByText("old news").closest(".toast-card")!; const old = screen.getByText("old news").closest(".toast-card")!;
expect(old.getAttribute("role")).toBeNull(); expect(old.getAttribute("role")).toBeNull();
act(() => void notify({ level: "error", message: "fresh" })); act(() => void notify({ level: "error", message: raw("fresh") }));
expect(screen.getByText("fresh").closest(".toast-card")!.getAttribute("role")).toBe("alert"); expect(screen.getByText("fresh").closest(".toast-card")!.getAttribute("role")).toBe("alert");
}); });
@@ -99,8 +99,8 @@ describe("Toaster — handing the toasts to another host", () => {
// host last showed — re-read on every render, this host's own "first" turned silent the moment // host last showed — re-read on every render, this host's own "first" turned silent the moment
// "second" arrived (its own screen had become the handover by then). // "second" arrived (its own screen had become the handover by then).
render(<Toaster />); render(<Toaster />);
act(() => void notify({ level: "error", message: "first" })); act(() => void notify({ level: "error", message: raw("first") }));
act(() => void notify({ level: "error", message: "second" })); act(() => void notify({ level: "error", message: raw("second") }));
const card = (text: string) => screen.getByText(text).closest(".toast-card")!; const card = (text: string) => screen.getByText(text).closest(".toast-card")!;
expect(card("first").getAttribute("role")).toBe("alert"); expect(card("first").getAttribute("role")).toBe("alert");
expect(card("second").getAttribute("role")).toBe("alert"); expect(card("second").getAttribute("role")).toBe("alert");
@@ -111,7 +111,7 @@ describe("Toaster — handing the toasts to another host", () => {
// inserted announces them anyway. React flushes effects in the SAME task as that render, so the // inserted announces them anyway. React flushes effects in the SAME task as that render, so the
// arming has to wait for the next one — a screen reader reads the region's state after the task. // arming has to wait for the next one — a screen reader reads the region's state after the task.
render(<Toaster />); render(<Toaster />);
act(() => void notify({ level: "info", message: "saved" })); act(() => void notify({ level: "info", message: raw("saved") }));
const overlay = render(<Toaster overlay />); const overlay = render(<Toaster overlay />);
const region = overlay.container.querySelector("[aria-live]")!; const region = overlay.container.querySelector("[aria-live]")!;
expect(region.textContent).toContain("saved"); // it took the toast over… expect(region.textContent).toContain("saved"); // it took the toast over…
@@ -135,7 +135,7 @@ describe("Toaster — handing the toasts to another host", () => {
<Toaster overlay /> <Toaster overlay />
<button <button
onClick={() => { onClick={() => {
notify({ level: "success", message: "upload queued" }); notify({ level: "success", message: raw("upload queued") });
setOpen(false); setOpen(false);
}} }}
> >
@@ -147,7 +147,7 @@ describe("Toaster — handing the toasts to another host", () => {
); );
} }
render(<Stage />); render(<Stage />);
act(() => void notify({ level: "error", message: "old news" })); act(() => void notify({ level: "error", message: raw("old news") }));
fireEvent.click(screen.getByText("upload")); fireEvent.click(screen.getByText("upload"));
const card = (text: string) => screen.getByText(text).closest(".toast-card")!; const card = (text: string) => screen.getByText(text).closest(".toast-card")!;
@@ -168,7 +168,7 @@ describe("Toaster — handing the toasts to another host", () => {
<Toaster overlay order={2} /> <Toaster overlay order={2} />
</div>, </div>,
); );
act(() => void notify({ level: "error", message: "boom" })); act(() => void notify({ level: "error", message: raw("boom") }));
expect(screen.getByTestId("dialog").textContent).toContain("boom"); expect(screen.getByTestId("dialog").textContent).toContain("boom");
// The player swaps its host (leaving true fullscreen) while the dialog stays open. // The player swaps its host (leaving true fullscreen) while the dialog stays open.
player.rerender( player.rerender(
@@ -188,7 +188,7 @@ describe("Toaster — handing the toasts to another host", () => {
const t0 = Date.now(); const t0 = Date.now();
const now = vi.spyOn(Date, "now").mockReturnValue(t0); const now = vi.spyOn(Date, "now").mockReturnValue(t0);
render(<Toaster />); render(<Toaster />);
act(() => void notify({ level: "info", message: "saved" })); act(() => void notify({ level: "info", message: raw("saved") }));
now.mockReturnValue(t0 + 4000); now.mockReturnValue(t0 + 4000);
render(<Toaster overlay />); render(<Toaster overlay />);
const bar = screen.getByText("saved").closest(".toast-card")!.lastElementChild as HTMLElement; const bar = screen.getByText("saved").closest(".toast-card")!.lastElementChild as HTMLElement;
@@ -199,7 +199,7 @@ describe("Toaster — handing the toasts to another host", () => {
// At the app's own spot (bottom-4) the stack sat on the Plex transport row and PlayerModal's // At the app's own spot (bottom-4) the stack sat on the Plex transport row and PlayerModal's
// docked bar, above them at z-overlay, and took their clicks for as long as a toast was up. // docked bar, above them at z-overlay, and took their clicks for as long as a toast was up.
render(<Toaster overlay />); render(<Toaster overlay />);
act(() => void notify({ level: "error", message: "boom" })); act(() => void notify({ level: "error", message: raw("boom") }));
const host = hosts()[0]!.closest("[data-toast-region]")!; const host = hosts()[0]!.closest("[data-toast-region]")!;
expect(host.className).toContain("bottom-24"); expect(host.className).toContain("bottom-24");
expect(host.className).toContain("pointer-events-none"); expect(host.className).toContain("pointer-events-none");
@@ -218,9 +218,9 @@ describe("Toaster — a toast's actions", () => {
() => () =>
void notify({ void notify({
level: "success", level: "success",
message: "Downloaded", message: raw("Downloaded"),
action: { label: "Copy link", onClick: copy }, action: { label: raw("Copy link"), onClick: copy },
secondaryAction: { label: "Open", onClick: open }, secondaryAction: { label: raw("Open"), onClick: open },
}), }),
); );
expect(screen.getByRole("button", { name: "Copy link" })).toBeTruthy(); expect(screen.getByRole("button", { name: "Copy link" })).toBeTruthy();
@@ -0,0 +1,104 @@
// @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("its button is said again too, beside the text it acts on", async () => {
render(<Toaster />);
act(
() =>
void notify({
message: msg("common.copy.done"),
action: { label: msg("common.undo"), onClick: () => {} },
}),
);
expect(screen.getByRole("button", { name: "Undo" })).toBeTruthy();
await act(async () => void (await i18n.changeLanguage("hu")));
expect(screen.getByRole("button", { name: "Visszavonás" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "Undo" })).toBeNull();
});
it("a server refusal is said again in the language of the moment", async () => {
render(<Toaster />);
act(
() =>
void notify({
level: "error",
message: { detail: { code: "not_found", reason: "view" } },
}),
);
expect(screen.getByText("This view doesn't exist any more")).toBeTruthy();
await act(async () => void (await i18n.changeLanguage("hu")));
expect(screen.getByText("Ez a nézet már nem létezik")).toBeTruthy();
});
it("a value inside the text is said again too, and a number is grouped the reader's way", async () => {
const text = msg("settings.takeout.read", {
name: raw("watch-history.json"),
count: 12345,
n: { number: 12345 },
});
const en = sayNotif(text);
await i18n.changeLanguage("hu");
const hu = sayNotif(text);
expect(en).toContain("12,345");
expect(hu).not.toContain("12,345");
expect(hu).toContain("watch-history.json");
expect(hu).not.toBe(en);
});
});
describe("the history kept from before", () => {
beforeEach(() => {
localStorage.clear();
vi.resetModules();
});
it("shows a notice stored as text as it was stored", async () => {
localStorage.setItem("siftlode.owner", "5");
localStorage.setItem(
"siftlode.notifications",
JSON.stringify([
{ id: 1, level: "info", title: "Old title", message: "Old text", ts: 1, read: true },
]),
);
const notifications = await import("../../lib/notifications");
const { claimLocalData } = await import("../../lib/storage");
claimLocalData(5);
const n = notifications.getNotifications()[0]!;
expect(n.message).toEqual(raw("Old text"));
expect(n.title).toEqual(raw("Old title"));
expect(notifications.sayNotif(n.message)).toBe("Old text");
});
});
@@ -23,7 +23,7 @@ vi.mock("react-i18next", async (importOriginal) => {
import Toaster from "./Toaster"; import Toaster from "./Toaster";
import { useOverlayOrder } from "./overlayOrder"; import { useOverlayOrder } from "./overlayOrder";
import { clearAll, notify } from "../../lib/notifications"; import { clearAll, notify, raw } from "../../lib/notifications";
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
@@ -37,7 +37,7 @@ function Harness() {
<> <>
<button <button
onClick={() => { onClick={() => {
notify({ level: "success", message: "Uploaded" }); notify({ level: "success", message: raw("Uploaded") });
setOpen(false); // …in the SAME handler: one batch, one commit setOpen(false); // …in the SAME handler: one batch, one commit
}} }}
> >
+8 -6
View File
@@ -1,7 +1,7 @@
import { useEffect, useId, useLayoutEffect, useState, useSyncExternalStore } from "react"; import { useEffect, useId, useLayoutEffect, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { dismiss, getActiveToasts, subscribe } from "../../lib/notifications"; import { dismiss, getActiveToasts, sayNotif, subscribe } from "../../lib/notifications";
import { createStore } from "../../lib/store"; import { createStore } from "../../lib/store";
import { useToastDock } from "../../lib/toastDock"; import { useToastDock } from "../../lib/toastDock";
import { useOverlayOrder } from "./overlayOrder"; import { useOverlayOrder } from "./overlayOrder";
@@ -162,9 +162,11 @@ export default function Toaster({
> >
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} /> <Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{toast.title && <div className="text-sm font-semibold">{toast.title}</div>} {toast.title && (
<div className="text-sm font-semibold">{sayNotif(toast.title, t)}</div>
)}
<div className="text-sm wrap-break-word"> <div className="text-sm wrap-break-word">
{toast.message} {sayNotif(toast.message, t)}
{toast.repeat > 1 && ( {toast.repeat > 1 && (
<span className="ml-1.5 align-middle text-2xs font-semibold text-muted tabular-nums"> <span className="ml-1.5 align-middle text-2xs font-semibold text-muted tabular-nums">
×{toast.repeat} ×{toast.repeat}
@@ -174,10 +176,10 @@ export default function Toaster({
{(toast.action || toast.secondaryAction) && ( {(toast.action || toast.secondaryAction) && (
<div className="mt-1 flex flex-wrap gap-x-4"> <div className="mt-1 flex flex-wrap gap-x-4">
{[toast.action, toast.secondaryAction].map( {[toast.action, toast.secondaryAction].map(
(action) => (action, i) =>
action && ( action && (
<Button <Button
key={action.label} key={i}
variant="link" variant="link"
onClick={() => { onClick={() => {
action.onClick(); action.onClick();
@@ -185,7 +187,7 @@ export default function Toaster({
}} }}
className="text-sm" className="text-sm"
> >
{action.label} {sayNotif(action.label, t)}
</Button> </Button>
), ),
)} )}
+5 -5
View File
@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
import { Copy, Link2 } from "lucide-react"; import { Copy, Link2 } from "lucide-react";
import { copyText } from "../../lib/clipboard"; import { copyText } from "../../lib/clipboard";
import { notify } from "../../lib/notifications"; import { msg, type NotifText, notify } from "../../lib/notifications";
import { type ActionMenuItem } from "./ActionMenu"; import { type ActionMenuItem } from "./ActionMenu";
// "Copy title" and "Copy link", the two rows every object's menu ends its middle group with // "Copy title" and "Copy link", the two rows every object's menu ends its middle group with
@@ -17,12 +17,12 @@ type Named = "title" | "name";
export function useCopyItems() { export function useCopyItems() {
const { t } = useTranslation(); const { t } = useTranslation();
const copy = (text: string, done: string) => { const copy = (text: string, done: NotifText) => {
void copyText(text).then((ok) => void copyText(text).then((ok) =>
notify( notify(
ok ok
? { level: "success", message: done } ? { level: "success", message: done }
: { level: "error", message: t("common.copy.failed") }, : { level: "error", message: msg("common.copy.failed") },
), ),
); );
}; };
@@ -43,7 +43,7 @@ export function useCopyItems() {
label: t(named === "title" ? "common.copy.title" : "common.copy.name"), label: t(named === "title" ? "common.copy.title" : "common.copy.name"),
icon: <Copy className="w-4 h-4" />, icon: <Copy className="w-4 h-4" />,
inGesture: true, inGesture: true,
onSelect: () => copy(text, t("common.copy.done")), onSelect: () => copy(text, msg("common.copy.done")),
}, },
] ]
: []), : []),
@@ -54,7 +54,7 @@ export function useCopyItems() {
label: linkLabel ?? t("common.copy.link"), label: linkLabel ?? t("common.copy.link"),
icon: <Link2 className="w-4 h-4" />, icon: <Link2 className="w-4 h-4" />,
inGesture: true, inGesture: true,
onSelect: () => copy(link, t("common.copy.linkDone")), onSelect: () => copy(link, msg("common.copy.linkDone")),
}, },
] ]
: []), : []),
@@ -19,8 +19,9 @@ const PLATFORM_NAMESPACES = new Set([
]); ]);
const files = readSources(__dirname, "./**/*.{ts,tsx}"); const files = readSources(__dirname, "./**/*.{ts,tsx}");
// `t("ns.…")` and `t(\`ns.…\`)` — the namespace is everything before the first dot. // `t("ns.…")` and `t(\`ns.…\`)`, and a notice's `msg("ns.…")` (the same key, said when shown) — the
const KEY = /\bt\(\s*["`]([A-Za-z]+)\./g; // namespace is everything before the first dot.
const KEY = /\b(?:t|msg)\(\s*["`]([A-Za-z]+)\./g;
describe("components/ui/ speaks only platform i18n namespaces", () => { describe("components/ui/ speaks only platform i18n namespaces", () => {
it("names no feature namespace", () => { it("names no feature namespace", () => {
@@ -37,5 +38,6 @@ describe("components/ui/ speaks only platform i18n namespaces", () => {
it("sees a feature namespace when one is used", () => { it("sees a feature namespace when one is used", () => {
expect([...'t("plex.loading")'.matchAll(KEY)].map((m) => m[1])).toEqual(["plex"]); expect([...'t("plex.loading")'.matchAll(KEY)].map((m) => m[1])).toEqual(["plex"]);
expect([...'msg("plex.loading")'.matchAll(KEY)].map((m) => m[1])).toEqual(["plex"]);
}); });
}); });
@@ -11,8 +11,12 @@ vi.mock("react-i18next", async (importOriginal) => ({
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
})); }));
vi.mock("../../lib/clipboard", () => ({ copyText: h.copyText })); vi.mock("../../lib/clipboard", () => ({ copyText: h.copyText }));
vi.mock("../../lib/notifications", () => ({ notify: h.notify })); vi.mock("../../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../../lib/notifications")>()),
notify: h.notify,
}));
import { msg } from "../../lib/notifications";
import { useCopyFeedback } from "./useCopyFeedback"; import { useCopyFeedback } from "./useCopyFeedback";
afterEach(() => { afterEach(() => {
@@ -27,7 +31,7 @@ describe("useCopyFeedback", () => {
await act(() => result.current.copy("sfl_x")); await act(() => result.current.copy("sfl_x"));
expect(h.copyText).toHaveBeenCalledWith("sfl_x"); expect(h.copyText).toHaveBeenCalledWith("sfl_x");
expect(result.current.copied).toBe(true); expect(result.current.copied).toBe(true);
expect(h.notify).toHaveBeenCalledWith({ level: "success", message: "common.copy.done" }); expect(h.notify).toHaveBeenCalledWith({ level: "success", message: msg("common.copy.done") });
act(() => void vi.advanceTimersByTime(2000)); act(() => void vi.advanceTimersByTime(2000));
expect(result.current.copied).toBe(false); expect(result.current.copied).toBe(false);
}); });
@@ -49,6 +53,6 @@ describe("useCopyFeedback", () => {
const { result } = renderHook(() => useCopyFeedback()); const { result } = renderHook(() => useCopyFeedback());
await act(() => result.current.copy("sfl_x")); await act(() => result.current.copy("sfl_x"));
expect(result.current.copied).toBe(false); expect(result.current.copied).toBe(false);
expect(h.notify).toHaveBeenCalledWith({ level: "warning", message: "common.copy.failed" }); expect(h.notify).toHaveBeenCalledWith({ level: "warning", message: msg("common.copy.failed") });
}); });
}); });
@@ -1,8 +1,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { copyText } from "../../lib/clipboard"; import { copyText } from "../../lib/clipboard";
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
// How long a Copy button reads "Copied" after it worked. // How long a Copy button reads "Copied" after it worked.
const COPIED_MS = 2000; const COPIED_MS = 2000;
@@ -15,7 +14,6 @@ const COPIED_MS = 2000;
* *
* A feature file, not `ui/`: it speaks the app's notifications (as `copyItems` does). */ * A feature file, not `ui/`: it speaks the app's notifications (as `copyItems` does). */
export function useCopyFeedback(): { copied: boolean; copy: (value: string) => Promise<void> } { export function useCopyFeedback(): { copied: boolean; copy: (value: string) => Promise<void> } {
const { t } = useTranslation();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
// One reset at a time: a copy again within the moment starts it over, not cut short by the last. // One reset at a time: a copy again within the moment starts it over, not cut short by the last.
const timer = useRef<number | undefined>(undefined); const timer = useRef<number | undefined>(undefined);
@@ -23,12 +21,12 @@ export function useCopyFeedback(): { copied: boolean; copy: (value: string) => P
const copy = async (value: string) => { const copy = async (value: string) => {
if (await copyText(value)) { if (await copyText(value)) {
setCopied(true); setCopied(true);
notify({ level: "success", message: t("common.copy.done") }); notify({ level: "success", message: msg("common.copy.done") });
window.clearTimeout(timer.current); window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setCopied(false), COPIED_MS); timer.current = window.setTimeout(() => setCopied(false), COPIED_MS);
return; return;
} }
notify({ level: "warning", message: t("common.copy.failed") }); notify({ level: "warning", message: msg("common.copy.failed") });
}; };
return { copied, copy }; return { copied, copy };
} }
@@ -1,4 +1,12 @@
[ [
{
"version": "0.114.1",
"date": "2026-10-30",
"new": [],
"fixed": [
"Notifications follow a language switch: a notice on screen and every row in the notification center are said again in the new language, their buttons too."
]
},
{ {
"version": "0.114.0", "version": "0.114.0",
"date": "2026-10-29", "date": "2026-10-29",
@@ -1,4 +1,12 @@
[ [
{
"version": "0.114.1",
"date": "2026-10-30",
"new": [],
"fixed": [
"Az értesítések követik a nyelvváltást: a képernyőn lévő értesítés és az értesítési központ minden sora az új nyelven szól, a gombjaik is."
]
},
{ {
"version": "0.114.0", "version": "0.114.0",
"date": "2026-10-29", "date": "2026-10-29",
+5 -5
View File
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { Plus, Trash2 } from "lucide-react"; import { Plus, Trash2 } from "lucide-react";
import { api, type Tag } from "../../lib/api"; import { api, type Tag } from "../../lib/api";
import { notify } from "../../lib/notifications"; import { msg, type NotifText, notify, raw } from "../../lib/notifications";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
import { useScrollFade } from "../../lib/useScrollFade"; import { useScrollFade } from "../../lib/useScrollFade";
import { Button } from "../../components/ui/Button"; import { Button } from "../../components/ui/Button";
@@ -137,22 +137,22 @@ export default function TagManager({
}); });
// A failure is said and the list refetched — a tag deleted in another tab used to fail in silence // A failure is said and the list refetched — a tag deleted in another tab used to fail in silence
// (a 404 `not_found/tag` gets no global dialog) and stay listed. // (a 404 `not_found/tag` gets no global dialog) and stay listed.
const onFailed = (e: unknown, fallback: string) => { const onFailed = (e: unknown, fallback: NotifText) => {
notifyActionError(e, fallback); notifyActionError(e, fallback);
invalidate(); invalidate();
}; };
const rename = useMutation({ const rename = useMutation({
mutationFn: (v: { id: number; name: string }) => api.updateTag(v.id, { name: v.name }), mutationFn: (v: { id: number; name: string }) => api.updateTag(v.id, { name: v.name }),
onSuccess: invalidate, onSuccess: invalidate,
onError: (e) => onFailed(e, t("tagManager.renameFailed")), onError: (e) => onFailed(e, msg("tagManager.renameFailed")),
}); });
const del = useMutation({ const del = useMutation({
mutationFn: (v: { id: number; name: string }) => api.deleteTag(v.id), mutationFn: (v: { id: number; name: string }) => api.deleteTag(v.id),
onSuccess: (_d, v) => { onSuccess: (_d, v) => {
invalidate(); invalidate();
notify({ level: "success", message: t("tagManager.deleted", { name: v.name }) }); notify({ level: "success", message: msg("tagManager.deleted", { name: raw(v.name) }) });
}, },
onError: (e) => onFailed(e, t("tagManager.deleteFailed")), onError: (e) => onFailed(e, msg("tagManager.deleteFailed")),
}); });
// Picking a channel from a tag's popover: close the dialog, then focus it in the manager. // Picking a channel from a tag's popover: close the dialog, then focus it in the manager.
+13 -13
View File
@@ -6,7 +6,7 @@ import { Loader2, Pin, PinOff } from "lucide-react";
import { plexLang } from "../../i18n"; import { plexLang } from "../../i18n";
import { api, type DiscoverCard, type PinKind } from "../../lib/api"; import { api, type DiscoverCard, type PinKind } from "../../lib/api";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
import { notify } from "../../lib/notifications"; import { msg, type NotifText, notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { type ActionMenuItem } from "../../components/ui/ActionMenu"; import { type ActionMenuItem } from "../../components/ui/ActionMenu";
@@ -81,9 +81,9 @@ export interface ComfortPin {
/** `pinIdOf` and `toggle` stay module-local until a second consumer needs them: publishing them /** `pinIdOf` and `toggle` stay module-local until a second consumer needs them: publishing them
* invites the next surface to re-derive the three-state loading dance (pinned / not pinned / * invites the next surface to re-derive the three-state loading dance (pinned / not pinned /
* not known yet) that `item` already encapsulates, which is what this file exists to prevent. */ * not known yet) that `item` already encapsulates, which is what this file exists to prevent. */
remove: (pinId: number, title: string) => void; remove: (pinId: number, title: NotifText) => void;
/** The pin/unpin row, ready to spread into any `ActionMenuItem[]`. Empty for a null target. */ /** The pin/unpin row, ready to spread into any `ActionMenuItem[]`. Empty for a null target. */
item: (target: PinTarget | null, title: string) => ActionMenuItem[]; item: (target: PinTarget | null, title: NotifText) => ActionMenuItem[];
} }
/** /**
@@ -134,37 +134,37 @@ export function useComfortPin(open: boolean): ComfortPin {
}, [qc]); }, [qc]);
const add = useCallback( const add = useCallback(
async (target: PinTarget, title: string) => { async (target: PinTarget, title: NotifText) => {
try { try {
await api.addComfortPin(target.kind, target.ref); await api.addComfortPin(target.kind, target.ref);
// The shelf lives on another page, so without this the click has no visible consequence at // The shelf lives on another page, so without this the click has no visible consequence at
// all from the feed — the one place a toast genuinely carries the whole feedback. // all from the feed — the one place a toast genuinely carries the whole feedback.
notify({ level: "success", message: t("home.pin.added", { title }) }); notify({ level: "success", message: msg("home.pin.added", { title }) });
} catch (e) { } catch (e) {
notifyActionError(e, t("home.pin.addFailed")); notifyActionError(e, msg("home.pin.addFailed"));
} }
invalidate(); invalidate();
}, },
[invalidate, t], [invalidate],
); );
const remove = useCallback( const remove = useCallback(
(pinId: number, title: string) => { (pinId: number, title: NotifText) => {
void (async () => { void (async () => {
try { try {
await api.removeComfortPin(pinId); await api.removeComfortPin(pinId);
notify({ level: "success", message: t("home.pin.removed", { title }) }); notify({ level: "success", message: msg("home.pin.removed", { title }) });
} catch (e) { } catch (e) {
notifyActionError(e, t("home.pin.removeFailed")); notifyActionError(e, msg("home.pin.removeFailed"));
} }
invalidate(); invalidate();
})(); })();
}, },
[invalidate, t], [invalidate],
); );
const toggle = useCallback( const toggle = useCallback(
(target: PinTarget, title: string) => { (target: PinTarget, title: NotifText) => {
const pinId = pinIdOf(target); const pinId = pinIdOf(target);
if (pinId == null) void add(target, title); if (pinId == null) void add(target, title);
else remove(pinId, title); else remove(pinId, title);
@@ -173,7 +173,7 @@ export function useComfortPin(open: boolean): ComfortPin {
); );
const item = useCallback( const item = useCallback(
(target: PinTarget | null, title: string): ActionMenuItem[] => { (target: PinTarget | null, title: NotifText): ActionMenuItem[] => {
if (!target) return []; if (!target) return [];
const pinId = pinIdOf(target); const pinId = pinIdOf(target);
// Still loading: an honest "we don't know yet" row rather than a confident "Pin" over // Still loading: an honest "we don't know yet" row rather than a confident "Pin" over
@@ -18,7 +18,7 @@ import {
} from "../../lib/debugRecorder"; } from "../../lib/debugRecorder";
import { formatDateTimeSec, formatDuration } from "../../lib/format"; import { formatDateTimeSec, formatDuration } from "../../lib/format";
import { useLayer } from "../../lib/layerStack"; import { useLayer } from "../../lib/layerStack";
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { dockUnderToasts } from "../../lib/toastDock"; import { dockUnderToasts } from "../../lib/toastDock";
import { useMe } from "../../lib/useMe"; import { useMe } from "../../lib/useMe";
@@ -73,7 +73,7 @@ export default function DebugRecorder() {
if (!id || !until) return; if (!id || !until) return;
const wait = Math.min(Date.parse(until) - Date.now(), 2 ** 31 - 1); const wait = Math.min(Date.parse(until) - Date.now(), 2 ** 31 - 1);
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
notify({ level: "warning", message: t("recorder.pill.expired", { count: minutes ?? 0 }) }); notify({ level: "warning", message: msg("recorder.pill.expired", { count: minutes ?? 0 }) });
openComposer(id); openComposer(id);
void qc.invalidateQueries({ queryKey: qk.me() }); void qc.invalidateQueries({ queryKey: qk.me() });
}, wait); }, wait);
@@ -6,7 +6,7 @@ import { ExternalLink, FileText, Plus, Server, X } from "lucide-react";
import { api, type DebugScreenshot, type DebugSession, type ServerLogRow } from "../../lib/api"; import { api, type DebugScreenshot, type DebugSession, type ServerLogRow } from "../../lib/api";
import { closeComposer } from "../../lib/debugRecorder"; import { closeComposer } from "../../lib/debugRecorder";
import { formatBytes, formatDateTimeSec, formatUnit } from "../../lib/format"; import { formatBytes, formatDateTimeSec, formatUnit } from "../../lib/format";
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { useMe } from "../../lib/useMe"; import { useMe } from "../../lib/useMe";
import { import {
draftSteps, draftSteps,
@@ -217,7 +217,7 @@ function ComposerBody({
meta: { failTitle: "recorder.composer.sendFailed" }, meta: { failTitle: "recorder.composer.sendFailed" },
onSuccess: (r) => { onSuccess: (r) => {
qc.setQueryData(qk.debugSession(session.id), r.session); qc.setQueryData(qk.debugSession(session.id), r.session);
notify({ level: "success", message: t("recorder.composer.sentOk") }); notify({ level: "success", message: msg("recorder.composer.sentOk") });
}, },
// A failure half-way still created the issue: show what the server now has. // A failure half-way still created the issue: show what the server now has.
onSettled: async () => { onSettled: async () => {
@@ -1,6 +1,5 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query"; import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { api, type DebugSession, type DebugSessionLevel } from "../../lib/api"; import { api, type DebugSession, type DebugSessionLevel } from "../../lib/api";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
@@ -14,7 +13,7 @@ import {
} from "../../lib/debugRecorder"; } from "../../lib/debugRecorder";
import { flushNow, liveSession } from "../../lib/diagnostics"; import { flushNow, liveSession } from "../../lib/diagnostics";
import { log } from "../../lib/log"; import { log } from "../../lib/log";
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { updateMeCache, useMe } from "../../lib/useMe"; import { updateMeCache, useMe } from "../../lib/useMe";
@@ -22,7 +21,6 @@ import { updateMeCache, useMe } from "../../lib/useMe";
* Diagnosztika and the command palette. The session itself is `me.debug_session` — the server's * Diagnosztika and the command palette. The session itself is `me.debug_session` — the server's
* answer, so a reload, another tab or the cap all show the same thing. */ * answer, so a reload, another tab or the cap all show the same thing. */
export function useDebugRecording() { export function useDebugRecording() {
const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const me = useMe(); const me = useMe();
const session = liveSession(me?.debug_session); const session = liveSession(me?.debug_session);
@@ -54,7 +52,7 @@ export function useDebugRecording() {
meta: { failTitle: "recorder.pill.stopFailed" }, meta: { failTitle: "recorder.pill.stopFailed" },
onSuccess: (r) => { onSuccess: (r) => {
setSession(null); setSession(null);
notify({ level: "success", message: t("recorder.pill.stopped") }); notify({ level: "success", message: msg("recorder.pill.stopped") });
openComposer(r.session.id); openComposer(r.session.id);
}, },
}); });
@@ -78,7 +76,7 @@ export function useDebugRecording() {
); );
// In the evidence too, where the step list is drafted from: which step it belongs to. // In the evidence too, where the step list is drafted from: which step it belongs to.
log.info("debug.screenshot", "screenshot taken", { screenshot: r.screenshot.id }); log.info("debug.screenshot", "screenshot taken", { screenshot: r.screenshot.id });
notify({ level: "success", message: t("recorder.pill.shotSaved") }); notify({ level: "success", message: msg("recorder.pill.shotSaved") });
}, },
}); });
@@ -95,9 +93,9 @@ export function useDebugRecording() {
const n = nextStep(sessionId); const n = nextStep(sessionId);
const text = label.trim(); const text = label.trim();
log.info("debug.step", text || `step ${n}`, { n, ...(text ? { label: text } : {}) }); log.info("debug.step", text || `step ${n}`, { n, ...(text ? { label: text } : {}) });
notify({ level: "info", message: t("recorder.pill.stepMarked", { n }) }); notify({ level: "info", message: msg("recorder.pill.stepMarked", { n }) });
}, },
[sessionId, t], [sessionId],
); );
/** Capture this tab and attach it. `hide` runs around the capture so the pill is not in the /** Capture this tab and attach it. `hide` runs around the capture so the pill is not in the
@@ -111,7 +109,7 @@ export function useDebugRecording() {
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
image = await captureTab(); image = await captureTab();
} catch (err) { } catch (err) {
notifyActionError(err, t("recorder.pill.shotFailed")); notifyActionError(err, msg("recorder.pill.shotFailed"));
} }
hide(false); hide(false);
if (image) shoot.mutate({ id: session.id, image }); if (image) shoot.mutate({ id: session.id, image });
@@ -9,7 +9,7 @@ import { invalidateDownloads } from "../../lib/downloads";
// Lazy: the full profile editor only opens from the dialog's "manage presets" affordance. // Lazy: the full profile editor only opens from the dialog's "manage presets" affordance.
const ProfileEditor = lazy(() => import("../../modules/settings/ProfileEditor")); const ProfileEditor = lazy(() => import("../../modules/settings/ProfileEditor"));
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
import { showInTray } from "../../lib/downloadQueue"; import { showInTray } from "../../lib/downloadQueue";
import { profileName } from "../../lib/vocab"; import { profileName } from "../../lib/vocab";
@@ -63,15 +63,15 @@ export default function DownloadDialog({
setLastProfile(chosen); setLastProfile(chosen);
notify({ notify({
level: "success", level: "success",
message: t("downloads.dialog.added"), message: msg("downloads.dialog.added"),
action: { label: t("downloads.dialog.view"), onClick: () => showInTray(job.id) }, action: { label: msg("downloads.dialog.view"), onClick: () => showInTray(job.id) },
}); });
onClose(); onClose();
} catch (e) { } catch (e) {
// A quota or validation refusal (422/409) is already in the global error dialog, which the // A quota or validation refusal (422/409) is already in the global error dialog, which the
// reporter skips; a 403/404/429 or a lost connection is not, and used to leave the dialog // reporter skips; a 403/404/429 or a lost connection is not, and used to leave the dialog
// open with nothing said. // open with nothing said.
notifyActionError(e, t("errors.writeFailed.enqueueDownload")); notifyActionError(e, msg("errors.writeFailed.enqueueDownload"));
setBusy(false); setBusy(false);
} }
}; };
@@ -70,6 +70,7 @@ vi.mock("../../lib/notifications", async (importOriginal) => ({
})); }));
import DownloadTray from "./DownloadTray"; import DownloadTray from "./DownloadTray";
import { msg, type NotifText, raw } from "../../lib/notifications";
import { noteQueue, resetQueueWatch, showInTray } from "../../lib/downloadQueue"; import { noteQueue, resetQueueWatch, showInTray } from "../../lib/downloadQueue";
import { pushLayer, removeLayer } from "../../lib/layerStack"; import { pushLayer, removeLayer } from "../../lib/layerStack";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
@@ -199,14 +200,15 @@ describe("DownloadTray", () => {
poll([job(1, "done")], view); poll([job(1, "done")], view);
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1)); await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
const notice = h.notify.mock.calls[0]![0] as { const notice = h.notify.mock.calls[0]![0] as {
title: string; title: NotifText;
message: string; message: NotifText;
action: { label: string; onClick: () => void }; action: { label: NotifText; onClick: () => void };
secondaryAction: { label: string; onClick: () => void }; secondaryAction: { label: NotifText; onClick: () => void };
}; };
expect(notice.title).toBe("tray.done.title:Clip 1"); expect(notice.title).toEqual(msg("tray.done.title", { title: raw("Clip 1") }));
expect(notice.message).toBe("tray.done.phone"); expect(notice.message).toEqual(msg("tray.done.phone"));
expect(notice.action.label).toBe("tray.done.copyLink"); expect(notice.action.label).toEqual(msg("tray.done.copyLink"));
expect(notice.secondaryAction.label).toEqual(msg("tray.done.open"));
notice.action.onClick(); notice.action.onClick();
expect(h.share).toHaveBeenCalled(); expect(h.share).toHaveBeenCalled();
notice.secondaryAction.onClick(); notice.secondaryAction.onClick();
@@ -225,9 +227,9 @@ describe("DownloadTray", () => {
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1)); await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
poll([job(1, "done"), job(2, "done")], view); poll([job(1, "done"), job(2, "done")], view);
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(2)); await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(2));
expect(h.notify.mock.calls.map((c) => (c[0] as { title: string }).title)).toEqual([ expect(h.notify.mock.calls.map((c) => (c[0] as { title: NotifText }).title)).toEqual([
"tray.done.title:Clip 1", msg("tray.done.title", { title: raw("Clip 1") }),
"tray.done.title:Clip 2", msg("tray.done.title", { title: raw("Clip 2") }),
]); ]);
}); });
@@ -236,7 +238,9 @@ describe("DownloadTray", () => {
const view = mount(); const view = mount();
poll([job(1, "done", { asset: { media_kind: "file" } as never })], view); poll([job(1, "done", { asset: { media_kind: "file" } as never })], view);
await waitFor(() => await waitFor(() =>
expect(h.notify).toHaveBeenCalledWith(expect.objectContaining({ message: "tray.done.file" })), expect(h.notify).toHaveBeenCalledWith(
expect.objectContaining({ message: msg("tray.done.file") }),
),
); );
}); });
@@ -257,7 +261,9 @@ describe("DownloadTray", () => {
poll([job(1, "done", { source_key: "upload" })], view); poll([job(1, "done", { source_key: "upload" })], view);
await waitFor(() => await waitFor(() =>
expect(h.notify).toHaveBeenCalledWith( expect(h.notify).toHaveBeenCalledWith(
expect.objectContaining({ title: "tray.done.uploadedTitle:Clip 1" }), expect.objectContaining({
title: msg("tray.done.uploadedTitle", { title: raw("Clip 1") }),
}),
), ),
); );
}); });
@@ -30,7 +30,7 @@ import {
} from "../../lib/downloadQueue"; } from "../../lib/downloadQueue";
import { anyBlockingLayer } from "../../lib/layerStack"; import { anyBlockingLayer } from "../../lib/layerStack";
import { focusNavItem, navItemAttrs } from "../../lib/listNav"; import { focusNavItem, navItemAttrs } from "../../lib/listNav";
import { notify } from "../../lib/notifications"; import { msg, notify, raw } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { LS } from "../../lib/storage"; import { LS } from "../../lib/storage";
import { useLiveQuery } from "../../lib/useLiveQuery"; import { useLiveQuery } from "../../lib/useLiveQuery";
@@ -191,16 +191,16 @@ export default function DownloadTray() {
notify({ notify({
level: "success", level: "success",
// An upload the worker had to convert first is done here too — it was never downloaded. // An upload the worker had to convert first is done here too — it was never downloaded.
title: t(job.source_key === "upload" ? "tray.done.uploadedTitle" : "tray.done.title", { title: msg(job.source_key === "upload" ? "tray.done.uploadedTitle" : "tray.done.title", {
title, title: raw(title),
}), }),
message: t(job.asset?.media_kind === "file" ? "tray.done.file" : "tray.done.phone"), message: msg(job.asset?.media_kind === "file" ? "tray.done.file" : "tray.done.phone"),
action: { action: {
label: t("tray.done.copyLink"), label: msg("tray.done.copyLink"),
onClick: () => void quickShareLink(qc, { kind: "file", id: job.id }, t), onClick: () => void quickShareLink(qc, { kind: "file", id: job.id }),
}, },
secondaryAction: { secondaryAction: {
label: t("tray.done.open"), label: msg("tray.done.open"),
onClick: () => openFile(job.id, job.folder_id), onClick: () => openFile(job.id, job.folder_id),
}, },
}); });
@@ -298,7 +298,7 @@ export default function DownloadTray() {
id: "copyLink", id: "copyLink",
label: t("tray.done.copyLink"), label: t("tray.done.copyLink"),
icon: <Link className="w-4 h-4" />, icon: <Link className="w-4 h-4" />,
onSelect: () => void quickShareLink(qc, { kind: "file", id: job.id }, t), onSelect: () => void quickShareLink(qc, { kind: "file", id: job.id }),
}, },
]); ]);
const menuItems = menu.target const menuItems = menu.target
@@ -9,6 +9,7 @@ import { useCopyItems } from "../../components/ui/copyItems";
import { jobTitle } from "./downloadTitle"; import { jobTitle } from "./downloadTitle";
import { type ActionMenuItem } from "../../components/ui/ActionMenu"; import { type ActionMenuItem } from "../../components/ui/ActionMenu";
import { menuGroups } from "../../components/ui/menuGroups"; import { menuGroups } from "../../components/ui/menuGroups";
import { msg } from "../../lib/notifications";
// A queued download's ONE action list: the row's "…", its right-click, long-press and Shift+F10. The // A queued download's ONE action list: the row's "…", its right-click, long-press and Shift+F10. The
// row keeps its primary action (pause / resume / retry) and its state (the error and its details) in // row keeps its primary action (pause / resume / retry) and its state (the error and its details) in
@@ -56,7 +57,7 @@ export function useDownloadActions() {
? api.resumeDownload(id) ? api.resumeDownload(id)
: api.cancelDownload(id), : api.cancelDownload(id),
onSuccess: () => invalidateDownloads(qc), onSuccess: () => invalidateDownloads(qc),
onError: (e, v) => notifyActionError(e, t(`errors.writeFailed.job.${v.op}`)), onError: (e, v) => notifyActionError(e, msg(`errors.writeFailed.job.${v.op}`)),
}); });
const confirmThen = async (title: string, body: string, action: string, fn: () => void) => { const confirmThen = async (title: string, body: string, action: string, fn: () => void) => {
@@ -8,6 +8,7 @@ import { notifyActionError } from "../../lib/actionErrors";
import { ownKey } from "../../lib/storage"; import { ownKey } from "../../lib/storage";
import { useConfirm } from "../../components/ui/confirmContext"; import { useConfirm } from "../../components/ui/confirmContext";
import type { SavedViewsContext, ViewSource } from "./savedViewsContext"; import type { SavedViewsContext, ViewSource } from "./savedViewsContext";
import { msg, type NotifText } from "../../lib/notifications";
// Saved "smart views" serve two modules — the feed and Plex — and each owns one store of them in its // Saved "smart views" serve two modules — the feed and Plex — and each owns one store of them in its
// own folder, built on this hook. The two never switch into each other: a visit to Plex leaves the // own folder, built on this hook. The two never switch into each other: a visit to Plex leaves the
@@ -114,7 +115,7 @@ export function useSavedViewsStore(cfg: SavedViewsConfig): SavedViewsContext {
const isDirty = displayView != null && activeView == null; const isDirty = displayView != null && activeView == null;
const refresh = () => void qc.invalidateQueries({ queryKey: qk.savedViews(source) }); const refresh = () => void qc.invalidateQueries({ queryKey: qk.savedViews(source) });
async function runViewMutation(fn: () => Promise<unknown>, failMsg: string): Promise<boolean> { async function runViewMutation(fn: () => Promise<unknown>, failMsg: NotifText): Promise<boolean> {
let ok = true; let ok = true;
try { try {
await fn(); await fn();
@@ -146,28 +147,28 @@ export function useSavedViewsStore(cfg: SavedViewsConfig): SavedViewsContext {
if (!clean) return false; if (!clean) return false;
return runViewMutation( return runViewMutation(
() => api.createView(clean, cfg.stripForSave(cfg.filters), source), () => api.createView(clean, cfg.stripForSave(cfg.filters), source),
t("views.actionFailed"), msg("views.actionFailed"),
); );
}, },
rename: async (id, name) => { rename: async (id, name) => {
const next = name.trim(); const next = name.trim();
if (!next) return false; if (!next) return false;
return runViewMutation(() => api.updateView(id, { name: next }), t("views.actionFailed")); return runViewMutation(() => api.updateView(id, { name: next }), msg("views.actionFailed"));
}, },
updateFilters: (id, filters) => updateFilters: (id, filters) =>
runViewMutation(() => api.updateView(id, { filters }), t("views.actionFailed")), runViewMutation(() => api.updateView(id, { filters }), msg("views.actionFailed")),
saveCurrent: async (id) => { saveCurrent: async (id) => {
const filters = cfg.stripForSave(cfg.filters); const filters = cfg.stripForSave(cfg.filters);
const ok = await runViewMutation( const ok = await runViewMutation(
() => api.updateView(id, { filters }), () => api.updateView(id, { filters }),
t("views.actionFailed"), msg("views.actionFailed"),
); );
return ok ? filters : null; return ok ? filters : null;
}, },
toggleDefault: (v) => toggleDefault: (v) =>
runViewMutation( runViewMutation(
() => api.updateView(v.id, { is_default: !v.is_default }), () => api.updateView(v.id, { is_default: !v.is_default }),
t("views.actionFailed"), msg("views.actionFailed"),
).then(() => undefined), ).then(() => undefined),
remove: async (v) => { remove: async (v) => {
const ok = await confirm({ const ok = await confirm({
@@ -177,14 +178,14 @@ export function useSavedViewsStore(cfg: SavedViewsConfig): SavedViewsContext {
}); });
if (!ok) return false; if (!ok) return false;
if (stickyId === v.id) setStickyId(null); if (stickyId === v.id) setStickyId(null);
return runViewMutation(() => api.deleteView(v.id), t("views.actionFailed")); return runViewMutation(() => api.deleteView(v.id), msg("views.actionFailed"));
}, },
reorder: async (nextIds) => { reorder: async (nextIds) => {
qc.setQueryData<SavedView[]>( qc.setQueryData<SavedView[]>(
qk.savedViews(source), qk.savedViews(source),
nextIds.map((id) => views.find((v) => v.id === id)!).filter(Boolean), nextIds.map((id) => views.find((v) => v.id === id)!).filter(Boolean),
); );
await runViewMutation(() => api.reorderViews(nextIds), t("views.reorderFailed")); await runViewMutation(() => api.reorderViews(nextIds), msg("views.reorderFailed"));
}, },
}; };
} }
@@ -30,10 +30,11 @@ vi.mock("../../lib/downloadQueue", () => ({ showInTray: (id: number) => showInTr
const notifyActionError = vi.fn(); const notifyActionError = vi.fn();
vi.mock("../../lib/actionErrors", async (importOriginal) => { vi.mock("../../lib/actionErrors", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/actionErrors")>(); const actual = await importOriginal<typeof import("../../lib/actionErrors")>();
return { ...actual, notifyActionError: (e: unknown, t: string) => notifyActionError(e, t) }; return { ...actual, notifyActionError: (e: unknown, t: NotifText) => notifyActionError(e, t) };
}); });
import ClipPanel, { type ClipCrop, type ClipSource } from "./ClipPanel"; import ClipPanel, { type ClipCrop, type ClipSource } from "./ClipPanel";
import { msg, type NotifText } from "../../lib/notifications";
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
@@ -188,7 +189,7 @@ describe("ClipPanel", () => {
display_name: "The bit", display_name: "The bit",
}); });
expect(notify).toHaveBeenCalledWith( expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "success", message: "clip.queued" }), expect.objectContaining({ level: "success", message: msg("clip.queued") }),
); );
// Its View opens the tray on the new clip. // Its View opens the tray on the new clip.
( (
@@ -214,7 +215,7 @@ describe("ClipPanel", () => {
fireEvent.click(screen.getByRole("button", { name: "clip.cut" })); fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
await waitFor(() => expect(notifyActionError).toHaveBeenCalledTimes(1)); await waitFor(() => expect(notifyActionError).toHaveBeenCalledTimes(1));
expect(notifyActionError.mock.calls[0]![0]).toBe(err); expect(notifyActionError.mock.calls[0]![0]).toBe(err);
expect(notifyActionError.mock.calls[0]![1]).toBe("clip.failed"); expect(notifyActionError.mock.calls[0]![1]).toEqual(msg("clip.failed"));
expect(onClose).not.toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled();
expect((screen.getByRole("button", { name: "clip.cut" }) as HTMLButtonElement).disabled).toBe( expect((screen.getByRole("button", { name: "clip.cut" }) as HTMLButtonElement).disabled).toBe(
false, false,
+4 -4
View File
@@ -5,7 +5,7 @@ import { Crop, Crosshair, Maximize, Minus, Plus, Repeat, Scissors, X } from "luc
import { api, type ClipRequest } from "../../lib/api"; import { api, type ClipRequest } from "../../lib/api";
import { DISPLAY_NAME_MAX } from "../../lib/api/downloads"; import { DISPLAY_NAME_MAX } from "../../lib/api/downloads";
import { invalidateDownloads } from "../../lib/downloads"; import { invalidateDownloads } from "../../lib/downloads";
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
import { showInTray } from "../../lib/downloadQueue"; import { showInTray } from "../../lib/downloadQueue";
import { log } from "../../lib/log"; import { log } from "../../lib/log";
@@ -197,14 +197,14 @@ export default function ClipPanel({
log.info("clip.queued", "clip queued", { source: src.source, seconds: Math.round(length) }); log.info("clip.queued", "clip queued", { source: src.source, seconds: Math.round(length) });
notify({ notify({
level: "success", level: "success",
message: t("clip.queued"), message: msg("clip.queued"),
action: { label: t("clip.view"), onClick: () => showInTray(job.id) }, action: { label: msg("clip.view"), onClick: () => showInTray(job.id) },
}); });
onClose(); onClose();
} catch (err) { } catch (err) {
// A refusal the server explains (too long, quota) is already in the error dialog; a lost // A refusal the server explains (too long, quota) is already in the error dialog; a lost
// connection or a 404 is not, and must not leave the panel looking as if nothing happened. // connection or a 404 is not, and must not leave the panel looking as if nothing happened.
notifyActionError(err, t("clip.failed")); notifyActionError(err, msg("clip.failed"));
setBusy(false); setBusy(false);
} }
}; };
@@ -1,5 +1,4 @@
import { lazy, Suspense, useState } from "react"; import { lazy, Suspense, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { type Video, type VideoStatus } from "../../lib/api"; import { type Video, type VideoStatus } from "../../lib/api";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
@@ -7,6 +6,7 @@ import { notifyActionError } from "../../lib/actionErrors";
import { useStableCallback } from "../../lib/useLatest"; import { useStableCallback } from "../../lib/useLatest";
import { writeVideoState } from "../video/videoWrites"; import { writeVideoState } from "../video/videoWrites";
import { useOpenLinkedPlayer } from "./useOpenLinkedPlayer"; import { useOpenLinkedPlayer } from "./useOpenLinkedPlayer";
import { msg } from "../../lib/notifications";
const PlayerModal = lazy(() => import("./PlayerModal")); const PlayerModal = lazy(() => import("./PlayerModal"));
@@ -14,7 +14,6 @@ const PlayerModal = lazy(() => import("./PlayerModal"));
* channel manager, where the watched-videos dialog played it; any page a link names): the video * channel manager, where the watched-videos dialog played it; any page a link names): the video
* alone. Decided at boot, once — `hosted` is whether the page on screen then opens it itself. */ * alone. Decided at boot, once — `hosted` is whether the page on screen then opens it itself. */
export function LinkedPlayer({ hosted }: { hosted: boolean }) { export function LinkedPlayer({ hosted }: { hosted: boolean }) {
const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const [own] = useState(!hosted); const [own] = useState(!hosted);
const [video, setVideo] = useState<Video | null>(null); const [video, setVideo] = useState<Video | null>(null);
@@ -27,7 +26,7 @@ export function LinkedPlayer({ hosted }: { hosted: boolean }) {
const write = writeVideoState(qc, id, status); const write = writeVideoState(qc, id, status);
write write
.then(() => qc.invalidateQueries({ queryKey: qk.feed() })) .then(() => qc.invalidateQueries({ queryKey: qk.feed() }))
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState"))); .catch((e: unknown) => notifyActionError(e, msg("errors.writeFailed.videoState")));
return write; return write;
}); });
const close = useStableCallback(() => setVideo(null)); const close = useStableCallback(() => setVideo(null));
@@ -40,7 +40,7 @@ import { closeKeyboardHelp, toggleKeyboardHelp } from "../../lib/keymap";
import { ConfirmProvider } from "../../components/ui/ConfirmProvider"; import { ConfirmProvider } from "../../components/ui/ConfirmProvider";
import { useConfirm } from "../../components/ui/confirmContext"; import { useConfirm } from "../../components/ui/confirmContext";
import { api, type Me, type Video, type VideoStatus } from "../../lib/api"; import { api, type Me, type Video, type VideoStatus } from "../../lib/api";
import { clearAll, notify } from "../../lib/notifications"; import { clearAll, notify, raw } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { writeVideoState } from "../video/videoWrites"; import { writeVideoState } from "../video/videoWrites";
@@ -314,7 +314,7 @@ describe("PlayerModal — toasts raised while it is open", () => {
onState={() => Promise.resolve()} onState={() => Promise.resolve()}
/>, />,
); );
act(() => void notify({ level: "error", message: "Couldn't remove the video." })); act(() => void notify({ level: "error", message: raw("Couldn't remove the video.") }));
expect(screen.getByTestId("player-modal").textContent).toContain("Couldn't remove the video."); expect(screen.getByTestId("player-modal").textContent).toContain("Couldn't remove the video.");
}); });
@@ -336,7 +336,7 @@ describe("PlayerModal — toasts raised while it is open", () => {
Object.defineProperty(document, "fullscreenElement", { configurable: true, get: () => stage }); Object.defineProperty(document, "fullscreenElement", { configurable: true, get: () => stage });
try { try {
act(() => void document.dispatchEvent(new Event("fullscreenchange"))); act(() => void document.dispatchEvent(new Event("fullscreenchange")));
act(() => void notify({ level: "error", message: "Connection lost" })); act(() => void notify({ level: "error", message: raw("Connection lost") }));
await waitFor(() => expect(stage.textContent).toContain("Connection lost")); await waitFor(() => expect(stage.textContent).toContain("Connection lost"));
} finally { } finally {
delete (document as { fullscreenElement?: unknown }).fullscreenElement; delete (document as { fullscreenElement?: unknown }).fullscreenElement;
@@ -26,6 +26,7 @@ import VideoCard from "../video/VideoCard";
import { VideoMenu } from "../video/VideoMenu"; import { VideoMenu } from "../video/VideoMenu";
import { announceVideoState } from "../video/stateNotice"; import { announceVideoState } from "../video/stateNotice";
import { writeVideoState, writeWatchLater } from "../video/videoWrites"; import { writeVideoState, writeWatchLater } from "../video/videoWrites";
import { msg } from "../../lib/notifications";
/** Which list YouTube fills in — on the video's topic or with what trends in Hungary — is a mood, /** Which list YouTube fills in — on the video's topic or with what trends in Hungary — is a mood,
* decided at the list, and the account keeps the last one (the owner's pick). */ * decided at the list, and the account keeps the last one (the owner's pick). */
@@ -100,7 +101,7 @@ export function RelatedVideos({
announceVideoState(id, status, v, () => onState(id, "new")); announceVideoState(id, status, v, () => onState(id, "new"));
}) })
.catch((e: unknown) => { .catch((e: unknown) => {
notifyActionError(e, t("errors.writeFailed.videoState")); notifyActionError(e, msg("errors.writeFailed.videoState"));
settle(seq); settle(seq);
}); });
}; };
@@ -118,7 +119,7 @@ export function RelatedVideos({
.catch((e: unknown) => { .catch((e: unknown) => {
notifyActionError( notifyActionError(
e, e,
t(saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove"), msg(saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove"),
); );
settle(seq); settle(seq);
}); });
@@ -26,6 +26,7 @@ import { useOpenLinkedPlayer } from "./useOpenLinkedPlayer";
import { LinkedPlayer } from "./LinkedPlayer"; import { LinkedPlayer } from "./LinkedPlayer";
import { api, type Video } from "../../lib/api"; import { api, type Video } from "../../lib/api";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
import { msg } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
const vid = (id: string) => ({ id, title: id }) as unknown as Video; const vid = (id: string) => ({ id, title: id }) as unknown as Video;
@@ -76,7 +77,7 @@ describe("useOpenLinkedPlayer", () => {
const open = vi.fn(); const open = vi.fn();
renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper }); renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper });
await waitFor(() => await waitFor(() =>
expect(notifyActionError).toHaveBeenCalledWith(gone, "player.linkedFailed"), expect(notifyActionError).toHaveBeenCalledWith(gone, msg("player.linkedFailed")),
); );
expect(open).not.toHaveBeenCalled(); expect(open).not.toHaveBeenCalled();
// Said once: the address no longer names it, so a reload or a copied link does not say it again. // Said once: the address no longer names it, so a reload or a copied link does not say it again.
@@ -1,11 +1,11 @@
import { useEffect, useEffectEvent, useRef, useState } from "react"; import { useEffect, useEffectEvent, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { api, type Video } from "../../lib/api"; import { api, type Video } from "../../lib/api";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { linkedPlayer, PLAYER_KEYS } from "../../lib/playerUrlState"; import { linkedPlayer, PLAYER_KEYS } from "../../lib/playerUrlState";
import { clearParams } from "../../lib/urlState"; import { clearParams } from "../../lib/urlState";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
import { msg } from "../../lib/notifications";
/** Opens the player an address names (a reload, a bookmark — lib/playerUrlState) on the page that /** Opens the player an address names (a reload, a bookmark — lib/playerUrlState) on the page that
* hosts it, once: read as the host mounts, opened once its list is in (`ready`). From the list when the * hosts it, once: read as the host mounts, opened once its list is in (`ready`). From the list when the
@@ -22,7 +22,6 @@ export function useOpenLinkedPlayer(
open: (video: Video, inList: boolean) => void, open: (video: Video, inList: boolean) => void,
scope: string | number | null = null, scope: string | number | null = null,
): void { ): void {
const { t } = useTranslation();
const [linked] = useState(() => linkedPlayer()?.watch ?? null); const [linked] = useState(() => linkedPlayer()?.watch ?? null);
const [linkedScope] = useState(scope); const [linkedScope] = useState(scope);
// Opened, said to be gone or given up, once. A ref for the effect (a StrictMode re-run of it must // Opened, said to be gone or given up, once. A ref for the effect (a StrictMode re-run of it must
@@ -40,7 +39,7 @@ export function useOpenLinkedPlayer(
}); });
const openIt = useEffectEvent(open); const openIt = useEffectEvent(open);
const failed = useEffectEvent((err: unknown) => { const failed = useEffectEvent((err: unknown) => {
notifyActionError(err, t("player.linkedFailed")); notifyActionError(err, msg("player.linkedFailed"));
clearParams(...PLAYER_KEYS); clearParams(...PLAYER_KEYS);
}); });
useEffect(() => { useEffect(() => {
@@ -8,6 +8,7 @@ import { PopoverSurface } from "../../components/ui/AnchoredPopover";
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks"; import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
import { MembershipMenu } from "../../components/ui/MembershipMenu"; import { MembershipMenu } from "../../components/ui/MembershipMenu";
import { SURFACE } from "../../components/ui/surfaces"; import { SURFACE } from "../../components/ui/surfaces";
import { msg } from "../../lib/notifications";
// "Add to collection…" from a movie or show card's menu: a panel beside the card that ticks the title // "Add to collection…" from a movie or show card's menu: a panel beside the card that ticks the title
// into or out of its library's manual collections, and names a new one — created with this title in // into or out of its library's manual collections, and names a new one — created with this title in
@@ -67,7 +68,7 @@ export default function PlexCollectionAddAt({
else await api.plexCollectionAddItem(c.id, ratingKey); else await api.plexCollectionAddItem(c.id, ratingKey);
qc.setQueryData<typeof list.data>(forKey, (old) => flipContains(old, c.id)); qc.setQueryData<typeof list.data>(forKey, (old) => flipContains(old, c.id));
} catch (e) { } catch (e) {
notifyActionError(e, t("plex.collection.editFailed")); notifyActionError(e, msg("plex.collection.editFailed"));
} }
refresh(); refresh();
setBusy(false); setBusy(false);
@@ -83,7 +84,7 @@ export default function PlexCollectionAddAt({
made = true; made = true;
} catch (e) { } catch (e) {
made = madeOnPlex(e); made = madeOnPlex(e);
notifyActionError(e, t("plex.collection.editFailed")); notifyActionError(e, msg("plex.collection.editFailed"));
} }
refresh(); refresh();
setBusy(false); setBusy(false);
@@ -12,6 +12,7 @@ import Modal from "../../components/ui/Modal";
import { Pressable } from "../../components/ui/Pressable"; import { Pressable } from "../../components/ui/Pressable";
import { Input } from "../../components/ui/Input"; import { Input } from "../../components/ui/Input";
import { SURFACE } from "../../components/ui/surfaces"; import { SURFACE } from "../../components/ui/surfaces";
import { msg } from "../../lib/notifications";
// What we're adding: a single leaf (movie/episode) or a whole group (a season or an entire show, as a // What we're adding: a single leaf (movie/episode) or a whole group (a season or an entire show, as a
// set of episode rating_keys). Groups get a tri-state (none / some / all) per playlist. // set of episode rating_keys). Groups get a tri-state (none / some / all) per playlist.
@@ -80,7 +81,7 @@ export default function PlexPlaylistAdd({
} catch (e) { } catch (e) {
notifyActionError( notifyActionError(
e, e,
t(allIn ? "plex.playlistAdd.removeFailed" : "plex.playlistAdd.addFailed"), msg(allIn ? "plex.playlistAdd.removeFailed" : "plex.playlistAdd.addFailed"),
); );
} }
refresh(); refresh();
@@ -104,7 +105,7 @@ export default function PlexPlaylistAdd({
} catch (e) { } catch (e) {
// The create may already have landed when the add fails — `refresh()` in `finally` is what // The create may already have landed when the add fails — `refresh()` in `finally` is what
// makes that half-done state visible instead of leaving a playlist the dialog never lists. // makes that half-done state visible instead of leaving a playlist the dialog never lists.
notifyActionError(e, t("plex.playlistAdd.createFailed")); notifyActionError(e, msg("plex.playlistAdd.createFailed"));
} }
refresh(); refresh();
setBusy(false); setBusy(false);
@@ -74,6 +74,8 @@ describe("a season's name", () => {
["Season 1", 1, "plex.seasonTitle(1)"], ["Season 1", 1, "plex.seasonTitle(1)"],
["Specials", 0, "plex.specials"], ["Specials", 0, "plex.specials"],
["Book One: Water", 1, "Book One: Water"], ["Book One: Water", 1, "Book One: Water"],
["Season 4", null, "Season 4"],
["", null, "plex.playlist.seasonUnknown"],
])("Plex's %j (season %s) reads %j", (title, n, shown) => { ])("Plex's %j (season %s) reads %j", (title, n, shown) => {
render(<SeasonCard se={season(title, n)} onOpen={vi.fn()} />); render(<SeasonCard se={season(title, n)} onOpen={vi.fn()} />);
expect(screen.getByText(shown)).toBeTruthy(); expect(screen.getByText(shown)).toBeTruthy();
@@ -10,6 +10,7 @@ import { pinPlex, useComfortPin } from "../comfort/comfortPin";
import { useCopyItems } from "../../components/ui/copyItems"; import { useCopyItems } from "../../components/ui/copyItems";
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd"; import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
import { type ActionMenuItem } from "../../components/ui/ActionMenu"; import { type ActionMenuItem } from "../../components/ui/ActionMenu";
import { raw } from "../../lib/notifications";
// A Plex title's ONE action list (docs/DESIGN.md "Action placement"): the right-click, the // A Plex title's ONE action list (docs/DESIGN.md "Action placement"): the right-click, the
// long-press, Shift+F10 and the card's "…" open a menu built here, so every poster grid and rail // long-press, Shift+F10 and the card's "…" open a menu built here, so every poster grid and rail
@@ -151,7 +152,7 @@ export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean)
] ]
: []), : []),
...copy(c.title, null).map((it) => ({ ...it, separatorBefore: !onInfo })), ...copy(c.title, null).map((it) => ({ ...it, separatorBefore: !onInfo })),
...(withPin ? pin.item(pinPlex(c), c.title) : []), ...(withPin ? pin.item(pinPlex(c), raw(c.title)) : []),
]; ];
}; };
@@ -13,6 +13,7 @@ import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
import { IconButton } from "../../components/ui/IconButton"; import { IconButton } from "../../components/ui/IconButton";
import { MembershipMenu } from "../../components/ui/MembershipMenu"; import { MembershipMenu } from "../../components/ui/MembershipMenu";
import { SURFACE } from "../../components/ui/surfaces"; import { SURFACE } from "../../components/ui/surfaces";
import { msg } from "../../lib/notifications";
// A small anchored menu for toggling a video's membership in the user's playlists, with inline // A small anchored menu for toggling a video's membership in the user's playlists, with inline
// "new playlist" creation. Its body is the shared MembershipMenu (as the channel tag editor's). // "new playlist" creation. Its body is the shared MembershipMenu (as the channel tag editor's).
@@ -122,7 +123,7 @@ function MembershipPanel({
old?.map((p) => (p.id === pl.id ? { ...p, has_video: !p.has_video } : p)), old?.map((p) => (p.id === pl.id ? { ...p, has_video: !p.has_video } : p)),
); );
} catch (e) { } catch (e) {
notifyActionError(e, t(pl.has_video ? "playlists.removeFailed" : "playlists.addFailed")); notifyActionError(e, msg(pl.has_video ? "playlists.removeFailed" : "playlists.addFailed"));
} }
refresh(); refresh();
setBusy(false); setBusy(false);
@@ -137,7 +138,7 @@ function MembershipPanel({
await addPlaylistItem(qc, pl, videoId); await addPlaylistItem(qc, pl, videoId);
made = true; made = true;
} catch (e) { } catch (e) {
notifyActionError(e, t("playlists.addFailed")); notifyActionError(e, msg("playlists.addFailed"));
} }
refresh(); refresh();
setBusy(false); setBusy(false);
+4 -4
View File
@@ -1,5 +1,5 @@
import i18n from "../../i18n"; import i18n from "../../i18n";
import { notify, resolveVideo } from "../../lib/notifications"; import { msg, notify, raw, resolveVideo } from "../../lib/notifications";
import type { Video, VideoStatus } from "../../lib/api"; import type { Video, VideoStatus } from "../../lib/api";
/** What a notice names the video by; any of it may be unknown to the surface that marked it. */ /** What a notice names the video by; any of it may be unknown to the surface that marked it. */
@@ -24,9 +24,9 @@ export function announceVideoState(
const hidden = status === "hidden"; const hidden = status === "hidden";
notify({ notify({
message: video?.title message: video?.title
? i18n.t(hidden ? "feed.hiddenNamed" : "feed.markedWatchedNamed", { title: video.title }) ? msg(hidden ? "feed.hiddenNamed" : "feed.markedWatchedNamed", { title: raw(video.title) })
: i18n.t(hidden ? "feed.hidden" : "feed.markedWatched"), : msg(hidden ? "feed.hidden" : "feed.markedWatched"),
action: { label: i18n.t(hidden ? "feed.undo" : "feed.unwatch"), onClick: undo }, action: { label: msg(hidden ? "feed.undo" : "feed.unwatch"), onClick: undo },
meta: { meta: {
kind: hidden ? "video-hidden" : "video-watched", kind: hidden ? "video-hidden" : "video-watched",
videoId: id, videoId: id,
@@ -27,6 +27,7 @@ import { useCopyItems } from "../../components/ui/copyItems";
import { useNavigationActions } from "../navigation/navigationContext"; import { useNavigationActions } from "../navigation/navigationContext";
import { type ActionMenuItem } from "../../components/ui/ActionMenu"; import { type ActionMenuItem } from "../../components/ui/ActionMenu";
import { VideoFollowUp, type VideoFollowUpState } from "./VideoFollowUp"; import { VideoFollowUp, type VideoFollowUpState } from "./VideoFollowUp";
import { raw } from "../../lib/notifications";
// A video's ONE action list (docs/DESIGN.md "Action placement"). The right-click, the // A video's ONE action list (docs/DESIGN.md "Action placement"). The right-click, the
// long-press, Shift+F10 and the card's "…" all open a menu built here, so the feed, a channel page // long-press, Shift+F10 and the card's "…" all open a menu built here, so the feed, a channel page
@@ -198,7 +199,7 @@ export function useVideoActions(handlers: VideoActionHandlers, open: boolean) {
}, },
] ]
: []), : []),
...(withPin ? pin.item(pinVideo(v.id), v.title ?? "") : []), ...(withPin ? pin.item(pinVideo(v.id), raw(v.title ?? "")) : []),
]; ];
}; };
@@ -23,7 +23,7 @@ vi.mock("../../lib/onboarding", () => ({ beginGrant: vi.fn(), endOnboarding: vi.
import OnboardingWizard from "./OnboardingWizard"; import OnboardingWizard from "./OnboardingWizard";
import { api, type Me } from "../../lib/api"; import { api, type Me } from "../../lib/api";
import { clearAll, notify } from "../../lib/notifications"; import { clearAll, notify, raw } from "../../lib/notifications";
const me = { id: 1, email: "a@b", can_read: false, can_write: false } as unknown as Me; const me = { id: 1, email: "a@b", can_read: false, can_write: false } as unknown as Me;
@@ -110,7 +110,7 @@ describe("the onboarding wizard as a dialog", () => {
document.body.appendChild(outside); document.body.appendChild(outside);
outside.focus(); outside.focus();
const { unmount } = mount(); const { unmount } = mount();
act(() => void notify({ level: "error", message: "offline" })); act(() => void notify({ level: "error", message: raw("offline") }));
const dismissToast = screen.getByTitle("notifications.dismiss"); const dismissToast = screen.getByTitle("notifications.dismiss");
dismissToast.focus(); dismissToast.focus();
expect(document.activeElement).toBe(dismissToast); expect(document.activeElement).toBe(dismissToast);
@@ -126,7 +126,7 @@ describe("the onboarding wizard as a dialog", () => {
// unreachable from the keyboard. `ui/Modal` spans the whole dialog for exactly this reason. // unreachable from the keyboard. `ui/Modal` spans the whole dialog for exactly this reason.
mount(); mount();
const panel = screen.getByRole("dialog"); const panel = screen.getByRole("dialog");
act(() => void notify({ level: "error", message: "offline" })); act(() => void notify({ level: "error", message: raw("offline") }));
try { try {
const dismissToast = screen.getByTitle("notifications.dismiss"); const dismissToast = screen.getByTitle("notifications.dismiss");
expect(panel.contains(dismissToast)).toBe(false); expect(panel.contains(dismissToast)).toBe(false);
@@ -89,7 +89,7 @@ const quotaActions = enumValues("quota.py");
// ruff wraps over several lines still matches. // ruff wraps over several lines still matches.
const adminCodes = [ const adminCodes = [
...( ...(
readFileSync(new URL("../lib/api/core.ts", import.meta.url), "utf-8").match( readFileSync(new URL("../lib/api/refusalText.ts", import.meta.url), "utf-8").match(
/ADMIN_REFUSALS = new Set\(\[([^\]]*)\]/, /ADMIN_REFUSALS = new Set\(\[([^\]]*)\]/,
)?.[1] ?? "" )?.[1] ?? ""
).matchAll(/"(\w+)"/g), ).matchAll(/"(\w+)"/g),
+1
View File
@@ -344,6 +344,7 @@
"playShow": "Play the show", "playShow": "Play the show",
"playSeason": "Play the season", "playSeason": "Play the season",
"removeFromPlaylist": "Remove from the playlist", "removeFromPlaylist": "Remove from the playlist",
"seasonOfShow": "{{show}} · {{season}}",
"removedToast": "“{{title}}” removed from the playlist", "removedToast": "“{{title}}” removed from the playlist",
"undoRemoveFailed": "Couldn't put it back in the playlist" "undoRemoveFailed": "Couldn't put it back in the playlist"
}, },
+1
View File
@@ -344,6 +344,7 @@
"playShow": "Sorozat lejátszása", "playShow": "Sorozat lejátszása",
"playSeason": "Évad lejátszása", "playSeason": "Évad lejátszása",
"removeFromPlaylist": "Eltávolítás a listáról", "removeFromPlaylist": "Eltávolítás a listáról",
"seasonOfShow": "{{show}} · {{season}}",
"removedToast": "„{{title}}” eltávolítva a listáról", "removedToast": "„{{title}}” eltávolítva a listáról",
"undoRemoveFailed": "Nem sikerült visszatenni a listára" "undoRemoveFailed": "Nem sikerült visszatenni a listára"
}, },
@@ -13,6 +13,7 @@ import {
reloadAfterRefusal, reloadAfterRefusal,
} from "./entryUrl"; } from "./entryUrl";
import { HttpError } from "./api/core"; import { HttpError } from "./api/core";
import { raw } from "./notifications";
const notice = (search: string, hash = "") => const notice = (search: string, hash = "") =>
accountSwitchNotice(new URLSearchParams(search), new URLSearchParams(hash)); accountSwitchNotice(new URLSearchParams(search), new URLSearchParams(hash));
@@ -27,13 +28,13 @@ describe("a switch of account, told to the account signed in", () => {
expect(notice("", "access=requested&email=b%2Btag%40example.com")).toEqual({ expect(notice("", "access=requested&email=b%2Btag%40example.com")).toEqual({
level: "info", level: "info",
key: "header.account.switchRequested", key: "header.account.switchRequested",
values: { email: "b+tag@example.com" }, values: { email: raw("b+tag@example.com") },
}); });
}); });
it("names the address the request lives under when that is another spelling", () => { it("names the address the request lives under when that is another spelling", () => {
expect(notice("", "access=requested&email=bob%40x.com&linked=b.ob%40x.com")?.values).toEqual({ expect(notice("", "access=requested&email=bob%40x.com&linked=b.ob%40x.com")?.values).toEqual({
email: "b.ob@x.com", email: raw("b.ob@x.com"),
}); });
}); });
+46 -24
View File
@@ -4,12 +4,16 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
// Spy on the toast sink so we can assert exactly what each error maps to. // Spy on the toast sink so we can assert exactly what each error maps to.
vi.mock("./notifications", () => ({ notify: vi.fn(() => 0) })); vi.mock("./notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("./notifications")>()),
notify: vi.fn(() => 0),
}));
vi.mock("./errorDialog", () => ({ reportError: vi.fn() })); vi.mock("./errorDialog", () => ({ reportError: vi.fn() }));
import { HttpError, api } from "./api"; import { HttpError, api } from "./api";
import { alreadyReported } from "./api/core"; import { alreadyReported } from "./api/core";
import { notify } from "./notifications"; import { msg, notify, raw, sayNotif } from "./notifications";
import { localizeDetail } from "./api/refusalText";
import { reportError } from "./errorDialog"; import { reportError } from "./errorDialog";
import { failureReason, notifyActionError, notifyKnownFailure } from "./actionErrors"; import { failureReason, notifyActionError, notifyKnownFailure } from "./actionErrors";
import i18n from "../i18n"; import i18n from "../i18n";
@@ -32,10 +36,19 @@ describe("a Plex collection name that is already taken", () => {
); );
const err = await api.plexCreateCollection("1", "Marvel", "m1").catch((e: unknown) => e); const err = await api.plexCreateCollection("1", "Marvel", "m1").catch((e: unknown) => e);
notifyMock.mockClear(); notifyMock.mockClear();
notifyActionError(err, "EDIT_FAILED"); notifyActionError(err, raw("EDIT_FAILED"));
expect(notifyMock.mock.calls).toEqual([ expect(notifyMock.mock.calls).toEqual([
[{ level: "error", title: "EDIT_FAILED", message: i18n.t("plex.collection.nameTaken") }], [
{
level: "error",
title: raw("EDIT_FAILED"),
message: { detail: { code: "collection", reason: "name_taken" } },
},
],
]); ]);
expect(sayNotif(notifyMock.mock.calls[0]![0].message)).toBe(
i18n.t("plex.collection.nameTaken"),
);
expect(reportError).not.toHaveBeenCalled(); expect(reportError).not.toHaveBeenCalled();
}); });
}); });
@@ -52,26 +65,26 @@ describe("notifyActionError", () => {
); );
const err = await api.plexSetState("1", "watched").catch((e: unknown) => e); const err = await api.plexSetState("1", "watched").catch((e: unknown) => e);
notifyMock.mockClear(); notifyMock.mockClear();
notifyActionError(err, "FAILED"); notifyActionError(err, raw("FAILED"));
expect(notifyMock).not.toHaveBeenCalled(); expect(notifyMock).not.toHaveBeenCalled();
}); });
it("does toast one the caller was left to handle", () => { it("does toast one the caller was left to handle", () => {
notifyActionError(new HttpError(404, "gone"), "FAILED"); notifyActionError(new HttpError(404, "gone"), raw("FAILED"));
expect(notifyMock).toHaveBeenCalled(); expect(notifyMock).toHaveBeenCalled();
}); });
it("reports one failure once, however many callers try", () => { it("reports one failure once, however many callers try", () => {
// What keeps the app-wide mutation reporter quiet for a mutation whose own onError already spoke. // What keeps the app-wide mutation reporter quiet for a mutation whose own onError already spoke.
const err = new HttpError(404, "gone"); const err = new HttpError(404, "gone");
notifyActionError(err, "FIRST"); notifyActionError(err, raw("FIRST"));
notifyActionError(err, "SECOND"); notifyActionError(err, raw("SECOND"));
expect(notifyMock).toHaveBeenCalledTimes(1); expect(notifyMock).toHaveBeenCalledTimes(1);
expect(alreadyReported(err)).toBe(true); expect(alreadyReported(err)).toBe(true);
}); });
it("says nothing about an upload the user cancelled", () => { it("says nothing about an upload the user cancelled", () => {
notifyActionError(new HttpError(0, "aborted"), "FAILED"); notifyActionError(new HttpError(0, "aborted"), raw("FAILED"));
expect(notifyMock).not.toHaveBeenCalled(); expect(notifyMock).not.toHaveBeenCalled();
}); });
@@ -79,27 +92,32 @@ describe("notifyActionError", () => {
// `reportFailure` leaves 403/404/429 to the caller, so for exactly those statuses whatever // `reportFailure` leaves 403/404/429 to the caller, so for exactly those statuses whatever
// English a route hand-wrote went straight into the toast — "No user with that email." in an // English a route hand-wrote went straight into the toast — "No user with that email." in an
// otherwise Hungarian UI, while the caller's translated fallback sat unused. // otherwise Hungarian UI, while the caller's translated fallback sat unused.
notifyActionError(new HttpError(404, "No user with that email."), "TRANSLATED"); notifyActionError(new HttpError(404, "No user with that email."), raw("TRANSLATED"));
expect(notifyMock).toHaveBeenCalledWith({ expect(notifyMock).toHaveBeenCalledWith({
level: "error", level: "error",
title: "TRANSLATED", title: raw("TRANSLATED"),
message: i18n.t("errors.notFound.generic"), message: msg("errors.notFound.generic"),
}); });
}); });
it("still prefers a STRUCTURED reason, which the request layer has already localized", () => { it("still prefers a STRUCTURED reason, kept as the refusal and said in the reader's language", () => {
// `req` runs `{code, reason}` through localizeDetail, so `detail` is in the reader's language // The notice keeps `{code, reason}` and says it through localizeDetail when shown: in the
// AND more specific than anything a status can say — quota numbers, which row is gone. // reader's language AND more specific than anything a status can say — quota numbers, which row
// is gone.
const err = new HttpError(404, "Ez a csatorna már nincs a könyvtárban.", { const err = new HttpError(404, "Ez a csatorna már nincs a könyvtárban.", {
code: "not_found", code: "not_found",
reason: "channel", reason: "channel",
}); });
notifyActionError(err, "TRANSLATED FALLBACK"); notifyActionError(err, raw("TRANSLATED FALLBACK"));
expect(notifyMock).toHaveBeenCalledWith({ expect(notifyMock).toHaveBeenCalledWith({
level: "error", level: "error",
title: "TRANSLATED FALLBACK", title: raw("TRANSLATED FALLBACK"),
message: "Ez a csatorna már nincs a könyvtárban.", message: { detail: { code: "not_found", reason: "channel" } },
}); });
// Said at display through localizeDetail, in the language of that moment.
expect(sayNotif(notifyMock.mock.calls[0]![0].message)).toBe(
localizeDetail({ code: "not_found", reason: "channel" }),
);
}); });
}); });
@@ -114,17 +132,21 @@ describe("failureReason — why a write failed, when the server did not say", ()
[0, "errors.reason.unreachable"], // uploadReq's network failure [0, "errors.reason.unreachable"], // uploadReq's network failure
[500, "errors.generic"], [500, "errors.generic"],
])("a %s reads %s", (status, key) => { ])("a %s reads %s", (status, key) => {
expect(failureReason(new HttpError(status, "hand-written English"))).toBe(i18n.t(key)); const reason = failureReason(new HttpError(status, "hand-written English"));
expect(reason).toEqual(msg(key));
expect(sayNotif(reason)).toBe(i18n.t(key));
}); });
it("a request that never reached the server reads as unreachable", () => { it("a request that never reached the server reads as unreachable", () => {
expect(failureReason(new TypeError("Failed to fetch"))).toBe( expect(sayNotif(failureReason(new TypeError("Failed to fetch")))).toBe(
i18n.t("errors.reason.unreachable"), i18n.t("errors.reason.unreachable"),
); );
}); });
it("anything else is the app's own fault, not the server's", () => { it("anything else is the app's own fault, not the server's", () => {
expect(failureReason(new Error("key import failed"))).toBe(i18n.t("errors.reason.unexpected")); expect(sayNotif(failureReason(new Error("key import failed")))).toBe(
i18n.t("errors.reason.unexpected"),
);
}); });
it("every reason exists in both languages", () => { it("every reason exists in both languages", () => {
@@ -142,9 +164,9 @@ describe("failureReason — why a write failed, when the server did not say", ()
describe("notifyKnownFailure", () => { describe("notifyKnownFailure", () => {
it("shows the caller's specific sentence and marks the failure reported", () => { it("shows the caller's specific sentence and marks the failure reported", () => {
const err = new HttpError(409, "x", { code: "resend", reason: "email_off" }); const err = new HttpError(409, "x", { code: "resend", reason: "email_off" });
notifyKnownFailure(err, "EMAIL IS OFF"); notifyKnownFailure(err, raw("EMAIL IS OFF"));
expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: "EMAIL IS OFF" }); expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: raw("EMAIL IS OFF") });
notifyActionError(err, "GENERIC"); notifyActionError(err, raw("GENERIC"));
expect(notifyMock).toHaveBeenCalledTimes(1); expect(notifyMock).toHaveBeenCalledTimes(1);
}); });
}); });
+9 -11
View File
@@ -1,26 +1,24 @@
// From the core, not the `./api` barrel: these are pure (no request), and a test that mocks the // From the core, not the `./api` barrel: these are pure (no request), and a test that mocks the
// barrel wholesale to stub its requests must not take the error path's own plumbing with it. // barrel wholesale to stub its requests must not take the error path's own plumbing with it.
import { alreadyReported, HttpError, isAborted, markReported } from "./api/core"; import { alreadyReported, HttpError, isAborted, markReported } from "./api/core";
import { notify } from "./notifications"; import { msg, notify, type NotifText } from "./notifications";
import i18n from "../i18n";
const GATEWAY = new Set([502, 503, 504]); const GATEWAY = new Set([502, 503, 504]);
/** /**
* WHY a write failed, in the reader's language — the line under the toast's title. * WHY a write failed — the line under the toast's title, said in the reader's language when shown.
* *
* The server's own reason wins: `req` has already run the structured detail through * The server's own reason wins: the structured detail, which `localizeDetail` says, and it is the most specific thing there is to say (quota numbers, which row is
* `localizeDetail`, and it is the most specific thing there is to say (quota numbers, which row is
* gone). Every refusal the app answers is structured now (backend `app/refusal.py`), and a string * gone). Every refusal the app answers is structured now (backend `app/refusal.py`), and a string
* detail never reaches `HttpError.detail` (see there) — the hand-written English that once put * detail never reaches `HttpError.detail` (see there) — the hand-written English that once put
* "No user with that email." into a Hungarian UI has nowhere left to come from. `detailData` still * "No user with that email." into a Hungarian UI has nowhere left to come from. `detailData` still
* gates it: status-0 errors carry a local sentinel in `detail`, not a reason. Otherwise the status * gates it: status-0 errors carry a local sentinel in `detail`, not a reason. Otherwise the status
* says it. * says it.
*/ */
export function failureReason(err: unknown): string { export function failureReason(err: unknown): NotifText {
const t = i18n.t.bind(i18n); const t = msg;
if (err instanceof HttpError) { if (err instanceof HttpError) {
if (err.detailData != null && err.detail) return err.detail; if (err.detailData != null && err.detail) return { detail: err.detailData };
// 0: `uploadReq`'s network failure. 502–504: the gateway could not reach the app. Either way the // 0: `uploadReq`'s network failure. 502–504: the gateway could not reach the app. Either way the
// change did not go through, which the connection notice alone does not say. // change did not go through, which the connection notice alone does not say.
if (err.status === 0 || GATEWAY.has(err.status)) return t("errors.reason.unreachable"); if (err.status === 0 || GATEWAY.has(err.status)) return t("errors.reason.unreachable");
@@ -36,13 +34,13 @@ export function failureReason(err: unknown): string {
/** /**
* Surface a failed write as an error toast instead of the click silently doing nothing: the title * Surface a failed write as an error toast instead of the click silently doing nothing: the title
* names WHAT failed (the caller's translated "Couldn't hide the channel"), the line under it says * names WHAT failed (the caller's "Couldn't hide the channel"), the line under it says
* WHY (`failureReason`). Domain-agnostic — the one way every action failure is reported, including * WHY (`failureReason`). Domain-agnostic — the one way every action failure is reported, including
* the app-wide mutation reporter in lib/queryClient. * the app-wide mutation reporter in lib/queryClient.
*/ */
export function notifyActionError( export function notifyActionError(
err: unknown, err: unknown,
title: string, title: NotifText,
level: "error" | "warning" = "error", level: "error" | "warning" = "error",
): void { ): void {
// Already on screen (`req` opened the error dialog, the tab is being signed out, or a caller's own // Already on screen (`req` opened the error dialog, the tab is being signed out, or a caller's own
@@ -57,7 +55,7 @@ export function notifyActionError(
* instance cannot send email", "someone registered on that address") — shown as it is, and marked, * instance cannot send email", "someone registered on that address") — shown as it is, and marked,
* so the app-wide mutation reporter does not put a second, generic toast next to it. * so the app-wide mutation reporter does not put a second, generic toast next to it.
*/ */
export function notifyKnownFailure(err: unknown, message: string): void { export function notifyKnownFailure(err: unknown, message: NotifText): void {
if (alreadyReported(err)) return; if (alreadyReported(err)) return;
notify({ level: "error", message }); notify({ level: "error", message });
markReported(err); markReported(err);
+6 -1
View File
@@ -14,7 +14,12 @@ const { reportError, notify, remove, getActiveToasts } = vi.hoisted(() => ({
})); }));
vi.mock("../errorDialog", () => ({ reportError })); vi.mock("../errorDialog", () => ({ reportError }));
vi.mock("../notifications", () => ({ notify, remove, getActiveToasts })); vi.mock("../notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../notifications")>()),
notify,
remove,
getActiveToasts,
}));
vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" } })); vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" } }));
import { setDebugSession } from "../log"; import { setDebugSession } from "../log";
+4 -119
View File
@@ -2,9 +2,9 @@
// error, structured-error localization, the connectivity toast, the identity headers, // error, structured-error localization, the connectivity toast, the identity headers,
// and the keepalive beacon. Domain modules import from here; nothing here imports a domain module, // and the keepalive beacon. Domain modules import from here; nothing here imports a domain module,
// so the runtime graph is a clean barrel → domain → core with no cycles. // so the runtime graph is a clean barrel → domain → core with no cycles.
import { getActiveToasts, notify, remove } from "../notifications"; import { getActiveToasts, msg, notify, remove } from "../notifications";
import i18n from "../../i18n"; import i18n from "../../i18n";
import { formatBytes, formatDecimal } from "../format"; import { localizeDetail } from "./refusalText";
import { reportError } from "../errorDialog"; import { reportError } from "../errorDialog";
import { logRequest, QUIET_HEADER, REQUEST_ID_HEADER, ROUTE_HEADER, send } from "../http"; import { logRequest, QUIET_HEADER, REQUEST_ID_HEADER, ROUTE_HEADER, send } from "../http";
import { currentDebugSession, newRequestId } from "../log"; import { currentDebugSession, newRequestId } from "../log";
@@ -71,121 +71,6 @@ export const refusalReason = (err: unknown): string | undefined =>
? (err.detailData as { reason?: string } | undefined)?.reason ? (err.detailData as { reason?: string } | undefined)?.reason
: undefined; : undefined;
// Localize a STRUCTURED server error ({ code, reason, ... }) into the user's language. FastAPI
// usually returns a plain string `detail`, but some routes (e.g. download quota/edit) return a
// structured object with an i18n reason key + numbers so HU/DE users don't get hardcoded English
// with a dot-decimal separator. Falls back to a generic message for unknown shapes.
// The refusal codes of the admin's account and invite actions (routes/admin.py). Their reasons share
// one namespace, `errors.admin.<reason>`, which i18n/backendIds.contract checks against the backend.
const ADMIN_REFUSALS = new Set(["activate", "resend", "invite_send"]);
function localizeDetail(d: unknown): string {
const t = i18n.t.bind(i18n);
// FastAPI request-validation errors: `detail` is an ARRAY of { loc, msg, type }. The UI sends
// well-formed requests, so this only fires on genuinely malformed input. The per-field `msg` and
// `loc` field names are English, snake_case internal tokens — surface a clean, fully localized
// "check your input" message rather than leaking them into a translated sentence.
if (Array.isArray(d)) return t("errors.validation");
const o = d as
| { code?: string; reason?: string; limit?: number; limit_s?: number; min?: number }
| null
| undefined;
if (o?.code === "quota") {
if (o.reason === "max_bytes") {
const gb = formatDecimal((o.limit || 0) / 1_073_741_824, i18n.language, 1, 0);
return t("downloads.errors.quota.max_bytes", { size: gb });
}
if (o.reason === "max_jobs") return t("downloads.errors.quota.max_jobs", { limit: o.limit });
return t("downloads.errors.quota.generic");
}
if (o?.code === "edit")
return t(`downloads.errors.edit.${o.reason}`, t("downloads.errors.edit.generic"));
// A clip the server will not cut (downloads/clip): the range, the frame, the source. An unknown
// reason reads as the plain failure rather than a key; a cap comes with the refusal (`limit_s`).
if (o?.code === "clip")
return t(`clip.errors.${o.reason}`, {
defaultValue: t("clip.failed"),
minutes: o.limit_s == null ? undefined : formatDecimal(o.limit_s / 60, i18n.language, 1, 0),
});
if (o?.code === "cookies")
return t(`downloads.errors.cookies.${o.reason}`, t("downloads.errors.cookies.generic"));
// Password sign-in refusals. The card renders the account-shaped ones as a full panel
// off `detailData`; this is the one-line form every other consumer (and the card's own fallback)
// gets. A reason this build doesn't know falls through to the generic message rather than showing
// a raw key, which is what an i18n miss would otherwise put on screen.
if (o?.code === "login")
return t(`welcome.auth.blocked.${o.reason}.short`, t("welcome.auth.genericError"));
// The two email-link confirmations. Their failure is one a user meets by simply clicking an old
// link, so it is the last place that may answer in hardcoded English under a bilingual UI.
if (o?.code === "verify") return t("welcome.auth.verifyInvalid");
if (o?.code === "reset")
return o.reason === "weak_password"
? t("welcome.auth.weakPassword", { min: o.limit ?? o.min })
: t("welcome.auth.deadLink.short");
// Local-upload ingest: the per-file cap carries the limit in bytes; empty is a bare code.
if (o?.code === "upload_too_large")
return t("downloads.errors.upload.tooLarge", { size: formatBytes(o.limit, i18n.language) });
if (o?.code === "upload_empty") return t("downloads.errors.upload.empty");
// The EDGE ceiling's refusal (`bodylimit.TOO_LARGE_BODY`), shared by every route and carrying no
// number. Not a theoretical case: on a DEFAULT install it is what an over-cap upload gets,
// because the ceiling and the route's own cap are the same figure and the edge answers first —
// the route's numbered `upload_too_large` above is the answer only once an admin has LOWERED the
// knob (see bodylimit.ROUTE_MAX_BODY_BYTES). Without this branch that refusal fell through to
// the generic "couldn't carry out that action", the one message no retry can fix.
//
// A BACKSTOP, not the plan. The middleware answers without draining, so whether the browser gets
// to read this at all depends on the client having finished sending: measured readable up to
// 200 MiB over loopback, and measured as a bare `onerror` through a dev proxy. That is why the
// upload dialog checks the file against `upload_max_bytes` BEFORE sending — this branch is what
// happens when that check could not run.
//
// `errors.tooLarge` stays PAYLOAD-NEUTRAL for that reason. It is also what a JSON form gets
// (`debug_sessions` refuses an over-size draft with the same bare code), so wording it as advice
// about a file told the issue composer's user to pick a smaller one — of nothing. The
// file-specific sentence is `downloads.errors.upload.tooLarge`, one branch up, which has a number.
if (o?.code === "too_large") return t("errors.tooLarge");
// SEC-12: a download URL the server refuses to fetch. The reasons are deliberately distinct —
// "we could not look that host up" is a different thing to tell someone than "we will not fetch
// from that address" — so an unknown reason falls back to the generic refusal rather than a key.
if (o?.code === "url_blocked")
return t(`downloads.errors.urlBlocked.${o.reason}`, t("downloads.errors.urlBlocked.generic"));
// The one-step "move to another list". Both reasons mean the same thing to a reader —
// the list moved under you — but they are different enough to be worth naming separately, and an
// unknown reason falls back to the plain failure rather than printing a key.
if (o?.code === "playlist_move")
return t(`playlists.moveErrors.${o.reason}`, t("playlists.moveFailed"));
// A Plex collection name already taken in that library (the one collection-edit refusal).
if (o?.code === "collection")
return o.reason === "name_taken"
? t("plex.collection.nameTaken")
: t("plex.collection.editFailed");
// An action YouTube refused (routes/_common.youtube_refusal): what the reader can do about it —
// wait for the budget, reconnect, or retry. An unknown reason reads as the retryable one.
if (o?.code === "youtube")
return t(`errors.youtube.${o.reason}`, t("errors.youtube.unavailable"));
// Every other refusal (backend `app/refusal.py`): one family, the reason naming the sentence and
// the rest of the detail its values (`{{min}}`, `{{name}}`, …). A reason this build does not know
// reads as the generic failure rather than a key.
if (o?.code === "refused")
return t(`errors.refused.${o.reason}`, { ...o, defaultValue: t("errors.generic") });
// A playlist action the server refused (Watch later can't be deleted, YouTube said no, …).
if (o?.code === "playlist") return t(`playlists.errors.${o.reason}`, t("errors.generic"));
// An admin action on an account or an invite that the server refused: activating an address that
// is no longer approved, a confirmation link or invitation with nothing to go to or no mail to go
// by. The toast's title already names the button; this is the WHY, and it used to be the generic
// "try again", which no retry could fix.
if (ADMIN_REFUSALS.has(o?.code ?? "")) return t(`errors.admin.${o?.reason}`, t("errors.generic"));
// Any row that is gone — deleted in another tab or device, or never the caller's (a stranger's
// row 404s the same way). `reason` names WHAT is gone; one this build doesn't know says it
// generically rather than printing a key.
if (o?.code === "not_found")
return t(`errors.notFound.${o.reason}`, t("errors.notFound.generic"));
// SEC-14: a manual sync trigger on cooldown. Not an error the user caused so much as a pace
// limit, and the copy says so.
if (o?.code === "sync_cooldown") return t("errors.syncCooldown");
return t("errors.generic");
}
// A single, self-resolving "connection lost" status: while the server is unreachable we // A single, self-resolving "connection lost" status: while the server is unreachable we
// show one sticky, transient (non-persisted) notice; the moment any request reaches the // show one sticky, transient (non-persisted) notice; the moment any request reaches the
// server again we remove it. This makes good on its "clears once it's back" copy and keeps // server again we remove it. This makes good on its "clears once it's back" copy and keeps
@@ -199,8 +84,8 @@ function markConnectivityLost(): void {
if (id !== null) remove(id); if (id !== null) remove(id);
connectivityNotifId = notify({ connectivityNotifId = notify({
level: "error", level: "error",
title: i18n.t("errors.offline.title"), title: msg("errors.offline.title"),
message: i18n.t("errors.offline.body"), message: msg("errors.offline.body"),
transient: true, transient: true,
}); });
} }
+2 -1
View File
@@ -7,7 +7,8 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../errorDialog", () => ({ reportError: vi.fn() })); vi.mock("../errorDialog", () => ({ reportError: vi.fn() }));
vi.mock("../notifications", () => ({ vi.mock("../notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../notifications")>()),
notify: vi.fn(() => 1), notify: vi.fn(() => 1),
remove: vi.fn(), remove: vi.fn(),
getActiveToasts: vi.fn((): { id: number }[] => []), getActiveToasts: vi.fn((): { id: number }[] => []),
+120
View File
@@ -0,0 +1,120 @@
// A server refusal ({ code, reason, … }) in the reader's language. Its own module, not the request
// core's: a notice keeps the refusal it reports and says it again at display, in the language of the
// moment, and the notice store cannot import the request core that imports it.
import i18n from "../../i18n";
import { formatBytes, formatDecimal } from "../format";
// Localize a STRUCTURED server error ({ code, reason, ... }) into the user's language. FastAPI
// usually returns a plain string `detail`, but some routes (e.g. download quota/edit) return a
// structured object with an i18n reason key + numbers so HU/DE users don't get hardcoded English
// with a dot-decimal separator. Falls back to a generic message for unknown shapes.
// The refusal codes of the admin's account and invite actions (routes/admin.py). Their reasons share
// one namespace, `errors.admin.<reason>`, which i18n/backendIds.contract checks against the backend.
const ADMIN_REFUSALS = new Set(["activate", "resend", "invite_send"]);
export function localizeDetail(d: unknown): string {
const t = i18n.t.bind(i18n);
// FastAPI request-validation errors: `detail` is an ARRAY of { loc, msg, type }. The UI sends
// well-formed requests, so this only fires on genuinely malformed input. The per-field `msg` and
// `loc` field names are English, snake_case internal tokens — surface a clean, fully localized
// "check your input" message rather than leaking them into a translated sentence.
if (Array.isArray(d)) return t("errors.validation");
const o = d as
| { code?: string; reason?: string; limit?: number; limit_s?: number; min?: number }
| null
| undefined;
if (o?.code === "quota") {
if (o.reason === "max_bytes") {
const gb = formatDecimal((o.limit || 0) / 1_073_741_824, i18n.language, 1, 0);
return t("downloads.errors.quota.max_bytes", { size: gb });
}
if (o.reason === "max_jobs") return t("downloads.errors.quota.max_jobs", { limit: o.limit });
return t("downloads.errors.quota.generic");
}
if (o?.code === "edit")
return t(`downloads.errors.edit.${o.reason}`, t("downloads.errors.edit.generic"));
// A clip the server will not cut (downloads/clip): the range, the frame, the source. An unknown
// reason reads as the plain failure rather than a key; a cap comes with the refusal (`limit_s`).
if (o?.code === "clip")
return t(`clip.errors.${o.reason}`, {
defaultValue: t("clip.failed"),
minutes: o.limit_s == null ? undefined : formatDecimal(o.limit_s / 60, i18n.language, 1, 0),
});
if (o?.code === "cookies")
return t(`downloads.errors.cookies.${o.reason}`, t("downloads.errors.cookies.generic"));
// Password sign-in refusals. The card renders the account-shaped ones as a full panel
// off `detailData`; this is the one-line form every other consumer (and the card's own fallback)
// gets. A reason this build doesn't know falls through to the generic message rather than showing
// a raw key, which is what an i18n miss would otherwise put on screen.
if (o?.code === "login")
return t(`welcome.auth.blocked.${o.reason}.short`, t("welcome.auth.genericError"));
// The two email-link confirmations. Their failure is one a user meets by simply clicking an old
// link, so it is the last place that may answer in hardcoded English under a bilingual UI.
if (o?.code === "verify") return t("welcome.auth.verifyInvalid");
if (o?.code === "reset")
return o.reason === "weak_password"
? t("welcome.auth.weakPassword", { min: o.limit ?? o.min })
: t("welcome.auth.deadLink.short");
// Local-upload ingest: the per-file cap carries the limit in bytes; empty is a bare code.
if (o?.code === "upload_too_large")
return t("downloads.errors.upload.tooLarge", { size: formatBytes(o.limit, i18n.language) });
if (o?.code === "upload_empty") return t("downloads.errors.upload.empty");
// The EDGE ceiling's refusal (`bodylimit.TOO_LARGE_BODY`), shared by every route and carrying no
// number. Not a theoretical case: on a DEFAULT install it is what an over-cap upload gets,
// because the ceiling and the route's own cap are the same figure and the edge answers first —
// the route's numbered `upload_too_large` above is the answer only once an admin has LOWERED the
// knob (see bodylimit.ROUTE_MAX_BODY_BYTES). Without this branch that refusal fell through to
// the generic "couldn't carry out that action", the one message no retry can fix.
//
// A BACKSTOP, not the plan. The middleware answers without draining, so whether the browser gets
// to read this at all depends on the client having finished sending: measured readable up to
// 200 MiB over loopback, and measured as a bare `onerror` through a dev proxy. That is why the
// upload dialog checks the file against `upload_max_bytes` BEFORE sending — this branch is what
// happens when that check could not run.
//
// `errors.tooLarge` stays PAYLOAD-NEUTRAL for that reason. It is also what a JSON form gets
// (`debug_sessions` refuses an over-size draft with the same bare code), so wording it as advice
// about a file told the issue composer's user to pick a smaller one — of nothing. The
// file-specific sentence is `downloads.errors.upload.tooLarge`, one branch up, which has a number.
if (o?.code === "too_large") return t("errors.tooLarge");
// SEC-12: a download URL the server refuses to fetch. The reasons are deliberately distinct —
// "we could not look that host up" is a different thing to tell someone than "we will not fetch
// from that address" — so an unknown reason falls back to the generic refusal rather than a key.
if (o?.code === "url_blocked")
return t(`downloads.errors.urlBlocked.${o.reason}`, t("downloads.errors.urlBlocked.generic"));
// The one-step "move to another list". Both reasons mean the same thing to a reader —
// the list moved under you — but they are different enough to be worth naming separately, and an
// unknown reason falls back to the plain failure rather than printing a key.
if (o?.code === "playlist_move")
return t(`playlists.moveErrors.${o.reason}`, t("playlists.moveFailed"));
// A Plex collection name already taken in that library (the one collection-edit refusal).
if (o?.code === "collection")
return o.reason === "name_taken"
? t("plex.collection.nameTaken")
: t("plex.collection.editFailed");
// An action YouTube refused (routes/_common.youtube_refusal): what the reader can do about it —
// wait for the budget, reconnect, or retry. An unknown reason reads as the retryable one.
if (o?.code === "youtube")
return t(`errors.youtube.${o.reason}`, t("errors.youtube.unavailable"));
// Every other refusal (backend `app/refusal.py`): one family, the reason naming the sentence and
// the rest of the detail its values (`{{min}}`, `{{name}}`, …). A reason this build does not know
// reads as the generic failure rather than a key.
if (o?.code === "refused")
return t(`errors.refused.${o.reason}`, { ...o, defaultValue: t("errors.generic") });
// A playlist action the server refused (Watch later can't be deleted, YouTube said no, …).
if (o?.code === "playlist") return t(`playlists.errors.${o.reason}`, t("errors.generic"));
// An admin action on an account or an invite that the server refused: activating an address that
// is no longer approved, a confirmation link or invitation with nothing to go to or no mail to go
// by. The toast's title already names the button; this is the WHY, and it used to be the generic
// "try again", which no retry could fix.
if (ADMIN_REFUSALS.has(o?.code ?? "")) return t(`errors.admin.${o?.reason}`, t("errors.generic"));
// Any row that is gone — deleted in another tab or device, or never the caller's (a stranger's
// row 404s the same way). `reason` names WHAT is gone; one this build doesn't know says it
// generically rather than printing a key.
if (o?.code === "not_found")
return t(`errors.notFound.${o.reason}`, t("errors.notFound.generic"));
// SEC-14: a manual sync trigger on cooldown. Not an error the user caused so much as a pace
// limit, and the copy says so.
if (o?.code === "sync_cooldown") return t("errors.syncCooldown");
return t("errors.generic");
}
@@ -9,13 +9,15 @@ import type { ReactNode } from "react";
import { act, renderHook, waitFor } from "@testing-library/react"; import { act, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("./notifications", () => ({ notify: vi.fn(() => 0) })); vi.mock("./notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("./notifications")>()),
notify: vi.fn(() => 0),
}));
import { api, HttpError } from "./api"; import { api, HttpError } from "./api";
import { notify } from "./notifications"; import { msg, notify } from "./notifications";
import { useChannelTagActions } from "./useChannelTagActions"; import { useChannelTagActions } from "./useChannelTagActions";
import { useChannelPriority } from "./useChannelPriority"; import { useChannelPriority } from "./useChannelPriority";
import i18n from "../i18n";
const notifyMock = vi.mocked(notify); const notifyMock = vi.mocked(notify);
beforeEach(() => notifyMock.mockClear()); beforeEach(() => notifyMock.mockClear());
@@ -37,8 +39,8 @@ describe("useChannelTagActions", () => {
await waitFor(() => expect(onError).toHaveBeenCalledWith("UC1")); await waitFor(() => expect(onError).toHaveBeenCalledWith("UC1"));
expect(notifyMock).toHaveBeenCalledWith({ expect(notifyMock).toHaveBeenCalledWith({
level: "error", level: "error",
title: i18n.t("channels.tags.updateFailed"), title: msg("channels.tags.updateFailed"),
message: i18n.t("errors.reason.forbidden"), message: msg("errors.reason.forbidden"),
}); });
}); });
@@ -52,8 +54,8 @@ describe("useChannelTagActions", () => {
await waitFor(() => await waitFor(() =>
expect(notifyMock).toHaveBeenCalledWith({ expect(notifyMock).toHaveBeenCalledWith({
level: "error", level: "error",
title: i18n.t("channels.tags.createFailed"), title: msg("channels.tags.createFailed"),
message: i18n.t("errors.reason.rateLimited"), message: msg("errors.reason.rateLimited"),
}), }),
); );
}); });
@@ -76,8 +78,8 @@ describe("useChannelPriority", () => {
await waitFor(() => expect(onError).toHaveBeenCalledWith("UC1")); await waitFor(() => expect(onError).toHaveBeenCalledWith("UC1"));
expect(notifyMock).toHaveBeenCalledWith({ expect(notifyMock).toHaveBeenCalledWith({
level: "error", level: "error",
title: i18n.t("errors.writeFailed.channelPriority"), title: msg("errors.writeFailed.channelPriority"),
message: i18n.t("errors.reason.unreachable"), message: msg("errors.reason.unreachable"),
}); });
}); });
}); });
+10 -9
View File
@@ -5,11 +5,12 @@
// notification store used to keep its empty, nobody's history, and the tab's first notice replaced the // notification store used to keep its empty, nobody's history, and the tab's first notice replaced the
// account's stored history with it. // account's stored history with it.
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { raw, sayNotif, type NotifText } from "./notifications";
const entry = (id: number, message: string) => ({ const entry = (id: number, message: string) => ({
id, id,
level: "info", level: "info",
message, message: raw(message),
ts: 1, ts: 1,
read: true, read: true,
dismissed: true, dismissed: true,
@@ -19,10 +20,10 @@ const entry = (id: number, message: string) => ({
}); });
const storedMessages = () => const storedMessages = () =>
( (
JSON.parse(localStorage.getItem("siftlode.notifications") ?? "[]") as { JSON.parse(localStorage.getItem("siftlode.notifHistory") ?? "[]") as {
message: string; message: NotifText;
}[] }[]
).map((n) => n.message); ).map((n) => sayNotif(n.message));
// Warm the transform cache once (see verifyEntry.dom.test.tsx): the first cold import costs ~0.8 s // Warm the transform cache once (see verifyEntry.dom.test.tsx): the first cold import costs ~0.8 s
// alone and outran the 5 s case timeout under a full coverage run. Transformed code outlives // alone and outran the 5 s case timeout under a full coverage run. Transformed code outlives
@@ -47,14 +48,14 @@ beforeEach(() => {
describe("stores read at import, before the account is known", { timeout: CASE_BUDGET }, () => { describe("stores read at import, before the account is known", { timeout: CASE_BUDGET }, () => {
it("keep the account's notification history and settings once it is claimed", async () => { it("keep the account's notification history and settings once it is claimed", async () => {
localStorage.setItem("siftlode.owner", "5"); localStorage.setItem("siftlode.owner", "5");
localStorage.setItem("siftlode.notifications", JSON.stringify([entry(7, "older")])); localStorage.setItem("siftlode.notifHistory", JSON.stringify([entry(7, "older")]));
localStorage.setItem("siftlode.notifSettings", JSON.stringify({ durationMs: 0 })); localStorage.setItem("siftlode.notifSettings", JSON.stringify({ durationMs: 0 }));
const notifications = await import("./notifications"); const notifications = await import("./notifications");
const { claimLocalData } = await import("./storage"); const { claimLocalData } = await import("./storage");
notifications.notify({ message: "before the claim" }); // e.g. a failed boot probe notifications.notify({ message: raw("before the claim") }); // e.g. a failed boot probe
claimLocalData(5); claimLocalData(5);
notifications.notify({ message: "after the claim" }); notifications.notify({ message: raw("after the claim") });
expect(storedMessages()).toEqual(["older", "before the claim", "after the claim"]); expect(storedMessages()).toEqual(["older", "before the claim", "after the claim"]);
expect(notifications.getNotifSettings().durationMs).toBe(0); expect(notifications.getNotifSettings().durationMs).toBe(0);
@@ -75,12 +76,12 @@ describe("stores read at import, before the account is known", { timeout: CASE_B
describe("a browser that held another account's state", { timeout: CASE_BUDGET }, () => { describe("a browser that held another account's state", { timeout: CASE_BUDGET }, () => {
it("starts the new account's notices from an empty history", async () => { it("starts the new account's notices from an empty history", async () => {
localStorage.setItem("siftlode.owner", "4"); localStorage.setItem("siftlode.owner", "4");
localStorage.setItem("siftlode.notifications", JSON.stringify([entry(7, "four's")])); localStorage.setItem("siftlode.notifHistory", JSON.stringify([entry(7, "four's")]));
const notifications = await import("./notifications"); const notifications = await import("./notifications");
const { claimLocalData } = await import("./storage"); const { claimLocalData } = await import("./storage");
claimLocalData(5); claimLocalData(5);
notifications.notify({ message: "as five" }); notifications.notify({ message: raw("as five") });
expect(storedMessages()).toEqual(["as five"]); expect(storedMessages()).toEqual(["as five"]);
}); });
+19 -16
View File
@@ -21,18 +21,21 @@ vi.mock("./api", () => ({
api: { downloadLinks, createDownloadLink, folderLinks, createFolderLink }, api: { downloadLinks, createDownloadLink, folderLinks, createFolderLink },
})); }));
vi.mock("./clipboard", () => ({ copyText })); vi.mock("./clipboard", () => ({ copyText }));
vi.mock("./notifications", () => ({ notify })); vi.mock("./notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("./notifications")>()),
notify,
}));
import { quickShareLink } from "./downloads"; import { quickShareLink } from "./downloads";
import { qk } from "./queryKeys"; import { qk } from "./queryKeys";
import { msg, type NotifText } from "./notifications";
const t = (k: string) => k;
const origin = window.location.origin; const origin = window.location.origin;
type ToastArg = { type ToastArg = {
level: string; level: string;
message: string; message: NotifText;
action?: { label: string; onClick: () => void }; action?: { label: NotifText; onClick: () => void };
}; };
const firstToast = () => notify.mock.calls[0]![0] as ToastArg; const firstToast = () => notify.mock.calls[0]![0] as ToastArg;
@@ -55,7 +58,7 @@ describe("quickShareLink", () => {
createDownloadLink.mockResolvedValue({ id: 7, url: "/watch/tok7" }); createDownloadLink.mockResolvedValue({ id: 7, url: "/watch/tok7" });
const openSpy = vi.spyOn(window, "open").mockReturnValue(null); const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
const url = await quickShareLink(qc(), { kind: "file", id: 42 }, t); const url = await quickShareLink(qc(), { kind: "file", id: 42 });
expect(url).toBe(`${origin}/watch/tok7`); expect(url).toBe(`${origin}/watch/tok7`);
// Minted with an EMPTY body so the backend default (allow_download=true) applies. // Minted with an EMPTY body so the backend default (allow_download=true) applies.
@@ -63,8 +66,8 @@ describe("quickShareLink", () => {
expect(copyText).toHaveBeenCalledWith(`${origin}/watch/tok7`); expect(copyText).toHaveBeenCalledWith(`${origin}/watch/tok7`);
const arg = firstToast(); const arg = firstToast();
expect(arg.level).toBe("success"); expect(arg.level).toBe("success");
expect(arg.message).toBe("downloads.share.linkCopied"); expect(arg.message).toEqual(msg("downloads.share.linkCopied"));
expect(arg.action!.label).toBe("downloads.share.openNewTab"); expect(arg.action!.label).toEqual(msg("downloads.share.openNewTab"));
arg.action!.onClick(); arg.action!.onClick();
expect(openSpy).toHaveBeenCalledWith(`${origin}/watch/tok7`, "_blank", "noopener,noreferrer"); expect(openSpy).toHaveBeenCalledWith(`${origin}/watch/tok7`, "_blank", "noopener,noreferrer");
}); });
@@ -72,7 +75,7 @@ describe("quickShareLink", () => {
it("reuses an existing link instead of minting a duplicate", async () => { it("reuses an existing link instead of minting a duplicate", async () => {
downloadLinks.mockResolvedValue([{ id: 3, url: "/watch/old" }]); downloadLinks.mockResolvedValue([{ id: 3, url: "/watch/old" }]);
const url = await quickShareLink(qc(), { kind: "file", id: 1 }, t); const url = await quickShareLink(qc(), { kind: "file", id: 1 });
expect(url).toBe(`${origin}/watch/old`); expect(url).toBe(`${origin}/watch/old`);
expect(createDownloadLink).not.toHaveBeenCalled(); expect(createDownloadLink).not.toHaveBeenCalled();
@@ -85,7 +88,7 @@ describe("quickShareLink", () => {
{ id: 3, url: "/watch/older" }, { id: 3, url: "/watch/older" },
]); ]);
const url = await quickShareLink(qc(), { kind: "file", id: 1 }, t); const url = await quickShareLink(qc(), { kind: "file", id: 1 });
expect(url).toBe(`${origin}/watch/older`); expect(url).toBe(`${origin}/watch/older`);
expect(createDownloadLink).not.toHaveBeenCalled(); expect(createDownloadLink).not.toHaveBeenCalled();
@@ -97,7 +100,7 @@ describe("quickShareLink", () => {
createDownloadLink.mockResolvedValue({ id: 8, url: "/watch/new" }); createDownloadLink.mockResolvedValue({ id: 8, url: "/watch/new" });
const client = qc(); const client = qc();
const url = await quickShareLink(client, { kind: "file", id: 6 }, t); const url = await quickShareLink(client, { kind: "file", id: 6 });
expect(url).toBe(`${origin}/watch/new`); expect(url).toBe(`${origin}/watch/new`);
expect(copyText).toHaveBeenCalledWith(`${origin}/watch/new`); expect(copyText).toHaveBeenCalledWith(`${origin}/watch/new`);
@@ -113,7 +116,7 @@ describe("quickShareLink", () => {
const client = qc(); const client = qc();
client.setQueryData(qk.fileFolders(), []); client.setQueryData(qk.fileFolders(), []);
const url = await quickShareLink(client, { kind: "folder", id: 5 }, t); const url = await quickShareLink(client, { kind: "folder", id: 5 });
expect(url).toBe(`${origin}/watch/fold`); expect(url).toBe(`${origin}/watch/fold`);
expect(folderLinks).toHaveBeenCalledWith(5); expect(folderLinks).toHaveBeenCalledWith(5);
@@ -129,26 +132,26 @@ describe("quickShareLink", () => {
downloadLinks.mockResolvedValue([{ id: 5, url: "/watch/x" }]); downloadLinks.mockResolvedValue([{ id: 5, url: "/watch/x" }]);
copyText.mockResolvedValue(false); copyText.mockResolvedValue(false);
const url = await quickShareLink(qc(), { kind: "file", id: 2 }, t); const url = await quickShareLink(qc(), { kind: "file", id: 2 });
expect(url).toBe(`${origin}/watch/x`); // the link is never a dead end expect(url).toBe(`${origin}/watch/x`); // the link is never a dead end
const arg = firstToast(); const arg = firstToast();
expect(arg.level).toBe("warning"); expect(arg.level).toBe("warning");
expect(arg.message).toBe("downloads.share.copyFailed"); expect(arg.message).toEqual(msg("downloads.share.copyFailed"));
expect(arg.action!.label).toBe("downloads.share.openNewTab"); expect(arg.action!.label).toEqual(msg("downloads.share.openNewTab"));
}); });
it("reports an error and returns null when link creation fails", async () => { it("reports an error and returns null when link creation fails", async () => {
downloadLinks.mockResolvedValue([]); downloadLinks.mockResolvedValue([]);
createDownloadLink.mockRejectedValue(new Error("boom")); createDownloadLink.mockRejectedValue(new Error("boom"));
const url = await quickShareLink(qc(), { kind: "file", id: 9 }, t); const url = await quickShareLink(qc(), { kind: "file", id: 9 });
expect(url).toBeNull(); expect(url).toBeNull();
expect(copyText).not.toHaveBeenCalled(); expect(copyText).not.toHaveBeenCalled();
// Titled with what failed, and carrying why (notifyActionError), not a bare message. // Titled with what failed, and carrying why (notifyActionError), not a bare message.
expect(notify).toHaveBeenCalledWith( expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "error", title: "downloads.share.linkFailed" }), expect.objectContaining({ level: "error", title: msg("downloads.share.linkFailed") }),
); );
}); });
}); });
+6 -10
View File
@@ -2,7 +2,7 @@ import type { QueryClient } from "@tanstack/react-query";
import { qk } from "./queryKeys"; import { qk } from "./queryKeys";
import { api, type NewShareLink } from "./api"; import { api, type NewShareLink } from "./api";
import { copyText } from "./clipboard"; import { copyText } from "./clipboard";
import { notify } from "./notifications"; import { msg, notify } from "./notifications";
import { notifyActionError } from "./actionErrors"; import { notifyActionError } from "./actionErrors";
// The download queue, the per-card download index, the library (its listings and folder counts) and // The download queue, the per-card download index, the library (its listings and folder counts) and
@@ -44,11 +44,7 @@ export function invalidateLinks(qc: QueryClient, target: LinkTarget) {
// new tab" action. A copy failure still toasts (warning) WITH that action, so the link is never a dead // new tab" action. A copy failure still toasts (warning) WITH that action, so the link is never a dead
// end. Advanced controls (password, expiry, revoke, share-with-a-user) live in the Share dialog. // end. Advanced controls (password, expiry, revoke, share-with-a-user) live in the Share dialog.
// Returns the shared URL, or null if link creation failed — so the caller/tests can react to the outcome. // Returns the shared URL, or null if link creation failed — so the caller/tests can react to the outcome.
export async function quickShareLink( export async function quickShareLink(qc: QueryClient, target: LinkTarget): Promise<string | null> {
qc: QueryClient,
target: LinkTarget,
t: (key: string) => string,
): Promise<string | null> {
let path; let path;
try { try {
const existing = await qc.fetchQuery({ const existing = await qc.fetchQuery({
@@ -65,18 +61,18 @@ export async function quickShareLink(
} }
if (!path) throw new Error("the new link came back without its address"); if (!path) throw new Error("the new link came back without its address");
} catch (e) { } catch (e) {
notifyActionError(e, t("downloads.share.linkFailed")); notifyActionError(e, msg("downloads.share.linkFailed"));
return null; return null;
} }
const url = window.location.origin + path; const url = window.location.origin + path;
const action = { const action = {
label: t("downloads.share.openNewTab"), label: msg("downloads.share.openNewTab"),
onClick: () => window.open(url, "_blank", "noopener,noreferrer"), onClick: () => window.open(url, "_blank", "noopener,noreferrer"),
}; };
notify( notify(
(await copyText(url)) (await copyText(url))
? { level: "success", message: t("downloads.share.linkCopied"), action } ? { level: "success", message: msg("downloads.share.linkCopied"), action }
: { level: "warning", message: t("downloads.share.copyFailed"), action }, : { level: "warning", message: msg("downloads.share.copyFailed"), action },
); );
return url; return url;
} }
+3 -3
View File
@@ -14,7 +14,7 @@
// names) — an email in a query string lands in every reverse-proxy access log. Plain status flags // names) — an email in a query string lands in every reverse-proxy access log. Plain status flags
// with nothing personal in them (login, deleted, account) ride the query string. // with nothing personal in them (login, deleted, account) ride the query string.
import type { NotifLevel } from "./notifications"; import { raw, type NotifLevel, type NotifValue } from "./notifications";
import { refusalReason } from "./api/core"; import { refusalReason } from "./api/core";
import { hrefHere } from "./urlState"; import { hrefHere } from "./urlState";
@@ -88,7 +88,7 @@ export function clearEntryUrl(): void {
export interface AccountNotice { export interface AccountNotice {
level: NotifLevel; level: NotifLevel;
key: string; key: string;
values?: Record<string, string>; values?: Record<string, NotifValue>;
} }
/** What the entry URL says to the account signed in here about a switch of account. A sign-in as /** What the entry URL says to the account signed in here about a switch of account. A sign-in as
@@ -110,7 +110,7 @@ export function accountSwitchNotice(
const access = hash.get("access"); const access = hash.get("access");
const email = hash.get("linked") || hash.get("email"); const email = hash.get("linked") || hash.get("email");
if (access === "requested" && email) if (access === "requested" && email)
return { level: "info", key: "header.account.switchRequested", values: { email } }; return { level: "info", key: "header.account.switchRequested", values: { email: raw(email) } };
if (access === "requested" || access === "denied") if (access === "requested" || access === "denied")
return { level: "warning", key: "header.account.switchNotApproved" }; return { level: "warning", key: "header.account.switchNotApproved" };
return null; return null;
+72
View File
@@ -0,0 +1,72 @@
// @vitest-environment jsdom
//
// The notice history changed shape (a notice keeps what to say, not the said text). A build from
// before it reads the old key and renders its rows as text, so an object there crashed its
// Notifications page after a rollback. The history now lives under a key of its own: the old one is
// read once, while the new one does not exist yet, and never written.
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { msg, raw, sayNotif, type NotifText } from "./notifications";
const OLD_KEY = "siftlode.notifications";
// A row as the build before wrote it: its text already said.
const oldRow = (id: number, message: string) => ({
id,
level: "info",
message,
ts: 1,
read: true,
dismissed: true,
transient: false,
repeat: 1,
requiresInteraction: false,
});
const saidHistory = (key: string) =>
(JSON.parse(localStorage.getItem(key) ?? "[]") as { message: NotifText }[]).map((n) =>
sayNotif(n.message),
);
beforeAll(async () => {
await import("./notifications");
await import("./storage");
}, 60_000);
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
vi.resetModules(); // the store loads at import: each case needs a fresh one
});
describe("the notice history's own key", { timeout: 30_000 }, () => {
it("carries the old history forward and leaves the old key as the build before wrote it", async () => {
const old = JSON.stringify([oldRow(7, "older")]);
localStorage.setItem("siftlode.owner", "5");
localStorage.setItem(OLD_KEY, old);
const notifications = await import("./notifications");
const { claimLocalData, LS } = await import("./storage");
claimLocalData(5);
notifications.notify({ message: msg("common.copy.done") });
expect(LS.notifHistory).not.toBe(OLD_KEY);
expect(localStorage.getItem(OLD_KEY)).toBe(old);
expect(saidHistory(LS.notifHistory)).toEqual(["older", "Copied to the clipboard"]);
});
it("reads the old key only while the new one does not exist", async () => {
const { LS } = await import("./storage");
localStorage.setItem("siftlode.owner", "5");
localStorage.setItem(OLD_KEY, JSON.stringify([oldRow(7, "older")]));
localStorage.setItem(
LS.notifHistory,
JSON.stringify([{ ...oldRow(8, ""), message: raw("newer") }]),
);
const notifications = await import("./notifications");
const { claimLocalData } = await import("./storage");
claimLocalData(5);
expect(notifications.getNotifications().map((n) => sayNotif(n.message))).toEqual(["newer"]);
});
});
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import ts from "typescript";
import { readSources } from "./rawSources.testkit";
// A notice says WHAT to say (lib/notifications NotifText) and is said when shown; a plain `t()` says
// it now. tsc cannot tell the two apart where they meet — `msg`'s key is a string, `detail` is
// unknown, and i18next's options take any value — so the slips are caught here, on the syntax:
// - a NotifText handed to `t()` as a value (`t("k", { name: raw(x) })`) reads "[object Object]";
// - an already-said text kept in a notice (`msg(t("k"))`, `raw(t("k"))`, `{ detail: t("k") }`)
// stays in the language it was raised in.
const isT = (e: ts.Expression) =>
(ts.isIdentifier(e) && e.text === "t") ||
(ts.isPropertyAccessExpression(e) && e.name.text === "t");
const isCallTo = (e: ts.Node, names: string[]): e is ts.CallExpression =>
ts.isCallExpression(e) && ts.isIdentifier(e.expression) && names.includes(e.expression.text);
const isTCall = (e: ts.Node): e is ts.CallExpression => ts.isCallExpression(e) && isT(e.expression);
function mixedNotifText(name: string, src: string): string[] {
const file = ts.createSourceFile(name, src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const out: string[] = [];
const at = (n: ts.Node, what: string) =>
out.push(`${name}:${file.getLineAndCharacterOfPosition(n.getStart()).line + 1} ${what}`);
const visit = (n: ts.Node) => {
if (isTCall(n)) {
for (const arg of n.arguments.slice(1)) {
if (!ts.isObjectLiteralExpression(arg)) continue;
for (const p of arg.properties) {
if (ts.isPropertyAssignment(p) && isCallTo(p.initializer, ["raw", "msg"]))
at(p, "a notice value handed to t()");
}
}
}
if (isCallTo(n, ["msg", "raw"]) && n.arguments[0] && isTCall(n.arguments[0]))
at(n, "a said text kept in a notice");
if (
ts.isPropertyAssignment(n) &&
ts.isIdentifier(n.name) &&
n.name.text === "detail" &&
isTCall(n.initializer)
)
at(n, "a said text kept as a notice's detail");
ts.forEachChild(n, visit);
};
visit(file);
return out;
}
describe("a notice's text and a plain t() do not mix", () => {
it("holds across src", () => {
const files = readSources(__dirname, ["../**/*.ts", "../**/*.tsx", "!../**/*.test.*"]);
expect(Object.keys(files).length).toBeGreaterThan(100); // the glob matched the app
expect(Object.entries(files).flatMap(([p, src]) => mixedNotifText(p, src))).toEqual([]);
});
it.each([
['t("users.delete.confirm", { email: raw(u.email) })', "a notice value handed to t()"],
['i18n.t("k", { n: msg("x") })', "a notice value handed to t()"],
['notify({ message: msg(t("feed.saved")) })', "a said text kept in a notice"],
['notify({ message: raw(t("feed.saved")) })', "a said text kept in a notice"],
['notify({ message: { detail: t("x") } })', "a said text kept as a notice's detail"],
])("sees %s", (src, what) => {
expect(mixedNotifText("x.tsx", src)).toEqual([`x.tsx:1 ${what}`]);
});
it.each([
'msg("channels.discovery.subscribedBody", { name: raw(nameOf(c)) })',
't("channels.discovery.confirmSubscribe", { name: nameOf(c) })',
"sayNotif(msg(key), t)",
])("lets %s through", (src) => {
expect(mixedNotifText("x.tsx", src)).toEqual([]);
});
});
@@ -10,6 +10,7 @@ import {
configureNotifications, configureNotifications,
getActiveToasts, getActiveToasts,
notify, notify,
raw,
} from "./notifications"; } from "./notifications";
beforeEach(() => vi.useFakeTimers()); beforeEach(() => vi.useFakeTimers());
@@ -19,14 +20,14 @@ afterEach(() => {
vi.useRealTimers(); vi.useRealTimers();
}); });
const undo = { label: "Undo", onClick: () => {} }; const undo = { label: raw("Undo"), onClick: () => {} };
const shown = (id: number) => getActiveToasts().some((n) => n.id === id); const shown = (id: number) => getActiveToasts().some((n) => n.id === id);
describe("an action toast's lifetime", () => { describe("an action toast's lifetime", () => {
it("stays for the floor when the reader's toasts last 3 s", () => { it("stays for the floor when the reader's toasts last 3 s", () => {
configureNotifications({ durationMs: 3000 }); configureNotifications({ durationMs: 3000 });
const plain = notify({ message: "plain" }); const plain = notify({ message: raw("plain") });
const withUndo = notify({ message: "removed", action: undo }); const withUndo = notify({ message: raw("removed"), action: undo });
vi.advanceTimersByTime(3001); vi.advanceTimersByTime(3001);
expect(shown(plain)).toBe(false); expect(shown(plain)).toBe(false);
expect(shown(withUndo)).toBe(true); expect(shown(withUndo)).toBe(true);
@@ -36,7 +37,7 @@ describe("an action toast's lifetime", () => {
it("keeps a longer setting", () => { it("keeps a longer setting", () => {
configureNotifications({ durationMs: 12000 }); configureNotifications({ durationMs: 12000 });
const id = notify({ message: "removed", action: undo }); const id = notify({ message: raw("removed"), action: undo });
vi.advanceTimersByTime(ACTION_TTL_MIN + 1); vi.advanceTimersByTime(ACTION_TTL_MIN + 1);
expect(shown(id)).toBe(true); expect(shown(id)).toBe(true);
vi.advanceTimersByTime(12000 - ACTION_TTL_MIN); vi.advanceTimersByTime(12000 - ACTION_TTL_MIN);
@@ -45,7 +46,7 @@ describe("an action toast's lifetime", () => {
it("never dismisses when the reader chose never", () => { it("never dismisses when the reader chose never", () => {
configureNotifications({ durationMs: 0 }); configureNotifications({ durationMs: 0 });
const id = notify({ message: "removed", action: undo }); const id = notify({ message: raw("removed"), action: undo });
vi.advanceTimersByTime(60000); vi.advanceTimersByTime(60000);
expect(shown(id)).toBe(true); expect(shown(id)).toBe(true);
}); });
@@ -0,0 +1,37 @@
// @vitest-environment jsdom
//
// A burst of the same notice is one entry with a count (×N), not a pile of copies. The texts are
// objects now (what to say, built afresh by every msg() call), so "the same" is the same key and
// values, never the same object: a compare by reference would let every repeat stack.
import { afterEach, describe, expect, it } from "vitest";
import { clearAll, getNotifications, msg, notify, raw } from "./notifications";
afterEach(() => clearAll());
describe("coalescing a repeated notice", () => {
it("folds the same key and values into one entry, counted", () => {
notify({
level: "error",
title: msg("errors.title"),
message: msg("errors.reason.unreachable"),
});
notify({
level: "error",
title: msg("errors.title"),
message: msg("errors.reason.unreachable"),
});
expect(getNotifications().map((n) => n.repeat)).toEqual([2]);
});
it("folds the same server refusal too", () => {
notify({ level: "error", message: { detail: { code: "not_found", reason: "view" } } });
notify({ level: "error", message: { detail: { code: "not_found", reason: "view" } } });
expect(getNotifications().map((n) => n.repeat)).toEqual([2]);
});
it("keeps two notices apart when only a value differs", () => {
notify({ message: msg("channels.notify.hidden", { name: raw("One") }) });
notify({ message: msg("channels.notify.hidden", { name: raw("Two") }) });
expect(getNotifications().map((n) => n.repeat)).toEqual([1, 1]);
});
});
+72 -12
View File
@@ -2,9 +2,61 @@
// the Notification Center shows (info events, actions awaiting interaction, and app // the Notification Center shows (info events, actions awaiting interaction, and app
// errors). History is persisted to localStorage so it survives reloads; action // errors). History is persisted to localStorage so it survives reloads; action
// callbacks are live-only and dropped on reload. // callbacks are live-only and dropped on reload.
import type { TFunction } from "i18next";
import i18n from "../i18n";
import { localizeDetail } from "./api/refusalText";
import { formatBytes, formatNumber } from "./format";
import { LS, onLocalDataClaimed, readAccount, readAccountMerged, writeAccount } from "./storage"; import { LS, onLocalDataClaimed, readAccount, readAccountMerged, writeAccount } from "./storage";
import { log, type Level } from "./log"; import { log, type Level } from "./log";
// What a notice SAYS, kept as what to say rather than the said text: a key and its values, said in
// the reader's language each time it is shown. The text a caller translated at raise time was kept
// in the history, so a language switch left every notice in the language it was raised in. `raw` is
// text kept as it was fetched (a video's title, a name — a Plex title in the language it was fetched
// in, since the card holds only that one); `detail` is a server refusal, localized at display like
// the key. `notify` takes nothing else, and tsc refuses a plain string as a value (a name goes in as
// `raw`, a translated word as its own `msg`). What tsc cannot see — `msg(t(…))`, `raw(t(…))`,
// `{ detail: t(…) }`, or a `raw`/`msg` handed to a plain `t()` — notifText.contract.test refuses.
// A value is formatted when shown too: `{ number }` / `{ bytes }` group digits the reader's way.
export type NotifValue = number | NotifText | { number: number } | { bytes: number };
export type NotifText =
| { 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 kept as it was fetched (a title, a name), shown as it is — never something `t()` said. */
export const raw = (text: string): NotifText => ({ raw: text });
/** A notice text in the language `t` speaks — the reader's, from a component's `useTranslation`. */
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;
// In the language `t` speaks: a fixed one (the English log) names it, a component's follows i18n.
const lang = (t as { lng?: string }).lng ?? i18n.language;
if ("number" in v) return formatNumber(v.number, lang);
if ("bytes" in v) return formatBytes(v.bytes, lang);
return sayNotif(v, t);
}
const sameText = (a: NotifText | undefined, b: NotifText | undefined): boolean =>
JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
// A notice the history kept from before it said keys: its text, in the language it was raised in.
const asText = (v: unknown): NotifText | undefined =>
typeof v === "string" ? raw(v) : (v as NotifText | undefined);
// A notification's level in the client log: what the user was TOLD is part of the story a log tells. // A notification's level in the client log: what the user was TOLD is part of the story a log tells.
const LOG_LEVEL: Record<NotifLevel, Level> = { const LOG_LEVEL: Record<NotifLevel, Level> = {
info: "INFO", info: "INFO",
@@ -17,7 +69,8 @@ const LOG_LEVEL: Record<NotifLevel, Level> = {
export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal"; export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal";
interface NotifAction { interface NotifAction {
label: string; // Said when shown, like the notice's text: a language switch must not leave the button behind.
label: NotifText;
onClick: () => void; onClick: () => void;
} }
@@ -56,8 +109,8 @@ export type NotifMeta =
export interface Notification { export interface Notification {
id: number; id: number;
level: NotifLevel; level: NotifLevel;
title?: string; title?: NotifText;
message: string; message: NotifText;
action?: NotifAction; action?: NotifAction;
// A second, lesser action beside it (a done download's "Open" after its "Copy link"). // A second, lesser action beside it (a done download's "Open" after its "Copy link").
secondaryAction?: NotifAction; secondaryAction?: NotifAction;
@@ -79,9 +132,9 @@ export interface Notification {
} }
export interface NotifyInput { export interface NotifyInput {
message: string; message: NotifText;
level?: NotifLevel; level?: NotifLevel;
title?: string; title?: NotifText;
action?: NotifAction; action?: NotifAction;
secondaryAction?: NotifAction; secondaryAction?: NotifAction;
meta?: NotifMeta; meta?: NotifMeta;
@@ -175,17 +228,21 @@ let cachedUnread = 0;
function load(): Notification[] { function load(): Notification[] {
try { try {
const raw = readAccount<unknown>(HISTORY_KEY, []); // The old key only until the new one exists: its rows carry forward, said text as `raw`.
if (!Array.isArray(raw)) return []; const stored =
readAccount<unknown>(HISTORY_KEY, null) ?? readAccount<unknown>(LS.legacyNotifHistory, []);
if (!Array.isArray(stored)) return [];
// Restored entries are history only. Mark them dismissed so they don't // Restored entries are history only. Mark them dismissed so they don't
// resurrect as active toasts on reload — their auto-dismiss timers aren't // resurrect as active toasts on reload — their auto-dismiss timers aren't
// re-armed across a reload, so otherwise they'd stick forever. Also drop the // re-armed across a reload, so otherwise they'd stick forever. Also drop the
// live `action` callback, which can't survive serialization. // live `action` callback, which can't survive serialization.
return raw.map( return stored.map(
(n) => (n) =>
({ ({
repeat: 1, repeat: 1,
...(n as object), ...(n as object),
title: asText((n as { title?: unknown }).title),
message: asText((n as { message?: unknown }).message) ?? raw(""),
action: undefined, action: undefined,
secondaryAction: undefined, secondaryAction: undefined,
dismissed: true, dismissed: true,
@@ -261,9 +318,12 @@ const COALESCE_WINDOW_MS = 5000;
export function notify(input: NotifyInput): number { export function notify(input: NotifyInput): number {
const requiresInteraction = input.requiresInteraction ?? false; const requiresInteraction = input.requiresInteraction ?? false;
const level = input.level ?? "info"; const level = input.level ?? "info";
log.at(LOG_LEVEL[level], "ui.notify", input.message, { // In English, whatever the reader's language (a server refusal aside: it says itself in the
// reader's), so one log reads the same for every reader.
const en = i18n.getFixedT("en");
log.at(LOG_LEVEL[level], "ui.notify", sayNotif(input.message, en), {
level, level,
...(input.title ? { title: input.title } : {}), ...(input.title ? { title: sayNotif(input.title, en) } : {}),
...(input.meta ? { kind: input.meta.kind } : {}), ...(input.meta ? { kind: input.meta.kind } : {}),
}); });
const now = Date.now(); const now = Date.now();
@@ -294,8 +354,8 @@ export function notify(input: NotifyInput): number {
!n.action && !n.action &&
now - n.ts <= COALESCE_WINDOW_MS && now - n.ts <= COALESCE_WINDOW_MS &&
n.level === level && n.level === level &&
(n.title ?? "") === (input.title ?? "") && sameText(n.title, input.title) &&
n.message === input.message, sameText(n.message, input.message),
); );
if (prior) { if (prior) {
// Re-surface the existing toast (undismiss + unread) and re-arm its auto-dismiss timer. // Re-surface the existing toast (undismiss + unread) and re-arm its auto-dismiss timer.
+6
View File
@@ -1,7 +1,13 @@
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import { msg, raw, type NotifText } from "./notifications";
/** Watch later is a built-in playlist whose stored `name` is not translatable — show the localized /** Watch later is a built-in playlist whose stored `name` is not translatable — show the localized
* label instead. Was hand-inlined in Playlists, PlaylistsRail and AddToPlaylist; one copy now. */ * label instead. Was hand-inlined in Playlists, PlaylistsRail and AddToPlaylist; one copy now. */
export function playlistName(p: { kind: string; name: string }, t: TFunction): string { export function playlistName(p: { kind: string; name: string }, t: TFunction): string {
return p.kind === "watch_later" ? t("playlists.watchLater") : p.name; return p.kind === "watch_later" ? t("playlists.watchLater") : p.name;
} }
/** The same name in a notice, said in the reader's language when it is shown. */
export function playlistNameText(p: { kind: string; name: string }): NotifText {
return p.kind === "watch_later" ? msg("playlists.watchLater") : raw(p.name);
}
+13 -4
View File
@@ -3,6 +3,7 @@ import { REDUCED_MOTION, useMediaQuery } from "./useMediaQuery";
import { usePref, useSavePref } from "./usePref"; import { usePref, useSavePref } from "./usePref";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import type { PlexCard, PlexTmdb } from "./api"; import type { PlexCard, PlexTmdb } from "./api";
import { msg, raw, sayNotif, type NotifText } from "./notifications";
// The non-component half of the shared Plex "detail page" UI (see plexDetailUi): the per-user display // The non-component half of the shared Plex "detail page" UI (see plexDetailUi): the per-user display
// prefs, the TMDB backdrop URL, the crossfading art backdrop hook and the episode label. Split out so // prefs, the TMDB backdrop URL, the crossfading art backdrop hook and the episode label. Split out so
@@ -241,9 +242,17 @@ export function seasonTitle(
se: { title?: string | null; season_number?: number | null }, se: { title?: string | null; season_number?: number | null },
t: TFunction, t: TFunction,
): string { ): string {
return sayNotif(seasonTitleText(se), t);
}
/** The season's name as a notice keeps it: said in the reader's language each time it is shown. */
export function seasonTitleText(se: {
title?: string | null;
season_number?: number | null;
}): NotifText {
const n = se.season_number; const n = se.season_number;
if (se.title && !GENERIC_SEASON.test(se.title.trim())) return se.title; if (se.title && !GENERIC_SEASON.test(se.title.trim())) return raw(se.title);
if (n === 0) return t("plex.specials"); if (n === 0) return msg("plex.specials");
if (n != null) return t("plex.seasonTitle", { n }); if (n != null) return msg("plex.seasonTitle", { n });
return se.title || t("plex.playlist.seasonUnknown"); return se.title ? raw(se.title) : msg("plex.playlist.seasonUnknown");
} }
+2 -3
View File
@@ -1,6 +1,6 @@
import type { TFunction } from "i18next";
import { api, type PlexCard } from "./api"; import { api, type PlexCard } from "./api";
import { notifyActionError } from "./actionErrors"; import { notifyActionError } from "./actionErrors";
import { msg } from "./notifications";
// Persist a watch-state toggle from any Plex surface: flip via the caller's `optimistic` cache // Persist a watch-state toggle from any Plex surface: flip via the caller's `optimistic` cache
// updater, call the API, surface a failure (the click used to look dead on 403/404/429), then // updater, call the API, surface a failure (the click used to look dead on 403/404/429), then
@@ -12,7 +12,6 @@ import { notifyActionError } from "./actionErrors";
// (react-query keeps the same `data`), or a cache whose refetch lands later than the click's answer. // (react-query keeps the same `data`), or a cache whose refetch lands later than the click's answer.
export async function toggleWatchState( export async function toggleWatchState(
card: PlexCard, card: PlexCard,
t: TFunction,
handlers: { optimistic: (next: string) => void; resync: () => void; revert?: () => void }, handlers: { optimistic: (next: string) => void; resync: () => void; revert?: () => void },
) { ) {
const next = card.status === "watched" ? "new" : "watched"; const next = card.status === "watched" ? "new" : "watched";
@@ -21,7 +20,7 @@ export async function toggleWatchState(
await api.plexSetState(card.id, next); await api.plexSetState(card.id, next);
} catch (e) { } catch (e) {
handlers.revert?.(); handlers.revert?.();
notifyActionError(e, t("plex.watchedFailed")); notifyActionError(e, msg("plex.watchedFailed"));
} }
handlers.resync(); handlers.resync();
} }
+12 -10
View File
@@ -5,16 +5,18 @@
// silence on a 403/404/429 or a lost connection — 34 of them. // silence on a 403/404/429 or a lost connection — 34 of them.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("./notifications", () => ({ notify: vi.fn(() => 0) })); vi.mock("./notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("./notifications")>()),
notify: vi.fn(() => 0),
}));
vi.mock("./errorDialog", () => ({ reportError: vi.fn() })); vi.mock("./errorDialog", () => ({ reportError: vi.fn() }));
import { MutationObserver, type MutationObserverOptions } from "@tanstack/react-query"; import { MutationObserver, type MutationObserverOptions } from "@tanstack/react-query";
import { createQueryClient } from "./queryClient"; import { createQueryClient } from "./queryClient";
import { HttpError, req } from "./api/core"; import { HttpError, req } from "./api/core";
import { notify } from "./notifications"; import { msg, notify, raw } from "./notifications";
import { reportError } from "./errorDialog"; import { reportError } from "./errorDialog";
import { notifyActionError } from "./actionErrors"; import { notifyActionError } from "./actionErrors";
import i18n from "../i18n";
const notifyMock = vi.mocked(notify); const notifyMock = vi.mocked(notify);
beforeEach(() => { beforeEach(() => {
@@ -39,7 +41,7 @@ describe("a failed mutation", () => {
meta: { failTitle: TITLE }, meta: { failTitle: TITLE },
}); });
expect(notifyMock.mock.calls).toEqual([ expect(notifyMock.mock.calls).toEqual([
[{ level: "error", title: i18n.t(TITLE), message: i18n.t("errors.notFound.generic") }], [{ level: "error", title: msg(TITLE), message: msg("errors.notFound.generic") }],
]); ]);
}); });
@@ -47,8 +49,8 @@ describe("a failed mutation", () => {
await fail({ mutationFn: () => Promise.reject(new HttpError(429, "slow down")) }); await fail({ mutationFn: () => Promise.reject(new HttpError(429, "slow down")) });
expect(notifyMock).toHaveBeenCalledWith({ expect(notifyMock).toHaveBeenCalledWith({
level: "error", level: "error",
title: i18n.t("errors.title"), title: msg("errors.title"),
message: i18n.t("errors.reason.rateLimited"), message: msg("errors.reason.rateLimited"),
}); });
}); });
@@ -58,7 +60,7 @@ describe("a failed mutation", () => {
meta: { failTitle: TITLE }, meta: { failTitle: TITLE },
}); });
expect(notifyMock).toHaveBeenCalledWith( expect(notifyMock).toHaveBeenCalledWith(
expect.objectContaining({ message: i18n.t("errors.reason.unreachable") }), expect.objectContaining({ message: msg("errors.reason.unreachable") }),
); );
}); });
@@ -66,9 +68,9 @@ describe("a failed mutation", () => {
await fail({ await fail({
mutationFn: () => Promise.reject(new HttpError(404, "gone")), mutationFn: () => Promise.reject(new HttpError(404, "gone")),
meta: { failTitle: TITLE }, meta: { failTitle: TITLE },
onError: (e) => notifyActionError(e, "OWN TITLE"), onError: (e) => notifyActionError(e, raw("OWN TITLE")),
}); });
expect(notifyMock.mock.calls.map(([n]) => n.title)).toEqual(["OWN TITLE"]); expect(notifyMock.mock.calls.map(([n]) => n.title)).toEqual([raw("OWN TITLE")]);
}); });
it("is still reported when the mutation's onError only rolls back", async () => { it("is still reported when the mutation's onError only rolls back", async () => {
@@ -79,7 +81,7 @@ describe("a failed mutation", () => {
onError: rollback, onError: rollback,
}); });
expect(rollback).toHaveBeenCalled(); expect(rollback).toHaveBeenCalled();
expect(notifyMock.mock.calls.map(([n]) => n.title)).toEqual([i18n.t(TITLE)]); expect(notifyMock.mock.calls.map(([n]) => n.title)).toEqual([msg(TITLE)]);
}); });
it("gets no toast when the page shows the failure itself", async () => { it("gets no toast when the page shows the failure itself", async () => {
+2 -2
View File
@@ -1,7 +1,7 @@
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query"; import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
import i18n from "../i18n";
import { notifyActionError } from "./actionErrors"; import { notifyActionError } from "./actionErrors";
import { log } from "./log"; import { log } from "./log";
import { msg } from "./notifications";
/** A failed query or mutation, for the client log. Only the key's HEAD (the resource, `"videos"`): /** A failed query or mutation, for the client log. Only the key's HEAD (the resource, `"videos"`):
* the rest of a key is filter state, which the base level does not keep. The status is what * the rest of a key is filter state, which the base level does not keep. The status is what
@@ -42,7 +42,7 @@ type FailMeta = { failTitle?: string; errorShownInline?: boolean } | undefined;
*/ */
function reportMutationFailure(err: unknown, meta: FailMeta): void { function reportMutationFailure(err: unknown, meta: FailMeta): void {
if (meta?.errorShownInline) return; if (meta?.errorShownInline) return;
setTimeout(() => notifyActionError(err, i18n.t(meta?.failTitle ?? "errors.title")), 0); setTimeout(() => notifyActionError(err, msg(meta?.failTitle ?? "errors.title")), 0);
} }
export function createQueryClient(): QueryClient { export function createQueryClient(): QueryClient {
+6 -1
View File
@@ -100,7 +100,12 @@ const KEYS = {
// flag). A YT.Player instance is created per video and starts at full volume, so without this // flag). A YT.Player instance is created per video and starts at full volume, so without this
// every next video is loud again (same idea as plexPlayerPrefs). // every next video is loud again (same idea as plexPlayerPrefs).
ytPlayerPrefs: { key: "siftlode.ytPlayerPrefs", kind: "device" }, ytPlayerPrefs: { key: "siftlode.ytPlayerPrefs", kind: "device" },
notifHistory: { key: "siftlode.notifications", kind: "cache" }, // The notice history: what each notice says (lib/notifications NotifText), not the said text.
notifHistory: { key: "siftlode.notifHistory", kind: "cache" },
// DEPRECATED — the history as said text, which a build from before the change still reads (and
// renders as text: an object there crashed its Notifications page after a rollback). Read once while
// `notifHistory` does not exist yet, then inert. Do not write.
legacyNotifHistory: { key: "siftlode.notifications", kind: "cache" },
notifSettings: { key: "siftlode.notifSettings", kind: "pref" }, notifSettings: { key: "siftlode.notifSettings", kind: "pref" },
onboardingDismissed: { key: "siftlode.onboarding.dismissed", kind: "pref" }, onboardingDismissed: { key: "siftlode.onboarding.dismissed", kind: "pref" },
seenVersion: { key: "siftlode.seenVersion", kind: "pref" }, seenVersion: { key: "siftlode.seenVersion", kind: "pref" },
@@ -28,6 +28,7 @@ vi.mock("./entryUrl", async (importOriginal) => {
return { ...actual, accountSwitchNotice: () => notice() }; return { ...actual, accountSwitchNotice: () => notice() };
}); });
const { useAccountSwitchNotice } = await import("./useAccountSwitchNotice"); const { useAccountSwitchNotice } = await import("./useAccountSwitchNotice");
const { msg, raw } = await import("./notifications");
afterEach(() => { afterEach(() => {
notifyMock.mockClear(); notifyMock.mockClear();
@@ -39,7 +40,7 @@ describe("the shell's word on a switch of account", () => {
notice.mockReturnValue({ notice.mockReturnValue({
level: "info", level: "info",
key: "header.account.switchRequested", key: "header.account.switchRequested",
values: { email: "b@example.com" }, values: { email: raw("b@example.com") },
}); });
const { rerender } = renderHook(({ signedIn }) => useAccountSwitchNotice(signedIn), { const { rerender } = renderHook(({ signedIn }) => useAccountSwitchNotice(signedIn), {
initialProps: { signedIn: false }, initialProps: { signedIn: false },
@@ -53,7 +54,7 @@ describe("the shell's word on a switch of account", () => {
expect(notifyMock).toHaveBeenCalledTimes(1); expect(notifyMock).toHaveBeenCalledTimes(1);
expect(notifyMock).toHaveBeenCalledWith({ expect(notifyMock).toHaveBeenCalledWith({
level: "info", level: "info",
message: 'header.account.switchRequested {"email":"b@example.com"}', message: msg("header.account.switchRequested", { email: raw("b@example.com") }),
}); });
}); });
+2 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { accountSwitchNotice } from "./entryUrl"; import { accountSwitchNotice } from "./entryUrl";
import { notify } from "./notifications"; import { msg, notify } from "./notifications";
/** Say, once per load, what the entry URL tells the account signed in here about a switch of /** Say, once per load, what the entry URL tells the account signed in here about a switch of
* account (`accountSwitchNotice`): a sign-in as someone else the server refused, or a tab that * account (`accountSwitchNotice`): a sign-in as someone else the server refused, or a tab that
@@ -14,6 +14,6 @@ export function useAccountSwitchNotice(signedIn: boolean): void {
if (!signedIn || said.current) return; if (!signedIn || said.current) return;
said.current = true; said.current = true;
const notice = accountSwitchNotice(); const notice = accountSwitchNotice();
if (notice) notify({ level: notice.level, message: t(notice.key, notice.values) }); if (notice) notify({ level: notice.level, message: msg(notice.key, notice.values) });
}, [signedIn, t]); }, [signedIn, t]);
} }
+2 -2
View File
@@ -1,8 +1,8 @@
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef } from "react";
import { api } from "./api"; import { api } from "./api";
import { notifyActionError } from "./actionErrors"; import { notifyActionError } from "./actionErrors";
import i18n from "../i18n";
import { useLatest } from "./useLatest"; import { useLatest } from "./useLatest";
import { msg } from "./notifications";
/** The shared feed-priority flow used by both the channel manager and the channel detail page: /** The shared feed-priority flow used by both the channel manager and the channel detail page:
* an optimistic bump/set with a debounced server flush (~400ms after the last notch) so a * an optimistic bump/set with a debounced server flush (~400ms after the last notch) so a
@@ -40,7 +40,7 @@ export function useChannelPriority(opts: {
.catch((err: unknown) => { .catch((err: unknown) => {
// Said here, once, for both callers: their `onError` only refetches, so a refused priority // Said here, once, for both callers: their `onError` only refetches, so a refused priority
// used to just snap back — often after the stepper had already been let go. // used to just snap back — often after the stepper had already been let go.
notifyActionError(err, i18n.t("errors.writeFailed.channelPriority")); notifyActionError(err, msg("errors.writeFailed.channelPriority"));
optsRef.current.onError(channelId); optsRef.current.onError(channelId);
}); });
}, },
+3 -4
View File
@@ -1,8 +1,8 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { api } from "./api"; import { api } from "./api";
import { qk } from "./queryKeys"; import { qk } from "./queryKeys";
import { notifyActionError } from "./actionErrors"; import { notifyActionError } from "./actionErrors";
import { msg } from "./notifications";
/** The shared channel-tag mutation flow used by both the channel manager and the channel detail /** The shared channel-tag mutation flow used by both the channel manager and the channel detail
* page: optimistic attach/detach + a create-then-attach shortcut, with the cache-shape-specific * page: optimistic attach/detach + a create-then-attach shortcut, with the cache-shape-specific
@@ -21,7 +21,6 @@ export function useChannelTagActions(opts: {
onError: (channelId: string) => void; onError: (channelId: string) => void;
}) { }) {
const qc = useQueryClient(); const qc = useQueryClient();
const { t } = useTranslation();
const toggleTag = (channelId: string, tagId: number, hasTag: boolean) => { const toggleTag = (channelId: string, tagId: number, hasTag: boolean) => {
opts.optimisticToggle(channelId, tagId, !hasTag); opts.optimisticToggle(channelId, tagId, !hasTag);
@@ -31,7 +30,7 @@ export function useChannelTagActions(opts: {
call call
.then(() => opts.onSuccess?.(channelId)) .then(() => opts.onSuccess?.(channelId))
.catch((err: unknown) => { .catch((err: unknown) => {
notifyActionError(err, t("channels.tags.updateFailed")); notifyActionError(err, msg("channels.tags.updateFailed"));
opts.onError(channelId); opts.onError(channelId);
}); });
}; };
@@ -45,7 +44,7 @@ export function useChannelTagActions(opts: {
qc.invalidateQueries({ queryKey: qk.tags() }); qc.invalidateQueries({ queryKey: qk.tags() });
toggleTag(channelId, tag.id, false); toggleTag(channelId, tag.id, false);
}) })
.catch((err: unknown) => notifyActionError(err, t("channels.tags.createFailed"))); .catch((err: unknown) => notifyActionError(err, msg("channels.tags.createFailed")));
}; };
return { toggleTag, createAndAttachTag }; return { toggleTag, createAndAttachTag };
@@ -17,7 +17,7 @@ vi.mock("./api", () => ({ api: { savePrefs, me: vi.fn() } }));
vi.mock("./actionErrors", () => ({ notifyActionError })); vi.mock("./actionErrors", () => ({ notifyActionError }));
import { usePref, useSavePref } from "./usePref"; import { usePref, useSavePref } from "./usePref";
import i18n from "../i18n"; import { msg } from "./notifications";
afterEach(() => { afterEach(() => {
savePrefs.mockReset(); savePrefs.mockReset();
@@ -34,7 +34,7 @@ describe("a refused preference save", () => {
const { result } = renderHook(() => usePref<boolean>("hintsOn", true), { wrapper }); const { result } = renderHook(() => usePref<boolean>("hintsOn", true), { wrapper });
act(() => result.current[1](false)); act(() => result.current[1](false));
await waitFor(() => await waitFor(() =>
expect(notifyActionError).toHaveBeenCalledWith(refusal, i18n.t("errors.writeFailed.prefs")), expect(notifyActionError).toHaveBeenCalledWith(refusal, msg("errors.writeFailed.prefs")),
); );
expect(result.current[0]).toBe(false); // the local value stays; the toast says it did not save expect(result.current[0]).toBe(false); // the local value stays; the toast says it did not save
}); });
@@ -45,7 +45,7 @@ describe("a refused preference save", () => {
const { result } = renderHook(() => useSavePref(), { wrapper }); const { result } = renderHook(() => useSavePref(), { wrapper });
act(() => result.current({ stripSources: { c1: false } })); act(() => result.current({ stripSources: { c1: false } }));
await waitFor(() => await waitFor(() =>
expect(notifyActionError).toHaveBeenCalledWith(refusal, i18n.t("errors.writeFailed.prefs")), expect(notifyActionError).toHaveBeenCalledWith(refusal, msg("errors.writeFailed.prefs")),
); );
}); });
}); });
+2 -2
View File
@@ -6,13 +6,13 @@ import type { Me } from "./api/account";
import { getAccountRaw, writeAccount } from "./storage"; import { getAccountRaw, writeAccount } from "./storage";
import { notifyActionError } from "./actionErrors"; import { notifyActionError } from "./actionErrors";
import { updateMeCache } from "./useMe"; import { updateMeCache } from "./useMe";
import i18n from "../i18n"; import { msg } from "./notifications";
// The local state + cache already show the new value, so a refused save is otherwise invisible until // The local state + cache already show the new value, so a refused save is otherwise invisible until
// the next load quietly puts the old one back. `req` reports none of 403/404/429 and only the // the next load quietly puts the old one back. `req` reports none of 403/404/429 and only the
// connection notice for a lost connection — say it. // connection notice for a lost connection — say it.
const reportPrefSaveFailure = (err: unknown) => const reportPrefSaveFailure = (err: unknown) =>
notifyActionError(err, i18n.t("errors.writeFailed.prefs")); notifyActionError(err, msg("errors.writeFailed.prefs"));
// The ONE per-account + server preference hook. A preference (unlike an ephemeral filter) persists to // The ONE per-account + server preference hook. A preference (unlike an ephemeral filter) persists to
// the account on the server (PUT /api/me/preferences, which MERGES the partial payload) and follows the // the account on the server (PUT /api/me/preferences, which MERGES the partial payload) and follows the
+2 -3
View File
@@ -1,6 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { api, type Me } from "./api"; import { api, type Me } from "./api";
import { qk } from "./queryKeys"; import { qk } from "./queryKeys";
import { notifyActionError } from "./actionErrors"; import { notifyActionError } from "./actionErrors";
@@ -15,6 +14,7 @@ import {
type PanelId, type PanelId,
type PanelLayout, type PanelLayout,
} from "./panelLayout"; } from "./panelLayout";
import { msg } from "./notifications";
// A side rail's own state: its group cards' layout and whether it is collapsed. Each rail reads it // A side rail's own state: its group cards' layout and whether it is collapsed. Each rail reads it
// here rather than being handed it, so the shell only places the rail. // here rather than being handed it, so the shell only places the rail.
@@ -24,7 +24,6 @@ import {
// it. The server keeps the copy that follows the account, this browser a local one for an account // it. The server keeps the copy that follows the account, this browser a local one for an account
// whose server copy has none. // whose server copy has none.
export function usePanelLayout(panel: PanelId): [PanelLayout, (next: PanelLayout) => void] { export function usePanelLayout(panel: PanelId): [PanelLayout, (next: PanelLayout) => void] {
const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const [layout, setState] = useState(() => const [layout, setState] = useState(() =>
seedLayout(panel, qc.getQueryData<Me | null>(qk.me())?.preferences), seedLayout(panel, qc.getQueryData<Me | null>(qk.me())?.preferences),
@@ -38,7 +37,7 @@ export function usePanelLayout(panel: PanelId): [PanelLayout, (next: PanelLayout
})); }));
api api
.savePrefs({ [PREF_KEY[panel]]: next }) .savePrefs({ [PREF_KEY[panel]]: next })
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.prefs"))); .catch((e: unknown) => notifyActionError(e, msg("errors.writeFailed.prefs")));
}; };
return [layout, setLayout]; return [layout, setLayout];
} }
+5 -8
View File
@@ -1,16 +1,15 @@
import { HttpError } from "./api"; import { HttpError } from "./api";
import { markReported } from "./api/core"; import { markReported } from "./api/core";
import { notify } from "./notifications"; import { msg, notify, type NotifText } from "./notifications";
import { notifyActionError } from "./actionErrors"; import { notifyActionError } from "./actionErrors";
import i18n from "../i18n";
// A YouTube-gated action (subscribe, sync, backfill, unsubscribe) that 403s means the user hasn't // A YouTube-gated action (subscribe, sync, backfill, unsubscribe) that 403s means the user hasn't
// granted the write scope — surface a "Connect" affordance instead of a vague failure. Pass // granted the write scope — surface a "Connect" affordance instead of a vague failure. Pass
// `onConnect` to offer the connect-wizard button (callers that have no wizard handle, e.g. the // `onConnect` to offer the connect-wizard button (callers that have no wizard handle, e.g. the
// channel page, just get the message). `fallbackMessage` is the already-translated non-403 error. // channel page, just get the message). `fallbackMessage` is what a non-403 error says.
export function notifyYouTubeActionError( export function notifyYouTubeActionError(
err: unknown, err: unknown,
fallbackMessage: string, fallbackMessage: NotifText,
onConnect?: () => void, onConnect?: () => void,
): void { ): void {
const httpErr = err instanceof HttpError ? err : null; const httpErr = err instanceof HttpError ? err : null;
@@ -18,10 +17,8 @@ export function notifyYouTubeActionError(
// A 403 on a YouTube write action means the write scope isn't granted — offer to connect. // A 403 on a YouTube write action means the write scope isn't granted — offer to connect.
notify({ notify({
level: "error", level: "error",
message: i18n.t("channels.notify.needYouTube"), message: msg("channels.notify.needYouTube"),
action: onConnect action: onConnect ? { label: msg("channels.notify.connect"), onClick: onConnect } : undefined,
? { label: i18n.t("channels.notify.connect"), onClick: onConnect }
: undefined,
}); });
markReported(err); markReported(err);
return; return;
@@ -8,7 +8,7 @@ import { notifyActionError } from "../../lib/actionErrors";
import { sinceOf } from "../../lib/activityUrlState"; import { sinceOf } from "../../lib/activityUrlState";
import { auditActionLabel, auditText } from "../../lib/auditText"; import { auditActionLabel, auditText } from "../../lib/auditText";
import { formatDateTimeSec } from "../../lib/format"; import { formatDateTimeSec } from "../../lib/format";
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { useHistoryList } from "../../lib/useHistoryList"; import { useHistoryList } from "../../lib/useHistoryList";
import type { Shared } from "./ActivityRuns"; import type { Shared } from "./ActivityRuns";
@@ -70,9 +70,9 @@ export default function ActivityAudit({
onSuccess: (r) => { onSuccess: (r) => {
void qc.invalidateQueries({ queryKey: qk.adminAudit() }); void qc.invalidateQueries({ queryKey: qk.adminAudit() });
void qc.invalidateQueries({ queryKey: qk.adminAuditFacets() }); void qc.invalidateQueries({ queryKey: qk.adminAuditFacets() });
notify({ level: "success", message: t("audit.cleared", { count: r.cleared }) }); notify({ level: "success", message: msg("audit.cleared", { count: r.cleared }) });
}, },
onError: (e) => notifyActionError(e, t("audit.clearFailed")), onError: (e) => notifyActionError(e, msg("audit.clearFailed")),
}); });
const onClear = async () => { const onClear = async () => {
if ( if (
+3 -3
View File
@@ -15,7 +15,7 @@ import { nextPageOnClick } from "../../lib/nextPage";
import { LEVEL_GROUPS, type LevelGroup } from "../../lib/log"; import { LEVEL_GROUPS, type LevelGroup } from "../../lib/log";
import { sinceOf } from "../../lib/activityUrlState"; import { sinceOf } from "../../lib/activityUrlState";
import { logsParamsToState, logsParamUpdates, type LogsUrlState } from "../../lib/logsUrlState"; import { logsParamsToState, logsParamUpdates, type LogsUrlState } from "../../lib/logsUrlState";
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { useUrlMirror } from "../../lib/useUrlMirror"; import { useUrlMirror } from "../../lib/useUrlMirror";
import { HistoryFrame } from "./HistoryList"; import { HistoryFrame } from "./HistoryList";
@@ -187,8 +187,8 @@ export default function AdminLogs({
const ok = await copyText(text); const ok = await copyText(text);
notify( notify(
ok ok
? { level: "success", message: t("adminLogs.copied", { count: rows.length }) } ? { level: "success", message: msg("adminLogs.copied", { count: rows.length }) }
: { level: "error", message: t("adminLogs.copyFailed") }, : { level: "error", message: msg("adminLogs.copyFailed") },
); );
}; };
@@ -11,7 +11,7 @@ import { hydrateSort, sortRows, type SortState } from "../../lib/columnSort";
import { usePref } from "../../lib/usePref"; import { usePref } from "../../lib/usePref";
import { useCardPager } from "../../lib/useCardPager"; import { useCardPager } from "../../lib/useCardPager";
import { subsColumn } from "./channelColumns"; import { subsColumn } from "./channelColumns";
import { notify } from "../../lib/notifications"; import { msg, notify, raw } from "../../lib/notifications";
import { notifyYouTubeActionError } from "../../lib/youtubeErrors"; import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
import Tooltip from "../../components/ui/Tooltip"; import Tooltip from "../../components/ui/Tooltip";
import ChannelLink from "./ChannelLink"; import ChannelLink from "./ChannelLink";
@@ -70,13 +70,13 @@ export default function ChannelDiscovery({
// callbacks are gone (see ChannelSubscribedMeta). // callbacks are gone (see ChannelSubscribedMeta).
notify({ notify({
level: "success", level: "success",
title: t("channels.discovery.subscribedTitle"), title: msg("channels.discovery.subscribedTitle"),
message: t("channels.discovery.subscribedBody", { name }), message: msg("channels.discovery.subscribedBody", { name: raw(name) }),
meta: { kind: "channel-subscribed", channelId: c.id, channelName: name }, meta: { kind: "channel-subscribed", channelId: c.id, channelName: name },
}); });
}, },
onError: (err: unknown) => onError: (err: unknown) =>
notifyYouTubeActionError(err, t("channels.discovery.subscribeFailed"), onOpenWizard), notifyYouTubeActionError(err, msg("channels.discovery.subscribeFailed"), onOpenWizard),
}); });
// Subscribing changes the user's real YouTube account and spends a little quota — confirm // Subscribing changes the user's real YouTube account and spends a little quota — confirm
+11 -8
View File
@@ -18,7 +18,7 @@ import { Sparkles } from "lucide-react";
import { notifyYouTubeActionError } from "../../lib/youtubeErrors"; import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
import { LS, ownKey } from "../../lib/storage"; import { LS, ownKey } from "../../lib/storage";
import { buildChannelColumns, applySyncFilter, tagFilterChips } from "./channelColumns"; import { buildChannelColumns, applySyncFilter, tagFilterChips } from "./channelColumns";
import { notify } from "../../lib/notifications"; import { msg, notify, raw } from "../../lib/notifications";
import { Badge } from "../../components/ui/Badge"; import { Badge } from "../../components/ui/Badge";
import { Button } from "../../components/ui/Button"; import { Button } from "../../components/ui/Button";
import Tooltip from "../../components/ui/Tooltip"; import Tooltip from "../../components/ui/Tooltip";
@@ -168,9 +168,9 @@ export default function Channels() {
(tagId: number, name: string) => { (tagId: number, name: string) => {
setFeedFilters({ ...feedFilters, tags: [tagId], channelIds: [] }); setFeedFilters({ ...feedFilters, tags: [tagId], channelIds: [] });
setPage("feed"); setPage("feed");
notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) }); notify({ level: "info", message: msg("channels.notify.filteredByTag", { name: raw(name) }) });
}, },
[setFeedFilters, feedFilters, setPage, t], [setFeedFilters, feedFilters, setPage],
); );
const channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels }); const channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels });
@@ -294,10 +294,10 @@ export default function Channels() {
invalidate(); invalidate();
notify({ notify({
level: "success", level: "success",
message: t("channels.notify.synced", { count: r.subscriptions ?? 0 }), message: msg("channels.notify.synced", { count: r.subscriptions ?? 0 }),
}); });
}, },
onError: (e) => notifyYouTubeActionError(e, t("channels.notify.syncFailed"), onOpenWizard), onError: (e) => notifyYouTubeActionError(e, msg("channels.notify.syncFailed"), onOpenWizard),
}); });
const deepAll = useMutation({ const deepAll = useMutation({
mutationFn: () => api.deepAll(true), mutationFn: () => api.deepAll(true),
@@ -306,11 +306,11 @@ export default function Channels() {
qc.invalidateQueries({ queryKey: qk.myStatus() }); qc.invalidateQueries({ queryKey: qk.myStatus() });
notify({ notify({
level: "success", level: "success",
message: t("channels.notify.fullHistoryRequested", { count: r.updated ?? 0 }), message: msg("channels.notify.fullHistoryRequested", { count: r.updated ?? 0 }),
}); });
}, },
onError: (e) => onError: (e) =>
notifyYouTubeActionError(e, t("channels.notify.fullHistoryFailed"), onOpenWizard), notifyYouTubeActionError(e, msg("channels.notify.fullHistoryFailed"), onOpenWizard),
}); });
// The tag-chip row is a controlled view over the Tags COLUMN filter (`filters.tags`, a string[] of // The tag-chip row is a controlled view over the Tags COLUMN filter (`filters.tags`, a string[] of
@@ -406,7 +406,10 @@ export default function Channels() {
((channelsQuery.data ?? []).find((c) => c.id === rowMenu.target!.id) ?? rowMenu.target); ((channelsQuery.data ?? []).find((c) => c.id === rowMenu.target!.id) ?? rowMenu.target);
const menuItems = menuRow const menuItems = menuRow
? channelActions.items(menuRow, { ? channelActions.items(menuRow, {
pin: rowPin.item(pinChannel(menuRow.id), menuRow.title ?? t("channels.row.thisChannel")), pin: rowPin.item(
pinChannel(menuRow.id),
menuRow.title == null ? msg("channels.row.thisChannel") : raw(menuRow.title),
),
}) })
: []; : [];
+2 -1
View File
@@ -12,6 +12,7 @@ import { Button } from "../../components/ui/Button";
import { IconButton } from "../../components/ui/IconButton"; import { IconButton } from "../../components/ui/IconButton";
import { Pressable } from "../../components/ui/Pressable"; import { Pressable } from "../../components/ui/Pressable";
import { SURFACE } from "../../components/ui/surfaces"; import { SURFACE } from "../../components/ui/surfaces";
import { msg } from "../../lib/notifications";
// Per-user status (not the global catalog): shows the number of videos available to *this* // Per-user status (not the global catalog): shows the number of videos available to *this*
// user, how many of *their* channels are still being fetched, and how many lack full // user, how many of *their* channels are still being fetched, and how many lack full
@@ -64,7 +65,7 @@ export default function SyncStatus({
onError: (e) => onError: (e) =>
notifyActionError( notifyActionError(
e, e,
t(data?.paused ? "errors.writeFailed.syncResume" : "errors.writeFailed.syncPause"), msg(data?.paused ? "errors.writeFailed.syncResume" : "errors.writeFailed.syncPause"),
), ),
}); });
@@ -59,6 +59,7 @@ vi.mock("../../features/player/PlayerModal", () => ({
import WatchedChannels from "./WatchedChannels"; import WatchedChannels from "./WatchedChannels";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { api, type Video, type WatchedChannel, type WatchedChannels as List } from "../../lib/api"; import { api, type Video, type WatchedChannel, type WatchedChannels as List } from "../../lib/api";
import { msg, type NotifText } from "../../lib/notifications";
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
@@ -149,8 +150,10 @@ describe("WatchedChannels", () => {
fireEvent.click(await screen.findByRole("button", { name: "channels.watched.dismiss" })); fireEvent.click(await screen.findByRole("button", { name: "channels.watched.dismiss" }));
await waitFor(() => expect(h.notify).toHaveBeenCalled()); await waitFor(() => expect(h.notify).toHaveBeenCalled());
expect(dismiss).toHaveBeenCalledWith("UC1"); expect(dismiss).toHaveBeenCalledWith("UC1");
const toast = h.notify.mock.calls[0]![0] as { action: { label: string; onClick: () => void } }; const toast = h.notify.mock.calls[0]![0] as {
expect(toast.action.label).toBe("common.undo"); action: { label: NotifText; onClick: () => void };
};
expect(toast.action.label).toEqual(msg("common.undo"));
act(() => toast.action.onClick()); act(() => toast.action.onClick());
await waitFor(() => expect(restore).toHaveBeenCalledWith("UC1")); await waitFor(() => expect(restore).toHaveBeenCalledWith("UC1"));
}); });
@@ -16,6 +16,7 @@ import { Pressable } from "../../components/ui/Pressable";
import RemoteImage from "../../components/ui/RemoteImage"; import RemoteImage from "../../components/ui/RemoteImage";
import Tooltip from "../../components/ui/Tooltip"; import Tooltip from "../../components/ui/Tooltip";
import type { WatchedChannelActions } from "./useWatchedChannelActions"; import type { WatchedChannelActions } from "./useWatchedChannelActions";
import { msg } from "../../lib/notifications";
const PlayerModal = lazy(() => import("../../features/player/PlayerModal")); const PlayerModal = lazy(() => import("../../features/player/PlayerModal"));
@@ -67,7 +68,7 @@ export default function WatchedVideosDialog({
); );
void qc.invalidateQueries({ queryKey: qk.feed() }); void qc.invalidateQueries({ queryKey: qk.feed() });
}) })
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState"))); .catch((e: unknown) => notifyActionError(e, msg("errors.writeFailed.videoState")));
return write; return write;
}); });
const closePlayer = useStableCallback(() => setPlaying(null)); const closePlayer = useStableCallback(() => setPlaying(null));
@@ -33,6 +33,7 @@ vi.mock("../../lib/useMe", () => ({ useMe: () => ({ role: h.role, can_write: h.c
import { useChannelActions } from "./useChannelActions"; import { useChannelActions } from "./useChannelActions";
import { api, type ManagedChannel, type Tag } from "../../lib/api"; import { api, type ManagedChannel, type Tag } from "../../lib/api";
import type { ActionMenuItem } from "../../components/ui/ActionMenu"; import type { ActionMenuItem } from "../../components/ui/ActionMenu";
import { msg, type NotifText, raw } from "../../lib/notifications";
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
@@ -195,14 +196,27 @@ describe("useChannelActions — hide and unsubscribe", () => {
expect(h.confirm).not.toHaveBeenCalled(); expect(h.confirm).not.toHaveBeenCalled();
expect(upd).toHaveBeenCalledWith("UC1", { hidden: true }); expect(upd).toHaveBeenCalledWith("UC1", { hidden: true });
const toast = h.notify.mock.calls[0]![0] as { const toast = h.notify.mock.calls[0]![0] as {
message: string; message: NotifText;
action: { onClick: () => void }; action: { onClick: () => void };
}; };
expect(toast.message).toBe("channels.notify.hidden"); expect(toast.message).toEqual(msg("channels.notify.hidden", { name: raw("Chan") }));
act(() => toast.action.onClick()); act(() => toast.action.onClick());
await waitFor(() => expect(upd).toHaveBeenCalledWith("UC1", { hidden: false })); await waitFor(() => expect(upd).toHaveBeenCalledWith("UC1", { hidden: false }));
}); });
it('names a channel with no title yet as "this channel" in the reader\'s language when shown', async () => {
vi.spyOn(api, "updateChannel").mockResolvedValue({} as never);
const { result } = mount();
act(() =>
result.current.items(ch({ title: null }), { pin }).find((i) => i.id === "hide")!.onSelect!(),
);
await waitFor(() => expect(h.notify).toHaveBeenCalled());
const toast = h.notify.mock.calls[0]![0] as { message: NotifText };
expect(toast.message).toEqual(
msg("channels.notify.hidden", { name: msg("channels.row.thisChannel") }),
);
});
it("unsubscribes only after the danger confirm says yes", async () => { it("unsubscribes only after the danger confirm says yes", async () => {
const unsub = vi.spyOn(api, "unsubscribeChannel").mockResolvedValue({} as never); const unsub = vi.spyOn(api, "unsubscribeChannel").mockResolvedValue({} as never);
const { result } = mount(); const { result } = mount();
@@ -7,7 +7,7 @@ import { api, type ManagedChannel, type Tag } from "../../lib/api";
import { priorityChoices } from "../../lib/channelOrder"; import { priorityChoices } from "../../lib/channelOrder";
import { compareText } from "../../lib/collate"; import { compareText } from "../../lib/collate";
import { channelYouTubeUrl } from "../../lib/format"; import { channelYouTubeUrl } from "../../lib/format";
import { notify } from "../../lib/notifications"; import { msg, notify, raw } from "../../lib/notifications";
import { notifyYouTubeActionError } from "../../lib/youtubeErrors"; import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
import { useMe } from "../../lib/useMe"; import { useMe } from "../../lib/useMe";
import { useConfirm } from "../../components/ui/confirmContext"; import { useConfirm } from "../../components/ui/confirmContext";
@@ -46,7 +46,9 @@ export function useChannelActions({
const me = useMe(); const me = useMe();
const isAdmin = me.role === "admin"; const isAdmin = me.role === "admin";
const copy = useCopyItems(); const copy = useCopyItems();
const nameOf = (c: ManagedChannel) => c.title ?? t("channels.row.thisChannel"); // The notice says it when shown: a channel with no title yet is "this channel" in the reader's language.
const nameOf = (c: ManagedChannel) =>
c.title == null ? msg("channels.row.thisChannel") : raw(c.title);
const refresh = () => { const refresh = () => {
qc.invalidateQueries({ queryKey: qk.channels() }); qc.invalidateQueries({ queryKey: qk.channels() });
@@ -70,9 +72,9 @@ export function useChannelActions({
refresh(); refresh();
notify({ notify({
level: "success", level: "success",
message: t("channels.notify.hidden", { name: nameOf(c) }), message: msg("channels.notify.hidden", { name: nameOf(c) }),
action: { action: {
label: t("common.undo"), label: msg("common.undo"),
onClick: () => patch.mutate({ id: c.id, body: { hidden: false } }), onClick: () => patch.mutate({ id: c.id, body: { hidden: false } }),
}, },
}); });
@@ -82,19 +84,22 @@ export function useChannelActions({
mutationFn: (c: ManagedChannel) => api.unsubscribeChannel(c.id), mutationFn: (c: ManagedChannel) => api.unsubscribeChannel(c.id),
onSuccess: (_d, c) => { onSuccess: (_d, c) => {
invalidateSubscription(qc, c.id); invalidateSubscription(qc, c.id);
notify({ level: "success", message: t("channels.notify.unsubscribed", { name: nameOf(c) }) }); notify({
level: "success",
message: msg("channels.notify.unsubscribed", { name: nameOf(c) }),
});
}, },
onError: (e) => onError: (e) =>
notifyYouTubeActionError(e, t("channels.notify.unsubscribeFailed"), onOpenWizard), notifyYouTubeActionError(e, msg("channels.notify.unsubscribeFailed"), onOpenWizard),
}); });
const resetBackfill = useMutation({ const resetBackfill = useMutation({
mutationFn: (c: ManagedChannel) => api.resetChannelBackfill(c.id), mutationFn: (c: ManagedChannel) => api.resetChannelBackfill(c.id),
onSuccess: (_d, c) => { onSuccess: (_d, c) => {
qc.invalidateQueries({ queryKey: qk.channels() }); qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.myStatus() }); qc.invalidateQueries({ queryKey: qk.myStatus() });
notify({ level: "success", message: t("channels.notify.resetDone", { name: nameOf(c) }) }); notify({ level: "success", message: msg("channels.notify.resetDone", { name: nameOf(c) }) });
}, },
onError: (e) => notifyYouTubeActionError(e, t("channels.notify.resetFailed"), onOpenWizard), onError: (e) => notifyYouTubeActionError(e, msg("channels.notify.resetFailed"), onOpenWizard),
}); });
async function onUnsubscribe(c: ManagedChannel) { async function onUnsubscribe(c: ManagedChannel) {
@@ -0,0 +1,39 @@
// @vitest-environment jsdom
//
// Subscribing changes the reader's real YouTube account, so the confirm names the channel it
// subscribes to — in both languages, through the real i18n. A notice's value (`raw`, `msg`) is said
// only by sayNotif: handed to a plain `t()` it reads "[object Object]".
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, renderHook } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import i18n from "../../i18n";
const h = vi.hoisted(() => ({ confirm: vi.fn(async (_o: { message: string }) => false) }));
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => h.confirm }));
import { useWatchedChannelActions } from "./useWatchedChannelActions";
import type { WatchedChannel } from "../../lib/api";
afterEach(async () => {
cleanup();
h.confirm.mockClear();
await i18n.changeLanguage("en");
});
const channel = { id: "UC1", title: "Kurzgesagt" } as WatchedChannel;
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={new QueryClient()}>{children}</QueryClientProvider>
);
describe("the subscribe confirm", () => {
it.each(["en", "hu"])("names the channel (%s)", async (lng) => {
await i18n.changeLanguage(lng);
const { result } = renderHook(() => useWatchedChannelActions(() => {}), { wrapper });
await act(() => result.current.onSubscribe(channel));
const said = h.confirm.mock.lastCall![0].message;
expect(said).toContain("Kurzgesagt");
expect(said).not.toContain("[object Object]");
});
});
@@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { invalidateSubscription } from "../../lib/subscriptionCache"; import { invalidateSubscription } from "../../lib/subscriptionCache";
import { api, type WatchedChannel } from "../../lib/api"; import { api, type WatchedChannel } from "../../lib/api";
import { notify } from "../../lib/notifications"; import { msg, notify, raw } from "../../lib/notifications";
import { notifyYouTubeActionError } from "../../lib/youtubeErrors"; import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
import { useConfirm } from "../../components/ui/confirmContext"; import { useConfirm } from "../../components/ui/confirmContext";
@@ -21,13 +21,13 @@ export function useWatchedChannelActions(onOpenWizard: () => void) {
invalidateSubscription(qc, c.id); invalidateSubscription(qc, c.id);
notify({ notify({
level: "success", level: "success",
title: t("channels.discovery.subscribedTitle"), title: msg("channels.discovery.subscribedTitle"),
message: t("channels.discovery.subscribedBody", { name: nameOf(c) }), message: msg("channels.discovery.subscribedBody", { name: raw(nameOf(c)) }),
meta: { kind: "channel-subscribed", channelId: c.id, channelName: nameOf(c) }, meta: { kind: "channel-subscribed", channelId: c.id, channelName: nameOf(c) },
}); });
}, },
onError: (err: unknown) => onError: (err: unknown) =>
notifyYouTubeActionError(err, t("channels.discovery.subscribeFailed"), onOpenWizard), notifyYouTubeActionError(err, msg("channels.discovery.subscribeFailed"), onOpenWizard),
}); });
const restore = useMutation({ const restore = useMutation({
mutationFn: (c: WatchedChannel) => api.restoreWatchedChannel(c.id), mutationFn: (c: WatchedChannel) => api.restoreWatchedChannel(c.id),
@@ -43,8 +43,8 @@ export function useWatchedChannelActions(onOpenWizard: () => void) {
void qc.invalidateQueries({ queryKey: qk.watchedChannels() }); void qc.invalidateQueries({ queryKey: qk.watchedChannels() });
notify({ notify({
level: "success", level: "success",
message: t("channels.watched.dismissed", { name: nameOf(c) }), message: msg("channels.watched.dismissed", { name: raw(nameOf(c)) }),
action: { label: t("common.undo"), onClick: () => restore.mutate(c) }, action: { label: msg("common.undo"), onClick: () => restore.mutate(c) },
}); });
}, },
}); });
+9 -8
View File
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plug, RotateCcw, Send } from "lucide-react"; import { Plug, RotateCcw, Send } from "lucide-react";
import { api, type ConfigItem, type PlexTestResult } from "../../lib/api"; import { api, type ConfigItem, type PlexTestResult } from "../../lib/api";
import { notify } from "../../lib/notifications"; import { msg, notify, raw } from "../../lib/notifications";
import Tooltip from "../../components/ui/Tooltip"; import Tooltip from "../../components/ui/Tooltip";
import InlineNotice from "../../components/ui/InlineNotice"; import InlineNotice from "../../components/ui/InlineNotice";
import { useMe } from "../../lib/useMe"; import { useMe } from "../../lib/useMe";
@@ -130,7 +130,7 @@ export default function ConfigPanel() {
setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 2500); setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 2500);
} catch (e) { } catch (e) {
setSaveState("error"); setSaveState("error");
notifyActionError(e, t("config.saveFailed")); notifyActionError(e, msg("config.saveFailed"));
} }
} }
@@ -142,8 +142,9 @@ export default function ConfigPanel() {
const testEmail = useMutation({ const testEmail = useMutation({
mutationFn: () => api.testEmail(), mutationFn: () => api.testEmail(),
onSuccess: (r) => notify({ level: "success", message: t("config.testSent", { to: r.to }) }), onSuccess: (r) =>
onError: (e) => notifyActionError(e, t("config.testFailed")), notify({ level: "success", message: msg("config.testSent", { to: raw(r.to) }) }),
onError: (e) => notifyActionError(e, msg("config.testFailed")),
}); });
// Plex "Test connection": verifies the server URL + token and returns the library sections, // Plex "Test connection": verifies the server URL + token and returns the library sections,
@@ -154,19 +155,19 @@ export default function ConfigPanel() {
mutationFn: () => api.testPlex(), mutationFn: () => api.testPlex(),
onSuccess: (r) => { onSuccess: (r) => {
setPlexResult(r); setPlexResult(r);
notify({ level: "success", message: t("config.plexTestOk", { name: r.server_name }) }); notify({ level: "success", message: msg("config.plexTestOk", { name: raw(r.server_name) }) });
}, },
onError: (e) => { onError: (e) => {
setPlexResult(null); setPlexResult(null);
notifyActionError(e, t("config.plexTestFailed")); notifyActionError(e, msg("config.plexTestFailed"));
}, },
}); });
// Bug reports' "Test connection": the repository answers, takes issues, is private. // Bug reports' "Test connection": the repository answers, takes issues, is private.
const testIssues = useMutation({ const testIssues = useMutation({
mutationFn: () => api.testIssues(), mutationFn: () => api.testIssues(),
onSuccess: (r) => onSuccess: (r) =>
notify({ level: "success", message: t("config.issuesTestOk", { repo: r.repo }) }), notify({ level: "success", message: msg("config.issuesTestOk", { repo: raw(r.repo) }) }),
onError: (e) => notifyActionError(e, t("config.issuesTestFailed")), onError: (e) => notifyActionError(e, msg("config.issuesTestFailed")),
}); });
const plexLibs = (draft["plex_libraries"] ?? "") const plexLibs = (draft["plex_libraries"] ?? "")
+8 -8
View File
@@ -42,7 +42,7 @@ import {
} from "../../lib/channelPageUrlState"; } from "../../lib/channelPageUrlState";
import { LS } from "../../lib/storage"; import { LS } from "../../lib/storage";
import { useConfirm } from "../../components/ui/confirmContext"; import { useConfirm } from "../../components/ui/confirmContext";
import { notify } from "../../lib/notifications"; import { msg, notify, raw } from "../../lib/notifications";
import { notifyYouTubeActionError } from "../../lib/youtubeErrors"; import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
import { import {
@@ -152,11 +152,11 @@ export default function ChannelPage({
}) })
// Both the automatic first-visit explore and "load more" used to fail in silence: an empty // Both the automatic first-visit explore and "load more" used to fail in silence: an empty
// channel page that looked like a channel with no videos. // channel page that looked like a channel with no videos.
.catch((err: unknown) => notifyActionError(err, t("errors.writeFailed.exploreChannel"))) .catch((err: unknown) => notifyActionError(err, msg("errors.writeFailed.exploreChannel")))
.finally(() => setExploring(false)) .finally(() => setExploring(false))
); );
}, },
[channelId, qc, t], [channelId, qc],
); );
// First visit of an un-subscribed channel → ingest its recent uploads. Skipped when videos are // First visit of an un-subscribed channel → ingest its recent uploads. Skipped when videos are
@@ -201,16 +201,16 @@ export default function ChannelPage({
mutationFn: () => api.subscribeChannel(channelId), mutationFn: () => api.subscribeChannel(channelId),
onSuccess: () => { onSuccess: () => {
invalidateSubscription(qc, channelId); invalidateSubscription(qc, channelId);
notify({ level: "info", message: t("channel.subscribed", { name: ch?.title ?? "" }) }); notify({ level: "info", message: msg("channel.subscribed", { name: raw(ch?.title ?? "") }) });
}, },
// A 403 (missing write scope) otherwise fails silently on the channel page (no wizard handle // A 403 (missing write scope) otherwise fails silently on the channel page (no wizard handle
// here, so no Connect button — but the user at least sees why it didn't work). // here, so no Connect button — but the user at least sees why it didn't work).
onError: (err) => notifyYouTubeActionError(err, t("channels.discovery.subscribeFailed")), onError: (err) => notifyYouTubeActionError(err, msg("channels.discovery.subscribeFailed")),
}); });
const unsubscribe = useMutation({ const unsubscribe = useMutation({
mutationFn: () => api.unsubscribeChannel(channelId), mutationFn: () => api.unsubscribeChannel(channelId),
onSuccess: () => invalidateSubscription(qc, channelId), onSuccess: () => invalidateSubscription(qc, channelId),
onError: (err) => notifyYouTubeActionError(err, t("channels.notify.unsubscribeFailed")), onError: (err) => notifyYouTubeActionError(err, msg("channels.notify.unsubscribeFailed")),
}); });
// "Get full history": the catalog only holds the recent-backfill/RSS subset of a channel's // "Get full history": the catalog only holds the recent-backfill/RSS subset of a channel's
// uploads (video_count is YouTube's raw total), so flipping deep_requested queues the full // uploads (video_count is YouTube's raw total), so flipping deep_requested queues the full
@@ -222,9 +222,9 @@ export default function ChannelPage({
qc.invalidateQueries({ queryKey: qk.feed() }); qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: qk.channels() }); qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.myStatus() }); qc.invalidateQueries({ queryKey: qk.myStatus() });
notify({ level: "info", message: t("channel.fullHistoryQueued") }); notify({ level: "info", message: msg("channel.fullHistoryQueued") });
}, },
onError: (err) => notifyYouTubeActionError(err, t("channel.fullHistoryFailed")), onError: (err) => notifyYouTubeActionError(err, msg("channel.fullHistoryFailed")),
}); });
// Feed priority, editable here with the same stepper + debounced-flush the manager uses (shared // Feed priority, editable here with the same stepper + debounced-flush the manager uses (shared
+6 -11
View File
@@ -31,7 +31,6 @@ import {
type Video, type Video,
type VideoStatus, type VideoStatus,
} from "../../lib/api"; } from "../../lib/api";
import i18n from "../../i18n";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
import { announceVideoState, type NoticeVideo } from "../../features/video/stateNotice"; import { announceVideoState, type NoticeVideo } from "../../features/video/stateNotice";
import { import {
@@ -39,7 +38,7 @@ import {
writeVideoState, writeVideoState,
writeWatchLater, writeWatchLater,
} from "../../features/video/videoWrites"; } from "../../features/video/videoWrites";
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { useMe } from "../../lib/useMe"; import { useMe } from "../../lib/useMe";
import { YT_COUNTS } from "../../lib/ytSearchUrlState"; import { YT_COUNTS } from "../../lib/ytSearchUrlState";
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext"; import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
@@ -419,7 +418,7 @@ export default function Feed({
// Server rejected the change — drop the optimistic override so the card // Server rejected the change — drop the optimistic override so the card
// reverts to the real (unchanged) state instead of staying phantom-hidden, and say so: // reverts to the real (unchanged) state instead of staying phantom-hidden, and say so:
// a card that silently snaps back reads as a misclick. // a card that silently snaps back reads as a misclick.
notifyActionError(e, i18n.t("errors.writeFailed.videoState")); notifyActionError(e, msg("errors.writeFailed.videoState"));
setOverrides((o) => withoutWrite(o, id, seq)); setOverrides((o) => withoutWrite(o, id, seq));
}); });
return write; return write;
@@ -447,7 +446,7 @@ export default function Feed({
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" }); qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
}) })
.catch((e: unknown) => { .catch((e: unknown) => {
notifyActionError(e, i18n.t("errors.writeFailed.videoReset")); notifyActionError(e, msg("errors.writeFailed.videoReset"));
setOverrides((o) => withoutWrite(o, id, seq)); setOverrides((o) => withoutWrite(o, id, seq));
if (resets.current.get(id) !== seq) return; if (resets.current.get(id) !== seq) return;
resets.current.delete(id); resets.current.delete(id);
@@ -471,9 +470,7 @@ export default function Feed({
.catch((e: unknown) => { .catch((e: unknown) => {
notifyActionError( notifyActionError(
e, e,
i18n.t( msg(saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove"),
saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove",
),
); );
setSavedOverrides((o) => withoutWrite(o, id, seq)); setSavedOverrides((o) => withoutWrite(o, id, seq));
}); });
@@ -619,11 +616,9 @@ export default function Feed({
const ids = ytItems.map((v) => v.id); const ids = ytItems.map((v) => v.id);
api api
.clearSearch(ids) .clearSearch(ids)
.then((r) => .then((r) => notify({ message: msg("feed.yt.cleared", { count: r.removed }) }))
notify({ message: i18n.t("feed.yt.cleared", { count: r.removed }) }),
)
.catch((e: unknown) => .catch((e: unknown) =>
notifyActionError(e, i18n.t("errors.writeFailed.clearSearch")), notifyActionError(e, msg("errors.writeFailed.clearSearch")),
); );
setFilters({ ...filters, librarySource: "organic", sort: FEED_DEFAULT_SORT }); setFilters({ ...filters, librarySource: "organic", sort: FEED_DEFAULT_SORT });
onExitYtSearch(); onExitYtSearch();
@@ -116,6 +116,7 @@ vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
})); }));
import Files from "./Files"; import Files from "./Files";
import { msg } from "../../lib/notifications";
import { FILES_LIST_CAP, type QueueJob } from "../../lib/api"; import { FILES_LIST_CAP, type QueueJob } from "../../lib/api";
import { noteQueue, resetQueueWatch, useFinished } from "../../lib/downloadQueue"; import { noteQueue, resetQueueWatch, useFinished } from "../../lib/downloadQueue";
@@ -589,10 +590,13 @@ describe("Files · a library past the list's cap", { timeout: 30_000 }, () => {
await waitFor(() => await waitFor(() =>
expect(mocks.notify).toHaveBeenCalledWith({ expect(mocks.notify).toHaveBeenCalledWith({
level: "info", level: "info",
message: "files.arrival.notListed", message: msg("files.arrival.notListed"),
}), }),
); );
expect(mocks.notify).not.toHaveBeenCalledWith({ level: "info", message: "files.arrival.gone" }); expect(mocks.notify).not.toHaveBeenCalledWith({
level: "info",
message: msg("files.arrival.gone"),
});
}); });
it("an arrival's cleared search is asked of the server at once, not after the typing pause", () => { it("an arrival's cleared search is asked of the server at once, not after the typing pause", () => {
@@ -805,7 +809,7 @@ describe("Files · a file the address names", () => {
mount(); mount();
await waitFor(() => await waitFor(() =>
expect(mocks.notify).toHaveBeenCalledWith( expect(mocks.notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "warning", message: "files.linkedGone" }), expect.objectContaining({ level: "warning", message: msg("files.linkedGone") }),
), ),
); );
expect(screen.queryByRole("dialog")).toBeNull(); expect(screen.queryByRole("dialog")).toBeNull();
@@ -822,11 +826,11 @@ describe("Files · a file the address names", () => {
mount(); mount();
await waitFor(() => await waitFor(() =>
expect(mocks.notify).toHaveBeenCalledWith( expect(mocks.notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "info", message: "files.linkedNotListed" }), expect.objectContaining({ level: "info", message: msg("files.linkedNotListed") }),
), ),
); );
expect(mocks.notify).not.toHaveBeenCalledWith( expect(mocks.notify).not.toHaveBeenCalledWith(
expect.objectContaining({ message: "files.linkedGone" }), expect.objectContaining({ message: msg("files.linkedGone") }),
); );
expect(here().get("fview")).toBe("9999"); expect(here().get("fview")).toBe("9999");
}, 30_000); // a full page of FILES_LIST_CAP rows }, 30_000); // a full page of FILES_LIST_CAP rows
@@ -839,11 +843,11 @@ describe("Files · a file the address names", () => {
mount(); mount();
await waitFor(() => await waitFor(() =>
expect(mocks.notify).toHaveBeenCalledWith( expect(mocks.notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "info", message: "files.linkedNotListed" }), expect.objectContaining({ level: "info", message: msg("files.linkedNotListed") }),
), ),
); );
expect(mocks.notify).not.toHaveBeenCalledWith( expect(mocks.notify).not.toHaveBeenCalledWith(
expect.objectContaining({ message: "files.linkedGone" }), expect.objectContaining({ message: msg("files.linkedGone") }),
); );
expect(here().get("fview")).toBe("9999"); expect(here().get("fview")).toBe("9999");
}, 30_000); // a full page of FILES_LIST_CAP rows }, 30_000); // a full page of FILES_LIST_CAP rows
@@ -1344,7 +1348,10 @@ describe("Files · when what it needs is missing", () => {
mocks.folders = [YT]; mocks.folders = [YT];
mount(ALL_FILES, vi.fn(), "", { focus: { id: 9, token: 1 } }); mount(ALL_FILES, vi.fn(), "", { focus: { id: 9, token: 1 } });
await waitFor(() => await waitFor(() =>
expect(mocks.notify).toHaveBeenCalledWith({ level: "info", message: "files.arrival.gone" }), expect(mocks.notify).toHaveBeenCalledWith({
level: "info",
message: msg("files.arrival.gone"),
}),
); );
}); });
}); });
+8 -5
View File
@@ -19,7 +19,7 @@ import {
import { libraryRowActions, sharedRowActions } from "../../lib/downloadActions"; import { libraryRowActions, sharedRowActions } from "../../lib/downloadActions";
import { formatBytes, formatDateTime, relativeTime } from "../../lib/format"; import { formatBytes, formatDateTime, relativeTime } from "../../lib/format";
import { focusNavItem } from "../../lib/listNav"; import { focusNavItem } from "../../lib/listNav";
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { LS, ownKey } from "../../lib/storage"; import { LS, ownKey } from "../../lib/storage";
import { useCardPager } from "../../lib/useCardPager"; import { useCardPager } from "../../lib/useCardPager";
@@ -296,9 +296,9 @@ export default function Files() {
const openLinked = useEffectEvent((file: LibraryFile | undefined) => { const openLinked = useEffectEvent((file: LibraryFile | undefined) => {
// Looked for in the list it was shown in: a cut one cannot tell (the newest files, or the first by // Looked for in the list it was shown in: a cut one cannot tell (the newest files, or the first by
// its sort or search), as the arrival decides it. // its sort or search), as the arrival decides it.
if (!file && cut) notify({ level: "info", message: t("files.linkedNotListed") }); if (!file && cut) notify({ level: "info", message: msg("files.linkedNotListed") });
else if (!file) { else if (!file) {
notify({ level: "warning", message: t("files.linkedGone") }); notify({ level: "warning", message: msg("files.linkedGone") });
clearParams(FILES_VIEW_KEY, FILES_PLAY_KEY); clearParams(FILES_VIEW_KEY, FILES_PLAY_KEY);
} else if (linkedFile?.open === "play") play(file); } else if (linkedFile?.open === "play") play(file);
else setViewing(file.id); else setViewing(file.id);
@@ -387,7 +387,10 @@ export default function Files() {
consumeFocus(null); consumeFocus(null);
// Said, not swallowed. A list the cap cut cannot tell a deleted file from one beyond its end; // Said, not swallowed. A list the cap cut cannot tell a deleted file from one beyond its end;
// a whole list can: an old notice's file may have expired or been deleted since. // a whole list can: an old notice's file may have expired or been deleted since.
notify({ level: "info", message: t(cut ? "files.arrival.notListed" : "files.arrival.gone") }); notify({
level: "info",
message: msg(cut ? "files.arrival.notListed" : "files.arrival.gone"),
});
} }
}, [here, missing, lookEverywhere, cut, viewKey, setView, consumeFocus, t]); }, [here, missing, lookEverywhere, cut, viewKey, setView, consumeFocus, t]);
const arrival = arrived && arrived.view === viewKey ? arrived : null; const arrival = arrived && arrived.view === viewKey ? arrived : null;
@@ -434,7 +437,7 @@ export default function Files() {
const dropped = e.dataTransfer.files; const dropped = e.dataTransfer.files;
const first = dropped.item(0); const first = dropped.item(0);
if (first) setUpload({ file: first }); if (first) setUpload({ file: first });
if (dropped.length > 1) notify({ level: "info", message: t("files.drop.oneAtATime") }); if (dropped.length > 1) notify({ level: "info", message: msg("files.drop.oneAtATime") });
} }
setDragging(dragDepth.current > 0); setDragging(dragDepth.current > 0);
}); });
+6 -3
View File
@@ -5,7 +5,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Copy, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react"; import { Check, Copy, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react";
import Modal from "../../components/ui/Modal"; import Modal from "../../components/ui/Modal";
import { useConfirm } from "../../components/ui/confirmContext"; import { useConfirm } from "../../components/ui/confirmContext";
import { notify } from "../../lib/notifications"; import { msg, notify, raw } from "../../lib/notifications";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
import { formatDate } from "../../lib/format"; import { formatDate } from "../../lib/format";
import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../../lib/api"; import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../../lib/api";
@@ -40,9 +40,12 @@ function UserShare({ job }: { job: DownloadJob }) {
const share = useMutation({ const share = useMutation({
mutationFn: (email: string) => api.shareDownload(job.id, email), mutationFn: (email: string) => api.shareDownload(job.id, email),
onSuccess: (r) => { onSuccess: (r) => {
notify({ level: "success", message: t("downloads.share.done", { email: r.shared_with }) }); notify({
level: "success",
message: msg("downloads.share.done", { email: raw(r.shared_with) }),
});
}, },
onError: (e) => notifyActionError(e, t("downloads.share.failed")), onError: (e) => notifyActionError(e, msg("downloads.share.failed")),
}); });
return ( return (
@@ -45,6 +45,7 @@ vi.mock("../../lib/downloadQueue", () => ({
import UploadDialog from "./UploadDialog"; import UploadDialog from "./UploadDialog";
import { FilesActionsContext } from "./filesContext"; import { FilesActionsContext } from "./filesContext";
import { msg, type NotifText } from "../../lib/notifications";
const wrap = ( const wrap = (
onClose = vi.fn(), onClose = vi.fn(),
@@ -101,7 +102,9 @@ describe("UploadDialog", () => {
expect(passedFile).toBe(f); expect(passedFile).toBe(f);
expect((opts as { displayName?: string }).displayName).toBe("My notes"); expect((opts as { displayName?: string }).displayName).toBe("My notes");
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1)); await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
expect(h.notify.mock.calls[0]![0]).toMatchObject({ message: "downloads.upload.added" }); expect((h.notify.mock.calls[0]![0] as { message: NotifText }).message).toEqual(
msg("downloads.upload.added"),
);
await waitFor(() => expect(onClose).toHaveBeenCalled()); await waitFor(() => expect(onClose).toHaveBeenCalled());
}); });
@@ -115,9 +118,9 @@ describe("UploadDialog", () => {
}); });
fireEvent.click(screen.getByText("downloads.upload.submit")); fireEvent.click(screen.getByText("downloads.upload.submit"));
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1)); await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
expect(h.notify.mock.calls[0]![0]).toMatchObject({ expect((h.notify.mock.calls[0]![0] as { message: NotifText }).message).toEqual(
message: "downloads.upload.addedConverting", msg("downloads.upload.addedConverting"),
}); );
// Its finish is still to come, and the tray's to announce. // Its finish is still to come, and the tray's to announce.
expect(h.uploadEnded).toHaveBeenCalledWith(null); expect(h.uploadEnded).toHaveBeenCalledWith(null);
}); });

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