diff --git a/frontend/src/i18n/locales/en/inbox.json b/frontend/src/i18n/locales/en/inbox.json index a34194aa..ca0d855b 100644 --- a/frontend/src/i18n/locales/en/inbox.json +++ b/frontend/src/i18n/locales/en/inbox.json @@ -9,8 +9,11 @@ "dismiss": "Dismiss", "rowMenu": "Notification actions", "menu": "Notifications actions", - "sectionSystem": "System", - "sectionActivity": "Activity", + "sectionNeedsYou_one": "Needs you · {{count}}", + "sectionNeedsYou_other": "Needs you · {{count}}", + "sectionRecent": "Recent", + "earlier_one": "Earlier ({{count}})", + "earlier_other": "Earlier ({{count}})", "andMore_one": "and {{count}} more", "andMore_other": "and {{count}} more", "openScheduler": "Open Scheduler", diff --git a/frontend/src/i18n/locales/hu/inbox.json b/frontend/src/i18n/locales/hu/inbox.json index 5ad64a7e..3b7b3f29 100644 --- a/frontend/src/i18n/locales/hu/inbox.json +++ b/frontend/src/i18n/locales/hu/inbox.json @@ -9,8 +9,11 @@ "dismiss": "Elrejtés", "rowMenu": "Értesítés műveletei", "menu": "Értesítések műveletei", - "sectionSystem": "Rendszer", - "sectionActivity": "Tevékenység", + "sectionNeedsYou_one": "Rád vár · {{count}}", + "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_other": "és még {{count}}", "openScheduler": "Ütemező megnyitása", diff --git a/frontend/src/lib/inboxOrder.test.ts b/frontend/src/lib/inboxOrder.test.ts new file mode 100644 index 00000000..a4e0357f --- /dev/null +++ b/frontend/src/lib/inboxOrder.test.ts @@ -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 = {}) => + ({ + 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 = 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"]); + }); +}); diff --git a/frontend/src/lib/inboxOrder.ts b/frontend/src/lib/inboxOrder.ts new file mode 100644 index 00000000..262947eb --- /dev/null +++ b/frontend/src/lib/inboxOrder.ts @@ -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 + * 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 }, +): 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; +} diff --git a/frontend/src/modules/notifications/NotificationsPanel.order.dom.test.tsx b/frontend/src/modules/notifications/NotificationsPanel.order.dom.test.tsx new file mode 100644 index 00000000..d95166bf --- /dev/null +++ b/frontend/src/modules/notifications/NotificationsPanel.order.dom.test.tsx @@ -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()), + 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()), + 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()), + 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 = {}) => + ({ + 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 = () => ( + + + + ); + 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("[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 ", 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("[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); + }); +}); diff --git a/frontend/src/modules/notifications/NotificationsPanel.tsx b/frontend/src/modules/notifications/NotificationsPanel.tsx index 1151674a..25f3805a 100644 --- a/frontend/src/modules/notifications/NotificationsPanel.tsx +++ b/frontend/src/modules/notifications/NotificationsPanel.tsx @@ -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 type { TFunction } from "i18next"; import { useMutation, useQueryClient } from "@tanstack/react-query"; @@ -8,6 +8,8 @@ import { Bell, Check, CheckCheck, + ChevronDown, + ChevronRight, ExternalLink, Eye, FolderOpen, @@ -41,6 +43,7 @@ import { type VideoWatchedMeta, } from "../../lib/notifications"; import { channelYouTubeUrl, formatDate } from "../../lib/format"; +import { entryKey, keyOf, orderInbox, type InboxEntry } from "../../lib/inboxOrder"; import { Button } from "../../components/ui/Button"; import { IconButton } from "../../components/ui/IconButton"; import { LEVEL_STYLE } from "../../components/ui/toastLevels"; @@ -55,7 +58,7 @@ import { useFeedFilters, useFeedFiltersActions } from "../feed"; import { SURFACE } from "../../components/ui/surfaces"; import { SectionLabel } from "../../components/ui/Headings"; 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 { ContextMenu } from "../../components/ui/ContextMenu"; import { MoreButton } from "../../components/ui/MoreButton"; @@ -65,10 +68,11 @@ import { HOVER_REVEAL } from "../../components/ui/reveal"; import { useConfirm } from "../../components/ui/confirmContext"; import { RelativeTime } from "../../components/ui/RelativeTime"; -// The single notification center. "System" = durable, server-backed notifications (inbox -// phase 1). "Activity" = the client-side transient events (action confirmations, errors, with -// their inline undo/find actions) that used to live behind a separate bell — folded in here so -// there's one indicator and one place to look. Transient toasts still pop via the Toaster. +// The single notification center, over two sources: the durable, server-backed notifications, and +// this browser's client-side events (action confirmations, errors, with their inline undo/find +// actions) that used to live behind a separate bell — folded in here so there's one indicator and +// 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 byId = (n: { id: number }) => n.id; @@ -93,6 +97,20 @@ export default function NotificationsPanel() { const clientItems = useSyncExternalStore(subscribe, getNotifications, getNotifications); 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>( + () => 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 // items stay unread until acted on individually. useEffect(() => { @@ -130,6 +148,13 @@ export default function NotificationsPanel() { function markAll() { 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(); 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" ? ( + readMut.mutate(e.n.id)} + onDismiss={() => dismissMut.mutate(e.n.id)} + onOpenScheduler={openScheduler} + onOpenTakeout={openTakeout} + onOpenFile={() => openDone(e.n)} + onOpenTokens={openTokens} + /> + ) : ( + removeClient(e.n.id)} + /> + ); + // --- The page's own menu: the header's "…" ------------------------------------------------------ const headerMenu = useContextMenu<"inbox">(); const headerItems: ActionMenuItem[] = [ @@ -409,51 +465,35 @@ export default function NotificationsPanel() { ) : (
- {serverItems.length > 0 && ( + {order.needsYou.length > 0 && (
- {t("inbox.sectionSystem")} - - {serverItems.map((n, i) => { - return ( - readMut.mutate(n.id)} - onDismiss={() => dismissMut.mutate(n.id)} - onOpenScheduler={openScheduler} - onOpenTakeout={openTakeout} - onOpenFile={() => openDone(n)} - onOpenTokens={openTokens} - /> - ); - })} - + + {t("inbox.sectionNeedsYou", { count: order.needsYou.length })} + + {order.needsYou.map(entry)}
)} - {clientItems.length > 0 && ( + {order.recent.length > 0 && (
- {t("inbox.sectionActivity")} - - {clientItems.map((n, i) => { - return ( - removeClient(n.id)} - /> - ); - })} - + {t("inbox.sectionRecent")} + {order.recent.map(entry)} +
+ )} + {order.earlier.length > 0 && ( +
+ + {earlierOpen && ( + {order.earlier.map(entry)} + )}
)}
@@ -517,7 +557,19 @@ function RowEnd({ return (
{onRead && ( - + { + // The button goes once the row reads read, and the focus on it would fall to : + // hand it to the row first, where j / k carry on. + const row = e.currentTarget.closest("[data-nav-index]"); + if (row?.parentElement && document.activeElement === e.currentTarget) + focusNavItem(row.parentElement, Number(row.getAttribute("data-nav-index"))); + onRead(); + }} + > )}