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
@@ -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 { msg, 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";
@@ -71,7 +71,7 @@ export default function ChannelDiscovery({
notify({
level: "success",
title: msg("channels.discovery.subscribedTitle"),
message: msg("channels.discovery.subscribedBody", { name }),
message: msg("channels.discovery.subscribedBody", { name: raw(name) }),
meta: { kind: "channel-subscribed", channelId: c.id, channelName: name },
});
},
+6 -3
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 { msg, 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,7 +168,7 @@ export default function Channels() {
(tagId: number, name: string) => {
setFeedFilters({ ...feedFilters, tags: [tagId], channelIds: [] });
setPage("feed");
notify({ level: "info", message: msg("channels.notify.filteredByTag", { name }) });
notify({ level: "info", message: msg("channels.notify.filteredByTag", { name: raw(name) }) });
},
[setFeedFilters, feedFilters, setPage],
);
@@ -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),
),
})
: [];
@@ -33,7 +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 } from "../../lib/notifications";
import { msg, type NotifText, raw } from "../../lib/notifications";
afterEach(() => {
cleanup();
@@ -199,11 +199,24 @@ describe("useChannelActions — hide and unsubscribe", () => {
message: NotifText;
action: { onClick: () => void };
};
expect(toast.message).toEqual(msg("channels.notify.hidden", { name: "Chan" }));
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 { msg, 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() });
@@ -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 { msg, notify } from "../../lib/notifications";
import { msg, notify, raw } from "../../lib/notifications";
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
import { useConfirm } from "../../components/ui/confirmContext";
@@ -22,7 +22,7 @@ export function useWatchedChannelActions(onOpenWizard: () => void) {
notify({
level: "success",
title: msg("channels.discovery.subscribedTitle"),
message: msg("channels.discovery.subscribedBody", { name: nameOf(c) }),
message: msg("channels.discovery.subscribedBody", { name: raw(nameOf(c)) }),
meta: { kind: "channel-subscribed", channelId: c.id, channelName: nameOf(c) },
});
},
@@ -43,7 +43,7 @@ export function useWatchedChannelActions(onOpenWizard: () => void) {
void qc.invalidateQueries({ queryKey: qk.watchedChannels() });
notify({
level: "success",
message: msg("channels.watched.dismissed", { name: nameOf(c) }),
message: msg("channels.watched.dismissed", { name: raw(nameOf(c)) }),
action: { label: t("common.undo"), onClick: () => restore.mutate(c) },
});
},
@@ -54,7 +54,7 @@ export function useWatchedChannelActions(onOpenWizard: () => void) {
const onSubscribe = async (c: WatchedChannel): Promise<boolean> => {
const ok = await confirm({
title: t("channels.discovery.subscribe"),
message: t("channels.discovery.confirmSubscribe", { name: nameOf(c) }),
message: t("channels.discovery.confirmSubscribe", { name: raw(nameOf(c)) }),
confirmLabel: t("channels.discovery.subscribe"),
});
if (ok) subscribe.mutate(c);
+5 -4
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 { msg, 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";
@@ -142,7 +142,8 @@ export default function ConfigPanel() {
const testEmail = useMutation({
mutationFn: () => api.testEmail(),
onSuccess: (r) => notify({ level: "success", message: msg("config.testSent", { to: r.to }) }),
onSuccess: (r) =>
notify({ level: "success", message: msg("config.testSent", { to: raw(r.to) }) }),
onError: (e) => notifyActionError(e, msg("config.testFailed")),
});
@@ -154,7 +155,7 @@ export default function ConfigPanel() {
mutationFn: () => api.testPlex(),
onSuccess: (r) => {
setPlexResult(r);
notify({ level: "success", message: msg("config.plexTestOk", { name: r.server_name }) });
notify({ level: "success", message: msg("config.plexTestOk", { name: raw(r.server_name) }) });
},
onError: (e) => {
setPlexResult(null);
@@ -165,7 +166,7 @@ export default function ConfigPanel() {
const testIssues = useMutation({
mutationFn: () => api.testIssues(),
onSuccess: (r) =>
notify({ level: "success", message: msg("config.issuesTestOk", { repo: r.repo }) }),
notify({ level: "success", message: msg("config.issuesTestOk", { repo: raw(r.repo) }) }),
onError: (e) => notifyActionError(e, msg("config.issuesTestFailed")),
});
+2 -2
View File
@@ -42,7 +42,7 @@ import {
} from "../../lib/channelPageUrlState";
import { LS } from "../../lib/storage";
import { useConfirm } from "../../components/ui/confirmContext";
import { msg, notify } from "../../lib/notifications";
import { msg, notify, raw } from "../../lib/notifications";
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
import { notifyActionError } from "../../lib/actionErrors";
import {
@@ -201,7 +201,7 @@ export default function ChannelPage({
mutationFn: () => api.subscribeChannel(channelId),
onSuccess: () => {
invalidateSubscription(qc, channelId);
notify({ level: "info", message: msg("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).
+5 -2
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 { msg, 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,7 +40,10 @@ function UserShare({ job }: { job: DownloadJob }) {
const share = useMutation({
mutationFn: (email: string) => api.shareDownload(job.id, email),
onSuccess: (r) => {
notify({ level: "success", message: msg("downloads.share.done", { email: r.shared_with }) });
notify({
level: "success",
message: msg("downloads.share.done", { email: raw(r.shared_with) }),
});
},
onError: (e) => notifyActionError(e, msg("downloads.share.failed")),
});
+3 -3
View File
@@ -81,7 +81,7 @@ import RemoteImage from "../../components/ui/RemoteImage";
import { useOpenLinkedPlayer } from "../../features/player/useOpenLinkedPlayer";
import { budgetFrom, budgetParams, DEFAULT_BUDGET } from "../../lib/homeUrlState";
import { useUrlMirror } from "../../lib/useUrlMirror";
import { msg } from "../../lib/notifications";
import { msg, raw } from "../../lib/notifications";
// The Home surface — "Mit nézzek most?".
//
@@ -238,10 +238,10 @@ function useHomeCardMenu({
danger: true,
// The pin id travels on the shelf card, so this needs no lookup — and unlike every
// other surface it can never be in the "not known yet" state.
onSelect: () => pin.remove(target.pin_id as number, target.title),
onSelect: () => pin.remove(target.pin_id as number, raw(target.title)),
},
]
: pin.item(pinTargetForCard(pinned ?? target), (pinned ?? target).title)),
: pin.item(pinTargetForCard(pinned ?? target), raw((pinned ?? target).title))),
];
return {
menu,
@@ -94,7 +94,7 @@ import { PageMenuSlot, type PageMenuOpener } from "../../components/ui/pageMenuS
import { api, HttpError, type Video } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
import { patchCachedProgress } from "../../lib/progressCache";
import { msg, type NotifText } from "../../lib/notifications";
import { msg, type NotifText, raw } from "../../lib/notifications";
beforeAll(() => {
class Stub {
@@ -688,7 +688,7 @@ describe("Playlists — removing a row, and undoing it", () => {
// No confirm: the toast's Undo is the safety net.
expect(h.confirm).not.toHaveBeenCalled();
await waitFor(() =>
expect(lastToast().message).toEqual(msg("playlists.removedToast", { title: "Video B" })),
expect(lastToast().message).toEqual(msg("playlists.removedToast", { title: raw("Video B") })),
);
expect(lastToast().action?.label).toBe("common.undo");
act(() => lastToast().action!.onClick());
+6 -6
View File
@@ -34,7 +34,7 @@ import {
type Video,
type VideoStatus,
} from "../../lib/api";
import { msg, notify } from "../../lib/notifications";
import { msg, notify, raw } from "../../lib/notifications";
import { notifyActionError } from "../../lib/actionErrors";
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
import { playlistName, playlistNameText } from "../../lib/playlistName";
@@ -545,7 +545,7 @@ function PlaylistDetail() {
}
notify({
level: "success",
message: msg("playlists.removedToast", { title: video.title ?? "" }),
message: msg("playlists.removedToast", { title: raw(video.title ?? "") }),
action: {
label: t("common.undo"),
// ONE request that puts it back at its index (the server renumbers around it): an append
@@ -630,7 +630,7 @@ function PlaylistDetail() {
setLastMoveTargetId(target.id);
notify({
level: "success",
message: msg("playlists.movedToast", { title, name: playlistNameText(target) }),
message: msg("playlists.movedToast", { title: raw(title), name: playlistNameText(target) }),
});
} catch (e) {
// NOT a silent revert. `req` deliberately leaves 401/403/404 to the caller as control flow, and
@@ -731,7 +731,7 @@ function PlaylistDetail() {
icon: <Play className="w-4 h-4" />,
onSelect: () => openPlayer(ctxRow.id),
},
...comfortPin.item(pinVideo(ctxRow.id), ctxRow.title ?? ""),
...comfortPin.item(pinVideo(ctxRow.id), raw(ctxRow.title ?? "")),
...copy(ctxRow.title, ctxRow.watch_url).map((it, i) => ({
...it,
separatorBefore: i === 0,
@@ -806,7 +806,7 @@ function PlaylistDetail() {
setRenameValue(headerPl.name);
setRenaming(true);
},
pin: headerPin.item(pinPlaylist(headerPl.id), plName(headerPl)),
pin: headerPin.item(pinPlaylist(headerPl.id), playlistNameText(headerPl)),
});
// The page's background menu mirrors the band: Play all, the view controls, the order's undo, and
@@ -855,7 +855,7 @@ function PlaylistDetail() {
setRenameValue(detail.name);
setRenaming(true);
},
pin: pagePin.item(pinPlaylist(detail.id), plName(detail)),
pin: pagePin.item(pinPlaylist(detail.id), playlistNameText(detail)),
})
.filter((it) => !("danger" in it && it.danger)),
);
@@ -23,7 +23,7 @@ import { type SortOption } from "../../lib/columnSort";
import { usePlaylists, usePlaylistsActions } from "./playlistsContext";
import { railDefaultId } from "./playlistsInit";
import { useMe } from "../../lib/useMe";
import { playlistName } from "../../lib/playlistName";
import { playlistName, playlistNameText } from "../../lib/playlistName";
import { usePlaylistActions } from "./usePlaylistActions";
import PlaylistLimitsHint from "./PlaylistLimitsHint";
import { IconButton } from "../../components/ui/IconButton";
@@ -206,7 +206,7 @@ export default function PlaylistsRail() {
setRenameValue(ctxPl.name);
setRenamingId(ctxPl.id);
},
pin: comfortPin.item(pinPlaylist(ctxPl.id), plName(ctxPl)),
pin: comfortPin.item(pinPlaylist(ctxPl.id), playlistNameText(ctxPl)),
});
const items: PanelItem[] = [
@@ -29,7 +29,7 @@ vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => h.confi
import PlexPlaylistView from "./PlexPlaylistView";
import { api, type PlexCard } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
import { msg, type NotifText } from "../../lib/notifications";
import { msg, type NotifText, raw } from "../../lib/notifications";
beforeAll(() => {
class Stub {
@@ -183,7 +183,9 @@ describe("PlexPlaylistView · a row's menu", () => {
await waitFor(() => expect(api.plexPlaylistRemoveItem).toHaveBeenCalledWith(5, "2"));
expect(h.confirm).not.toHaveBeenCalled();
await waitFor(() =>
expect(toast()?.message).toEqual(msg("plex.playlist.removedToast", { title: "Movie 2" })),
expect(toast()?.message).toEqual(
msg("plex.playlist.removedToast", { title: raw("Movie 2") }),
),
);
act(() => toast()!.action!.onClick());
await waitFor(() => expect(add).toHaveBeenCalledWith(5, ["2"], [1]));
@@ -212,7 +214,9 @@ describe("PlexPlaylistView · a row's menu", () => {
await pick(() => fireEvent.click(moreOf("The Show")), "plex.playlist.removeShow");
await waitFor(() => expect(bulk).toHaveBeenCalledWith(5, ["e1", "e2"]));
await waitFor(() =>
expect(toast()?.message).toEqual(msg("plex.playlist.removedToast", { title: "The Show" })),
expect(toast()?.message).toEqual(
msg("plex.playlist.removedToast", { title: raw("The Show") }),
),
);
});
@@ -232,6 +236,17 @@ describe("PlexPlaylistView · a row's menu", () => {
.find((b) => b.textContent === "plex.playlist.removeSeason")!,
);
await waitFor(() => expect(bulk).toHaveBeenCalledWith(5, ["e2", "e3"]));
// The season's name is kept as what to say, so the toast follows a language switch.
await waitFor(() =>
expect(toast()?.message).toEqual(
msg("plex.playlist.removedToast", {
title: msg("plex.playlist.seasonOfShow", {
show: raw("The Show"),
season: msg("plex.seasonTitle", { n: 2 }),
}),
}),
),
);
});
});
@@ -55,10 +55,10 @@ import { useReturnFocus } from "../../components/ui/useReturnFocus";
import { SURFACE } from "../../components/ui/surfaces";
import { PageHeading } from "../../components/ui/Headings";
import { MEDIA_BADGE } from "../../components/ui/mediaCardStyles";
import { seasonTitle } from "../../lib/plexDetail";
import { seasonTitle, seasonTitleText } from "../../lib/plexDetail";
import { cardImage } from "../../lib/plexImage";
import RemoteImage from "../../components/ui/RemoteImage";
import { msg } from "../../lib/notifications";
import { msg, raw, type NotifText } from "../../lib/notifications";
// A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the
// same show collapses into a season/show group so a whole series doesn't sprawl into an endless flat
@@ -563,7 +563,6 @@ export default function PlexPlaylistView({
// Keyed: a row builds its target afresh each render, and its "…" must close the menu it opened.
const rowMenu = useContextMenu<MenuTarget>({ keyOf: targetKey });
const copy = useCopyItems();
const seasonLabel = useSeasonLabel();
const titleActions = usePlexCardActions(
{
onOpen: (c) => onPlay(c.id, order),
@@ -581,14 +580,17 @@ export default function PlexPlaylistView({
rowMenu.open,
);
// What a row's removal takes out, and the name its toast says.
const removal = (target: MenuTarget): { keys: string[]; title: string } =>
const removal = (target: MenuTarget): { keys: string[]; title: NotifText } =>
target.kind === "item"
? { keys: [target.item.id], title: target.item.title }
? { keys: [target.item.id], title: raw(target.item.title) }
: target.kind === "show"
? { keys: target.block.items.map((i) => i.id), title: target.block.title }
? { keys: target.block.items.map((i) => i.id), title: raw(target.block.title) }
: {
keys: target.season.items.map((i) => i.id),
title: `${target.block.title} · ${seasonLabel(target.season)}`,
title: msg("plex.playlist.seasonOfShow", {
show: raw(target.block.title),
season: seasonTitleText({ season_number: target.season.number }),
}),
};
const remove = (target: MenuTarget) => {
const { keys, title } = removal(target);
@@ -27,7 +27,7 @@ vi.mock("../../lib/notifications", async (importOriginal) => ({
import { usePlexPlaylistEdits } from "./usePlexPlaylistEdits";
import { api, type PlexCard } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
import { msg, type NotifText } from "../../lib/notifications";
import { msg, type NotifText, raw } from "../../lib/notifications";
afterEach(() => {
cleanup();
@@ -125,7 +125,7 @@ describe("usePlexPlaylistEdits · the removal", () => {
await waitFor(() => expect(hook.result.current.items).toHaveLength(2));
vi.spyOn(api, "plexPlaylistRemoveItem").mockRejectedValue(new Error("offline"));
await act(() => hook.result.current.onRemove(["2"], "Movie 2"));
await act(() => hook.result.current.onRemove(["2"], raw("Movie 2")));
expect(errors()).toEqual([msg("plex.playlist.removeFailed")]);
expect(ids(hook.result.current.items)).toEqual(["1", "2"]);
expect(hook.result.current.busy).toBe(false);
@@ -144,9 +144,11 @@ describe("usePlexPlaylistEdits · the removal", () => {
const add = vi.spyOn(api, "plexPlaylistAddBulk").mockResolvedValue({} as never);
// A season whose episodes do not sit together: rows 2 and 4.
await act(() => hook.result.current.onRemove(["4", "2"], "Show · Season 1"));
await act(() => hook.result.current.onRemove(["4", "2"], raw("Show · Season 1")));
const toast = notify.mock.calls.map(([n]) => n).find((n) => n.action)!;
expect(toast.message).toEqual(msg("plex.playlist.removedToast", { title: "Show · Season 1" }));
expect(toast.message).toEqual(
msg("plex.playlist.removedToast", { title: raw("Show · Season 1") }),
);
expect(toast.action!.label).toBe("common.undo");
act(() => toast.action!.onClick());
await waitFor(() => expect(add).toHaveBeenCalledTimes(1));
@@ -160,7 +162,7 @@ describe("usePlexPlaylistEdits · the removal", () => {
await waitFor(() => expect(hook.result.current.items).toHaveLength(3));
vi.spyOn(api, "plexPlaylistRemoveItem").mockResolvedValue({} as never);
const add = vi.spyOn(api, "plexPlaylistAddBulk").mockResolvedValue({} as never);
await act(() => hook.result.current.onRemove(["2"], "Movie 2"));
await act(() => hook.result.current.onRemove(["2"], raw("Movie 2")));
detail.mockResolvedValue({
id: 5,
title: "F",
@@ -179,7 +181,7 @@ describe("usePlexPlaylistEdits · the removal", () => {
return {} as never;
});
vi.spyOn(api, "plexPlaylistAddBulk").mockRejectedValue(new Error("offline"));
await act(() => hook.result.current.onRemove(["1"], "Movie 1"));
await act(() => hook.result.current.onRemove(["1"], raw("Movie 1")));
const toast = notify.mock.calls.map(([n]) => n).find((n) => n.action)!;
act(() => toast.action!.onClick());
await waitFor(() => expect(errors()).toEqual([msg("plex.playlist.undoRemoveFailed")]));
@@ -5,7 +5,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { plexLang } from "../../i18n";
import { api, type PlexCard } 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 { reinsertAt } from "../../lib/reinsert";
@@ -88,7 +88,7 @@ export function usePlexPlaylistEdits(playlistId: number) {
* each row back where it stood (C1 in DESIGN.md: the same bargain as a YouTube playlist's rows) — after
* its old neighbour in the list as it is at Undo time (`reinsertAt`), since a raw index drifts
* once anything changes in between. `title` names what went in that toast. */
async function onRemove(keys: string[], title: string) {
async function onRemove(keys: string[], title: NotifText) {
if (busy || !keys.length) return;
setBusy(true);
// The list as it stands now, which is the server's order: a season's episodes need not sit
+2 -2
View File
@@ -31,7 +31,7 @@ import { Button } from "../../components/ui/Button";
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
import { useConfirm } from "../../components/ui/confirmContext";
import { formatDateTime, formatNumber, formatUnit, relativeTime } from "../../lib/format";
import { msg, notify } from "../../lib/notifications";
import { msg, notify, raw } from "../../lib/notifications";
import Tooltip from "../../components/ui/Tooltip";
import { notifyActionError } from "../../lib/actionErrors";
import { IconButton } from "../../components/ui/IconButton";
@@ -569,7 +569,7 @@ export default function Scheduler() {
notify({
level: "success",
message: msg("scheduler.triggered", {
job: msg(`scheduler.jobs.${jobId}`, { defaultValue: jobId }),
job: msg(`scheduler.jobs.${jobId}`, { defaultValue: raw(jobId) }),
}),
});
qc.invalidateQueries({ queryKey: qk.scheduler() });
@@ -29,7 +29,7 @@ import { LS, useTabPersistedState } from "../../lib/storage";
import { SETTINGS_TAB_KEY, settingsTabParams } from "../../lib/settingsTab";
import { useUrlMirror } from "../../lib/useUrlMirror";
import Avatar from "../../components/ui/Avatar";
import { msg, type NotifSettings, notify } from "../../lib/notifications";
import { msg, type NotifSettings, notify, raw } from "../../lib/notifications";
import Tooltip from "../../components/ui/Tooltip";
import { Section, SwitchRow } from "../../components/ui/form";
import { Badge } from "../../components/ui/Badge";
@@ -402,7 +402,7 @@ export function SignInMethods({ me }: { me: Me }) {
await qc.invalidateQueries({ queryKey: qk.me() });
notify({
level: "success",
message: msg("settings.account.password.saved", { email: me.email }),
message: msg("settings.account.password.saved", { email: raw(me.email) }),
});
} catch (e) {
setErr(
@@ -33,7 +33,7 @@ vi.mock("../../lib/format", async (importOriginal) => ({
import type { TFunction } from "i18next";
import TakeoutImport from "./TakeoutImport";
import { msg, sayNotif, type NotifText } from "../../lib/notifications";
import { msg, type NotifText, raw, sayNotif } from "../../lib/notifications";
afterEach(() => {
cleanup();
@@ -282,7 +282,7 @@ describe("YouTube history import · while it runs", () => {
expect(mocks.notify).toHaveBeenCalledWith({
level: "error",
message: msg("settings.takeout.tooLarge", {
name: "watch-history.json",
name: raw("watch-history.json"),
limit: { bytes: 10 },
}),
});
@@ -9,7 +9,7 @@ import { StateMessage } from "../../components/ui/QueryState";
import { api, type TakeoutStatus, type TakeoutUpload } from "../../lib/api";
import type { TAKEOUT_OUTCOMES } from "../../lib/api/feed";
import { qk } from "../../lib/queryKeys";
import { msg, notify } from "../../lib/notifications";
import { msg, notify, raw } from "../../lib/notifications";
import { notifyActionError } from "../../lib/actionErrors";
import { formatDateTime, formatNumber } from "../../lib/format";
import { importBusy, invalidateImported, watchTakeout } from "./takeoutWatch";
@@ -129,7 +129,7 @@ export default function TakeoutImport() {
// Refused here, not after minutes of upload, and with the limit named.
notify({
level: "error",
message: msg("settings.takeout.tooLarge", { name: file.name, limit: { bytes: max } }),
message: msg("settings.takeout.tooLarge", { name: raw(file.name), limit: { bytes: max } }),
});
return false;
}
@@ -145,7 +145,7 @@ export default function TakeoutImport() {
notify({
level: "success",
message: msg("settings.takeout.read", {
name: file.name,
name: raw(file.name),
count: res.videos,
n: { number: res.videos },
}),
+16 -10
View File
@@ -24,7 +24,7 @@ import {
type PendingAccessRow,
} from "../../lib/api";
import { displayName } from "../../lib/api/account";
import { msg, type NotifText, notify } from "../../lib/notifications";
import { msg, type NotifText, notify, raw } from "../../lib/notifications";
import { formatDateTime, relativeTime } from "../../lib/format";
import { invalidateUserAccess } from "../../lib/adminUsers";
import { ADMIN_USERS_TAB_KEY, USERS_TAB_URL_KEY, usersTabParams } from "../../lib/adminUsersTab";
@@ -144,7 +144,7 @@ function UsersRoles({ me }: { me: Me }) {
onMutate: (vars) => startPending(vars.id),
onSuccess: (_d, vars) => {
qc.invalidateQueries({ queryKey: qk.adminUsers() });
notify({ level: "success", message: msg("users.roles.updated", { email: vars.email }) });
notify({ level: "success", message: msg("users.roles.updated", { email: raw(vars.email) }) });
},
onSettled: (_d, _e, vars) => endPending(vars.id),
// Guards (last admin / self) surface via the global error dialog with the server's detail; what
@@ -161,7 +161,7 @@ function UsersRoles({ me }: { me: Me }) {
notify({
level: "success",
message: msg(vars.suspended ? "users.suspend.done" : "users.suspend.undone", {
email: vars.email,
email: raw(vars.email),
}),
});
},
@@ -174,7 +174,7 @@ function UsersRoles({ me }: { me: Me }) {
onMutate: (vars) => startPending(vars.id),
onSuccess: (_d, vars) => {
qc.invalidateQueries({ queryKey: qk.adminUsers() });
notify({ level: "success", message: msg("users.delete.done", { email: vars.email }) });
notify({ level: "success", message: msg("users.delete.done", { email: raw(vars.email) }) });
},
onSettled: (_d, _e, vars) => endPending(vars.id),
onError: (e) => onGoneOrFailed(e, msg("users.delete.failed")),
@@ -209,7 +209,7 @@ function UsersRoles({ me }: { me: Me }) {
const onDelete = async (u: AdminUserRow) => {
const ok = await confirm({
title: t("users.delete.title"),
message: t("users.delete.confirm", { email: u.email }),
message: t("users.delete.confirm", { email: raw(u.email) }),
confirmLabel: t("users.delete.action"),
danger: true,
});
@@ -369,7 +369,7 @@ function PendingAccess() {
refresh();
notify({
level: "success",
message: msg("users.pendingAccess.activated", { email: u.email }),
message: msg("users.pendingAccess.activated", { email: raw(u.email) }),
});
},
onError: (err) => notifyActionError(err, msg("users.pendingAccess.activateFailed")),
@@ -379,7 +379,10 @@ function PendingAccess() {
mutationFn: (u: PendingAccessRow) => api.resendUserVerification(u.id),
onSuccess: (_r, u) => {
refresh();
notify({ level: "success", message: msg("users.pendingAccess.resent", { email: u.email }) });
notify({
level: "success",
message: msg("users.pendingAccess.resent", { email: raw(u.email) }),
});
},
// The public resend is deliberately silent; this one is not, and the server's reason says WHY
// (no working SMTP, a refused send) — a success toast for a mail never sent would be the lie.
@@ -548,7 +551,7 @@ function AdminInvites() {
refresh();
notify({
level: "success",
message: msg("settings.invites.approved", { email: vars.email }),
message: msg("settings.invites.approved", { email: raw(vars.email) }),
});
},
onError: (e) => notifyActionError(e, msg("settings.invites.approveFailed")),
@@ -615,7 +618,7 @@ function AdminInvites() {
refresh();
notify({
level: "success",
message: msg("settings.invites.invited", { email: v.inv.email }),
message: msg("settings.invites.invited", { email: raw(v.inv.email) }),
});
},
onError: (err) => notifyActionError(err, msg("settings.invites.inviteFailed")),
@@ -657,7 +660,10 @@ function AdminInvites() {
onSuccess: (_d, email) => {
setNewEmail("");
refresh();
notify({ level: "success", message: msg("settings.invites.addedToWhitelist", { email }) });
notify({
level: "success",
message: msg("settings.invites.addedToWhitelist", { email: raw(email) }),
});
},
onError: (e) => notifyActionError(e, msg("settings.invites.addFailed")),
});
@@ -81,7 +81,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
import { HttpError } from "../../lib/api";
import AdminUsers from "./AdminUsers";
import { msg, sayNotif, type NotifText } from "../../lib/notifications";
import { msg, type NotifText, raw, sayNotif } from "../../lib/notifications";
import i18n from "../../i18n";
const invite = (over: Partial<Record<string, unknown>> = {}) => ({
@@ -264,7 +264,7 @@ describe("inviting someone the admin whitelisted", () => {
await waitFor(() => expect(inviteMock).toHaveBeenCalledWith(1, "en"));
expect(notifyMock).toHaveBeenCalledWith(
expect.objectContaining({
message: msg("settings.invites.invited", { email: "asker@example.com" }),
message: msg("settings.invites.invited", { email: raw("asker@example.com") }),
}),
);
});
@@ -69,7 +69,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
});
import AdminUsers from "./AdminUsers";
import { msg, sayNotif, type NotifText } from "../../lib/notifications";
import { msg, type NotifText, raw, sayNotif } from "../../lib/notifications";
import i18n from "../../i18n";
/** A row as /api/admin/pending-access serves it. */
@@ -116,7 +116,7 @@ describe("the stuck sign-ups section", () => {
await waitFor(() => expect(activateMock).toHaveBeenCalledWith(7));
expect(notifyMock).toHaveBeenCalledWith(
expect.objectContaining({
message: msg("users.pendingAccess.activated", { email: "later@example.com" }),
message: msg("users.pendingAccess.activated", { email: raw("later@example.com") }),
}),
);
});