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 * 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; }