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:
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { qk } from "../../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
import { api, type Tag } from "../../lib/api";
|
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 { notifyActionError } from "../../lib/actionErrors";
|
||||||
import { useScrollFade } from "../../lib/useScrollFade";
|
import { useScrollFade } from "../../lib/useScrollFade";
|
||||||
import { Button } from "../../components/ui/Button";
|
import { Button } from "../../components/ui/Button";
|
||||||
@@ -150,7 +150,7 @@ export default function TagManager({
|
|||||||
mutationFn: (v: { id: number; name: string }) => api.deleteTag(v.id),
|
mutationFn: (v: { id: number; name: string }) => api.deleteTag(v.id),
|
||||||
onSuccess: (_d, v) => {
|
onSuccess: (_d, v) => {
|
||||||
invalidate();
|
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")),
|
onError: (e) => onFailed(e, msg("tagManager.deleteFailed")),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Loader2, Pin, PinOff } from "lucide-react";
|
|||||||
import { plexLang } from "../../i18n";
|
import { plexLang } from "../../i18n";
|
||||||
import { api, type DiscoverCard, type PinKind } from "../../lib/api";
|
import { api, type DiscoverCard, type PinKind } from "../../lib/api";
|
||||||
import { notifyActionError } from "../../lib/actionErrors";
|
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 { qk } from "../../lib/queryKeys";
|
||||||
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
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
|
/** `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 /
|
* 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. */
|
* 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. */
|
/** 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]);
|
}, [qc]);
|
||||||
|
|
||||||
const add = useCallback(
|
const add = useCallback(
|
||||||
async (target: PinTarget, title: string) => {
|
async (target: PinTarget, title: NotifText) => {
|
||||||
try {
|
try {
|
||||||
await api.addComfortPin(target.kind, target.ref);
|
await api.addComfortPin(target.kind, target.ref);
|
||||||
// The shelf lives on another page, so without this the click has no visible consequence at
|
// 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(
|
const remove = useCallback(
|
||||||
(pinId: number, title: string) => {
|
(pinId: number, title: NotifText) => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
await api.removeComfortPin(pinId);
|
await api.removeComfortPin(pinId);
|
||||||
@@ -164,7 +164,7 @@ export function useComfortPin(open: boolean): ComfortPin {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const toggle = useCallback(
|
const toggle = useCallback(
|
||||||
(target: PinTarget, title: string) => {
|
(target: PinTarget, title: NotifText) => {
|
||||||
const pinId = pinIdOf(target);
|
const pinId = pinIdOf(target);
|
||||||
if (pinId == null) void add(target, title);
|
if (pinId == null) void add(target, title);
|
||||||
else remove(pinId, title);
|
else remove(pinId, title);
|
||||||
@@ -173,7 +173,7 @@ export function useComfortPin(open: boolean): ComfortPin {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const item = useCallback(
|
const item = useCallback(
|
||||||
(target: PinTarget | null, title: string): ActionMenuItem[] => {
|
(target: PinTarget | null, title: NotifText): ActionMenuItem[] => {
|
||||||
if (!target) return [];
|
if (!target) return [];
|
||||||
const pinId = pinIdOf(target);
|
const pinId = pinIdOf(target);
|
||||||
// Still loading: an honest "we don't know yet" row rather than a confident "Pin" over
|
// 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 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 { noteQueue, resetQueueWatch, showInTray } from "../../lib/downloadQueue";
|
||||||
import { pushLayer, removeLayer } from "../../lib/layerStack";
|
import { pushLayer, removeLayer } from "../../lib/layerStack";
|
||||||
import { qk } from "../../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
@@ -205,7 +205,7 @@ describe("DownloadTray", () => {
|
|||||||
action: { label: string; onClick: () => void };
|
action: { label: string; onClick: () => void };
|
||||||
secondaryAction: { 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.message).toEqual(msg("tray.done.phone"));
|
||||||
expect(notice.action.label).toBe("tray.done.copyLink");
|
expect(notice.action.label).toBe("tray.done.copyLink");
|
||||||
notice.action.onClick();
|
notice.action.onClick();
|
||||||
@@ -227,8 +227,8 @@ describe("DownloadTray", () => {
|
|||||||
poll([job(1, "done"), job(2, "done")], view);
|
poll([job(1, "done"), job(2, "done")], view);
|
||||||
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(2));
|
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(2));
|
||||||
expect(h.notify.mock.calls.map((c) => (c[0] as { title: NotifText }).title)).toEqual([
|
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: raw("Clip 1") }),
|
||||||
msg("tray.done.title", { title: "Clip 2" }),
|
msg("tray.done.title", { title: raw("Clip 2") }),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -260,7 +260,9 @@ describe("DownloadTray", () => {
|
|||||||
poll([job(1, "done", { source_key: "upload" })], view);
|
poll([job(1, "done", { source_key: "upload" })], view);
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(h.notify).toHaveBeenCalledWith(
|
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";
|
} from "../../lib/downloadQueue";
|
||||||
import { anyBlockingLayer } from "../../lib/layerStack";
|
import { anyBlockingLayer } from "../../lib/layerStack";
|
||||||
import { focusNavItem, navItemAttrs } from "../../lib/listNav";
|
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 { qk } from "../../lib/queryKeys";
|
||||||
import { LS } from "../../lib/storage";
|
import { LS } from "../../lib/storage";
|
||||||
import { useLiveQuery } from "../../lib/useLiveQuery";
|
import { useLiveQuery } from "../../lib/useLiveQuery";
|
||||||
@@ -192,7 +192,7 @@ export default function DownloadTray() {
|
|||||||
level: "success",
|
level: "success",
|
||||||
// An upload the worker had to convert first is done here too — it was never downloaded.
|
// 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: 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"),
|
message: msg(job.asset?.media_kind === "file" ? "tray.done.file" : "tray.done.phone"),
|
||||||
action: {
|
action: {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { pinPlex, useComfortPin } from "../comfort/comfortPin";
|
|||||||
import { useCopyItems } from "../../components/ui/copyItems";
|
import { useCopyItems } from "../../components/ui/copyItems";
|
||||||
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
|
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
|
||||||
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
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
|
// 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
|
// 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 })),
|
...copy(c.title, null).map((it) => ({ ...it, separatorBefore: !onInfo })),
|
||||||
...(withPin ? pin.item(pinPlex(c), c.title) : []),
|
...(withPin ? pin.item(pinPlex(c), raw(c.title)) : []),
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import i18n from "../../i18n";
|
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";
|
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. */
|
/** 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";
|
const hidden = status === "hidden";
|
||||||
notify({
|
notify({
|
||||||
message: video?.title
|
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"),
|
: msg(hidden ? "feed.hidden" : "feed.markedWatched"),
|
||||||
action: { label: i18n.t(hidden ? "feed.undo" : "feed.unwatch"), onClick: undo },
|
action: { label: i18n.t(hidden ? "feed.undo" : "feed.unwatch"), onClick: undo },
|
||||||
meta: {
|
meta: {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { useCopyItems } from "../../components/ui/copyItems";
|
|||||||
import { useNavigationActions } from "../navigation/navigationContext";
|
import { useNavigationActions } from "../navigation/navigationContext";
|
||||||
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||||
import { VideoFollowUp, type VideoFollowUpState } from "./VideoFollowUp";
|
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
|
// 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
|
// 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 ?? "")) : []),
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -344,6 +344,7 @@
|
|||||||
"playShow": "Play the show",
|
"playShow": "Play the show",
|
||||||
"playSeason": "Play the season",
|
"playSeason": "Play the season",
|
||||||
"removeFromPlaylist": "Remove from the playlist",
|
"removeFromPlaylist": "Remove from the playlist",
|
||||||
|
"seasonOfShow": "{{show}} · {{season}}",
|
||||||
"removedToast": "“{{title}}” removed from the playlist",
|
"removedToast": "“{{title}}” removed from the playlist",
|
||||||
"undoRemoveFailed": "Couldn't put it back in the playlist"
|
"undoRemoveFailed": "Couldn't put it back in the playlist"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -344,6 +344,7 @@
|
|||||||
"playShow": "Sorozat lejátszása",
|
"playShow": "Sorozat lejátszása",
|
||||||
"playSeason": "Évad lejátszása",
|
"playSeason": "Évad lejátszása",
|
||||||
"removeFromPlaylist": "Eltávolítás a listáról",
|
"removeFromPlaylist": "Eltávolítás a listáról",
|
||||||
|
"seasonOfShow": "{{show}} · {{season}}",
|
||||||
"removedToast": "„{{title}}” eltávolítva a listáról",
|
"removedToast": "„{{title}}” eltávolítva a listáról",
|
||||||
"undoRemoveFailed": "Nem sikerült visszatenni a listára"
|
"undoRemoveFailed": "Nem sikerült visszatenni a listára"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
reloadAfterRefusal,
|
reloadAfterRefusal,
|
||||||
} from "./entryUrl";
|
} from "./entryUrl";
|
||||||
import { HttpError } from "./api/core";
|
import { HttpError } from "./api/core";
|
||||||
|
import { raw } from "./notifications";
|
||||||
|
|
||||||
const notice = (search: string, hash = "") =>
|
const notice = (search: string, hash = "") =>
|
||||||
accountSwitchNotice(new URLSearchParams(search), new URLSearchParams(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({
|
expect(notice("", "access=requested&email=b%2Btag%40example.com")).toEqual({
|
||||||
level: "info",
|
level: "info",
|
||||||
key: "header.account.switchRequested",
|
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", () => {
|
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({
|
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"),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
// names) — an email in a query string lands in every reverse-proxy access log. Plain status flags
|
// 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.
|
// 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 { refusalReason } from "./api/core";
|
||||||
import { hrefHere } from "./urlState";
|
import { hrefHere } from "./urlState";
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ export function clearEntryUrl(): void {
|
|||||||
export interface AccountNotice {
|
export interface AccountNotice {
|
||||||
level: NotifLevel;
|
level: NotifLevel;
|
||||||
key: string;
|
key: string;
|
||||||
values?: Record<string, string>;
|
values?: Record<string, NotifValue>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What the entry URL says to the account signed in here about a switch of account. A sign-in as
|
/** 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 access = hash.get("access");
|
||||||
const email = hash.get("linked") || hash.get("email");
|
const email = hash.get("linked") || hash.get("email");
|
||||||
if (access === "requested" && 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")
|
if (access === "requested" || access === "denied")
|
||||||
return { level: "warning", key: "header.account.switchNotApproved" };
|
return { level: "warning", key: "header.account.switchNotApproved" };
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -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
|
// 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
|
// 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
|
// 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.
|
// 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 =
|
export type NotifText =
|
||||||
| { key: string; values?: Readonly<Record<string, NotifValue>> }
|
| { key: string; values?: Readonly<Record<string, NotifValue>> }
|
||||||
| { raw: string }
|
| { raw: string }
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { REDUCED_MOTION, useMediaQuery } from "./useMediaQuery";
|
|||||||
import { usePref, useSavePref } from "./usePref";
|
import { usePref, useSavePref } from "./usePref";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import type { PlexCard, PlexTmdb } from "./api";
|
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
|
// 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
|
// 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 },
|
se: { title?: string | null; season_number?: number | null },
|
||||||
t: TFunction,
|
t: TFunction,
|
||||||
): string {
|
): 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;
|
const n = se.season_number;
|
||||||
if (se.title && !GENERIC_SEASON.test(se.title.trim())) return se.title;
|
if (se.title && !GENERIC_SEASON.test(se.title.trim())) return raw(se.title);
|
||||||
if (n === 0) return t("plex.specials");
|
if (n === 0) return msg("plex.specials");
|
||||||
if (n != null) return t("plex.seasonTitle", { n });
|
if (n != null) return msg("plex.seasonTitle", { n });
|
||||||
return se.title || t("plex.playlist.seasonUnknown");
|
return se.title ? raw(se.title) : msg("plex.playlist.seasonUnknown");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ vi.mock("./entryUrl", async (importOriginal) => {
|
|||||||
return { ...actual, accountSwitchNotice: () => notice() };
|
return { ...actual, accountSwitchNotice: () => notice() };
|
||||||
});
|
});
|
||||||
const { useAccountSwitchNotice } = await import("./useAccountSwitchNotice");
|
const { useAccountSwitchNotice } = await import("./useAccountSwitchNotice");
|
||||||
const { msg } = await import("./notifications");
|
const { msg, raw } = await import("./notifications");
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
notifyMock.mockClear();
|
notifyMock.mockClear();
|
||||||
@@ -40,7 +40,7 @@ describe("the shell's word on a switch of account", () => {
|
|||||||
notice.mockReturnValue({
|
notice.mockReturnValue({
|
||||||
level: "info",
|
level: "info",
|
||||||
key: "header.account.switchRequested",
|
key: "header.account.switchRequested",
|
||||||
values: { email: "b@example.com" },
|
values: { email: raw("b@example.com") },
|
||||||
});
|
});
|
||||||
const { rerender } = renderHook(({ signedIn }) => useAccountSwitchNotice(signedIn), {
|
const { rerender } = renderHook(({ signedIn }) => useAccountSwitchNotice(signedIn), {
|
||||||
initialProps: { signedIn: false },
|
initialProps: { signedIn: false },
|
||||||
@@ -54,7 +54,7 @@ describe("the shell's word on a switch of account", () => {
|
|||||||
expect(notifyMock).toHaveBeenCalledTimes(1);
|
expect(notifyMock).toHaveBeenCalledTimes(1);
|
||||||
expect(notifyMock).toHaveBeenCalledWith({
|
expect(notifyMock).toHaveBeenCalledWith({
|
||||||
level: "info",
|
level: "info",
|
||||||
message: msg("header.account.switchRequested", { email: "b@example.com" }),
|
message: msg("header.account.switchRequested", { email: raw("b@example.com") }),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { hydrateSort, sortRows, type SortState } from "../../lib/columnSort";
|
|||||||
import { usePref } from "../../lib/usePref";
|
import { usePref } from "../../lib/usePref";
|
||||||
import { useCardPager } from "../../lib/useCardPager";
|
import { useCardPager } from "../../lib/useCardPager";
|
||||||
import { subsColumn } from "./channelColumns";
|
import { subsColumn } from "./channelColumns";
|
||||||
import { msg, notify } from "../../lib/notifications";
|
import { msg, notify, raw } from "../../lib/notifications";
|
||||||
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
|
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
|
||||||
import Tooltip from "../../components/ui/Tooltip";
|
import Tooltip from "../../components/ui/Tooltip";
|
||||||
import ChannelLink from "./ChannelLink";
|
import ChannelLink from "./ChannelLink";
|
||||||
@@ -71,7 +71,7 @@ export default function ChannelDiscovery({
|
|||||||
notify({
|
notify({
|
||||||
level: "success",
|
level: "success",
|
||||||
title: msg("channels.discovery.subscribedTitle"),
|
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 },
|
meta: { kind: "channel-subscribed", channelId: c.id, channelName: name },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { Sparkles } from "lucide-react";
|
|||||||
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
|
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
|
||||||
import { LS, ownKey } from "../../lib/storage";
|
import { LS, ownKey } from "../../lib/storage";
|
||||||
import { buildChannelColumns, applySyncFilter, tagFilterChips } from "./channelColumns";
|
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 { Badge } from "../../components/ui/Badge";
|
||||||
import { Button } from "../../components/ui/Button";
|
import { Button } from "../../components/ui/Button";
|
||||||
import Tooltip from "../../components/ui/Tooltip";
|
import Tooltip from "../../components/ui/Tooltip";
|
||||||
@@ -168,7 +168,7 @@ export default function Channels() {
|
|||||||
(tagId: number, name: string) => {
|
(tagId: number, name: string) => {
|
||||||
setFeedFilters({ ...feedFilters, tags: [tagId], channelIds: [] });
|
setFeedFilters({ ...feedFilters, tags: [tagId], channelIds: [] });
|
||||||
setPage("feed");
|
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],
|
[setFeedFilters, feedFilters, setPage],
|
||||||
);
|
);
|
||||||
@@ -406,7 +406,10 @@ export default function Channels() {
|
|||||||
((channelsQuery.data ?? []).find((c) => c.id === rowMenu.target!.id) ?? rowMenu.target);
|
((channelsQuery.data ?? []).find((c) => c.id === rowMenu.target!.id) ?? rowMenu.target);
|
||||||
const menuItems = menuRow
|
const menuItems = menuRow
|
||||||
? channelActions.items(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 { useChannelActions } from "./useChannelActions";
|
||||||
import { api, type ManagedChannel, type Tag } from "../../lib/api";
|
import { api, type ManagedChannel, type Tag } from "../../lib/api";
|
||||||
import type { ActionMenuItem } from "../../components/ui/ActionMenu";
|
import type { ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||||
import { msg, type NotifText } from "../../lib/notifications";
|
import { msg, type NotifText, raw } from "../../lib/notifications";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
@@ -199,11 +199,24 @@ describe("useChannelActions — hide and unsubscribe", () => {
|
|||||||
message: NotifText;
|
message: NotifText;
|
||||||
action: { onClick: () => void };
|
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());
|
act(() => toast.action.onClick());
|
||||||
await waitFor(() => expect(upd).toHaveBeenCalledWith("UC1", { hidden: false }));
|
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 () => {
|
it("unsubscribes only after the danger confirm says yes", async () => {
|
||||||
const unsub = vi.spyOn(api, "unsubscribeChannel").mockResolvedValue({} as never);
|
const unsub = vi.spyOn(api, "unsubscribeChannel").mockResolvedValue({} as never);
|
||||||
const { result } = mount();
|
const { result } = mount();
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { api, type ManagedChannel, type Tag } from "../../lib/api";
|
|||||||
import { priorityChoices } from "../../lib/channelOrder";
|
import { priorityChoices } from "../../lib/channelOrder";
|
||||||
import { compareText } from "../../lib/collate";
|
import { compareText } from "../../lib/collate";
|
||||||
import { channelYouTubeUrl } from "../../lib/format";
|
import { channelYouTubeUrl } from "../../lib/format";
|
||||||
import { msg, notify } from "../../lib/notifications";
|
import { msg, notify, raw } from "../../lib/notifications";
|
||||||
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
|
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
|
||||||
import { useMe } from "../../lib/useMe";
|
import { useMe } from "../../lib/useMe";
|
||||||
import { useConfirm } from "../../components/ui/confirmContext";
|
import { useConfirm } from "../../components/ui/confirmContext";
|
||||||
@@ -46,7 +46,9 @@ export function useChannelActions({
|
|||||||
const me = useMe();
|
const me = useMe();
|
||||||
const isAdmin = me.role === "admin";
|
const isAdmin = me.role === "admin";
|
||||||
const copy = useCopyItems();
|
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 = () => {
|
const refresh = () => {
|
||||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { qk } from "../../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
import { invalidateSubscription } from "../../lib/subscriptionCache";
|
import { invalidateSubscription } from "../../lib/subscriptionCache";
|
||||||
import { api, type WatchedChannel } from "../../lib/api";
|
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 { notifyYouTubeActionError } from "../../lib/youtubeErrors";
|
||||||
import { useConfirm } from "../../components/ui/confirmContext";
|
import { useConfirm } from "../../components/ui/confirmContext";
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ export function useWatchedChannelActions(onOpenWizard: () => void) {
|
|||||||
notify({
|
notify({
|
||||||
level: "success",
|
level: "success",
|
||||||
title: msg("channels.discovery.subscribedTitle"),
|
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) },
|
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() });
|
void qc.invalidateQueries({ queryKey: qk.watchedChannels() });
|
||||||
notify({
|
notify({
|
||||||
level: "success",
|
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) },
|
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 onSubscribe = async (c: WatchedChannel): Promise<boolean> => {
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
title: t("channels.discovery.subscribe"),
|
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"),
|
confirmLabel: t("channels.discovery.subscribe"),
|
||||||
});
|
});
|
||||||
if (ok) subscribe.mutate(c);
|
if (ok) subscribe.mutate(c);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Plug, RotateCcw, Send } from "lucide-react";
|
import { Plug, RotateCcw, Send } from "lucide-react";
|
||||||
import { api, type ConfigItem, type PlexTestResult } from "../../lib/api";
|
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 Tooltip from "../../components/ui/Tooltip";
|
||||||
import InlineNotice from "../../components/ui/InlineNotice";
|
import InlineNotice from "../../components/ui/InlineNotice";
|
||||||
import { useMe } from "../../lib/useMe";
|
import { useMe } from "../../lib/useMe";
|
||||||
@@ -142,7 +142,8 @@ export default function ConfigPanel() {
|
|||||||
|
|
||||||
const testEmail = useMutation({
|
const testEmail = useMutation({
|
||||||
mutationFn: () => api.testEmail(),
|
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")),
|
onError: (e) => notifyActionError(e, msg("config.testFailed")),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -154,7 +155,7 @@ export default function ConfigPanel() {
|
|||||||
mutationFn: () => api.testPlex(),
|
mutationFn: () => api.testPlex(),
|
||||||
onSuccess: (r) => {
|
onSuccess: (r) => {
|
||||||
setPlexResult(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) => {
|
onError: (e) => {
|
||||||
setPlexResult(null);
|
setPlexResult(null);
|
||||||
@@ -165,7 +166,7 @@ export default function ConfigPanel() {
|
|||||||
const testIssues = useMutation({
|
const testIssues = useMutation({
|
||||||
mutationFn: () => api.testIssues(),
|
mutationFn: () => api.testIssues(),
|
||||||
onSuccess: (r) =>
|
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")),
|
onError: (e) => notifyActionError(e, msg("config.issuesTestFailed")),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ import {
|
|||||||
} from "../../lib/channelPageUrlState";
|
} from "../../lib/channelPageUrlState";
|
||||||
import { LS } from "../../lib/storage";
|
import { LS } from "../../lib/storage";
|
||||||
import { useConfirm } from "../../components/ui/confirmContext";
|
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 { notifyYouTubeActionError } from "../../lib/youtubeErrors";
|
||||||
import { notifyActionError } from "../../lib/actionErrors";
|
import { notifyActionError } from "../../lib/actionErrors";
|
||||||
import {
|
import {
|
||||||
@@ -201,7 +201,7 @@ export default function ChannelPage({
|
|||||||
mutationFn: () => api.subscribeChannel(channelId),
|
mutationFn: () => api.subscribeChannel(channelId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
invalidateSubscription(qc, channelId);
|
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
|
// 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).
|
// here, so no Connect button — but the user at least sees why it didn't work).
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { Check, Copy, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react";
|
import { Check, Copy, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react";
|
||||||
import Modal from "../../components/ui/Modal";
|
import Modal from "../../components/ui/Modal";
|
||||||
import { useConfirm } from "../../components/ui/confirmContext";
|
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 { notifyActionError } from "../../lib/actionErrors";
|
||||||
import { formatDate } from "../../lib/format";
|
import { formatDate } from "../../lib/format";
|
||||||
import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../../lib/api";
|
import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../../lib/api";
|
||||||
@@ -40,7 +40,10 @@ function UserShare({ job }: { job: DownloadJob }) {
|
|||||||
const share = useMutation({
|
const share = useMutation({
|
||||||
mutationFn: (email: string) => api.shareDownload(job.id, email),
|
mutationFn: (email: string) => api.shareDownload(job.id, email),
|
||||||
onSuccess: (r) => {
|
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")),
|
onError: (e) => notifyActionError(e, msg("downloads.share.failed")),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ import RemoteImage from "../../components/ui/RemoteImage";
|
|||||||
import { useOpenLinkedPlayer } from "../../features/player/useOpenLinkedPlayer";
|
import { useOpenLinkedPlayer } from "../../features/player/useOpenLinkedPlayer";
|
||||||
import { budgetFrom, budgetParams, DEFAULT_BUDGET } from "../../lib/homeUrlState";
|
import { budgetFrom, budgetParams, DEFAULT_BUDGET } from "../../lib/homeUrlState";
|
||||||
import { useUrlMirror } from "../../lib/useUrlMirror";
|
import { useUrlMirror } from "../../lib/useUrlMirror";
|
||||||
import { msg } from "../../lib/notifications";
|
import { msg, raw } from "../../lib/notifications";
|
||||||
|
|
||||||
// The Home surface — "Mit nézzek most?".
|
// The Home surface — "Mit nézzek most?".
|
||||||
//
|
//
|
||||||
@@ -238,10 +238,10 @@ function useHomeCardMenu({
|
|||||||
danger: true,
|
danger: true,
|
||||||
// The pin id travels on the shelf card, so this needs no lookup — and unlike every
|
// 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.
|
// 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 {
|
return {
|
||||||
menu,
|
menu,
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ import { PageMenuSlot, type PageMenuOpener } from "../../components/ui/pageMenuS
|
|||||||
import { api, HttpError, type Video } from "../../lib/api";
|
import { api, HttpError, type Video } from "../../lib/api";
|
||||||
import { qk } from "../../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
import { patchCachedProgress } from "../../lib/progressCache";
|
import { patchCachedProgress } from "../../lib/progressCache";
|
||||||
import { msg, type NotifText } from "../../lib/notifications";
|
import { msg, type NotifText, raw } from "../../lib/notifications";
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
class Stub {
|
class Stub {
|
||||||
@@ -688,7 +688,7 @@ describe("Playlists — removing a row, and undoing it", () => {
|
|||||||
// No confirm: the toast's Undo is the safety net.
|
// No confirm: the toast's Undo is the safety net.
|
||||||
expect(h.confirm).not.toHaveBeenCalled();
|
expect(h.confirm).not.toHaveBeenCalled();
|
||||||
await waitFor(() =>
|
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");
|
expect(lastToast().action?.label).toBe("common.undo");
|
||||||
act(() => lastToast().action!.onClick());
|
act(() => lastToast().action!.onClick());
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import {
|
|||||||
type Video,
|
type Video,
|
||||||
type VideoStatus,
|
type VideoStatus,
|
||||||
} from "../../lib/api";
|
} from "../../lib/api";
|
||||||
import { msg, notify } from "../../lib/notifications";
|
import { msg, notify, raw } from "../../lib/notifications";
|
||||||
import { notifyActionError } from "../../lib/actionErrors";
|
import { notifyActionError } from "../../lib/actionErrors";
|
||||||
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
|
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
|
||||||
import { playlistName, playlistNameText } from "../../lib/playlistName";
|
import { playlistName, playlistNameText } from "../../lib/playlistName";
|
||||||
@@ -545,7 +545,7 @@ function PlaylistDetail() {
|
|||||||
}
|
}
|
||||||
notify({
|
notify({
|
||||||
level: "success",
|
level: "success",
|
||||||
message: msg("playlists.removedToast", { title: video.title ?? "" }),
|
message: msg("playlists.removedToast", { title: raw(video.title ?? "") }),
|
||||||
action: {
|
action: {
|
||||||
label: t("common.undo"),
|
label: t("common.undo"),
|
||||||
// ONE request that puts it back at its index (the server renumbers around it): an append
|
// 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);
|
setLastMoveTargetId(target.id);
|
||||||
notify({
|
notify({
|
||||||
level: "success",
|
level: "success",
|
||||||
message: msg("playlists.movedToast", { title, name: playlistNameText(target) }),
|
message: msg("playlists.movedToast", { title: raw(title), name: playlistNameText(target) }),
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// NOT a silent revert. `req` deliberately leaves 401/403/404 to the caller as control flow, and
|
// 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" />,
|
icon: <Play className="w-4 h-4" />,
|
||||||
onSelect: () => openPlayer(ctxRow.id),
|
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) => ({
|
...copy(ctxRow.title, ctxRow.watch_url).map((it, i) => ({
|
||||||
...it,
|
...it,
|
||||||
separatorBefore: i === 0,
|
separatorBefore: i === 0,
|
||||||
@@ -806,7 +806,7 @@ function PlaylistDetail() {
|
|||||||
setRenameValue(headerPl.name);
|
setRenameValue(headerPl.name);
|
||||||
setRenaming(true);
|
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
|
// 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);
|
setRenameValue(detail.name);
|
||||||
setRenaming(true);
|
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)),
|
.filter((it) => !("danger" in it && it.danger)),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { type SortOption } from "../../lib/columnSort";
|
|||||||
import { usePlaylists, usePlaylistsActions } from "./playlistsContext";
|
import { usePlaylists, usePlaylistsActions } from "./playlistsContext";
|
||||||
import { railDefaultId } from "./playlistsInit";
|
import { railDefaultId } from "./playlistsInit";
|
||||||
import { useMe } from "../../lib/useMe";
|
import { useMe } from "../../lib/useMe";
|
||||||
import { playlistName } from "../../lib/playlistName";
|
import { playlistName, playlistNameText } from "../../lib/playlistName";
|
||||||
import { usePlaylistActions } from "./usePlaylistActions";
|
import { usePlaylistActions } from "./usePlaylistActions";
|
||||||
import PlaylistLimitsHint from "./PlaylistLimitsHint";
|
import PlaylistLimitsHint from "./PlaylistLimitsHint";
|
||||||
import { IconButton } from "../../components/ui/IconButton";
|
import { IconButton } from "../../components/ui/IconButton";
|
||||||
@@ -206,7 +206,7 @@ export default function PlaylistsRail() {
|
|||||||
setRenameValue(ctxPl.name);
|
setRenameValue(ctxPl.name);
|
||||||
setRenamingId(ctxPl.id);
|
setRenamingId(ctxPl.id);
|
||||||
},
|
},
|
||||||
pin: comfortPin.item(pinPlaylist(ctxPl.id), plName(ctxPl)),
|
pin: comfortPin.item(pinPlaylist(ctxPl.id), playlistNameText(ctxPl)),
|
||||||
});
|
});
|
||||||
|
|
||||||
const items: PanelItem[] = [
|
const items: PanelItem[] = [
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => h.confi
|
|||||||
import PlexPlaylistView from "./PlexPlaylistView";
|
import PlexPlaylistView from "./PlexPlaylistView";
|
||||||
import { api, type PlexCard } from "../../lib/api";
|
import { api, type PlexCard } from "../../lib/api";
|
||||||
import { qk } from "../../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
import { msg, type NotifText } from "../../lib/notifications";
|
import { msg, type NotifText, raw } from "../../lib/notifications";
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
class Stub {
|
class Stub {
|
||||||
@@ -183,7 +183,9 @@ describe("PlexPlaylistView · a row's menu", () => {
|
|||||||
await waitFor(() => expect(api.plexPlaylistRemoveItem).toHaveBeenCalledWith(5, "2"));
|
await waitFor(() => expect(api.plexPlaylistRemoveItem).toHaveBeenCalledWith(5, "2"));
|
||||||
expect(h.confirm).not.toHaveBeenCalled();
|
expect(h.confirm).not.toHaveBeenCalled();
|
||||||
await waitFor(() =>
|
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());
|
act(() => toast()!.action!.onClick());
|
||||||
await waitFor(() => expect(add).toHaveBeenCalledWith(5, ["2"], [1]));
|
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 pick(() => fireEvent.click(moreOf("The Show")), "plex.playlist.removeShow");
|
||||||
await waitFor(() => expect(bulk).toHaveBeenCalledWith(5, ["e1", "e2"]));
|
await waitFor(() => expect(bulk).toHaveBeenCalledWith(5, ["e1", "e2"]));
|
||||||
await waitFor(() =>
|
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")!,
|
.find((b) => b.textContent === "plex.playlist.removeSeason")!,
|
||||||
);
|
);
|
||||||
await waitFor(() => expect(bulk).toHaveBeenCalledWith(5, ["e2", "e3"]));
|
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 { SURFACE } from "../../components/ui/surfaces";
|
||||||
import { PageHeading } from "../../components/ui/Headings";
|
import { PageHeading } from "../../components/ui/Headings";
|
||||||
import { MEDIA_BADGE } from "../../components/ui/mediaCardStyles";
|
import { MEDIA_BADGE } from "../../components/ui/mediaCardStyles";
|
||||||
import { seasonTitle } from "../../lib/plexDetail";
|
import { seasonTitle, seasonTitleText } from "../../lib/plexDetail";
|
||||||
import { cardImage } from "../../lib/plexImage";
|
import { cardImage } from "../../lib/plexImage";
|
||||||
import RemoteImage from "../../components/ui/RemoteImage";
|
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
|
// 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
|
// 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.
|
// Keyed: a row builds its target afresh each render, and its "…" must close the menu it opened.
|
||||||
const rowMenu = useContextMenu<MenuTarget>({ keyOf: targetKey });
|
const rowMenu = useContextMenu<MenuTarget>({ keyOf: targetKey });
|
||||||
const copy = useCopyItems();
|
const copy = useCopyItems();
|
||||||
const seasonLabel = useSeasonLabel();
|
|
||||||
const titleActions = usePlexCardActions(
|
const titleActions = usePlexCardActions(
|
||||||
{
|
{
|
||||||
onOpen: (c) => onPlay(c.id, order),
|
onOpen: (c) => onPlay(c.id, order),
|
||||||
@@ -581,14 +580,17 @@ export default function PlexPlaylistView({
|
|||||||
rowMenu.open,
|
rowMenu.open,
|
||||||
);
|
);
|
||||||
// What a row's removal takes out, and the name its toast says.
|
// 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"
|
target.kind === "item"
|
||||||
? { keys: [target.item.id], title: target.item.title }
|
? { keys: [target.item.id], title: raw(target.item.title) }
|
||||||
: target.kind === "show"
|
: 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),
|
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 remove = (target: MenuTarget) => {
|
||||||
const { keys, title } = removal(target);
|
const { keys, title } = removal(target);
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ vi.mock("../../lib/notifications", async (importOriginal) => ({
|
|||||||
import { usePlexPlaylistEdits } from "./usePlexPlaylistEdits";
|
import { usePlexPlaylistEdits } from "./usePlexPlaylistEdits";
|
||||||
import { api, type PlexCard } from "../../lib/api";
|
import { api, type PlexCard } from "../../lib/api";
|
||||||
import { qk } from "../../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
import { msg, type NotifText } from "../../lib/notifications";
|
import { msg, type NotifText, raw } from "../../lib/notifications";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
@@ -125,7 +125,7 @@ describe("usePlexPlaylistEdits · the removal", () => {
|
|||||||
await waitFor(() => expect(hook.result.current.items).toHaveLength(2));
|
await waitFor(() => expect(hook.result.current.items).toHaveLength(2));
|
||||||
vi.spyOn(api, "plexPlaylistRemoveItem").mockRejectedValue(new Error("offline"));
|
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(errors()).toEqual([msg("plex.playlist.removeFailed")]);
|
||||||
expect(ids(hook.result.current.items)).toEqual(["1", "2"]);
|
expect(ids(hook.result.current.items)).toEqual(["1", "2"]);
|
||||||
expect(hook.result.current.busy).toBe(false);
|
expect(hook.result.current.busy).toBe(false);
|
||||||
@@ -144,9 +144,11 @@ describe("usePlexPlaylistEdits · the removal", () => {
|
|||||||
const add = vi.spyOn(api, "plexPlaylistAddBulk").mockResolvedValue({} as never);
|
const add = vi.spyOn(api, "plexPlaylistAddBulk").mockResolvedValue({} as never);
|
||||||
|
|
||||||
// A season whose episodes do not sit together: rows 2 and 4.
|
// 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)!;
|
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");
|
expect(toast.action!.label).toBe("common.undo");
|
||||||
act(() => toast.action!.onClick());
|
act(() => toast.action!.onClick());
|
||||||
await waitFor(() => expect(add).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(add).toHaveBeenCalledTimes(1));
|
||||||
@@ -160,7 +162,7 @@ describe("usePlexPlaylistEdits · the removal", () => {
|
|||||||
await waitFor(() => expect(hook.result.current.items).toHaveLength(3));
|
await waitFor(() => expect(hook.result.current.items).toHaveLength(3));
|
||||||
vi.spyOn(api, "plexPlaylistRemoveItem").mockResolvedValue({} as never);
|
vi.spyOn(api, "plexPlaylistRemoveItem").mockResolvedValue({} as never);
|
||||||
const add = vi.spyOn(api, "plexPlaylistAddBulk").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({
|
detail.mockResolvedValue({
|
||||||
id: 5,
|
id: 5,
|
||||||
title: "F",
|
title: "F",
|
||||||
@@ -179,7 +181,7 @@ describe("usePlexPlaylistEdits · the removal", () => {
|
|||||||
return {} as never;
|
return {} as never;
|
||||||
});
|
});
|
||||||
vi.spyOn(api, "plexPlaylistAddBulk").mockRejectedValue(new Error("offline"));
|
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)!;
|
const toast = notify.mock.calls.map(([n]) => n).find((n) => n.action)!;
|
||||||
act(() => toast.action!.onClick());
|
act(() => toast.action!.onClick());
|
||||||
await waitFor(() => expect(errors()).toEqual([msg("plex.playlist.undoRemoveFailed")]));
|
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 { plexLang } from "../../i18n";
|
||||||
import { api, type PlexCard } from "../../lib/api";
|
import { api, type PlexCard } from "../../lib/api";
|
||||||
import { notifyActionError } from "../../lib/actionErrors";
|
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 { qk } from "../../lib/queryKeys";
|
||||||
import { reinsertAt } from "../../lib/reinsert";
|
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
|
* 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
|
* 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. */
|
* 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;
|
if (busy || !keys.length) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
// The list as it stands now, which is the server's order: a season's episodes need not sit
|
// The list as it stands now, which is the server's order: a season's episodes need not sit
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import { Button } from "../../components/ui/Button";
|
|||||||
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
||||||
import { useConfirm } from "../../components/ui/confirmContext";
|
import { useConfirm } from "../../components/ui/confirmContext";
|
||||||
import { formatDateTime, formatNumber, formatUnit, relativeTime } from "../../lib/format";
|
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 Tooltip from "../../components/ui/Tooltip";
|
||||||
import { notifyActionError } from "../../lib/actionErrors";
|
import { notifyActionError } from "../../lib/actionErrors";
|
||||||
import { IconButton } from "../../components/ui/IconButton";
|
import { IconButton } from "../../components/ui/IconButton";
|
||||||
@@ -569,7 +569,7 @@ export default function Scheduler() {
|
|||||||
notify({
|
notify({
|
||||||
level: "success",
|
level: "success",
|
||||||
message: msg("scheduler.triggered", {
|
message: msg("scheduler.triggered", {
|
||||||
job: msg(`scheduler.jobs.${jobId}`, { defaultValue: jobId }),
|
job: msg(`scheduler.jobs.${jobId}`, { defaultValue: raw(jobId) }),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
qc.invalidateQueries({ queryKey: qk.scheduler() });
|
qc.invalidateQueries({ queryKey: qk.scheduler() });
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { LS, useTabPersistedState } from "../../lib/storage";
|
|||||||
import { SETTINGS_TAB_KEY, settingsTabParams } from "../../lib/settingsTab";
|
import { SETTINGS_TAB_KEY, settingsTabParams } from "../../lib/settingsTab";
|
||||||
import { useUrlMirror } from "../../lib/useUrlMirror";
|
import { useUrlMirror } from "../../lib/useUrlMirror";
|
||||||
import Avatar from "../../components/ui/Avatar";
|
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 Tooltip from "../../components/ui/Tooltip";
|
||||||
import { Section, SwitchRow } from "../../components/ui/form";
|
import { Section, SwitchRow } from "../../components/ui/form";
|
||||||
import { Badge } from "../../components/ui/Badge";
|
import { Badge } from "../../components/ui/Badge";
|
||||||
@@ -402,7 +402,7 @@ export function SignInMethods({ me }: { me: Me }) {
|
|||||||
await qc.invalidateQueries({ queryKey: qk.me() });
|
await qc.invalidateQueries({ queryKey: qk.me() });
|
||||||
notify({
|
notify({
|
||||||
level: "success",
|
level: "success",
|
||||||
message: msg("settings.account.password.saved", { email: me.email }),
|
message: msg("settings.account.password.saved", { email: raw(me.email) }),
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr(
|
setErr(
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ vi.mock("../../lib/format", async (importOriginal) => ({
|
|||||||
|
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import TakeoutImport from "./TakeoutImport";
|
import TakeoutImport from "./TakeoutImport";
|
||||||
import { msg, sayNotif, type NotifText } from "../../lib/notifications";
|
import { msg, type NotifText, raw, sayNotif } from "../../lib/notifications";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
@@ -282,7 +282,7 @@ describe("YouTube history import · while it runs", () => {
|
|||||||
expect(mocks.notify).toHaveBeenCalledWith({
|
expect(mocks.notify).toHaveBeenCalledWith({
|
||||||
level: "error",
|
level: "error",
|
||||||
message: msg("settings.takeout.tooLarge", {
|
message: msg("settings.takeout.tooLarge", {
|
||||||
name: "watch-history.json",
|
name: raw("watch-history.json"),
|
||||||
limit: { bytes: 10 },
|
limit: { bytes: 10 },
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { StateMessage } from "../../components/ui/QueryState";
|
|||||||
import { api, type TakeoutStatus, type TakeoutUpload } from "../../lib/api";
|
import { api, type TakeoutStatus, type TakeoutUpload } from "../../lib/api";
|
||||||
import type { TAKEOUT_OUTCOMES } from "../../lib/api/feed";
|
import type { TAKEOUT_OUTCOMES } from "../../lib/api/feed";
|
||||||
import { qk } from "../../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
import { msg, notify } from "../../lib/notifications";
|
import { msg, notify, raw } from "../../lib/notifications";
|
||||||
import { notifyActionError } from "../../lib/actionErrors";
|
import { notifyActionError } from "../../lib/actionErrors";
|
||||||
import { formatDateTime, formatNumber } from "../../lib/format";
|
import { formatDateTime, formatNumber } from "../../lib/format";
|
||||||
import { importBusy, invalidateImported, watchTakeout } from "./takeoutWatch";
|
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.
|
// Refused here, not after minutes of upload, and with the limit named.
|
||||||
notify({
|
notify({
|
||||||
level: "error",
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -145,7 +145,7 @@ export default function TakeoutImport() {
|
|||||||
notify({
|
notify({
|
||||||
level: "success",
|
level: "success",
|
||||||
message: msg("settings.takeout.read", {
|
message: msg("settings.takeout.read", {
|
||||||
name: file.name,
|
name: raw(file.name),
|
||||||
count: res.videos,
|
count: res.videos,
|
||||||
n: { number: res.videos },
|
n: { number: res.videos },
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
type PendingAccessRow,
|
type PendingAccessRow,
|
||||||
} from "../../lib/api";
|
} from "../../lib/api";
|
||||||
import { displayName } from "../../lib/api/account";
|
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 { formatDateTime, relativeTime } from "../../lib/format";
|
||||||
import { invalidateUserAccess } from "../../lib/adminUsers";
|
import { invalidateUserAccess } from "../../lib/adminUsers";
|
||||||
import { ADMIN_USERS_TAB_KEY, USERS_TAB_URL_KEY, usersTabParams } from "../../lib/adminUsersTab";
|
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),
|
onMutate: (vars) => startPending(vars.id),
|
||||||
onSuccess: (_d, vars) => {
|
onSuccess: (_d, vars) => {
|
||||||
qc.invalidateQueries({ queryKey: qk.adminUsers() });
|
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),
|
onSettled: (_d, _e, vars) => endPending(vars.id),
|
||||||
// Guards (last admin / self) surface via the global error dialog with the server's detail; what
|
// 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({
|
notify({
|
||||||
level: "success",
|
level: "success",
|
||||||
message: msg(vars.suspended ? "users.suspend.done" : "users.suspend.undone", {
|
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),
|
onMutate: (vars) => startPending(vars.id),
|
||||||
onSuccess: (_d, vars) => {
|
onSuccess: (_d, vars) => {
|
||||||
qc.invalidateQueries({ queryKey: qk.adminUsers() });
|
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),
|
onSettled: (_d, _e, vars) => endPending(vars.id),
|
||||||
onError: (e) => onGoneOrFailed(e, msg("users.delete.failed")),
|
onError: (e) => onGoneOrFailed(e, msg("users.delete.failed")),
|
||||||
@@ -209,7 +209,7 @@ function UsersRoles({ me }: { me: Me }) {
|
|||||||
const onDelete = async (u: AdminUserRow) => {
|
const onDelete = async (u: AdminUserRow) => {
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
title: t("users.delete.title"),
|
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"),
|
confirmLabel: t("users.delete.action"),
|
||||||
danger: true,
|
danger: true,
|
||||||
});
|
});
|
||||||
@@ -369,7 +369,7 @@ function PendingAccess() {
|
|||||||
refresh();
|
refresh();
|
||||||
notify({
|
notify({
|
||||||
level: "success",
|
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")),
|
onError: (err) => notifyActionError(err, msg("users.pendingAccess.activateFailed")),
|
||||||
@@ -379,7 +379,10 @@ function PendingAccess() {
|
|||||||
mutationFn: (u: PendingAccessRow) => api.resendUserVerification(u.id),
|
mutationFn: (u: PendingAccessRow) => api.resendUserVerification(u.id),
|
||||||
onSuccess: (_r, u) => {
|
onSuccess: (_r, u) => {
|
||||||
refresh();
|
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
|
// 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.
|
// (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();
|
refresh();
|
||||||
notify({
|
notify({
|
||||||
level: "success",
|
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")),
|
onError: (e) => notifyActionError(e, msg("settings.invites.approveFailed")),
|
||||||
@@ -615,7 +618,7 @@ function AdminInvites() {
|
|||||||
refresh();
|
refresh();
|
||||||
notify({
|
notify({
|
||||||
level: "success",
|
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")),
|
onError: (err) => notifyActionError(err, msg("settings.invites.inviteFailed")),
|
||||||
@@ -657,7 +660,10 @@ function AdminInvites() {
|
|||||||
onSuccess: (_d, email) => {
|
onSuccess: (_d, email) => {
|
||||||
setNewEmail("");
|
setNewEmail("");
|
||||||
refresh();
|
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")),
|
onError: (e) => notifyActionError(e, msg("settings.invites.addFailed")),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
|||||||
|
|
||||||
import { HttpError } from "../../lib/api";
|
import { HttpError } from "../../lib/api";
|
||||||
import AdminUsers from "./AdminUsers";
|
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";
|
import i18n from "../../i18n";
|
||||||
|
|
||||||
const invite = (over: Partial<Record<string, unknown>> = {}) => ({
|
const invite = (over: Partial<Record<string, unknown>> = {}) => ({
|
||||||
@@ -264,7 +264,7 @@ describe("inviting someone the admin whitelisted", () => {
|
|||||||
await waitFor(() => expect(inviteMock).toHaveBeenCalledWith(1, "en"));
|
await waitFor(() => expect(inviteMock).toHaveBeenCalledWith(1, "en"));
|
||||||
expect(notifyMock).toHaveBeenCalledWith(
|
expect(notifyMock).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
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 AdminUsers from "./AdminUsers";
|
||||||
import { msg, sayNotif, type NotifText } from "../../lib/notifications";
|
import { msg, type NotifText, raw, sayNotif } from "../../lib/notifications";
|
||||||
import i18n from "../../i18n";
|
import i18n from "../../i18n";
|
||||||
|
|
||||||
/** A row as /api/admin/pending-access serves it. */
|
/** 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));
|
await waitFor(() => expect(activateMock).toHaveBeenCalledWith(7));
|
||||||
expect(notifyMock).toHaveBeenCalledWith(
|
expect(notifyMock).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
message: msg("users.pendingAccess.activated", { email: "later@example.com" }),
|
message: msg("users.pendingAccess.activated", { email: raw("later@example.com") }),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user