feat(notifications): what asks for you comes first, then one stream; old read rows fold

The center showed two fixed sections, the server's above this browser's, so an
admin's "access requests waiting" sat under every server row, week-old read
"Download ready" notices among them. One order now, for both sources
(lib/inboxOrder): "Needs you" (unread, an access-requests nudge, a notice
waiting on an interaction), then everything else newest first, and read rows
older than a week fold under "Earlier (n)", closed by default.

A row that asked in this visit keeps its place once read: marking it read no
longer moves it to another list under the reader. Mark all read empties the
band, the client notices of the visit included (the server rows read at once,
not when the write lands). Marking a row read from its button hands the focus
to the row instead of dropping it on <body> as the button goes.
This commit is contained in:
2026-10-28 20:01:21 +01:00
parent 0d410592e2
commit f49dc6532f
6 changed files with 507 additions and 52 deletions
+5 -2
View File
@@ -9,8 +9,11 @@
"dismiss": "Dismiss", "dismiss": "Dismiss",
"rowMenu": "Notification actions", "rowMenu": "Notification actions",
"menu": "Notifications actions", "menu": "Notifications actions",
"sectionSystem": "System", "sectionNeedsYou_one": "Needs you · {{count}}",
"sectionActivity": "Activity", "sectionNeedsYou_other": "Needs you · {{count}}",
"sectionRecent": "Recent",
"earlier_one": "Earlier ({{count}})",
"earlier_other": "Earlier ({{count}})",
"andMore_one": "and {{count}} more", "andMore_one": "and {{count}} more",
"andMore_other": "and {{count}} more", "andMore_other": "and {{count}} more",
"openScheduler": "Open Scheduler", "openScheduler": "Open Scheduler",
+5 -2
View File
@@ -9,8 +9,11 @@
"dismiss": "Elrejtés", "dismiss": "Elrejtés",
"rowMenu": "Értesítés műveletei", "rowMenu": "Értesítés műveletei",
"menu": "Értesítések műveletei", "menu": "Értesítések műveletei",
"sectionSystem": "Rendszer", "sectionNeedsYou_one": "Rád vár · {{count}}",
"sectionActivity": "Tevékenység", "sectionNeedsYou_other": "Rád vár · {{count}}",
"sectionRecent": "Legutóbbiak",
"earlier_one": "Korábbiak ({{count}})",
"earlier_other": "Korábbiak ({{count}})",
"andMore_one": "és még {{count}}", "andMore_one": "és még {{count}}",
"andMore_other": "és még {{count}}", "andMore_other": "és még {{count}}",
"openScheduler": "Ütemező megnyitása", "openScheduler": "Ütemező megnyitása",
+113
View File
@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import { FOLD_AFTER_MS, entryKey, orderInbox, type InboxOrder } from "./inboxOrder";
import type { AppNotification } from "./api";
import type { Notification as ClientNotif } from "./notifications";
const NOW = Date.parse("2026-10-28T12:00:00Z");
const ago = (ms: number) => NOW - ms;
const H = 3600 * 1000;
const D = 24 * H;
const server = (id: number, at: number | null, read: boolean) =>
({
id,
type: "download_done",
title: "t",
body: null,
data: null,
read,
dismissed: false,
created_at: at === null ? null : new Date(at).toISOString(),
}) as AppNotification;
const client = (id: number, at: number, extra: Partial<ClientNotif> = {}) =>
({
id,
level: "info",
message: "m",
requiresInteraction: false,
ts: at,
read: true,
dismissed: true,
transient: false,
repeat: 1,
...extra,
}) as ClientNotif;
const keys = (o: InboxOrder) => ({
needsYou: o.needsYou.map(entryKey),
recent: o.recent.map(entryKey),
earlier: o.earlier.map(entryKey),
});
const order = (s: AppNotification[], c: ClientNotif[], held: ReadonlySet<string> = new Set()) =>
keys(orderInbox(s, c, { now: NOW, held }));
describe("orderInbox", () => {
it("puts a waiting access-requests nudge above every server row, read or not", () => {
const o = order(
[server(1, ago(1 * H), false), server(2, ago(1 * D), true)],
[client(1, ago(5 * 60 * 1000), { meta: { kind: "access-requests" } })],
);
expect(o.needsYou).toEqual(["client:1", "server:1"]);
expect(o.recent).toEqual(["server:2"]);
});
it("an old unread row still asks: it never folds or drifts below newer read ones", () => {
const o = order([server(1, ago(30 * D), false), server(2, ago(1 * H), true)], []);
expect(o.needsYou).toEqual(["server:1"]);
expect(o.recent).toEqual(["server:2"]);
expect(o.earlier).toEqual([]);
});
it("merges both sources into one stream, newest first", () => {
const o = order(
[server(1, ago(3 * H), true), server(2, ago(1 * H), true)],
[client(7, ago(2 * H)), client(8, ago(4 * H))],
);
expect(o.recent).toEqual(["server:2", "client:7", "server:1", "client:8"]);
});
it("folds read rows older than a week, and only those", () => {
const o = order(
[server(1, ago(FOLD_AFTER_MS + H), true), server(2, ago(FOLD_AFTER_MS - H), true)],
[client(3, ago(9 * D))],
);
expect(o.recent).toEqual(["server:2"]);
expect(o.earlier).toEqual(["server:1", "client:3"]);
});
it("keeps a row that asked earlier in the visit in its place once read, from either source", () => {
// Opening the center marks the client notices read: the band must not empty as it renders; and
// a server row marked read must not jump lists under the reader.
const o = order(
[server(1, ago(30 * D), true), server(2, ago(3 * H), true)],
[client(4, ago(2 * H)), client(5, ago(1 * H))],
new Set(["client:4", "server:1"]),
);
expect(o.needsYou).toEqual(["client:4", "server:1"]);
expect(o.recent).toEqual(["client:5", "server:2"]);
expect(o.earlier).toEqual([]);
});
it("an unread client notice (raised while the center is open) asks", () => {
const o = order([], [client(6, ago(H), { read: false })]);
expect(o.needsYou).toEqual(["client:6"]);
});
it("a notice waiting on an interaction asks until it is dealt with", () => {
const o = order(
[],
[
client(1, ago(H), { requiresInteraction: true, dismissed: false }),
client(2, ago(H), { requiresInteraction: true, dismissed: true }),
],
);
expect(o.needsYou).toEqual(["client:1"]);
expect(o.recent).toEqual(["client:2"]);
});
it("a server row of no known date sorts last and never folds", () => {
const o = order([server(1, null, true), server(2, ago(30 * D), true)], [client(3, ago(H))]);
expect(o.recent).toEqual(["client:3", "server:1"]);
expect(o.earlier).toEqual(["server:2"]);
});
});
+72
View File
@@ -0,0 +1,72 @@
import type { AppNotification } from "./api";
import type { Notification as ClientNotif } from "./notifications";
// The notification center's order, in ONE rule for both sources. It used to be two fixed sections,
// the server's above this browser's — so an admin's "access requests waiting" sat under up to a
// hundred server rows, week-old read "Download ready" notices among them. Now what asks for the
// reader comes first, then everything else as one time-ordered stream, and read rows older than a
// week fold away (kept, not deleted).
export type InboxEntry =
| { src: "server"; n: AppNotification; at: number }
| { src: "client"; n: ClientNotif; at: number };
export interface InboxOrder {
/** Unread, or waiting on an action: newest first. Never folded, whatever its age. */
needsYou: InboxEntry[];
/** The rest from the last week (or of no known date), newest first. */
recent: InboxEntry[];
/** Read rows older than `FOLD_AFTER_MS`, newest first — shown only on request. */
earlier: InboxEntry[];
}
export const FOLD_AFTER_MS = 7 * 24 * 3600 * 1000;
// A row of no known date (a server row stored without one) sorts last and never folds: its age is
// unknown, and hiding it on a guess is how a row goes missing.
const NO_DATE = Number.NEGATIVE_INFINITY;
function serverAt(n: AppNotification): number {
const at = n.created_at ? Date.parse(n.created_at) : NaN;
return Number.isNaN(at) ? NO_DATE : at;
}
/** A row's key across both sources: their ids are separate sequences, and they share one list now. */
export const keyOf = (src: InboxEntry["src"], id: number): string => `${src}:${id}`;
export const entryKey = (e: InboxEntry): string => keyOf(e.src, e.n.id);
/** Whether a client notice asks for the reader by what it is: an access-requests nudge for as long
* as it exists (its producer removes it when nothing is pending), a notice that waits on an
* interaction until it is dealt with. */
const clientAsks = (n: ClientNotif): boolean =>
n.meta?.kind === "access-requests" || (n.requiresInteraction && !n.dismissed);
export function orderInbox(
server: readonly AppNotification[],
client: readonly ClientNotif[],
/** `held`: the rows that asked earlier in this visit (entry keys). They keep their place once read —
* a row marked read must not jump to another list under the reader, taking the keyboard focus to
* <body> as it remounts there — and they are how a client notice stays up at all: opening the
* center marks every client notice read, so "unread" alone would empty the band as it renders. */
{ now, held }: { now: number; held: ReadonlySet<string> },
): InboxOrder {
const out: InboxOrder = { needsYou: [], recent: [], earlier: [] };
const place = (e: InboxEntry, asks: boolean) => {
if (asks) out.needsYou.push(e);
else if (e.at !== NO_DATE && now - e.at > FOLD_AFTER_MS) out.earlier.push(e);
else out.recent.push(e);
};
for (const n of server)
place({ src: "server", n, at: serverAt(n) }, !n.read || held.has(keyOf("server", n.id)));
for (const n of client)
place(
{ src: "client", n, at: n.ts },
!n.read || held.has(keyOf("client", n.id)) || clientAsks(n),
);
// Newest first; equal times keep their source's own order (Array.sort is stable).
const newest = (a: InboxEntry, b: InboxEntry) => (b.at === a.at ? 0 : b.at > a.at ? 1 : -1);
out.needsYou.sort(newest);
out.recent.sort(newest);
out.earlier.sort(newest);
return out;
}
@@ -0,0 +1,212 @@
// @vitest-environment jsdom
//
// The notification center's order: what asks for the reader comes first, whichever source it is
// from, then one time-ordered stream; read rows older than a week fold under "Earlier (n)". It was
// two fixed sections, the server's above this browser's, so an admin's "access requests waiting"
// sat under every server row — week-old read "Download ready" notices among them.
// i18n is key-passthrough, so labels read as keys.
import { afterEach, beforeEach, describe, expect, it, 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";
const h = vi.hoisted(() => ({
server: [] as unknown[],
client: [] as unknown[],
}));
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({
i18n: { language: "en" },
t: (key: string, o?: { count?: number }) =>
o?.count === undefined ? key : `${key}:${o.count}`,
}),
}));
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => vi.fn() }));
vi.mock("../../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../../lib/notifications")>()),
subscribe: () => () => {},
getNotifications: () => h.client,
getUnreadCount: () => 0,
markAllRead: () => {},
}));
vi.mock("../../features/navigation/navigationContext", () => ({
useNavigationActions: () => ({ setPage: vi.fn() }),
}));
vi.mock("../files/filesContext", () => ({ useFilesActions: () => ({ openFile: vi.fn() }) }));
vi.mock("../channels/channelsContext", () => ({
useChannelsActions: () => ({ focusChannel: vi.fn() }),
}));
vi.mock("../feed/feedFiltersContext", () => ({
useFeedFilters: () => ({}),
useFeedFiltersActions: () => ({ setFilters: vi.fn() }),
}));
vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
...(await importOriginal<typeof import("../../components/ui/PageShell")>()),
PageToolbar: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
import NotificationsPanel from "./NotificationsPanel";
// The server list goes through the real live query, so an optimistic patch of its cache is seen.
beforeEach(() => {
vi.spyOn(api, "notifications").mockImplementation(async () => ({
items: h.server as AppNotification[],
total: h.server.length,
}));
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const H = 3600 * 1000;
const D = 24 * H;
const server = (id: number, ageMs: number, read: boolean) =>
({
id,
type: "download_done",
title: `Server ${id}`,
body: null,
data: { title: `Ready ${id}` },
read,
dismissed: false,
created_at: new Date(Date.now() - ageMs).toISOString(),
}) as unknown as AppNotification;
const client = (id: number, ageMs: number, extra: Partial<ClientNotif> = {}) =>
({
id,
level: "success",
message: `Activity ${id}.`,
requiresInteraction: false,
ts: Date.now() - ageMs,
read: true,
dismissed: true,
transient: false,
repeat: 1,
...extra,
}) as unknown as ClientNotif;
function mount() {
const qc = new QueryClient();
const tree = () => (
<QueryClientProvider client={qc}>
<NotificationsPanel />
</QueryClientProvider>
);
const { rerender } = render(tree());
return { rerender: () => rerender(tree()) };
}
// Every test has a "Ready 1" on screen once the server list has landed.
const loaded = () => screen.findByText("Ready 1");
// The rows of the list under a section's heading.
const listUnder = (heading: string) =>
[...screen.getByText(heading).closest("section")!.querySelectorAll("[data-nav-index]")].map(
(r) => (r.textContent ?? "").match(/Ready \d+|Activity \d+/)?.[0],
);
// Every row on screen, in document order, by its text.
const rows = () =>
[...document.querySelectorAll<HTMLElement>("[data-nav-index]")].map(
(r) => (r.textContent ?? "").match(/Ready \d+|Activity \d+|common\.accessRequestsTitle/)?.[0],
);
describe("NotificationsPanel · order", () => {
it("puts a waiting access-requests nudge first, above every server row", async () => {
h.server = [server(1, 1 * H, false), server(2, 1 * D, true)];
h.client = [
client(9, 5 * 60 * 1000, {
level: "warning",
title: "common.accessRequestsTitle",
message: "1 pending",
meta: { kind: "access-requests" },
}),
client(8, 2 * D),
];
mount();
await loaded();
expect(rows()).toEqual(["common.accessRequestsTitle", "Ready 1", "Ready 2", "Activity 8"]);
expect(screen.getByText("inbox.sectionNeedsYou:2")).toBeTruthy();
expect(screen.getByText("inbox.sectionRecent")).toBeTruthy();
});
it("folds read rows older than a week under Earlier (n), closed until opened", async () => {
h.server = [server(1, 1 * H, true), server(2, 10 * D, true)];
h.client = [client(3, 9 * D)];
mount();
await loaded();
expect(rows()).toEqual(["Ready 1"]);
const fold = screen.getByRole("button", { name: "inbox.earlier:2" });
expect(fold.getAttribute("aria-expanded")).toBe("false");
fireEvent.click(fold);
expect(fold.getAttribute("aria-expanded")).toBe("true");
expect(rows()).toEqual(["Ready 1", "Activity 3", "Ready 2"]);
});
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.client = [client(4, 3 * H)];
mount();
await loaded();
fireEvent.click(screen.getByRole("button", { name: "inbox.earlier:1" }));
const lists = [...document.querySelectorAll("[data-nav-list]")].map((l) =>
[...l.querySelectorAll("[data-nav-index]")].map((r) => r.getAttribute("data-nav-index")),
);
expect(lists).toEqual([["0"], ["0", "1"], ["0"]]);
});
it("a row marked read keeps its place in Needs you for the visit, in the same list", async () => {
vi.spyOn(api, "markNotificationRead").mockResolvedValue({ ok: true } as never);
h.server = [server(1, 1 * H, false), server(2, 2 * H, true)];
h.client = [];
const { rerender } = mount();
await loaded();
const before = screen.getByText("Ready 1").closest("[data-nav-index]");
fireEvent.click(within(before as HTMLElement).getByRole("button", { name: "inbox.markRead" }));
h.server = [server(1, 1 * H, true), server(2, 2 * H, true)]; // what the refetch after the write reads
await waitFor(() =>
expect(screen.queryByRole("button", { name: "inbox.markRead" })).toBeNull(),
);
await act(async () => rerender());
expect(listUnder("inbox.sectionNeedsYou:1")).toEqual(["Ready 1"]);
// The same element, not a remount in another list: the keyboard focus stays on it.
expect(screen.getByText("Ready 1").closest("[data-nav-index]")).toBe(before);
});
it("Mark all read empties Needs you, the client notices of the visit included", async () => {
vi.spyOn(api, "markAllNotificationsRead").mockImplementation(async () => {
h.server = [server(1, 1 * H, true)];
return { ok: true } as never;
});
h.server = [server(1, 1 * H, false)];
h.client = [client(2, 2 * H, { read: false })];
const { rerender } = mount();
await loaded();
expect(listUnder("inbox.sectionNeedsYou:2")).toEqual(["Ready 1", "Activity 2"]);
h.client = [client(2, 2 * H, { read: true })]; // what the store's markAllRead leaves
fireEvent.click(screen.getByRole("button", { name: "inbox.markAllRead" }));
await act(async () => rerender());
expect(screen.queryByText(/inbox\.sectionNeedsYou/)).toBeNull();
await waitFor(() => expect(api.markAllNotificationsRead).toHaveBeenCalled());
expect(listUnder("inbox.sectionRecent")).toEqual(["Ready 1", "Activity 2"]);
});
it("marking a row read from its button hands the focus to the row, not <body>", async () => {
vi.spyOn(api, "markNotificationRead").mockResolvedValue({ ok: true } as never);
h.server = [server(1, 1 * H, false)];
h.client = [];
mount();
await loaded();
const row = screen.getByText("Ready 1").closest<HTMLElement>("[data-nav-index]")!;
const button = within(row).getByRole("button", { name: "inbox.markRead" });
button.focus();
h.server = [server(1, 1 * H, true)]; // what the refetch after the write reads
fireEvent.click(button);
await waitFor(() =>
expect(screen.queryByRole("button", { name: "inbox.markRead" })).toBeNull(),
);
expect(row.isConnected).toBe(true);
expect(document.activeElement).toBe(row);
});
});
@@ -1,4 +1,4 @@
import { useEffect, useSyncExternalStore, type ReactNode } from "react"; import { useEffect, useState, useSyncExternalStore, type ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
@@ -8,6 +8,8 @@ import {
Bell, Bell,
Check, Check,
CheckCheck, CheckCheck,
ChevronDown,
ChevronRight,
ExternalLink, ExternalLink,
Eye, Eye,
FolderOpen, FolderOpen,
@@ -41,6 +43,7 @@ import {
type VideoWatchedMeta, type VideoWatchedMeta,
} from "../../lib/notifications"; } from "../../lib/notifications";
import { channelYouTubeUrl, formatDate } from "../../lib/format"; import { channelYouTubeUrl, formatDate } from "../../lib/format";
import { entryKey, keyOf, orderInbox, type InboxEntry } from "../../lib/inboxOrder";
import { Button } from "../../components/ui/Button"; import { Button } from "../../components/ui/Button";
import { IconButton } from "../../components/ui/IconButton"; import { IconButton } from "../../components/ui/IconButton";
import { LEVEL_STYLE } from "../../components/ui/toastLevels"; import { LEVEL_STYLE } from "../../components/ui/toastLevels";
@@ -55,7 +58,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 { navItemAttrs } from "../../lib/listNav"; import { focusNavItem, navItemAttrs } 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";
@@ -65,10 +68,11 @@ import { HOVER_REVEAL } from "../../components/ui/reveal";
import { useConfirm } from "../../components/ui/confirmContext"; import { useConfirm } from "../../components/ui/confirmContext";
import { RelativeTime } from "../../components/ui/RelativeTime"; import { RelativeTime } from "../../components/ui/RelativeTime";
// The single notification center. "System" = durable, server-backed notifications (inbox // The single notification center, over two sources: the durable, server-backed notifications, and
// phase 1). "Activity" = the client-side transient events (action confirmations, errors, with // this browser's client-side events (action confirmations, errors, with their inline undo/find
// their inline undo/find actions) that used to live behind a separate bell — folded in here so // actions) that used to live behind a separate bell — folded in here so there's one indicator and
// there's one indicator and one place to look. Transient toasts still pop via the Toaster. // one place to look. Transient toasts still pop via the Toaster. Both sources share one order
// (`lib/inboxOrder`): what asks for the reader first, then one stream, old read rows folded.
const POLL_MS = 8000; const POLL_MS = 8000;
const byId = (n: { id: number }) => n.id; const byId = (n: { id: number }) => n.id;
@@ -93,6 +97,20 @@ export default function NotificationsPanel() {
const clientItems = useSyncExternalStore(subscribe, getNotifications, getNotifications); const clientItems = useSyncExternalStore(subscribe, getNotifications, getNotifications);
const clientUnread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount); const clientUnread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount);
// The rows that asked for the reader in this visit keep their place in "Needs you" once read
// (`lib/inboxOrder`): seeded with the client notices unread at open, which the effect below marks
// read, and grown by every row that asks while the center is open. Mark all read empties it — the
// one gesture that means "I have seen the lot". The fold is measured from the time the center
// opened, fixed for the visit, so a row does not fold away under the reader.
const [held, setHeld] = useState<ReadonlySet<string>>(
() => new Set(getNotifications().flatMap((n) => (n.read ? [] : [keyOf("client", n.id)]))),
);
const [openedAt] = useState(() => Date.now());
const [earlierOpen, setEarlierOpen] = useState(false);
const order = orderInbox(serverItems, clientItems, { now: openedAt, held });
const asking = order.needsYou.map(entryKey).filter((k) => !held.has(k));
if (asking.length > 0) setHeld(new Set([...held, ...asking]));
// 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.
useEffect(() => { useEffect(() => {
@@ -130,6 +148,13 @@ export default function NotificationsPanel() {
function markAll() { function markAll() {
markAllClientRead(); markAllClientRead();
// Read at once, not when the write lands: until then the rows still read unread, and the band
// would take them straight back. A failed write refetches the truth (refresh, then the poll).
void qc.cancelQueries({ queryKey: qk.notifications() });
qc.setQueryData<{ items: AppNotification[]; total: number }>(qk.notifications(), (d) =>
d ? { ...d, items: d.items.map((n) => (n.read ? n : { ...n, read: true })) } : d,
);
setHeld(new Set());
if (serverUnread) readAllMut.mutate(); if (serverUnread) readAllMut.mutate();
else refresh(); else refresh();
} }
@@ -340,6 +365,37 @@ export default function NotificationsPanel() {
); );
}; };
// One row of either source; `i` is its place in the list it is rendered into.
const entry = (e: InboxEntry, i: number) =>
e.src === "server" ? (
<NotificationRow
key={entryKey(e)}
n={e.n}
t={t}
navIndex={i}
rowProps={serverMenu.triggerProps(e.n)}
more={more(serverMenu, e.n)}
onRead={() => readMut.mutate(e.n.id)}
onDismiss={() => dismissMut.mutate(e.n.id)}
onOpenScheduler={openScheduler}
onOpenTakeout={openTakeout}
onOpenFile={() => openDone(e.n)}
onOpenTokens={openTokens}
/>
) : (
<ClientActivityRow
key={entryKey(e)}
n={e.n}
t={t}
navIndex={i}
rowProps={clientMenu.triggerProps(e.n)}
more={more(clientMenu, e.n)}
onFocusChannel={onFocusChannel}
onReviewAccess={reviewAccess}
onRemove={() => removeClient(e.n.id)}
/>
);
// --- The page's own menu: the header's "…" ------------------------------------------------------ // --- The page's own menu: the header's "…" ------------------------------------------------------
const headerMenu = useContextMenu<"inbox">(); const headerMenu = useContextMenu<"inbox">();
const headerItems: ActionMenuItem[] = [ const headerItems: ActionMenuItem[] = [
@@ -409,51 +465,35 @@ export default function NotificationsPanel() {
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{serverItems.length > 0 && ( {order.needsYou.length > 0 && (
<section> <section>
<SectionLabel className="mb-2">{t("inbox.sectionSystem")}</SectionLabel> <SectionLabel className="mb-2">
<NavGrid className="flex flex-col gap-2"> {t("inbox.sectionNeedsYou", { count: order.needsYou.length })}
{serverItems.map((n, i) => { </SectionLabel>
return ( <NavGrid className="flex flex-col gap-2">{order.needsYou.map(entry)}</NavGrid>
<NotificationRow
key={n.id}
n={n}
t={t}
navIndex={i}
rowProps={serverMenu.triggerProps(n)}
more={more(serverMenu, n)}
onRead={() => readMut.mutate(n.id)}
onDismiss={() => dismissMut.mutate(n.id)}
onOpenScheduler={openScheduler}
onOpenTakeout={openTakeout}
onOpenFile={() => openDone(n)}
onOpenTokens={openTokens}
/>
);
})}
</NavGrid>
</section> </section>
)} )}
{clientItems.length > 0 && ( {order.recent.length > 0 && (
<section> <section>
<SectionLabel className="mb-2">{t("inbox.sectionActivity")}</SectionLabel> <SectionLabel className="mb-2">{t("inbox.sectionRecent")}</SectionLabel>
<NavGrid className="flex flex-col gap-2"> <NavGrid className="flex flex-col gap-2">{order.recent.map(entry)}</NavGrid>
{clientItems.map((n, i) => { </section>
return ( )}
<ClientActivityRow {order.earlier.length > 0 && (
key={n.id} <section>
n={n} <Button
t={t} variant="ghost"
navIndex={i} size="sm"
rowProps={clientMenu.triggerProps(n)} aria-expanded={earlierOpen}
more={more(clientMenu, n)} onClick={() => setEarlierOpen((o) => !o)}
onFocusChannel={onFocusChannel} icon={earlierOpen ? <ChevronDown /> : <ChevronRight />}
onReviewAccess={reviewAccess} className={earlierOpen ? "mb-2" : undefined}
onRemove={() => removeClient(n.id)} >
/> {t("inbox.earlier", { count: order.earlier.length })}
); </Button>
})} {earlierOpen && (
</NavGrid> <NavGrid className="flex flex-col gap-2">{order.earlier.map(entry)}</NavGrid>
)}
</section> </section>
)} )}
</div> </div>
@@ -517,7 +557,19 @@ function RowEnd({
return ( return (
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
{onRead && ( {onRead && (
<IconButton label={t("inbox.markRead")} size="sm" variant="ghost" onClick={onRead}> <IconButton
label={t("inbox.markRead")}
size="sm"
variant="ghost"
onClick={(e) => {
// The button goes once the row reads read, and the focus on it would fall to <body>:
// hand it to the row first, where j / k carry on.
const row = e.currentTarget.closest<HTMLElement>("[data-nav-index]");
if (row?.parentElement && document.activeElement === e.currentTarget)
focusNavItem(row.parentElement, Number(row.getAttribute("data-nav-index")));
onRead();
}}
>
<Check className="w-4 h-4" /> <Check className="w-4 h-4" />
</IconButton> </IconButton>
)} )}