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:
2026-10-30 00:03:48 +01:00
parent cb8b99e0e7
commit 4a0d4b0964
37 changed files with 174 additions and 109 deletions
+16 -10
View File
@@ -24,7 +24,7 @@ import {
type PendingAccessRow,
} from "../../lib/api";
import { displayName } from "../../lib/api/account";
import { msg, type NotifText, notify } from "../../lib/notifications";
import { msg, type NotifText, notify, raw } from "../../lib/notifications";
import { formatDateTime, relativeTime } from "../../lib/format";
import { invalidateUserAccess } from "../../lib/adminUsers";
import { ADMIN_USERS_TAB_KEY, USERS_TAB_URL_KEY, usersTabParams } from "../../lib/adminUsersTab";
@@ -144,7 +144,7 @@ function UsersRoles({ me }: { me: Me }) {
onMutate: (vars) => startPending(vars.id),
onSuccess: (_d, vars) => {
qc.invalidateQueries({ queryKey: qk.adminUsers() });
notify({ level: "success", message: msg("users.roles.updated", { email: vars.email }) });
notify({ level: "success", message: msg("users.roles.updated", { email: raw(vars.email) }) });
},
onSettled: (_d, _e, vars) => endPending(vars.id),
// Guards (last admin / self) surface via the global error dialog with the server's detail; what
@@ -161,7 +161,7 @@ function UsersRoles({ me }: { me: Me }) {
notify({
level: "success",
message: msg(vars.suspended ? "users.suspend.done" : "users.suspend.undone", {
email: vars.email,
email: raw(vars.email),
}),
});
},
@@ -174,7 +174,7 @@ function UsersRoles({ me }: { me: Me }) {
onMutate: (vars) => startPending(vars.id),
onSuccess: (_d, vars) => {
qc.invalidateQueries({ queryKey: qk.adminUsers() });
notify({ level: "success", message: msg("users.delete.done", { email: vars.email }) });
notify({ level: "success", message: msg("users.delete.done", { email: raw(vars.email) }) });
},
onSettled: (_d, _e, vars) => endPending(vars.id),
onError: (e) => onGoneOrFailed(e, msg("users.delete.failed")),
@@ -209,7 +209,7 @@ function UsersRoles({ me }: { me: Me }) {
const onDelete = async (u: AdminUserRow) => {
const ok = await confirm({
title: t("users.delete.title"),
message: t("users.delete.confirm", { email: u.email }),
message: t("users.delete.confirm", { email: raw(u.email) }),
confirmLabel: t("users.delete.action"),
danger: true,
});
@@ -369,7 +369,7 @@ function PendingAccess() {
refresh();
notify({
level: "success",
message: msg("users.pendingAccess.activated", { email: u.email }),
message: msg("users.pendingAccess.activated", { email: raw(u.email) }),
});
},
onError: (err) => notifyActionError(err, msg("users.pendingAccess.activateFailed")),
@@ -379,7 +379,10 @@ function PendingAccess() {
mutationFn: (u: PendingAccessRow) => api.resendUserVerification(u.id),
onSuccess: (_r, u) => {
refresh();
notify({ level: "success", message: msg("users.pendingAccess.resent", { email: u.email }) });
notify({
level: "success",
message: msg("users.pendingAccess.resent", { email: raw(u.email) }),
});
},
// The public resend is deliberately silent; this one is not, and the server's reason says WHY
// (no working SMTP, a refused send) — a success toast for a mail never sent would be the lie.
@@ -548,7 +551,7 @@ function AdminInvites() {
refresh();
notify({
level: "success",
message: msg("settings.invites.approved", { email: vars.email }),
message: msg("settings.invites.approved", { email: raw(vars.email) }),
});
},
onError: (e) => notifyActionError(e, msg("settings.invites.approveFailed")),
@@ -615,7 +618,7 @@ function AdminInvites() {
refresh();
notify({
level: "success",
message: msg("settings.invites.invited", { email: v.inv.email }),
message: msg("settings.invites.invited", { email: raw(v.inv.email) }),
});
},
onError: (err) => notifyActionError(err, msg("settings.invites.inviteFailed")),
@@ -657,7 +660,10 @@ function AdminInvites() {
onSuccess: (_d, email) => {
setNewEmail("");
refresh();
notify({ level: "success", message: msg("settings.invites.addedToWhitelist", { email }) });
notify({
level: "success",
message: msg("settings.invites.addedToWhitelist", { email: raw(email) }),
});
},
onError: (e) => notifyActionError(e, msg("settings.invites.addFailed")),
});
@@ -81,7 +81,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
import { HttpError } from "../../lib/api";
import AdminUsers from "./AdminUsers";
import { msg, sayNotif, type NotifText } from "../../lib/notifications";
import { msg, type NotifText, raw, sayNotif } from "../../lib/notifications";
import i18n from "../../i18n";
const invite = (over: Partial<Record<string, unknown>> = {}) => ({
@@ -264,7 +264,7 @@ describe("inviting someone the admin whitelisted", () => {
await waitFor(() => expect(inviteMock).toHaveBeenCalledWith(1, "en"));
expect(notifyMock).toHaveBeenCalledWith(
expect.objectContaining({
message: msg("settings.invites.invited", { email: "asker@example.com" }),
message: msg("settings.invites.invited", { email: raw("asker@example.com") }),
}),
);
});
@@ -69,7 +69,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
});
import AdminUsers from "./AdminUsers";
import { msg, sayNotif, type NotifText } from "../../lib/notifications";
import { msg, type NotifText, raw, sayNotif } from "../../lib/notifications";
import i18n from "../../i18n";
/** A row as /api/admin/pending-access serves it. */
@@ -116,7 +116,7 @@ describe("the stuck sign-ups section", () => {
await waitFor(() => expect(activateMock).toHaveBeenCalledWith(7));
expect(notifyMock).toHaveBeenCalledWith(
expect.objectContaining({
message: msg("users.pendingAccess.activated", { email: "later@example.com" }),
message: msg("users.pendingAccess.activated", { email: raw("later@example.com") }),
}),
);
});