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
+10 -11
View File
@@ -30,13 +30,12 @@ import { quickShareLink } from "./downloads";
import { qk } from "./queryKeys";
import { msg, type NotifText } from "./notifications";
const t = (k: string) => k;
const origin = window.location.origin;
type ToastArg = {
level: string;
message: NotifText;
action?: { label: string; onClick: () => void };
action?: { label: NotifText; onClick: () => void };
};
const firstToast = () => notify.mock.calls[0]![0] as ToastArg;
@@ -59,7 +58,7 @@ describe("quickShareLink", () => {
createDownloadLink.mockResolvedValue({ id: 7, url: "/watch/tok7" });
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`);
// Minted with an EMPTY body so the backend default (allow_download=true) applies.
@@ -68,7 +67,7 @@ describe("quickShareLink", () => {
const arg = firstToast();
expect(arg.level).toBe("success");
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();
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 () => {
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(createDownloadLink).not.toHaveBeenCalled();
@@ -89,7 +88,7 @@ describe("quickShareLink", () => {
{ 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(createDownloadLink).not.toHaveBeenCalled();
@@ -101,7 +100,7 @@ describe("quickShareLink", () => {
createDownloadLink.mockResolvedValue({ id: 8, url: "/watch/new" });
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(copyText).toHaveBeenCalledWith(`${origin}/watch/new`);
@@ -117,7 +116,7 @@ describe("quickShareLink", () => {
const client = qc();
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(folderLinks).toHaveBeenCalledWith(5);
@@ -133,20 +132,20 @@ describe("quickShareLink", () => {
downloadLinks.mockResolvedValue([{ id: 5, url: "/watch/x" }]);
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
const arg = firstToast();
expect(arg.level).toBe("warning");
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 () => {
downloadLinks.mockResolvedValue([]);
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(copyText).not.toHaveBeenCalled();
+2 -6
View File
@@ -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
// 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.
export async function quickShareLink(
qc: QueryClient,
target: LinkTarget,
t: (key: string) => string,
): Promise<string | null> {
export async function quickShareLink(qc: QueryClient, target: LinkTarget): Promise<string | null> {
let path;
try {
const existing = await qc.fetchQuery({
@@ -70,7 +66,7 @@ export async function quickShareLink(
}
const url = window.location.origin + path;
const action = {
label: t("downloads.share.openNewTab"),
label: msg("downloads.share.openNewTab"),
onClick: () => window.open(url, "_blank", "noopener,noreferrer"),
};
notify(
@@ -20,7 +20,7 @@ afterEach(() => {
vi.useRealTimers();
});
const undo = { label: "Undo", onClick: () => {} };
const undo = { label: raw("Undo"), onClick: () => {} };
const shown = (id: number) => getActiveToasts().some((n) => n.id === id);
describe("an action toast's lifetime", () => {
+2 -1
View File
@@ -66,7 +66,8 @@ const LOG_LEVEL: Record<NotifLevel, Level> = {
export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal";
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;
}
+1 -4
View File
@@ -2,7 +2,6 @@ import { HttpError } from "./api";
import { markReported } from "./api/core";
import { msg, notify, type NotifText } from "./notifications";
import { notifyActionError } from "./actionErrors";
import i18n from "../i18n";
// 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
@@ -19,9 +18,7 @@ export function notifyYouTubeActionError(
notify({
level: "error",
message: msg("channels.notify.needYouTube"),
action: onConnect
? { label: i18n.t("channels.notify.connect"), onClick: onConnect }
: undefined,
action: onConnect ? { label: msg("channels.notify.connect"), onClick: onConnect } : undefined,
});
markReported(err);
return;