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.
73 lines
3.4 KiB
TypeScript
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;
|
|
}
|