fix(notifications): Back to a row that folded away since opens Earlier on it

This commit is contained in:
2026-10-29 00:47:52 +01:00
parent e3a4593acf
commit d6e72d7576
3 changed files with 97 additions and 37 deletions
+2 -1
View File
@@ -381,7 +381,8 @@ Which actions are visible and which live in a menu.
"…" holds find / undo for an activity notice. The notification center lists what asks for the "…" holds find / undo for an activity notice. The notification center lists what asks for the
reader first ("Needs you · n": an unread row, a notice waiting on an action — one read during the reader first ("Needs you · n": an unread row, a notice waiting on an action — one read during the
visit keeps its place there until Mark all read), then one stream of both sources, newest first, visit keeps its place there until Mark all read), then one stream of both sources, newest first,
and folds read rows older than a week under "Earlier (n)", a heading-style disclosure, closed. Its band keeps Mark all read and folds read rows older than a week under "Earlier (n)", a heading-style disclosure, closed — Back to a row
that folded away since opens it on that row. Its band keeps Mark all read
and puts Clear everything, now behind a confirm, in its "…"; the Scheduler's strip keeps Run all and puts Clear everything, now behind a confirm, in its "…"; the Scheduler's strip keeps Run all
and Pause and puts Purge discovery in its "…". A list whose row objects are rebuilt each render or replaced by a and Pause and puts Purge discovery in its "…". A list whose row objects are rebuilt each render or replaced by a
poll passes `useContextMenu({ keyOf })` (its id), so the "…" still closes its own menu. A menu's poll passes `useContextMenu({ keyOf })` (its id), so the "…" still closes its own menu. A menu's
@@ -49,7 +49,7 @@ vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
})); }));
import NotificationsPanel from "./NotificationsPanel"; import NotificationsPanel from "./NotificationsPanel";
import { focusNavItem } from "../../lib/listNav"; import { focusNavItem, restoreNavPosition } from "../../lib/listNav";
// The server list goes through the real live query, so an optimistic patch of its cache is seen. // The server list goes through the real live query, so an optimistic patch of its cache is seen.
beforeEach(() => { beforeEach(() => {
@@ -94,7 +94,10 @@ function mount() {
const qc = new QueryClient(); const qc = new QueryClient();
const tree = () => ( const tree = () => (
<QueryClientProvider client={qc}> <QueryClientProvider client={qc}>
<NotificationsPanel /> {/* The page's scroller, as in the app: Back finds a row in another of the page's lists. */}
<main>
<NotificationsPanel />
</main>
</QueryClientProvider> </QueryClientProvider>
); );
const { rerender } = render(tree()); const { rerender } = render(tree());
@@ -147,6 +150,29 @@ describe("NotificationsPanel · order", () => {
expect(rows()).toEqual(["Ready 1", "Activity 3", "Ready 2"]); expect(rows()).toEqual(["Ready 1", "Activity 3", "Ready 2"]);
}); });
it("Back to a row that folded away while the page was gone opens Earlier on it", async () => {
// Read during the visit it was opened from, the row kept its place in Needs you; on the next
// visit it is read and over a week old, and folded under Earlier the restore found no list
// holding it — the focus fell on the neighbour at its old place.
h.server = [server(1, 8 * D, false), server(2, 9 * D, true)];
h.client = [];
const cancel = restoreNavPosition({
list: 0,
lists: 2,
index: 0,
name: "inbox.downloadDone.title: Ready 2",
});
mount();
await loaded();
await waitFor(() =>
expect(document.activeElement?.closest("[data-nav-index]")?.textContent).toMatch(/Ready 2/),
);
cancel();
expect(
screen.getByRole("button", { name: "inbox.earlier:1" }).getAttribute("aria-expanded"),
).toBe("true");
});
it("numbers each list's rows from 0 for the keyboard walk", async () => { it("numbers each list's rows from 0 for the keyboard walk", async () => {
h.server = [server(1, 1 * H, false), server(2, 2 * H, true), server(3, 10 * D, true)]; h.server = [server(1, 1 * H, false), server(2, 2 * H, true), server(3, 10 * D, true)];
h.client = [client(4, 3 * H)]; h.client = [client(4, 3 * H)];
@@ -59,7 +59,7 @@ import { useFeedFilters, useFeedFiltersActions } from "../feed";
import { SURFACE } from "../../components/ui/surfaces"; import { SURFACE } from "../../components/ui/surfaces";
import { SectionLabel } from "../../components/ui/Headings"; import { SectionLabel } from "../../components/ui/Headings";
import NavGrid from "../../components/ui/NavGrid"; import NavGrid from "../../components/ui/NavGrid";
import { focusNavItem, navItemAttrs } from "../../lib/listNav"; import { focusNavItem, navItemAttrs, pendingNavName } from "../../lib/listNav";
import { useContextMenu, type ContextMenuController } from "../../components/ui/useContextMenu"; import { useContextMenu, type ContextMenuController } from "../../components/ui/useContextMenu";
import { ContextMenu } from "../../components/ui/ContextMenu"; import { ContextMenu } from "../../components/ui/ContextMenu";
import { MoreButton } from "../../components/ui/MoreButton"; import { MoreButton } from "../../components/ui/MoreButton";
@@ -79,7 +79,7 @@ const POLL_MS = 8000;
const byId = (n: { id: number }) => n.id; const byId = (n: { id: number }) => n.id;
export default function NotificationsPanel() { export default function NotificationsPanel() {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const { setPage } = useNavigationActions(); const { setPage } = useNavigationActions();
const { openFile } = useFilesActions(); const { openFile } = useFilesActions();
const filters = useFeedFilters(); const filters = useFeedFilters();
@@ -108,6 +108,10 @@ export default function NotificationsPanel() {
); );
const [openedAt] = useState(() => Date.now()); const [openedAt] = useState(() => Date.now());
const [earlierOpen, setEarlierOpen] = useState(false); const [earlierOpen, setEarlierOpen] = useState(false);
// Back to a row that folded away since it was opened (read on that visit, and over a week old):
// Earlier opens on it, or the restore finds no list holding it and the focus falls on the row now
// at its old place. Read once, as the center opens; settled once the server's list is in.
const [seeking, setSeeking] = useState(pendingNavName);
// Opening the center marks the (ephemeral) client items read, matching the old bell. Server // Opening the center marks the (ephemeral) client items read, matching the old bell. Server
// items stay unread until acted on individually. // items stay unread until acted on individually.
@@ -157,6 +161,10 @@ export default function NotificationsPanel() {
const order = orderInbox(shownServer, clientItems, { now: openedAt, held }); const order = orderInbox(shownServer, clientItems, { now: openedAt, held });
const asking = order.needsYou.map(entryKey).filter((k) => !held.has(k)); const asking = order.needsYou.map(entryKey).filter((k) => !held.has(k));
if (asking.length > 0) setHeld(new Set([...held, ...asking])); if (asking.length > 0) setHeld(new Set([...held, ...asking]));
if (seeking && q.data) {
setSeeking(null);
if (order.earlier.some((e) => entryName(e, t, i18n.language) === seeking)) setEarlierOpen(true);
}
const serverUnread = serverItems.some((n) => !n.read); const serverUnread = serverItems.some((n) => !n.read);
const hasAny = serverItems.length > 0 || clientItems.length > 0; const hasAny = serverItems.length > 0 || clientItems.length > 0;
@@ -614,36 +622,12 @@ function RowEnd({
); );
} }
function NotificationRow({ /** What a server row says: its title and body — in the reader's language for a known type. */
n, function serverRowText(
t, n: AppNotification,
navIndex, t: TFunction,
rowProps, lang: string,
more, ): { title: string; body: string | null } {
onRead,
onDismiss,
onOpenScheduler,
onOpenTakeout,
onOpenFile,
onOpenTokens,
}: {
n: AppNotification;
t: TFunction;
navIndex: number;
rowProps: Record<string, unknown>;
more: ReactNode;
onRead: () => void;
onDismiss: () => void;
onOpenScheduler: () => void;
onOpenTakeout: () => void;
onOpenFile: () => void;
onOpenTokens: () => void;
}) {
const lang = useTranslation().i18n.language;
// The maintenance producer ships the affected videos in `data.videos`; list a few titles.
const videos: { id: string; title: string | null }[] = Array.isArray(n.data?.videos)
? n.data!.videos
: [];
// Known notification types are rendered from i18n (so they're trilingual) using the typed // Known notification types are rendered from i18n (so they're trilingual) using the typed
// payload; the server-stored title/body are an English fallback for any unknown type. // payload; the server-stored title/body are an English fallback for any unknown type.
const isMaintenance = n.type === "maintenance" && typeof n.data?.count === "number"; const isMaintenance = n.type === "maintenance" && typeof n.data?.count === "number";
@@ -698,9 +682,58 @@ function NotificationRow({
title = t(DONE_TITLE[d.kind ?? ""] ?? "inbox.downloadDone.title"); title = t(DONE_TITLE[d.kind ?? ""] ?? "inbox.downloadDone.title");
body = d.title || n.body; body = d.title || n.body;
} }
return { title, body };
}
/** A row's name, for a screen reader and for Back to find it by: what it is about, not its kind. */
const rowName = (title: string, body: string | null | undefined): string =>
body ? `${title}: ${body}` : title;
const clientRowName = (n: ClientNotif): string =>
n.title ? rowName(n.title, n.message) : n.message;
const entryName = (e: InboxEntry, t: TFunction, lang: string): string => {
if (e.src === "client") return clientRowName(e.n);
const { title, body } = serverRowText(e.n, t, lang);
return rowName(title, body);
};
function NotificationRow({
n,
t,
navIndex,
rowProps,
more,
onRead,
onDismiss,
onOpenScheduler,
onOpenTakeout,
onOpenFile,
onOpenTokens,
}: {
n: AppNotification;
t: TFunction;
navIndex: number;
rowProps: Record<string, unknown>;
more: ReactNode;
onRead: () => void;
onDismiss: () => void;
onOpenScheduler: () => void;
onOpenTakeout: () => void;
onOpenFile: () => void;
onOpenTokens: () => void;
}) {
const lang = useTranslation().i18n.language;
// The maintenance producer ships the affected videos in `data.videos`; list a few titles.
const videos: { id: string; title: string | null }[] = Array.isArray(n.data?.videos)
? n.data!.videos
: [];
const { title, body } = serverRowText(n, t, lang);
const isTakeout = serverKind(n) === "takeout";
const isScheduler = serverKind(n) === "scheduler" || isTakeout;
const isDownloadDone = serverKind(n) === "download";
const isTokenExpiring = serverKind(n) === "token";
// Named by what it is about, not its kind alone: every "Download ready" read the same to a screen // Named by what it is about, not its kind alone: every "Download ready" read the same to a screen
// reader, and Back could not tell the row it was opened from by its name. // reader, and Back could not tell the row it was opened from by its name.
const name = body ? `${title}: ${body}` : title; const name = rowName(title, body);
return ( return (
<div <div
// The unread state is spoken too (the dot is only drawn), apart from the name Back finds it by. // The unread state is spoken too (the dot is only drawn), apart from the name Back finds it by.
@@ -802,7 +835,7 @@ function ClientActivityRow({
const access = n.meta?.kind === "access-requests" ? n.meta : null; const access = n.meta?.kind === "access-requests" ? n.meta : null;
return ( return (
<div <div
{...navItemAttrs(navIndex, n.title ? `${n.title}: ${n.message}` : n.message)} {...navItemAttrs(navIndex, clientRowName(n))}
{...rowProps} {...rowProps}
className={`${SURFACE.card} group p-3.5 flex items-start gap-3 ${needsAction ? "ring-1 ring-accent/30" : ""}`} className={`${SURFACE.card} group p-3.5 flex items-start gap-3 ${needsAction ? "ring-1 ring-accent/30" : ""}`}
> >