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:
@@ -219,8 +219,8 @@ describe("Toaster — a toast's actions", () => {
|
|||||||
void notify({
|
void notify({
|
||||||
level: "success",
|
level: "success",
|
||||||
message: raw("Downloaded"),
|
message: raw("Downloaded"),
|
||||||
action: { label: "Copy link", onClick: copy },
|
action: { label: raw("Copy link"), onClick: copy },
|
||||||
secondaryAction: { label: "Open", onClick: open },
|
secondaryAction: { label: raw("Open"), onClick: open },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(screen.getByRole("button", { name: "Copy link" })).toBeTruthy();
|
expect(screen.getByRole("button", { name: "Copy link" })).toBeTruthy();
|
||||||
|
|||||||
@@ -32,6 +32,37 @@ describe("a notice speaks the reader's language of the moment", () => {
|
|||||||
expect(screen.queryByText(en)).toBeNull();
|
expect(screen.queryByText(en)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("its button is said again too, beside the text it acts on", async () => {
|
||||||
|
render(<Toaster />);
|
||||||
|
act(
|
||||||
|
() =>
|
||||||
|
void notify({
|
||||||
|
message: msg("common.copy.done"),
|
||||||
|
action: { label: msg("common.undo"), onClick: () => {} },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("button", { name: "Undo" })).toBeTruthy();
|
||||||
|
|
||||||
|
await act(async () => void (await i18n.changeLanguage("hu")));
|
||||||
|
expect(screen.getByRole("button", { name: "Visszavonás" })).toBeTruthy();
|
||||||
|
expect(screen.queryByRole("button", { name: "Undo" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a server refusal is said again in the language of the moment", async () => {
|
||||||
|
render(<Toaster />);
|
||||||
|
act(
|
||||||
|
() =>
|
||||||
|
void notify({
|
||||||
|
level: "error",
|
||||||
|
message: { detail: { code: "not_found", reason: "view" } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(screen.getByText("This view doesn't exist any more")).toBeTruthy();
|
||||||
|
|
||||||
|
await act(async () => void (await i18n.changeLanguage("hu")));
|
||||||
|
expect(screen.getByText("Ez a nézet már nem létezik")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it("a value inside the text is said again too, and a number is grouped the reader's way", async () => {
|
it("a value inside the text is said again too, and a number is grouped the reader's way", async () => {
|
||||||
const text = msg("settings.takeout.read", {
|
const text = msg("settings.takeout.read", {
|
||||||
name: raw("watch-history.json"),
|
name: raw("watch-history.json"),
|
||||||
|
|||||||
@@ -176,10 +176,10 @@ export default function Toaster({
|
|||||||
{(toast.action || toast.secondaryAction) && (
|
{(toast.action || toast.secondaryAction) && (
|
||||||
<div className="mt-1 flex flex-wrap gap-x-4">
|
<div className="mt-1 flex flex-wrap gap-x-4">
|
||||||
{[toast.action, toast.secondaryAction].map(
|
{[toast.action, toast.secondaryAction].map(
|
||||||
(action) =>
|
(action, i) =>
|
||||||
action && (
|
action && (
|
||||||
<Button
|
<Button
|
||||||
key={action.label}
|
key={i}
|
||||||
variant="link"
|
variant="link"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
action.onClick();
|
action.onClick();
|
||||||
@@ -187,7 +187,7 @@ export default function Toaster({
|
|||||||
}}
|
}}
|
||||||
className="text-sm"
|
className="text-sm"
|
||||||
>
|
>
|
||||||
{action.label}
|
{sayNotif(action.label, t)}
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export default function DownloadDialog({
|
|||||||
notify({
|
notify({
|
||||||
level: "success",
|
level: "success",
|
||||||
message: msg("downloads.dialog.added"),
|
message: msg("downloads.dialog.added"),
|
||||||
action: { label: t("downloads.dialog.view"), onClick: () => showInTray(job.id) },
|
action: { label: msg("downloads.dialog.view"), onClick: () => showInTray(job.id) },
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -202,12 +202,13 @@ describe("DownloadTray", () => {
|
|||||||
const notice = h.notify.mock.calls[0]![0] as {
|
const notice = h.notify.mock.calls[0]![0] as {
|
||||||
title: NotifText;
|
title: NotifText;
|
||||||
message: NotifText;
|
message: NotifText;
|
||||||
action: { label: string; onClick: () => void };
|
action: { label: NotifText; onClick: () => void };
|
||||||
secondaryAction: { label: string; onClick: () => void };
|
secondaryAction: { label: NotifText; onClick: () => void };
|
||||||
};
|
};
|
||||||
expect(notice.title).toEqual(msg("tray.done.title", { title: raw("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).toEqual(msg("tray.done.copyLink"));
|
||||||
|
expect(notice.secondaryAction.label).toEqual(msg("tray.done.open"));
|
||||||
notice.action.onClick();
|
notice.action.onClick();
|
||||||
expect(h.share).toHaveBeenCalled();
|
expect(h.share).toHaveBeenCalled();
|
||||||
notice.secondaryAction.onClick();
|
notice.secondaryAction.onClick();
|
||||||
|
|||||||
@@ -196,11 +196,11 @@ export default function DownloadTray() {
|
|||||||
}),
|
}),
|
||||||
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: {
|
||||||
label: t("tray.done.copyLink"),
|
label: msg("tray.done.copyLink"),
|
||||||
onClick: () => void quickShareLink(qc, { kind: "file", id: job.id }, t),
|
onClick: () => void quickShareLink(qc, { kind: "file", id: job.id }),
|
||||||
},
|
},
|
||||||
secondaryAction: {
|
secondaryAction: {
|
||||||
label: t("tray.done.open"),
|
label: msg("tray.done.open"),
|
||||||
onClick: () => openFile(job.id, job.folder_id),
|
onClick: () => openFile(job.id, job.folder_id),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -298,7 +298,7 @@ export default function DownloadTray() {
|
|||||||
id: "copyLink",
|
id: "copyLink",
|
||||||
label: t("tray.done.copyLink"),
|
label: t("tray.done.copyLink"),
|
||||||
icon: <Link className="w-4 h-4" />,
|
icon: <Link className="w-4 h-4" />,
|
||||||
onSelect: () => void quickShareLink(qc, { kind: "file", id: job.id }, t),
|
onSelect: () => void quickShareLink(qc, { kind: "file", id: job.id }),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const menuItems = menu.target
|
const menuItems = menu.target
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ export default function ClipPanel({
|
|||||||
notify({
|
notify({
|
||||||
level: "success",
|
level: "success",
|
||||||
message: msg("clip.queued"),
|
message: msg("clip.queued"),
|
||||||
action: { label: t("clip.view"), onClick: () => showInTray(job.id) },
|
action: { label: msg("clip.view"), onClick: () => showInTray(job.id) },
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function announceVideoState(
|
|||||||
message: video?.title
|
message: video?.title
|
||||||
? msg(hidden ? "feed.hiddenNamed" : "feed.markedWatchedNamed", { title: raw(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: msg(hidden ? "feed.undo" : "feed.unwatch"), onClick: undo },
|
||||||
meta: {
|
meta: {
|
||||||
kind: hidden ? "video-hidden" : "video-watched",
|
kind: hidden ? "video-hidden" : "video-watched",
|
||||||
videoId: id,
|
videoId: id,
|
||||||
|
|||||||
@@ -30,13 +30,12 @@ import { quickShareLink } from "./downloads";
|
|||||||
import { qk } from "./queryKeys";
|
import { qk } from "./queryKeys";
|
||||||
import { msg, type NotifText } from "./notifications";
|
import { msg, type NotifText } from "./notifications";
|
||||||
|
|
||||||
const t = (k: string) => k;
|
|
||||||
const origin = window.location.origin;
|
const origin = window.location.origin;
|
||||||
|
|
||||||
type ToastArg = {
|
type ToastArg = {
|
||||||
level: string;
|
level: string;
|
||||||
message: NotifText;
|
message: NotifText;
|
||||||
action?: { label: string; onClick: () => void };
|
action?: { label: NotifText; onClick: () => void };
|
||||||
};
|
};
|
||||||
const firstToast = () => notify.mock.calls[0]![0] as ToastArg;
|
const firstToast = () => notify.mock.calls[0]![0] as ToastArg;
|
||||||
|
|
||||||
@@ -59,7 +58,7 @@ describe("quickShareLink", () => {
|
|||||||
createDownloadLink.mockResolvedValue({ id: 7, url: "/watch/tok7" });
|
createDownloadLink.mockResolvedValue({ id: 7, url: "/watch/tok7" });
|
||||||
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
|
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
|
||||||
|
|
||||||
const url = await quickShareLink(qc(), { kind: "file", id: 42 }, t);
|
const url = await quickShareLink(qc(), { kind: "file", id: 42 });
|
||||||
|
|
||||||
expect(url).toBe(`${origin}/watch/tok7`);
|
expect(url).toBe(`${origin}/watch/tok7`);
|
||||||
// Minted with an EMPTY body so the backend default (allow_download=true) applies.
|
// Minted with an EMPTY body so the backend default (allow_download=true) applies.
|
||||||
@@ -68,7 +67,7 @@ describe("quickShareLink", () => {
|
|||||||
const arg = firstToast();
|
const arg = firstToast();
|
||||||
expect(arg.level).toBe("success");
|
expect(arg.level).toBe("success");
|
||||||
expect(arg.message).toEqual(msg("downloads.share.linkCopied"));
|
expect(arg.message).toEqual(msg("downloads.share.linkCopied"));
|
||||||
expect(arg.action!.label).toBe("downloads.share.openNewTab");
|
expect(arg.action!.label).toEqual(msg("downloads.share.openNewTab"));
|
||||||
arg.action!.onClick();
|
arg.action!.onClick();
|
||||||
expect(openSpy).toHaveBeenCalledWith(`${origin}/watch/tok7`, "_blank", "noopener,noreferrer");
|
expect(openSpy).toHaveBeenCalledWith(`${origin}/watch/tok7`, "_blank", "noopener,noreferrer");
|
||||||
});
|
});
|
||||||
@@ -76,7 +75,7 @@ describe("quickShareLink", () => {
|
|||||||
it("reuses an existing link instead of minting a duplicate", async () => {
|
it("reuses an existing link instead of minting a duplicate", async () => {
|
||||||
downloadLinks.mockResolvedValue([{ id: 3, url: "/watch/old" }]);
|
downloadLinks.mockResolvedValue([{ id: 3, url: "/watch/old" }]);
|
||||||
|
|
||||||
const url = await quickShareLink(qc(), { kind: "file", id: 1 }, t);
|
const url = await quickShareLink(qc(), { kind: "file", id: 1 });
|
||||||
|
|
||||||
expect(url).toBe(`${origin}/watch/old`);
|
expect(url).toBe(`${origin}/watch/old`);
|
||||||
expect(createDownloadLink).not.toHaveBeenCalled();
|
expect(createDownloadLink).not.toHaveBeenCalled();
|
||||||
@@ -89,7 +88,7 @@ describe("quickShareLink", () => {
|
|||||||
{ id: 3, url: "/watch/older" },
|
{ id: 3, url: "/watch/older" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const url = await quickShareLink(qc(), { kind: "file", id: 1 }, t);
|
const url = await quickShareLink(qc(), { kind: "file", id: 1 });
|
||||||
|
|
||||||
expect(url).toBe(`${origin}/watch/older`);
|
expect(url).toBe(`${origin}/watch/older`);
|
||||||
expect(createDownloadLink).not.toHaveBeenCalled();
|
expect(createDownloadLink).not.toHaveBeenCalled();
|
||||||
@@ -101,7 +100,7 @@ describe("quickShareLink", () => {
|
|||||||
createDownloadLink.mockResolvedValue({ id: 8, url: "/watch/new" });
|
createDownloadLink.mockResolvedValue({ id: 8, url: "/watch/new" });
|
||||||
const client = qc();
|
const client = qc();
|
||||||
|
|
||||||
const url = await quickShareLink(client, { kind: "file", id: 6 }, t);
|
const url = await quickShareLink(client, { kind: "file", id: 6 });
|
||||||
|
|
||||||
expect(url).toBe(`${origin}/watch/new`);
|
expect(url).toBe(`${origin}/watch/new`);
|
||||||
expect(copyText).toHaveBeenCalledWith(`${origin}/watch/new`);
|
expect(copyText).toHaveBeenCalledWith(`${origin}/watch/new`);
|
||||||
@@ -117,7 +116,7 @@ describe("quickShareLink", () => {
|
|||||||
const client = qc();
|
const client = qc();
|
||||||
client.setQueryData(qk.fileFolders(), []);
|
client.setQueryData(qk.fileFolders(), []);
|
||||||
|
|
||||||
const url = await quickShareLink(client, { kind: "folder", id: 5 }, t);
|
const url = await quickShareLink(client, { kind: "folder", id: 5 });
|
||||||
|
|
||||||
expect(url).toBe(`${origin}/watch/fold`);
|
expect(url).toBe(`${origin}/watch/fold`);
|
||||||
expect(folderLinks).toHaveBeenCalledWith(5);
|
expect(folderLinks).toHaveBeenCalledWith(5);
|
||||||
@@ -133,20 +132,20 @@ describe("quickShareLink", () => {
|
|||||||
downloadLinks.mockResolvedValue([{ id: 5, url: "/watch/x" }]);
|
downloadLinks.mockResolvedValue([{ id: 5, url: "/watch/x" }]);
|
||||||
copyText.mockResolvedValue(false);
|
copyText.mockResolvedValue(false);
|
||||||
|
|
||||||
const url = await quickShareLink(qc(), { kind: "file", id: 2 }, t);
|
const url = await quickShareLink(qc(), { kind: "file", id: 2 });
|
||||||
|
|
||||||
expect(url).toBe(`${origin}/watch/x`); // the link is never a dead end
|
expect(url).toBe(`${origin}/watch/x`); // the link is never a dead end
|
||||||
const arg = firstToast();
|
const arg = firstToast();
|
||||||
expect(arg.level).toBe("warning");
|
expect(arg.level).toBe("warning");
|
||||||
expect(arg.message).toEqual(msg("downloads.share.copyFailed"));
|
expect(arg.message).toEqual(msg("downloads.share.copyFailed"));
|
||||||
expect(arg.action!.label).toBe("downloads.share.openNewTab");
|
expect(arg.action!.label).toEqual(msg("downloads.share.openNewTab"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports an error and returns null when link creation fails", async () => {
|
it("reports an error and returns null when link creation fails", async () => {
|
||||||
downloadLinks.mockResolvedValue([]);
|
downloadLinks.mockResolvedValue([]);
|
||||||
createDownloadLink.mockRejectedValue(new Error("boom"));
|
createDownloadLink.mockRejectedValue(new Error("boom"));
|
||||||
|
|
||||||
const url = await quickShareLink(qc(), { kind: "file", id: 9 }, t);
|
const url = await quickShareLink(qc(), { kind: "file", id: 9 });
|
||||||
|
|
||||||
expect(url).toBeNull();
|
expect(url).toBeNull();
|
||||||
expect(copyText).not.toHaveBeenCalled();
|
expect(copyText).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -44,11 +44,7 @@ export function invalidateLinks(qc: QueryClient, target: LinkTarget) {
|
|||||||
// new tab" action. A copy failure still toasts (warning) WITH that action, so the link is never a dead
|
// new tab" action. A copy failure still toasts (warning) WITH that action, so the link is never a dead
|
||||||
// end. Advanced controls (password, expiry, revoke, share-with-a-user) live in the Share dialog.
|
// end. Advanced controls (password, expiry, revoke, share-with-a-user) live in the Share dialog.
|
||||||
// Returns the shared URL, or null if link creation failed — so the caller/tests can react to the outcome.
|
// Returns the shared URL, or null if link creation failed — so the caller/tests can react to the outcome.
|
||||||
export async function quickShareLink(
|
export async function quickShareLink(qc: QueryClient, target: LinkTarget): Promise<string | null> {
|
||||||
qc: QueryClient,
|
|
||||||
target: LinkTarget,
|
|
||||||
t: (key: string) => string,
|
|
||||||
): Promise<string | null> {
|
|
||||||
let path;
|
let path;
|
||||||
try {
|
try {
|
||||||
const existing = await qc.fetchQuery({
|
const existing = await qc.fetchQuery({
|
||||||
@@ -70,7 +66,7 @@ export async function quickShareLink(
|
|||||||
}
|
}
|
||||||
const url = window.location.origin + path;
|
const url = window.location.origin + path;
|
||||||
const action = {
|
const action = {
|
||||||
label: t("downloads.share.openNewTab"),
|
label: msg("downloads.share.openNewTab"),
|
||||||
onClick: () => window.open(url, "_blank", "noopener,noreferrer"),
|
onClick: () => window.open(url, "_blank", "noopener,noreferrer"),
|
||||||
};
|
};
|
||||||
notify(
|
notify(
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ afterEach(() => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
const undo = { label: "Undo", onClick: () => {} };
|
const undo = { label: raw("Undo"), onClick: () => {} };
|
||||||
const shown = (id: number) => getActiveToasts().some((n) => n.id === id);
|
const shown = (id: number) => getActiveToasts().some((n) => n.id === id);
|
||||||
|
|
||||||
describe("an action toast's lifetime", () => {
|
describe("an action toast's lifetime", () => {
|
||||||
|
|||||||
@@ -66,7 +66,8 @@ const LOG_LEVEL: Record<NotifLevel, Level> = {
|
|||||||
export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal";
|
export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal";
|
||||||
|
|
||||||
interface NotifAction {
|
interface NotifAction {
|
||||||
label: string;
|
// Said when shown, like the notice's text: a language switch must not leave the button behind.
|
||||||
|
label: NotifText;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { HttpError } from "./api";
|
|||||||
import { markReported } from "./api/core";
|
import { markReported } from "./api/core";
|
||||||
import { msg, notify, type NotifText } from "./notifications";
|
import { msg, notify, type NotifText } from "./notifications";
|
||||||
import { notifyActionError } from "./actionErrors";
|
import { notifyActionError } from "./actionErrors";
|
||||||
import i18n from "../i18n";
|
|
||||||
|
|
||||||
// A YouTube-gated action (subscribe, sync, backfill, unsubscribe) that 403s means the user hasn't
|
// A YouTube-gated action (subscribe, sync, backfill, unsubscribe) that 403s means the user hasn't
|
||||||
// granted the write scope — surface a "Connect" affordance instead of a vague failure. Pass
|
// granted the write scope — surface a "Connect" affordance instead of a vague failure. Pass
|
||||||
@@ -19,9 +18,7 @@ export function notifyYouTubeActionError(
|
|||||||
notify({
|
notify({
|
||||||
level: "error",
|
level: "error",
|
||||||
message: msg("channels.notify.needYouTube"),
|
message: msg("channels.notify.needYouTube"),
|
||||||
action: onConnect
|
action: onConnect ? { label: msg("channels.notify.connect"), onClick: onConnect } : undefined,
|
||||||
? { label: i18n.t("channels.notify.connect"), onClick: onConnect }
|
|
||||||
: undefined,
|
|
||||||
});
|
});
|
||||||
markReported(err);
|
markReported(err);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ vi.mock("../../features/player/PlayerModal", () => ({
|
|||||||
import WatchedChannels from "./WatchedChannels";
|
import WatchedChannels from "./WatchedChannels";
|
||||||
import { qk } from "../../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
import { api, type Video, type WatchedChannel, type WatchedChannels as List } from "../../lib/api";
|
import { api, type Video, type WatchedChannel, type WatchedChannels as List } from "../../lib/api";
|
||||||
|
import { msg, type NotifText } from "../../lib/notifications";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
@@ -149,8 +150,10 @@ describe("WatchedChannels", () => {
|
|||||||
fireEvent.click(await screen.findByRole("button", { name: "channels.watched.dismiss" }));
|
fireEvent.click(await screen.findByRole("button", { name: "channels.watched.dismiss" }));
|
||||||
await waitFor(() => expect(h.notify).toHaveBeenCalled());
|
await waitFor(() => expect(h.notify).toHaveBeenCalled());
|
||||||
expect(dismiss).toHaveBeenCalledWith("UC1");
|
expect(dismiss).toHaveBeenCalledWith("UC1");
|
||||||
const toast = h.notify.mock.calls[0]![0] as { action: { label: string; onClick: () => void } };
|
const toast = h.notify.mock.calls[0]![0] as {
|
||||||
expect(toast.action.label).toBe("common.undo");
|
action: { label: NotifText; onClick: () => void };
|
||||||
|
};
|
||||||
|
expect(toast.action.label).toEqual(msg("common.undo"));
|
||||||
act(() => toast.action.onClick());
|
act(() => toast.action.onClick());
|
||||||
await waitFor(() => expect(restore).toHaveBeenCalledWith("UC1"));
|
await waitFor(() => expect(restore).toHaveBeenCalledWith("UC1"));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export function useChannelActions({
|
|||||||
level: "success",
|
level: "success",
|
||||||
message: msg("channels.notify.hidden", { name: nameOf(c) }),
|
message: msg("channels.notify.hidden", { name: nameOf(c) }),
|
||||||
action: {
|
action: {
|
||||||
label: t("common.undo"),
|
label: msg("common.undo"),
|
||||||
onClick: () => patch.mutate({ id: c.id, body: { hidden: false } }),
|
onClick: () => patch.mutate({ id: c.id, body: { hidden: false } }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function useWatchedChannelActions(onOpenWizard: () => void) {
|
|||||||
notify({
|
notify({
|
||||||
level: "success",
|
level: "success",
|
||||||
message: msg("channels.watched.dismissed", { name: raw(nameOf(c)) }),
|
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) },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export default function UploadDialog({
|
|||||||
level: "success",
|
level: "success",
|
||||||
message: msg(uploadedKey(job.status)),
|
message: msg(uploadedKey(job.status)),
|
||||||
action: {
|
action: {
|
||||||
label: t("downloads.dialog.view"),
|
label: msg("downloads.dialog.view"),
|
||||||
onClick: () => openUploaded(job),
|
onClick: () => openUploaded(job),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export function useFileActions({
|
|||||||
const quickShare = async (file: LibraryFile) => {
|
const quickShare = async (file: LibraryFile) => {
|
||||||
if (sharingId !== null) return;
|
if (sharingId !== null) return;
|
||||||
setSharingId(file.id);
|
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" />;
|
const icon = (I: typeof PlayCircle) => <I className="w-4 h-4" />;
|
||||||
|
|||||||
@@ -101,9 +101,7 @@ export function useFolderActions({ onRename }: { onRename: (folder: FileFolder)
|
|||||||
disabled: copying,
|
disabled: copying,
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
setCopying(true);
|
setCopying(true);
|
||||||
void quickShareLink(qc, { kind: "folder", id: folder.id }, t).finally(() =>
|
void quickShareLink(qc, { kind: "folder", id: folder.id }).finally(() => setCopying(false));
|
||||||
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 { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { api, type AppNotification } from "../../lib/api";
|
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(() => ({
|
const h = vi.hoisted(() => ({
|
||||||
server: [] as unknown[],
|
server: [] as unknown[],
|
||||||
@@ -295,7 +295,7 @@ describe("NotificationsPanel · an activity row", () => {
|
|||||||
h.client = [
|
h.client = [
|
||||||
client(7, {
|
client(7, {
|
||||||
dismissed: false,
|
dismissed: false,
|
||||||
action: { label: "feed.undo", onClick: () => {} },
|
action: { label: msg("feed.undo"), onClick: () => {} },
|
||||||
meta: { kind: "video-hidden", videoId: "v", channelId: "c" },
|
meta: { kind: "video-hidden", videoId: "v", channelId: "c" },
|
||||||
} as never),
|
} as never),
|
||||||
];
|
];
|
||||||
@@ -321,8 +321,8 @@ describe("NotificationsPanel · an activity row", () => {
|
|||||||
h.client = [
|
h.client = [
|
||||||
client(5, {
|
client(5, {
|
||||||
dismissed: false,
|
dismissed: false,
|
||||||
action: { label: "tray.done.copyLink", onClick: copy },
|
action: { label: msg("tray.done.copyLink"), onClick: copy },
|
||||||
secondaryAction: { label: "tray.done.open", onClick: open },
|
secondaryAction: { label: msg("tray.done.open"), onClick: open },
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
mount();
|
mount();
|
||||||
|
|||||||
@@ -393,7 +393,7 @@ export default function NotificationsPanel() {
|
|||||||
: !n.dismissed
|
: !n.dismissed
|
||||||
? liveActions(n).map((action, i) => ({
|
? liveActions(n).map((action, i) => ({
|
||||||
id: `action${i}`,
|
id: `action${i}`,
|
||||||
label: action.label,
|
label: sayNotif(action.label, t),
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
action.onClick();
|
action.onClick();
|
||||||
dismissClient(n.id);
|
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. */
|
/** 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);
|
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">
|
<div className="mt-1 flex flex-wrap gap-x-4">
|
||||||
{liveActions(n).map((action, i) => (
|
{liveActions(n).map((action, i) => (
|
||||||
<Button
|
<Button
|
||||||
key={action.label}
|
key={i}
|
||||||
variant="link"
|
variant="link"
|
||||||
className="text-sm"
|
className="text-sm"
|
||||||
// The first is the row's own: Enter on the row runs it.
|
// The first is the row's own: Enter on the row runs it.
|
||||||
@@ -930,7 +930,7 @@ function ClientActivityRow({
|
|||||||
dismissClient(n.id);
|
dismissClient(n.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{action.label}
|
{sayNotif(action.label, t)}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -663,7 +663,9 @@ describe("Playlists — removing a row, and undoing it", () => {
|
|||||||
// removal may notify too.
|
// removal may notify too.
|
||||||
const lastToast = () =>
|
const lastToast = () =>
|
||||||
h.notify.mock.calls
|
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")
|
.filter((n) => "key" in n.message && n.message.key === "playlists.removedToast")
|
||||||
.pop()!;
|
.pop()!;
|
||||||
// The list's keys are live once its effect has registered it (`data-nav-list`), a passive effect that
|
// 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(() =>
|
await waitFor(() =>
|
||||||
expect(lastToast().message).toEqual(msg("playlists.removedToast", { title: raw("Video B") })),
|
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());
|
act(() => lastToast().action!.onClick());
|
||||||
// Index 1 of the STORED order: where it was, not the end of the list.
|
// Index 1 of the STORED order: where it was, not the end of the list.
|
||||||
await waitFor(() => expect(api.addToPlaylist).toHaveBeenCalledWith(1, "B", 1));
|
await waitFor(() => expect(api.addToPlaylist).toHaveBeenCalledWith(1, "B", 1));
|
||||||
|
|||||||
@@ -547,7 +547,7 @@ function PlaylistDetail() {
|
|||||||
level: "success",
|
level: "success",
|
||||||
message: msg("playlists.removedToast", { title: raw(video.title ?? "") }),
|
message: msg("playlists.removedToast", { title: raw(video.title ?? "") }),
|
||||||
action: {
|
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
|
// 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
|
// 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.
|
// 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(() => ({
|
const { notify } = vi.hoisted(() => ({
|
||||||
notify: vi.fn(
|
notify: vi.fn(
|
||||||
(_n: { level: string; message: NotifText; action?: { label: string; onClick: () => void } }) =>
|
(_n: {
|
||||||
0,
|
level: string;
|
||||||
|
message: NotifText;
|
||||||
|
action?: { label: NotifText; onClick: () => void };
|
||||||
|
}) => 0,
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
vi.mock("../../lib/notifications", async (importOriginal) => ({
|
vi.mock("../../lib/notifications", async (importOriginal) => ({
|
||||||
@@ -149,7 +152,7 @@ describe("usePlexPlaylistEdits · the removal", () => {
|
|||||||
expect(toast.message).toEqual(
|
expect(toast.message).toEqual(
|
||||||
msg("plex.playlist.removedToast", { title: raw("Show · Season 1") }),
|
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());
|
act(() => toast.action!.onClick());
|
||||||
await waitFor(() => expect(add).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(add).toHaveBeenCalledTimes(1));
|
||||||
expect(add).toHaveBeenCalledWith(5, ["2", "4"], [1, 3]);
|
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
|
* 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. */
|
* driven by a dnd-kit drag, and neither of its sensors activates under jsdom. */
|
||||||
export function usePlexPlaylistEdits(playlistId: number) {
|
export function usePlexPlaylistEdits(playlistId: number) {
|
||||||
const { t, i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
// The rows' titles are in the reader's language, so the cache entry is per language — and ONE
|
// 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",
|
level: "success",
|
||||||
message: msg("plex.playlist.removedToast", { title }),
|
message: msg("plex.playlist.removedToast", { title }),
|
||||||
action: {
|
action: {
|
||||||
label: t("common.undo"),
|
label: msg("common.undo"),
|
||||||
onClick: () =>
|
onClick: () =>
|
||||||
void qc
|
void qc
|
||||||
.fetchQuery({
|
.fetchQuery({
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import type { Me } from "../lib/api";
|
import type { Me } from "../lib/api";
|
||||||
import type { Page } from "../lib/urlState";
|
import type { Page } from "../lib/urlState";
|
||||||
import { msg, notify, removeByMetaKind } from "../lib/notifications";
|
import { msg, notify, removeByMetaKind } from "../lib/notifications";
|
||||||
@@ -8,13 +7,11 @@ import { focusAccessRequestsTab } from "../lib/adminUsersTab";
|
|||||||
type SetPage = (next: Page, then?: () => void) => void;
|
type SetPage = (next: Page, then?: () => void) => void;
|
||||||
|
|
||||||
export function useAccessRequestsNudge(me: Me | null | undefined, setPage: SetPage): void {
|
export function useAccessRequestsNudge(me: Me | null | undefined, setPage: SetPage): void {
|
||||||
const { t } = useTranslation();
|
|
||||||
// Nudge admins when access requests are waiting (in lieu of a server-push bell). Keyed on the
|
// Nudge admins when access requests are waiting (in lieu of a server-push bell). Keyed on the
|
||||||
// pending COUNT so it re-resolves the moment an approval/denial changes it — not only on a
|
// pending COUNT so it re-resolves the moment an approval/denial changes it — not only on a
|
||||||
// fresh load (which is why the notice used to linger until F5 after an approval).
|
// fresh load (which is why the notice used to linger until F5 after an approval).
|
||||||
// Honest deps, and `t` among them is a fix rather than a concession: the nudge is a persisted
|
// No `t` among the deps: the nudge keeps what it says (its title, message and button), said in the
|
||||||
// toast whose text is translated at issue time, so a language switch must re-issue it. It did not
|
// reader's language each time it is shown, so a language switch needs no re-issue.
|
||||||
// before — the notice stayed in the old language until the count changed.
|
|
||||||
const role = me?.role;
|
const role = me?.role;
|
||||||
const pendingInvites = me?.pending_invites ?? 0;
|
const pendingInvites = me?.pending_invites ?? 0;
|
||||||
const pendingActivations = me?.pending_activations ?? 0;
|
const pendingActivations = me?.pending_activations ?? 0;
|
||||||
@@ -36,7 +33,7 @@ export function useAccessRequestsNudge(me: Me | null | undefined, setPage: SetPa
|
|||||||
// gets a click via the action.
|
// gets a click via the action.
|
||||||
meta: { kind: "access-requests" },
|
meta: { kind: "access-requests" },
|
||||||
action: {
|
action: {
|
||||||
label: t("common.accessRequestsReview"),
|
label: msg("common.accessRequestsReview"),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
focusAccessRequestsTab();
|
focusAccessRequestsTab();
|
||||||
// Clicked inside whatever overlay hosts the toast; setPage closes it before pushing.
|
// Clicked inside whatever overlay hosts the toast; setPage closes it before pushing.
|
||||||
@@ -45,5 +42,5 @@ export function useAccessRequestsNudge(me: Me | null | undefined, setPage: SetPa
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [role, pendingInvites, pendingActivations, t, setPage]);
|
}, [role, pendingInvites, pendingActivations, setPage]);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user