fix(notifications): a notice's button is said when shown, like its text

An action label was translated when the notice was raised, so after a language switch a live
toast or center row read in one language with its button in the other. The access-requests nudge
no longer re-issues on a switch for it. New tests: the button and a server refusal follow a
switch.
This commit is contained in:
2026-10-30 00:10:38 +01:00
parent 4a0d4b0964
commit b515c16117
26 changed files with 98 additions and 70 deletions
@@ -59,6 +59,7 @@ vi.mock("../../features/player/PlayerModal", () => ({
import WatchedChannels from "./WatchedChannels";
import { qk } from "../../lib/queryKeys";
import { api, type Video, type WatchedChannel, type WatchedChannels as List } from "../../lib/api";
import { msg, type NotifText } from "../../lib/notifications";
afterEach(() => {
cleanup();
@@ -149,8 +150,10 @@ describe("WatchedChannels", () => {
fireEvent.click(await screen.findByRole("button", { name: "channels.watched.dismiss" }));
await waitFor(() => expect(h.notify).toHaveBeenCalled());
expect(dismiss).toHaveBeenCalledWith("UC1");
const toast = h.notify.mock.calls[0]![0] as { action: { label: string; onClick: () => void } };
expect(toast.action.label).toBe("common.undo");
const toast = h.notify.mock.calls[0]![0] as {
action: { label: NotifText; onClick: () => void };
};
expect(toast.action.label).toEqual(msg("common.undo"));
act(() => toast.action.onClick());
await waitFor(() => expect(restore).toHaveBeenCalledWith("UC1"));
});
@@ -74,7 +74,7 @@ export function useChannelActions({
level: "success",
message: msg("channels.notify.hidden", { name: nameOf(c) }),
action: {
label: t("common.undo"),
label: msg("common.undo"),
onClick: () => patch.mutate({ id: c.id, body: { hidden: false } }),
},
});
@@ -44,7 +44,7 @@ export function useWatchedChannelActions(onOpenWizard: () => void) {
notify({
level: "success",
message: msg("channels.watched.dismissed", { name: raw(nameOf(c)) }),
action: { label: t("common.undo"), onClick: () => restore.mutate(c) },
action: { label: msg("common.undo"), onClick: () => restore.mutate(c) },
});
},
});
+1 -1
View File
@@ -107,7 +107,7 @@ export default function UploadDialog({
level: "success",
message: msg(uploadedKey(job.status)),
action: {
label: t("downloads.dialog.view"),
label: msg("downloads.dialog.view"),
onClick: () => openUploaded(job),
},
});
@@ -109,7 +109,7 @@ export function useFileActions({
const quickShare = async (file: LibraryFile) => {
if (sharingId !== null) return;
setSharingId(file.id);
await quickShareLink(qc, { kind: "file", id: file.id }, t).finally(() => setSharingId(null));
await quickShareLink(qc, { kind: "file", id: file.id }).finally(() => setSharingId(null));
};
const icon = (I: typeof PlayCircle) => <I className="w-4 h-4" />;
@@ -101,9 +101,7 @@ export function useFolderActions({ onRename }: { onRename: (folder: FileFolder)
disabled: copying,
onSelect: () => {
setCopying(true);
void quickShareLink(qc, { kind: "folder", id: folder.id }, t).finally(() =>
setCopying(false),
);
void quickShareLink(qc, { kind: "folder", id: folder.id }).finally(() => setCopying(false));
},
},
{
@@ -9,7 +9,7 @@ import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { api, type AppNotification } from "../../lib/api";
import { raw, type Notification as ClientNotif } from "../../lib/notifications";
import { msg, type Notification as ClientNotif, raw } from "../../lib/notifications";
const h = vi.hoisted(() => ({
server: [] as unknown[],
@@ -295,7 +295,7 @@ describe("NotificationsPanel · an activity row", () => {
h.client = [
client(7, {
dismissed: false,
action: { label: "feed.undo", onClick: () => {} },
action: { label: msg("feed.undo"), onClick: () => {} },
meta: { kind: "video-hidden", videoId: "v", channelId: "c" },
} as never),
];
@@ -321,8 +321,8 @@ describe("NotificationsPanel · an activity row", () => {
h.client = [
client(5, {
dismissed: false,
action: { label: "tray.done.copyLink", onClick: copy },
secondaryAction: { label: "tray.done.open", onClick: open },
action: { label: msg("tray.done.copyLink"), onClick: copy },
secondaryAction: { label: msg("tray.done.open"), onClick: open },
}),
];
mount();
@@ -393,7 +393,7 @@ export default function NotificationsPanel() {
: !n.dismissed
? liveActions(n).map((action, i) => ({
id: `action${i}`,
label: action.label,
label: sayNotif(action.label, t),
onSelect: () => {
action.onClick();
dismissClient(n.id);
@@ -570,7 +570,7 @@ export default function NotificationsPanel() {
}
/** A client notice's live actions (they do not survive a reload): its action, then the lesser one. */
function liveActions(n: ClientNotif): { label: string; onClick: () => void }[] {
function liveActions(n: ClientNotif): NonNullable<ClientNotif["action"]>[] {
return [n.action, n.secondaryAction].filter((a) => a !== undefined);
}
@@ -920,7 +920,7 @@ function ClientActivityRow({
<div className="mt-1 flex flex-wrap gap-x-4">
{liveActions(n).map((action, i) => (
<Button
key={action.label}
key={i}
variant="link"
className="text-sm"
// The first is the row's own: Enter on the row runs it.
@@ -930,7 +930,7 @@ function ClientActivityRow({
dismissClient(n.id);
}}
>
{action.label}
{sayNotif(action.label, t)}
</Button>
))}
</div>
@@ -663,7 +663,9 @@ describe("Playlists — removing a row, and undoing it", () => {
// removal may notify too.
const lastToast = () =>
h.notify.mock.calls
.map((c) => c[0] as { message: NotifText; action?: { label: string; onClick: () => void } })
.map(
(c) => c[0] as { message: NotifText; action?: { label: NotifText; onClick: () => void } },
)
.filter((n) => "key" in n.message && n.message.key === "playlists.removedToast")
.pop()!;
// The list's keys are live once its effect has registered it (`data-nav-list`), a passive effect that
@@ -690,7 +692,7 @@ describe("Playlists — removing a row, and undoing it", () => {
await waitFor(() =>
expect(lastToast().message).toEqual(msg("playlists.removedToast", { title: raw("Video B") })),
);
expect(lastToast().action?.label).toBe("common.undo");
expect(lastToast().action?.label).toEqual(msg("common.undo"));
act(() => lastToast().action!.onClick());
// Index 1 of the STORED order: where it was, not the end of the list.
await waitFor(() => expect(api.addToPlaylist).toHaveBeenCalledWith(1, "B", 1));
+1 -1
View File
@@ -547,7 +547,7 @@ function PlaylistDetail() {
level: "success",
message: msg("playlists.removedToast", { title: raw(video.title ?? "") }),
action: {
label: t("common.undo"),
label: msg("common.undo"),
// ONE request that puts it back at its index (the server renumbers around it): an append
// plus a whole-list reorder would land it at the end whenever the second call failed. The
// index is worked out against the list as it is NOW, fetched fresh.
@@ -15,8 +15,11 @@ vi.mock("react-i18next", async (importOriginal) => ({
}));
const { notify } = vi.hoisted(() => ({
notify: vi.fn(
(_n: { level: string; message: NotifText; action?: { label: string; onClick: () => void } }) =>
0,
(_n: {
level: string;
message: NotifText;
action?: { label: NotifText; onClick: () => void };
}) => 0,
),
}));
vi.mock("../../lib/notifications", async (importOriginal) => ({
@@ -149,7 +152,7 @@ describe("usePlexPlaylistEdits · the removal", () => {
expect(toast.message).toEqual(
msg("plex.playlist.removedToast", { title: raw("Show · Season 1") }),
);
expect(toast.action!.label).toBe("common.undo");
expect(toast.action!.label).toEqual(msg("common.undo"));
act(() => toast.action!.onClick());
await waitFor(() => expect(add).toHaveBeenCalledTimes(1));
expect(add).toHaveBeenCalledWith(5, ["2", "4"], [1, 3]);
@@ -13,7 +13,7 @@ import { reinsertAt } from "../../lib/reinsert";
* Split from `PlexPlaylistView` so the failure paths are reachable from a test: the reorder is
* driven by a dnd-kit drag, and neither of its sensors activates under jsdom. */
export function usePlexPlaylistEdits(playlistId: number) {
const { t, i18n } = useTranslation();
const { i18n } = useTranslation();
const qc = useQueryClient();
const [busy, setBusy] = useState(false);
// The rows' titles are in the reader's language, so the cache entry is per language — and ONE
@@ -112,7 +112,7 @@ export function usePlexPlaylistEdits(playlistId: number) {
level: "success",
message: msg("plex.playlist.removedToast", { title }),
action: {
label: t("common.undo"),
label: msg("common.undo"),
onClick: () =>
void qc
.fetchQuery({