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
+2 -2
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 { msg, type NotifText, 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";
@@ -150,7 +150,7 @@ export default function TagManager({
mutationFn: (v: { id: number; name: string }) => api.deleteTag(v.id),
onSuccess: (_d, v) => {
invalidate();
notify({ level: "success", message: msg("tagManager.deleted", { name: v.name }) });
notify({ level: "success", message: msg("tagManager.deleted", { name: raw(v.name) }) });
},
onError: (e) => onFailed(e, msg("tagManager.deleteFailed")),
});
+7 -7
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 { msg, 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,7 +134,7 @@ 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
@@ -149,7 +149,7 @@ export function useComfortPin(open: boolean): ComfortPin {
);
const remove = useCallback(
(pinId: number, title: string) => {
(pinId: number, title: NotifText) => {
void (async () => {
try {
await api.removeComfortPin(pinId);
@@ -164,7 +164,7 @@ export function useComfortPin(open: boolean): ComfortPin {
);
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
@@ -70,7 +70,7 @@ vi.mock("../../lib/notifications", async (importOriginal) => ({
}));
import DownloadTray from "./DownloadTray";
import { msg, type NotifText } from "../../lib/notifications";
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";
@@ -205,7 +205,7 @@ describe("DownloadTray", () => {
action: { label: string; onClick: () => void };
secondaryAction: { label: string; onClick: () => void };
};
expect(notice.title).toEqual(msg("tray.done.title", { title: "Clip 1" }));
expect(notice.title).toEqual(msg("tray.done.title", { title: raw("Clip 1") }));
expect(notice.message).toEqual(msg("tray.done.phone"));
expect(notice.action.label).toBe("tray.done.copyLink");
notice.action.onClick();
@@ -227,8 +227,8 @@ describe("DownloadTray", () => {
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: NotifText }).title)).toEqual([
msg("tray.done.title", { title: "Clip 1" }),
msg("tray.done.title", { title: "Clip 2" }),
msg("tray.done.title", { title: raw("Clip 1") }),
msg("tray.done.title", { title: raw("Clip 2") }),
]);
});
@@ -260,7 +260,9 @@ describe("DownloadTray", () => {
poll([job(1, "done", { source_key: "upload" })], view);
await waitFor(() =>
expect(h.notify).toHaveBeenCalledWith(
expect.objectContaining({ title: msg("tray.done.uploadedTitle", { title: "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 { msg, 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";
@@ -192,7 +192,7 @@ export default function DownloadTray() {
level: "success",
// An upload the worker had to convert first is done here too — it was never downloaded.
title: msg(job.source_key === "upload" ? "tray.done.uploadedTitle" : "tray.done.title", {
title,
title: raw(title),
}),
message: msg(job.asset?.media_kind === "file" ? "tray.done.file" : "tray.done.phone"),
action: {
@@ -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)) : []),
];
};
+2 -2
View File
@@ -1,5 +1,5 @@
import i18n from "../../i18n";
import { msg, 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,7 +24,7 @@ export function announceVideoState(
const hidden = status === "hidden";
notify({
message: video?.title
? msg(hidden ? "feed.hiddenNamed" : "feed.markedWatchedNamed", { title: video.title })
? msg(hidden ? "feed.hiddenNamed" : "feed.markedWatchedNamed", { title: raw(video.title) })
: msg(hidden ? "feed.hidden" : "feed.markedWatched"),
action: { label: i18n.t(hidden ? "feed.undo" : "feed.unwatch"), onClick: undo },
meta: {
@@ -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 ?? "")) : []),
];
};