107 lines
3.2 KiB
TypeScript
107 lines
3.2 KiB
TypeScript
/**
|
|
* 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[] {
|
|
// Defensive on both inputs: this renders content from the network, and a
|
|
// missing body should show nothing rather than take down the screen it is
|
|
// on. The broadcast modal renders through here too, and it mounts on every
|
|
// page — so a crash here is not confined to chat.
|
|
const text = body ?? "";
|
|
const sorted = [...(entities ?? [])].sort(
|
|
(a, b) => a.offset - b.offset || b.length - a.length,
|
|
);
|
|
return build(text, 0, text.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";
|
|
}
|