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

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