Files
siftlode/frontend/src/lib/inboxOrder.ts
T
peter fa271a31b9 fix(notifications): Mark all read waits for the fresh list, is not offered where it cannot act, and a row says it is unread
The mask over a stale read lifted when the write settled, before the refetch landed, and the band took the rows back. A band holding only what asks by its kind (an access-requests nudge) left the button enabled with nothing to do. The unread dot was only drawn: the spoken label now says it, apart from the name Back finds the row by, and follows the row while it holds the focus.
2026-10-29 00:38:34 +01:00

73 lines
3.4 KiB
TypeScript

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. */
export 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;
}