diff --git a/frontend/src/features/channel/TagManager.tsx b/frontend/src/features/channel/TagManager.tsx index 91660987..c0aad6ed 100644 --- a/frontend/src/features/channel/TagManager.tsx +++ b/frontend/src/features/channel/TagManager.tsx @@ -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")), }); diff --git a/frontend/src/features/comfort/comfortPin.tsx b/frontend/src/features/comfort/comfortPin.tsx index cb5b4db7..a0a42525 100644 --- a/frontend/src/features/comfort/comfortPin.tsx +++ b/frontend/src/features/comfort/comfortPin.tsx @@ -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 diff --git a/frontend/src/features/downloads/DownloadTray.dom.test.tsx b/frontend/src/features/downloads/DownloadTray.dom.test.tsx index 2755a017..f1be5ebd 100644 --- a/frontend/src/features/downloads/DownloadTray.dom.test.tsx +++ b/frontend/src/features/downloads/DownloadTray.dom.test.tsx @@ -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") }), + }), ), ); }); diff --git a/frontend/src/features/downloads/DownloadTray.tsx b/frontend/src/features/downloads/DownloadTray.tsx index c6f30696..b59056b3 100644 --- a/frontend/src/features/downloads/DownloadTray.tsx +++ b/frontend/src/features/downloads/DownloadTray.tsx @@ -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: { diff --git a/frontend/src/features/plexCards/usePlexCardActions.tsx b/frontend/src/features/plexCards/usePlexCardActions.tsx index 5f240466..f4c39174 100644 --- a/frontend/src/features/plexCards/usePlexCardActions.tsx +++ b/frontend/src/features/plexCards/usePlexCardActions.tsx @@ -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)) : []), ]; }; diff --git a/frontend/src/features/video/stateNotice.ts b/frontend/src/features/video/stateNotice.ts index 99e6c054..743d51f0 100644 --- a/frontend/src/features/video/stateNotice.ts +++ b/frontend/src/features/video/stateNotice.ts @@ -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: { diff --git a/frontend/src/features/video/useVideoActions.tsx b/frontend/src/features/video/useVideoActions.tsx index f665d2ea..1abfeba2 100644 --- a/frontend/src/features/video/useVideoActions.tsx +++ b/frontend/src/features/video/useVideoActions.tsx @@ -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 ?? "")) : []), ]; }; diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index bd7db65a..cbbe8a75 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -344,6 +344,7 @@ "playShow": "Play the show", "playSeason": "Play the season", "removeFromPlaylist": "Remove from the playlist", + "seasonOfShow": "{{show}} · {{season}}", "removedToast": "“{{title}}” removed from the playlist", "undoRemoveFailed": "Couldn't put it back in the playlist" }, diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 05030cab..bbf6264b 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -344,6 +344,7 @@ "playShow": "Sorozat lejátszása", "playSeason": "Évad lejátszása", "removeFromPlaylist": "Eltávolítás a listáról", + "seasonOfShow": "{{show}} · {{season}}", "removedToast": "„{{title}}” eltávolítva a listáról", "undoRemoveFailed": "Nem sikerült visszatenni a listára" }, diff --git a/frontend/src/lib/accountSwitchNotice.dom.test.ts b/frontend/src/lib/accountSwitchNotice.dom.test.ts index 974f84e5..8074f531 100644 --- a/frontend/src/lib/accountSwitchNotice.dom.test.ts +++ b/frontend/src/lib/accountSwitchNotice.dom.test.ts @@ -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"), }); }); diff --git a/frontend/src/lib/entryUrl.ts b/frontend/src/lib/entryUrl.ts index 0a148ffa..025e3960 100644 --- a/frontend/src/lib/entryUrl.ts +++ b/frontend/src/lib/entryUrl.ts @@ -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; + values?: Record; } /** 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; diff --git a/frontend/src/lib/notifications.ts b/frontend/src/lib/notifications.ts index f3e327af..00ab7203 100644 --- a/frontend/src/lib/notifications.ts +++ b/frontend/src/lib/notifications.ts @@ -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> } | { raw: string } diff --git a/frontend/src/lib/plexDetail.ts b/frontend/src/lib/plexDetail.ts index 3e6cac85..a772b69d 100644 --- a/frontend/src/lib/plexDetail.ts +++ b/frontend/src/lib/plexDetail.ts @@ -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"); } diff --git a/frontend/src/lib/useAccountSwitchNotice.dom.test.ts b/frontend/src/lib/useAccountSwitchNotice.dom.test.ts index c22fbe2c..229d4bf9 100644 --- a/frontend/src/lib/useAccountSwitchNotice.dom.test.ts +++ b/frontend/src/lib/useAccountSwitchNotice.dom.test.ts @@ -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") }), }); }); diff --git a/frontend/src/modules/channels/ChannelDiscovery.tsx b/frontend/src/modules/channels/ChannelDiscovery.tsx index 4b01c686..617f69fb 100644 --- a/frontend/src/modules/channels/ChannelDiscovery.tsx +++ b/frontend/src/modules/channels/ChannelDiscovery.tsx @@ -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 }, }); }, diff --git a/frontend/src/modules/channels/Channels.tsx b/frontend/src/modules/channels/Channels.tsx index a1f92739..4a45b5d5 100644 --- a/frontend/src/modules/channels/Channels.tsx +++ b/frontend/src/modules/channels/Channels.tsx @@ -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), + ), }) : []; diff --git a/frontend/src/modules/channels/useChannelActions.dom.test.tsx b/frontend/src/modules/channels/useChannelActions.dom.test.tsx index 4e980c00..108ccfa8 100644 --- a/frontend/src/modules/channels/useChannelActions.dom.test.tsx +++ b/frontend/src/modules/channels/useChannelActions.dom.test.tsx @@ -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(); diff --git a/frontend/src/modules/channels/useChannelActions.tsx b/frontend/src/modules/channels/useChannelActions.tsx index d29ab2fc..bc455ed9 100644 --- a/frontend/src/modules/channels/useChannelActions.tsx +++ b/frontend/src/modules/channels/useChannelActions.tsx @@ -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() }); diff --git a/frontend/src/modules/channels/useWatchedChannelActions.ts b/frontend/src/modules/channels/useWatchedChannelActions.ts index 467396bf..4a6f0b2e 100644 --- a/frontend/src/modules/channels/useWatchedChannelActions.ts +++ b/frontend/src/modules/channels/useWatchedChannelActions.ts @@ -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 => { 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); diff --git a/frontend/src/modules/config/ConfigPanel.tsx b/frontend/src/modules/config/ConfigPanel.tsx index ac9f577d..9d6d91bb 100644 --- a/frontend/src/modules/config/ConfigPanel.tsx +++ b/frontend/src/modules/config/ConfigPanel.tsx @@ -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")), }); diff --git a/frontend/src/modules/feed/ChannelPage.tsx b/frontend/src/modules/feed/ChannelPage.tsx index a9282d0a..a68f8713 100644 --- a/frontend/src/modules/feed/ChannelPage.tsx +++ b/frontend/src/modules/feed/ChannelPage.tsx @@ -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). diff --git a/frontend/src/modules/files/ShareDialog.tsx b/frontend/src/modules/files/ShareDialog.tsx index 55359e98..1d00c40e 100644 --- a/frontend/src/modules/files/ShareDialog.tsx +++ b/frontend/src/modules/files/ShareDialog.tsx @@ -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")), }); diff --git a/frontend/src/modules/home/Home.tsx b/frontend/src/modules/home/Home.tsx index 926a749e..67203451 100644 --- a/frontend/src/modules/home/Home.tsx +++ b/frontend/src/modules/home/Home.tsx @@ -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, diff --git a/frontend/src/modules/playlists/Playlists.dom.test.tsx b/frontend/src/modules/playlists/Playlists.dom.test.tsx index ed57bb99..2b30390a 100644 --- a/frontend/src/modules/playlists/Playlists.dom.test.tsx +++ b/frontend/src/modules/playlists/Playlists.dom.test.tsx @@ -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()); diff --git a/frontend/src/modules/playlists/Playlists.tsx b/frontend/src/modules/playlists/Playlists.tsx index 6e89bf4e..3ddd5be6 100644 --- a/frontend/src/modules/playlists/Playlists.tsx +++ b/frontend/src/modules/playlists/Playlists.tsx @@ -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: , 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)), ); diff --git a/frontend/src/modules/playlists/PlaylistsRail.tsx b/frontend/src/modules/playlists/PlaylistsRail.tsx index 1849ebbd..c1d05aee 100644 --- a/frontend/src/modules/playlists/PlaylistsRail.tsx +++ b/frontend/src/modules/playlists/PlaylistsRail.tsx @@ -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[] = [ diff --git a/frontend/src/modules/plex/PlexPlaylistView.menu.dom.test.tsx b/frontend/src/modules/plex/PlexPlaylistView.menu.dom.test.tsx index 2aa52235..4f1dffa5 100644 --- a/frontend/src/modules/plex/PlexPlaylistView.menu.dom.test.tsx +++ b/frontend/src/modules/plex/PlexPlaylistView.menu.dom.test.tsx @@ -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 }), + }), + }), + ), + ); }); }); diff --git a/frontend/src/modules/plex/PlexPlaylistView.tsx b/frontend/src/modules/plex/PlexPlaylistView.tsx index 68d1d350..415d98e7 100644 --- a/frontend/src/modules/plex/PlexPlaylistView.tsx +++ b/frontend/src/modules/plex/PlexPlaylistView.tsx @@ -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({ 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); diff --git a/frontend/src/modules/plex/usePlexPlaylistEdits.dom.test.tsx b/frontend/src/modules/plex/usePlexPlaylistEdits.dom.test.tsx index f3edf9a0..04f2630c 100644 --- a/frontend/src/modules/plex/usePlexPlaylistEdits.dom.test.tsx +++ b/frontend/src/modules/plex/usePlexPlaylistEdits.dom.test.tsx @@ -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")])); diff --git a/frontend/src/modules/plex/usePlexPlaylistEdits.ts b/frontend/src/modules/plex/usePlexPlaylistEdits.ts index d09971b7..62ba4b34 100644 --- a/frontend/src/modules/plex/usePlexPlaylistEdits.ts +++ b/frontend/src/modules/plex/usePlexPlaylistEdits.ts @@ -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 diff --git a/frontend/src/modules/scheduler/Scheduler.tsx b/frontend/src/modules/scheduler/Scheduler.tsx index 8a5355d1..9efaa42a 100644 --- a/frontend/src/modules/scheduler/Scheduler.tsx +++ b/frontend/src/modules/scheduler/Scheduler.tsx @@ -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() }); diff --git a/frontend/src/modules/settings/SettingsPanel.tsx b/frontend/src/modules/settings/SettingsPanel.tsx index 59254aa4..2b416c66 100644 --- a/frontend/src/modules/settings/SettingsPanel.tsx +++ b/frontend/src/modules/settings/SettingsPanel.tsx @@ -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( diff --git a/frontend/src/modules/settings/TakeoutImport.dom.test.tsx b/frontend/src/modules/settings/TakeoutImport.dom.test.tsx index 61ffc4cc..7474fe9c 100644 --- a/frontend/src/modules/settings/TakeoutImport.dom.test.tsx +++ b/frontend/src/modules/settings/TakeoutImport.dom.test.tsx @@ -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 }, }), }); diff --git a/frontend/src/modules/settings/TakeoutImport.tsx b/frontend/src/modules/settings/TakeoutImport.tsx index e2cf53ac..86305ef7 100644 --- a/frontend/src/modules/settings/TakeoutImport.tsx +++ b/frontend/src/modules/settings/TakeoutImport.tsx @@ -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 }, }), diff --git a/frontend/src/modules/users/AdminUsers.tsx b/frontend/src/modules/users/AdminUsers.tsx index ec6f627a..54f1e7e3 100644 --- a/frontend/src/modules/users/AdminUsers.tsx +++ b/frontend/src/modules/users/AdminUsers.tsx @@ -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")), }); diff --git a/frontend/src/modules/users/adminInvites.dom.test.tsx b/frontend/src/modules/users/adminInvites.dom.test.tsx index 83d18e6a..08806286 100644 --- a/frontend/src/modules/users/adminInvites.dom.test.tsx +++ b/frontend/src/modules/users/adminInvites.dom.test.tsx @@ -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> = {}) => ({ @@ -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") }), }), ); }); diff --git a/frontend/src/modules/users/adminPendingAccess.dom.test.tsx b/frontend/src/modules/users/adminPendingAccess.dom.test.tsx index c0f259b3..e8dbd055 100644 --- a/frontend/src/modules/users/adminPendingAccess.dom.test.tsx +++ b/frontend/src/modules/users/adminPendingAccess.dom.test.tsx @@ -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") }), }), ); });