// --------------------------------------------------------------------------- // inbox/inboxModel.ts — WAVE 32 · T20 (owner item 16, ruling R7, contract C3): // the Inbox module's PURE half. React-free and fetch-free, so `verify_alerts.py` // compiles it and RUNS it under node. // // Owner, item 16: *"Alerts becomes an Inbox that looks like email."* An email // client is three decisions, and every one of them is a function here rather // than markup, because markup a gate cannot reach is markup no control can // mutate: // // 1. ORDER — unread ABOVE read, newest first inside each. Not a sort key on // one axis: a reader opens an inbox to find what they have not seen, and // an unread item that has aged below a read one is functionally lost. // 2. THE HEADER — `subject` (what this is about) sits above `label` (what // happened). They were ONE field until C3, which is precisely why the old // pane could only render a sentence with no sender. // 3. WHETHER IT OPENS AT ALL — `target` is ABSENT when the server cannot // resolve a destination, and that absence must reach the eye as a row that // does not pretend to be clickable. // // ⛔ THERE IS NO CLIENT-SIDE `target` DERIVATION HERE, DELIBERATELY. The server // derives it (`routes_alerts.notification_view`) and this module only reads what // arrived. A fallback that re-derived the same answer from `topic`/`alertId` // would be a SECOND normaliser for one question — the exact defect this wave's // item 6 is about in `routes_connectors` vs `routes_keychain`, and the reason // every tenant currently sees another tenant's Odoo connection. One question, // one answer, on the side that owns the vocabulary. // --------------------------------------------------------------------------- import { NOTIF_KIND_AUTOMATION, NOTIF_KIND_SHARE, TARGET_MODULE_AUTOMATION, TARGET_MODULE_DATABASE, } from "../alerts/alertsModel"; import type { Notification, NotificationTarget } from "../alerts/alertsModel"; const str = (v: unknown): string => (typeof v === "string" ? v : ""); /** One block of the list. `unread` renders first and always, even when empty — an * inbox that hides its own "Unread" heading at zero makes "I have read everything" * and "this failed to load" look identical. */ export interface InboxSections { unread: Notification[]; read: Notification[]; } /** * The mail order: unread above read, newest first within each. * * ⛔ TWO SECTIONS, NOT ONE SORT, and the difference is visible the moment an inbox has both. A * single comparator that puts `read` before `at` produces the same sequence but no SEAM — so a * reader scrolling past the last unread item cannot tell they have crossed it, which is the one * thing the ordering exists to tell them. * * ⚠ STABLE within a timestamp: `_queue` mints several notifications with an IDENTICAL `at` * (one write, N entrants), so a comparator returning non-zero for equal stamps would reshuffle * them on every render. */ export function inboxSections(items: Notification[], held: ReadonlySet = EMPTY_HELD): InboxSections { const byAt = (a: Notification, b: Notification) => a.at < b.at ? 1 : a.at > b.at ? -1 : 0; // ⭐⭐ W33-T28 — `held` IS WHY THE ROW YOU CLICKED STAYS WHERE YOU CLICKED IT. // // ⛔ THE DEFECT, found by a `verifier` and worth stating in full because it is the first thing // a reader meets: selecting a row marks it read IN THE SAME CLICK (that is deliberate — see // `InboxPage.select`), which moved it out of `unread`, into `read`, under a different heading, // and decremented "Unread (3)". **The list re-sectioned itself while the cursor was still on // the row.** No mail client does that; every one of them leaves a message where it is until // the next load. The bug is not the marking, it is that the SECTIONING is a live function of // the same flag. // // ⚠ SO THE FIX IS A HOLD, NOT A DELAY. `held` is the ids read during THIS visit; they render // in place, styled as read, and settle into "Earlier" on the next load. The unread COUNT is // untouched (it is the server's number and the badge's — see `parseInbox`'s note), so the badge // still clears immediately, which is the half the reader wanted. const isUnread = (n: Notification) => !n.read || held.has(n.id); return { unread: items.filter(isUnread).sort(byAt), read: items.filter((n) => !isUnread(n)).sort(byAt), }; } /** A stable empty identity, so the default argument does not mint a Set per call and defeat * `useMemo` at the one call site that has one. */ const EMPTY_HELD: ReadonlySet = new Set(); /** * The header line. * * ⚠ IT FALLS BACK RATHER THAN RENDERING BLANK. A notification queued before C3 existed carries no * `subject`, and a mail list whose header row is empty reads as a corrupt item — the reader * cannot tell it apart from one whose subject really is missing. `alertLabel` is what the server * derives `subject` FROM, so the fallback lands on the same words rather than on a placeholder. */ export function subjectOf(n: Notification): string { return ( str(n.subject).trim() || str(n.alertLabel).trim() || str(n.label).trim() || "Notification" ); } /** * The preview line — what actually happened. * * ⚠ EMPTY WHEN IT WOULD MERELY REPEAT THE SUBJECT. On a notification with no `subject` the * fallback above already used `label`, and printing it twice makes a two-line row that says one * thing — the shape that reads as a rendering bug rather than as an item. */ export function previewOf(n: Notification): string { const label = str(n.label).trim(); return label && label !== subjectOf(n) ? label : ""; } /** The kind, in the reader's words. Unknown kinds fall to the alert wording rather than to a raw * server token — a row is never allowed to print a vocabulary word at somebody. */ export function kindLabel(kind: string | undefined): string { if (kind === NOTIF_KIND_AUTOMATION) return "Automation"; if (kind === NOTIF_KIND_SHARE) return "Shared with you"; return "Alert"; } /** * ⭐⭐ W33-T28 — WHO IT IS FROM, which this list did not have. * * ⛔ `kindLabel` WAS STANDING IN THE SENDER'S PLACE and that is the defect, not the styling: a * `verifier` reading the finished wave-32 Inbox found the row's first meta token was always * "Alert" / "Automation" / "Shared with you" — a CATEGORY where mail puts a who. The two are * different questions and they now have different functions; `kindLabel` keeps its own job. * * ⚠ THE FALLBACK IS BY KIND, NOT A BLANK. A notification queued before the server learned to * send `sender` still has to render a From column, and an empty one reads as a broken inbox * rather than as an old row — so it degrades to the honest machine name for its kind. */ export function senderOf(n: Notification): string { const sent = String(n.sender || "").trim(); if (sent) return sent; if (n.kind === NOTIF_KIND_AUTOMATION) return "Automation"; if (n.kind === NOTIF_KIND_SHARE) return "A teammate"; return "Alerts"; } /** * Where this item opens, or `null`. * * ⛔ `null` IS A RENDERED STATE, NOT A SWALLOWED ONE. An alert outlives the view it was made from * and a database can be un-shared out from under a notification; when that happens the server * sends no `target` and the Inbox must show a row that is visibly not a link. The failure this * replaces is the old pane's: it rendered every row as a button and told the reader *"That * alert's table is no longer available to this account"* only AFTER they clicked. */ export function targetOf(n: Notification): NotificationTarget | null { return n.target ?? null; } /** Can this row be opened? The one predicate the row's markup and its click handler share, so * they cannot disagree about whether a row is a link. */ export function canOpen(n: Notification): boolean { return targetOf(n) !== null; } /** * Why a row cannot be opened, for the reader. * * ⚠ SAID ON THE ROW, NOT ON THE CLICK. The sentence is the same one the pane used to raise as a * toast; moving it onto the row is the whole difference between "this is unavailable" and "you * pressed a button and were told off". */ export const UNOPENABLE_NOTE = "The thing this is about is no longer available to this account."; /** * ⭐ C3's dispatch, as data rather than as a branch in the frame. * * The FRAME owns routing (it holds the router and the module surfaces); this module owns the * question *"which surface, and what does it need?"*. Returning a discriminated answer means A's * `onOpenTarget` is a switch over two literals it can exhaust, instead of a second copy of the * `topic`→route table that `routeForTopic` already owns. * * ⚠ AN UNKNOWN MODULE ANSWERS `null` AND THE FRAME MUST SAY SO. It must not silently do nothing: * an unknown module means this client is older than the server that sent it, and a reader who * clicks and sees no change concludes the Inbox is broken rather than that their tab is stale. */ export type TargetRoute = | { surface: "database"; key: string; viewId?: string } | { surface: "automation"; autoId: string; tab: string }; /** * ⭐⭐ WAVE 32 · T29 (owner item 19) — WHY OPENING A TARGET NEEDS MORE THAN ONE EMIT. * * `CustomerGrid`'s `VIEW_OPEN_EVENT` listener drops anything it cannot yet resolve: * * if (!detail || detail.topic !== scope) return; * if (!views.some((v) => v.id === detail.viewId)) return; * * Both guards are RIGHT — an alert outlives the view it watches, and one grid must not react to * another's event. But together they mean a single emit fired the instant the hash changes is * **silently discarded**: the destination grid has not fetched its views yet, and there is no ack * channel to wait on. The click appears to work, the table opens, and the view is simply not * selected — this repo's most-repeated failure shape, on the feature whose whole purpose is * "clicking an Inbox item opens the thing it is about". * * ⭐ FOUND BY LANE E, NOT BY ME, and re-verified here against `CustomerGrid.tsx` before being * built on: `VIEW_OPEN_EVENT` had a listener and NO emitter anywhere in the tree until this wave, * so wave 20's click-through had never actually been exercised. * * ⚠ RE-EMITTING IS FREE. `selectView` early-returns once the view is already active, and the * listener drops a duplicate that arrives after selection, so a late tick costs nothing. The * ladder is coarse on purpose — a tight interval would fire a dozen times inside one render. */ export const OPEN_RETRY_MS = [0, 250, 700, 1500, 2600] as const; /** * Fire `emit` on the {@link OPEN_RETRY_MS} ladder, stopping early when it reports success. * * Returns a CANCEL function: a reader who clicks a second notification while the first is still * retrying would otherwise have two ladders racing, and the older one would yank them back. * * ⚠ `emit` returning `true` means "this landed" and ends the ladder. It is allowed to return * nothing — `VIEW_OPEN_EVENT` has no ack, so the honest answer there is `undefined` and the * ladder simply runs out. The signature admits both rather than pretending a confirmation exists. */ export function retryEmit( emit: () => boolean | void, schedule: (fn: () => void, ms: number) => number = setTimeout as never ): () => void { const timers: number[] = []; let done = false; for (const ms of OPEN_RETRY_MS) { timers.push( schedule(() => { if (done) return; if (emit() === true) done = true; }, ms) ); } return () => { done = true; for (const t of timers) clearTimeout(t as never); }; } export function routeForTarget(t: NotificationTarget | null): TargetRoute | null { if (!t) return null; if (t.module === TARGET_MODULE_AUTOMATION) return { surface: "automation", autoId: t.id, tab: str(t.tab).trim() || "runs" }; if (t.module === TARGET_MODULE_DATABASE) { const viewId = str(t.tab).trim(); return { surface: "database", key: t.id, ...(viewId ? { viewId } : {}) }; } return null; }