fix(notifications): a notice value is no plain string, so a translated name cannot freeze in one language

A name goes in as raw(), a translated word as its own msg(): tsc now lists every value, and the
ones that froze are said when shown — Watch later and an untitled channel in the pin and channel
notices, a Plex season's name in its removal toast (a new seasonTitleText, which seasonTitle says).
This commit is contained in:
2026-10-30 00:03:48 +01:00
parent cb8b99e0e7
commit 4a0d4b0964
37 changed files with 174 additions and 109 deletions
@@ -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"),
});
});
+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;
+4 -2
View File
@@ -13,9 +13,11 @@ import { log, type Level } from "./log";
// 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.
// display like the key. `notify` takes nothing else, and a value is no plain string either (a name
// goes in as `raw`, a translated word as its own `msg`), so a pre-translated string does not
// type-check anywhere in a notice — only `raw(t(…))` would say it once, and that reads as what it is.
// 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 NotifValue = number | NotifText | { number: number } | { bytes: number };
export type NotifText =
| { key: string; values?: Readonly<Record<string, NotifValue>> }
| { raw: string }
+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");
}
@@ -28,7 +28,7 @@ vi.mock("./entryUrl", async (importOriginal) => {
return { ...actual, accountSwitchNotice: () => notice() };
});
const { useAccountSwitchNotice } = await import("./useAccountSwitchNotice");
const { msg } = await import("./notifications");
const { msg, raw } = await import("./notifications");
afterEach(() => {
notifyMock.mockClear();
@@ -40,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 },
@@ -54,7 +54,7 @@ describe("the shell's word on a switch of account", () => {
expect(notifyMock).toHaveBeenCalledTimes(1);
expect(notifyMock).toHaveBeenCalledWith({
level: "info",
message: msg("header.account.switchRequested", { email: "b@example.com" }),
message: msg("header.account.switchRequested", { email: raw("b@example.com") }),
});
});