feat: chat
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 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";
|
||||
}
|
||||
Reference in New Issue
Block a user