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

This commit is contained in:
2026-10-29 22:37:14 +01:00
parent 6986eb1937
commit 4ef772d37b
125 changed files with 973 additions and 674 deletions
+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"),
});
});
});
+7 -6
View File
@@ -5,11 +5,12 @@
// notification store used to keep its empty, nobody's history, and the tab's first notice replaced the
// account's stored history with it.
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { raw, sayNotif, type NotifText } from "./notifications";
const entry = (id: number, message: string) => ({
id,
level: "info",
message,
message: raw(message),
ts: 1,
read: true,
dismissed: true,
@@ -20,9 +21,9 @@ const entry = (id: number, message: string) => ({
const storedMessages = () =>
(
JSON.parse(localStorage.getItem("siftlode.notifications") ?? "[]") as {
message: string;
message: NotifText;
}[]
).map((n) => n.message);
).map((n) => sayNotif(n.message));
// Warm the transform cache once (see verifyEntry.dom.test.tsx): the first cold import costs ~0.8 s
// alone and outran the 5 s case timeout under a full coverage run. Transformed code outlives
@@ -52,9 +53,9 @@ describe("stores read at import, before the account is known", { timeout: CASE_B
const notifications = await import("./notifications");
const { claimLocalData } = await import("./storage");
notifications.notify({ message: "before the claim" }); // e.g. a failed boot probe
notifications.notify({ message: raw("before the claim") }); // e.g. a failed boot probe
claimLocalData(5);
notifications.notify({ message: "after the claim" });
notifications.notify({ message: raw("after the claim") });
expect(storedMessages()).toEqual(["older", "before the claim", "after the claim"]);
expect(notifications.getNotifSettings().durationMs).toBe(0);
@@ -80,7 +81,7 @@ describe("a browser that held another account's state", { timeout: CASE_BUDGET }
const { claimLocalData } = await import("./storage");
claimLocalData(5);
notifications.notify({ message: "as five" });
notifications.notify({ message: raw("as five") });
expect(storedMessages()).toEqual(["as five"]);
});
+9 -5
View File
@@ -21,17 +21,21 @@ vi.mock("./api", () => ({
api: { downloadLinks, createDownloadLink, folderLinks, createFolderLink },
}));
vi.mock("./clipboard", () => ({ copyText }));
vi.mock("./notifications", () => ({ notify }));
vi.mock("./notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("./notifications")>()),
notify,
}));
import { quickShareLink } from "./downloads";
import { qk } from "./queryKeys";
import { msg, type NotifText } from "./notifications";
const t = (k: string) => k;
const origin = window.location.origin;
type ToastArg = {
level: string;
message: string;
message: NotifText;
action?: { label: string; onClick: () => void };
};
const firstToast = () => notify.mock.calls[0]![0] as ToastArg;
@@ -63,7 +67,7 @@ describe("quickShareLink", () => {
expect(copyText).toHaveBeenCalledWith(`${origin}/watch/tok7`);
const arg = firstToast();
expect(arg.level).toBe("success");
expect(arg.message).toBe("downloads.share.linkCopied");
expect(arg.message).toEqual(msg("downloads.share.linkCopied"));
expect(arg.action!.label).toBe("downloads.share.openNewTab");
arg.action!.onClick();
expect(openSpy).toHaveBeenCalledWith(`${origin}/watch/tok7`, "_blank", "noopener,noreferrer");
@@ -134,7 +138,7 @@ describe("quickShareLink", () => {
expect(url).toBe(`${origin}/watch/x`); // the link is never a dead end
const arg = firstToast();
expect(arg.level).toBe("warning");
expect(arg.message).toBe("downloads.share.copyFailed");
expect(arg.message).toEqual(msg("downloads.share.copyFailed"));
expect(arg.action!.label).toBe("downloads.share.openNewTab");
});
@@ -148,7 +152,7 @@ describe("quickShareLink", () => {
expect(copyText).not.toHaveBeenCalled();
// Titled with what failed, and carrying why (notifyActionError), not a bare message.
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "error", title: "downloads.share.linkFailed" }),
expect.objectContaining({ level: "error", title: msg("downloads.share.linkFailed") }),
);
});
});
+4 -4
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
@@ -65,7 +65,7 @@ export async function quickShareLink(
}
if (!path) throw new Error("the new link came back without its address");
} catch (e) {
notifyActionError(e, t("downloads.share.linkFailed"));
notifyActionError(e, msg("downloads.share.linkFailed"));
return null;
}
const url = window.location.origin + path;
@@ -75,8 +75,8 @@ export async function quickShareLink(
};
notify(
(await copyText(url))
? { level: "success", message: t("downloads.share.linkCopied"), action }
: { level: "warning", message: t("downloads.share.copyFailed"), action },
? { level: "success", message: msg("downloads.share.linkCopied"), action }
: { level: "warning", message: msg("downloads.share.copyFailed"), action },
);
return url;
}
@@ -10,6 +10,7 @@ import {
configureNotifications,
getActiveToasts,
notify,
raw,
} from "./notifications";
beforeEach(() => vi.useFakeTimers());
@@ -25,8 +26,8 @@ const shown = (id: number) => getActiveToasts().some((n) => n.id === id);
describe("an action toast's lifetime", () => {
it("stays for the floor when the reader's toasts last 3 s", () => {
configureNotifications({ durationMs: 3000 });
const plain = notify({ message: "plain" });
const withUndo = notify({ message: "removed", action: undo });
const plain = notify({ message: raw("plain") });
const withUndo = notify({ message: raw("removed"), action: undo });
vi.advanceTimersByTime(3001);
expect(shown(plain)).toBe(false);
expect(shown(withUndo)).toBe(true);
@@ -36,7 +37,7 @@ describe("an action toast's lifetime", () => {
it("keeps a longer setting", () => {
configureNotifications({ durationMs: 12000 });
const id = notify({ message: "removed", action: undo });
const id = notify({ message: raw("removed"), action: undo });
vi.advanceTimersByTime(ACTION_TTL_MIN + 1);
expect(shown(id)).toBe(true);
vi.advanceTimersByTime(12000 - ACTION_TTL_MIN);
@@ -45,7 +46,7 @@ describe("an action toast's lifetime", () => {
it("never dismisses when the reader chose never", () => {
configureNotifications({ durationMs: 0 });
const id = notify({ message: "removed", action: undo });
const id = notify({ message: raw("removed"), action: undo });
vi.advanceTimersByTime(60000);
expect(shown(id)).toBe(true);
});
+63 -11
View File
@@ -2,9 +2,56 @@
// the Notification Center shows (info events, actions awaiting interaction, and app
// errors). History is persisted to localStorage so it survives reloads; action
// callbacks are live-only and dropped on reload.
import type { TFunction } from "i18next";
import i18n from "../i18n";
import { localizeDetail } from "./api/refusalText";
import { formatBytes, formatNumber } from "./format";
import { LS, onLocalDataClaimed, readAccount, readAccountMerged, writeAccount } from "./storage";
import { log, type Level } from "./log";
// What a notice SAYS, kept as what to say rather than the said text: a key and its values, said in
// the reader's language each time it is shown. The text a caller translated at raise time was kept
// in the history, so a language switch left every notice in the language it was raised in. `raw` is
// text that has no translation (a video's title, a name); `detail` is a server refusal, localized at
// display like the key. `notify` takes nothing else, so a pre-translated string does not type-check.
// A value is formatted when shown too: `{ number }` / `{ bytes }` group digits the reader's way.
export type NotifValue = string | number | NotifText | { number: number } | { bytes: number };
export type NotifText =
| { key: string; values?: Readonly<Record<string, NotifValue>> }
| { raw: string }
| { detail: unknown };
/** The notice text for `t(key, values)`, said when it is shown. */
export const msg = (key: string, values?: Record<string, NotifValue>): NotifText =>
values ? { key, values } : { key };
/** Text with no translation of its own (a title, a name), shown as it is. */
export const raw = (text: string): NotifText => ({ raw: text });
/** A notice text in the language `t` speaks — the reader's, from a component's `useTranslation`. */
export function sayNotif(text: NotifText, t: TFunction = i18n.t.bind(i18n)): string {
if ("raw" in text) return text.raw;
if ("detail" in text) return localizeDetail(text.detail);
if (!text.values) return t(text.key);
const values = Object.fromEntries(
Object.entries(text.values).map(([k, v]) => [k, sayValue(v, t)]),
);
return t(text.key, values);
}
function sayValue(v: NotifValue, t: TFunction): string | number {
if (typeof v !== "object") return v;
if ("number" in v) return formatNumber(v.number, i18n.language);
if ("bytes" in v) return formatBytes(v.bytes, i18n.language);
return sayNotif(v, t);
}
const sameText = (a: NotifText | undefined, b: NotifText | undefined): boolean =>
JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
// A notice the history kept from before it said keys: its text, in the language it was raised in.
const asText = (v: unknown): NotifText | undefined =>
typeof v === "string" ? raw(v) : (v as NotifText | undefined);
// A notification's level in the client log: what the user was TOLD is part of the story a log tells.
const LOG_LEVEL: Record<NotifLevel, Level> = {
info: "INFO",
@@ -56,8 +103,8 @@ export type NotifMeta =
export interface Notification {
id: number;
level: NotifLevel;
title?: string;
message: string;
title?: NotifText;
message: NotifText;
action?: NotifAction;
// A second, lesser action beside it (a done download's "Open" after its "Copy link").
secondaryAction?: NotifAction;
@@ -79,9 +126,9 @@ export interface Notification {
}
export interface NotifyInput {
message: string;
message: NotifText;
level?: NotifLevel;
title?: string;
title?: NotifText;
action?: NotifAction;
secondaryAction?: NotifAction;
meta?: NotifMeta;
@@ -175,17 +222,19 @@ let cachedUnread = 0;
function load(): Notification[] {
try {
const raw = readAccount<unknown>(HISTORY_KEY, []);
if (!Array.isArray(raw)) return [];
const stored = readAccount<unknown>(HISTORY_KEY, []);
if (!Array.isArray(stored)) return [];
// Restored entries are history only. Mark them dismissed so they don't
// resurrect as active toasts on reload — their auto-dismiss timers aren't
// re-armed across a reload, so otherwise they'd stick forever. Also drop the
// live `action` callback, which can't survive serialization.
return raw.map(
return stored.map(
(n) =>
({
repeat: 1,
...(n as object),
title: asText((n as { title?: unknown }).title),
message: asText((n as { message?: unknown }).message) ?? raw(""),
action: undefined,
secondaryAction: undefined,
dismissed: true,
@@ -261,9 +310,12 @@ const COALESCE_WINDOW_MS = 5000;
export function notify(input: NotifyInput): number {
const requiresInteraction = input.requiresInteraction ?? false;
const level = input.level ?? "info";
log.at(LOG_LEVEL[level], "ui.notify", input.message, {
// In English, whatever the reader's language (a server refusal aside: it says itself in the
// reader's), so one log reads the same for every reader.
const en = i18n.getFixedT("en");
log.at(LOG_LEVEL[level], "ui.notify", sayNotif(input.message, en), {
level,
...(input.title ? { title: input.title } : {}),
...(input.title ? { title: sayNotif(input.title, en) } : {}),
...(input.meta ? { kind: input.meta.kind } : {}),
});
const now = Date.now();
@@ -294,8 +346,8 @@ export function notify(input: NotifyInput): number {
!n.action &&
now - n.ts <= COALESCE_WINDOW_MS &&
n.level === level &&
(n.title ?? "") === (input.title ?? "") &&
n.message === input.message,
sameText(n.title, input.title) &&
sameText(n.message, input.message),
);
if (prior) {
// Re-surface the existing toast (undismiss + unread) and re-arm its auto-dismiss timer.
+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);
}
+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 {
@@ -28,6 +28,7 @@ vi.mock("./entryUrl", async (importOriginal) => {
return { ...actual, accountSwitchNotice: () => notice() };
});
const { useAccountSwitchNotice } = await import("./useAccountSwitchNotice");
const { msg } = await import("./notifications");
afterEach(() => {
notifyMock.mockClear();
@@ -53,7 +54,7 @@ describe("the shell's word on a switch of account", () => {
expect(notifyMock).toHaveBeenCalledTimes(1);
expect(notifyMock).toHaveBeenCalledWith({
level: "info",
message: 'header.account.switchRequested {"email":"b@example.com"}',
message: msg("header.account.switchRequested", { email: "b@example.com" }),
});
});
+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];
}
+4 -4
View File
@@ -1,16 +1,16 @@
import { HttpError } from "./api";
import { markReported } from "./api/core";
import { notify } from "./notifications";
import { msg, notify, type NotifText } from "./notifications";
import { notifyActionError } from "./actionErrors";
import i18n from "../i18n";
// A YouTube-gated action (subscribe, sync, backfill, unsubscribe) that 403s means the user hasn't
// granted the write scope — surface a "Connect" affordance instead of a vague failure. Pass
// `onConnect` to offer the connect-wizard button (callers that have no wizard handle, e.g. the
// channel page, just get the message). `fallbackMessage` is the already-translated non-403 error.
// channel page, just get the message). `fallbackMessage` is what a non-403 error says.
export function notifyYouTubeActionError(
err: unknown,
fallbackMessage: string,
fallbackMessage: NotifText,
onConnect?: () => void,
): void {
const httpErr = err instanceof HttpError ? err : null;
@@ -18,7 +18,7 @@ export function notifyYouTubeActionError(
// A 403 on a YouTube write action means the write scope isn't granted — offer to connect.
notify({
level: "error",
message: i18n.t("channels.notify.needYouTube"),
message: msg("channels.notify.needYouTube"),
action: onConnect
? { label: i18n.t("channels.notify.connect"), onClick: onConnect }
: undefined,