fix(notifications): a notice keeps what to say and says it in the reader's language when shown

This commit is contained in:
2026-10-29 22:37:14 +01:00
parent 6986eb1937
commit 4ef772d37b
125 changed files with 973 additions and 674 deletions
@@ -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 type { Notification as ClientNotif } from "../../lib/notifications";
import { raw, type Notification as ClientNotif } from "../../lib/notifications";
const h = vi.hoisted(() => ({
server: [] as unknown[],
@@ -116,7 +116,7 @@ const client = (id: number, extra: Partial<ClientNotif> = {}) =>
({
id,
level: "success",
message: `Activity ${id}`,
message: raw(`Activity ${id}`),
requiresInteraction: false,
ts: Date.now(),
read: true,
@@ -50,6 +50,7 @@ vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
import NotificationsPanel from "./NotificationsPanel";
import { focusNavItem, restoreNavPosition } from "../../lib/listNav";
import { raw } from "../../lib/notifications";
// The server list goes through the real live query, so an optimistic patch of its cache is seen.
beforeEach(() => {
@@ -80,7 +81,7 @@ const client = (id: number, ageMs: number, extra: Partial<ClientNotif> = {}) =>
({
id,
level: "success",
message: `Activity ${id}.`,
message: raw(`Activity ${id}.`),
requiresInteraction: false,
ts: Date.now() - ageMs,
read: true,
@@ -122,8 +123,8 @@ describe("NotificationsPanel · order", () => {
h.client = [
client(9, 5 * 60 * 1000, {
level: "warning",
title: "common.accessRequestsTitle",
message: "1 pending",
title: raw("common.accessRequestsTitle"),
message: raw("1 pending"),
meta: { kind: "access-requests" },
}),
client(8, 2 * D),
@@ -366,8 +367,8 @@ describe("NotificationsPanel · order", () => {
h.client = [
client(9, 5 * 60 * 1000, {
level: "warning",
title: "common.accessRequestsTitle",
message: "1 pending",
title: raw("common.accessRequestsTitle"),
message: raw("1 pending"),
meta: { kind: "access-requests" },
}),
];
@@ -465,7 +466,9 @@ describe("NotificationsPanel · order", () => {
// A screen reader hears the name while the keyboard is on the row, and Back finds the row it
// left by it: "Download ready" alone was every download's name.
h.server = [server(1, 1 * H, true), server(2, 2 * H, true)];
h.client = [client(3, 3 * H, { title: "Something broke", message: "Unexpected error." })];
h.client = [
client(3, 3 * H, { title: raw("Something broke"), message: raw("Unexpected error.") }),
];
mount();
await loaded();
expect(
@@ -29,16 +29,18 @@ import { useLiveQuery } from "../../lib/useLiveQuery";
import { focusAccessRequestsTab } from "../../lib/adminUsersTab";
import { focusSettingsTab } from "../../lib/settingsTab";
import {
type ChannelSubscribedMeta,
clearAll as clearClient,
dismiss as dismissClient,
getNotifications,
getUnreadCount,
markAllRead as markAllClientRead,
msg,
type Notification as ClientNotif,
remove as removeClient,
resolveVideo,
sayNotif,
subscribe,
type Notification as ClientNotif,
type ChannelSubscribedMeta,
type VideoHiddenMeta,
type VideoWatchedMeta,
} from "../../lib/notifications";
@@ -250,7 +252,7 @@ export default function NotificationsPanel() {
// Quietly resolve the now-stale notice (no confirmation toast — the change is visible).
resolveVideo(meta.videoId);
})
.catch((e) => notifyActionError(e, t("inbox.revertFailed")));
.catch((e) => notifyActionError(e, msg("inbox.revertFailed")));
}
// --- The rows' ONE menu -------------------------------------------------------------
@@ -707,10 +709,12 @@ function serverRowText(
* Trimmed as Back saves it (`lib/listNav`), or a title ending in a space never matches its row. */
const rowName = (title: string, body: string | null | undefined): string =>
(body ? `${title}: ${body}` : title).trim();
const clientRowName = (n: ClientNotif): string =>
n.title ? rowName(n.title, n.message) : rowName(n.message, null);
const clientRowName = (n: ClientNotif, t: TFunction): string =>
n.title
? rowName(sayNotif(n.title, t), sayNotif(n.message, t))
: rowName(sayNotif(n.message, t), null);
const entryName = (e: InboxEntry, t: TFunction, lang: string): string => {
if (e.src === "client") return clientRowName(e.n);
if (e.src === "client") return clientRowName(e.n, t);
const { title, body } = serverRowText(e.n, t, lang);
return rowName(title, body);
};
@@ -854,15 +858,15 @@ function ClientActivityRow({
const access = n.meta?.kind === "access-requests" ? n.meta : null;
return (
<div
{...navItemAttrs(navIndex, clientRowName(n))}
{...navItemAttrs(navIndex, clientRowName(n, t))}
{...rowProps}
className={`${SURFACE.card} group p-3.5 flex items-start gap-3 ${needsAction ? "ring-1 ring-accent/30" : ""}`}
>
<Icon className={`w-4 h-4 shrink-0 mt-0.5 ${color}`} />
<div className="min-w-0 flex-1">
{n.title && <div className="text-sm font-semibold">{n.title}</div>}
{n.title && <div className="text-sm font-semibold">{sayNotif(n.title, t)}</div>}
<div className="text-sm wrap-break-word">
{n.message}
{sayNotif(n.message, t)}
{n.repeat > 1 && (
<span className="ml-1.5 align-middle text-2xs font-semibold text-muted tabular-nums">
×{n.repeat}