| // --------------------------------------------------------------------------- | |
| // 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[]): InboxSections { | |
| const byAt = (a: Notification, b: Notification) => | |
| a.at < b.at ? 1 : a.at > b.at ? -1 : 0; | |
| return { | |
| unread: items.filter((n) => !n.read).sort(byAt), | |
| read: items.filter((n) => n.read).sort(byAt), | |
| }; | |
| } | |
| /** | |
| * 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"; | |
| } | |
| /** | |
| * 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; | |
| } | |