/** * Rendering helpers for chat messages — the client-side counterpart to the * BFF's entity parser. * * The server stores clean text plus spans; nothing here interprets markup, so * a message can never inject markup into someone else's thread. * * Entity offsets are UTF-16 code units, which is exactly what String.slice * indexes by — so the arithmetic below needs no conversion. The server counts * the same way for this reason; it matters constantly in this app, where * Devanāgarī is multi-byte and emoji are surrogate pairs. */ import type { MessageEntity, Receipts } from "@/lib/chat"; export type EntityNode = | { kind: "text"; text: string } | { kind: "entity"; entity: MessageEntity; children: EntityNode[] }; /** * Turns a body plus its spans into a nestable tree, so `*bold with `code`*` * renders as one bold run containing a code run. * * Spans that only partially overlap another are dropped rather than clipped: * the server never emits them, and rendering half a span is a worse failure * than rendering none of it. */ export function buildEntityTree( body: string, entities: MessageEntity[], ): EntityNode[] { const sorted = [...(entities ?? [])].sort( (a, b) => a.offset - b.offset || b.length - a.length, ); return build(body, 0, body.length, sorted); } function build( body: string, start: number, end: number, entities: MessageEntity[], ): EntityNode[] { const nodes: EntityNode[] = []; let cursor = start; let i = 0; while (i < entities.length) { const e = entities[i]; const eStart = e.offset; const eEnd = e.offset + e.length; if (eStart >= end) break; // Partially overlapping or already-consumed spans: skip. if (eEnd > end || eStart < cursor || e.length <= 0) { i += 1; continue; } if (eStart > cursor) { nodes.push({ kind: "text", text: body.slice(cursor, eStart) }); } // Everything fully inside this span belongs to it. const children: MessageEntity[] = []; let j = i + 1; while (j < entities.length && entities[j].offset + entities[j].length <= eEnd) { children.push(entities[j]); j += 1; } nodes.push({ kind: "entity", entity: e, children: build(body, eStart, eEnd, children), }); cursor = eEnd; i = j; } if (cursor < end) { nodes.push({ kind: "text", text: body.slice(cursor, end) }); } return nodes; } /** Tick state for one of your own messages. */ export type TickState = "sent" | "delivered" | "read"; /** * Read wins over delivered, delivered over sent. * * The cursors are the MINIMUM across every other participant, so in a group a * message only turns blue once everyone has read it. */ export function tickState(seq: number, receipts: Receipts | null): TickState { if (!receipts) return "sent"; if (receipts.read_seq >= seq) return "read"; if (receipts.delivered_seq >= seq) return "delivered"; return "sent"; }