/** * @yaebal/preview โ€” render a telegram-style chat from plain objects to an SVG string. * zero runtime, no `` (so it rasterizes and survives github's SVG * sanitizer). media fields use the real `@yaebal/types` shapes, so you can hand it a * `ctx.message` almost verbatim; add `src` to show real pixels (a `file_id` has none). * * import { renderChat } from "@yaebal/preview"; * import { md } from "@yaebal/fmt"; * * renderChat([ * { from: "user", text: "/start", time: "23:33", status: "read" }, * { from: "bot", name: "yaebal", ...md`hello, **unknown** person`, time: "23:33" }, * { from: "bot", name: "yaebal", photo: [], src: "cat.jpg", caption: "a cat" }, * { from: "bot", name: "yaebal", voice: { duration: 7 } }, * { * from: "bot", name: "yaebal", * reply: { name: "unknown person", text: "hello, unknown person" }, * text: "of course. what did you expect?", * reactions: [{ emoji: "๐Ÿ”ฅ", count: 3, chosen: true }], * }, * ], { theme: "light" }); */ import type { MessageEntity } from "@yaebal/types"; import { computeGroups, forwardBlock, reactionsRow, replyBlock } from "./bubble.js"; import { badge, card, durPill, ellipsize, MW, mapTile, picture, playGlyph, pollBlock, waveform, webpageBlock, } from "./media.js"; import { clamp, dur, esc, hash, IdScope, meta, metaWidth, round, rr } from "./svg.js"; import type { Block } from "./text.js"; import { layoutText, wrapPlainText } from "./text.js"; import { AVATAR_COLORS, FONT, LH, PADX, PADY, resolvePalette } from "./theme.js"; import type { ChatMessage, RenderOptions } from "./types.js"; import { graphemes, initialOf } from "./unicode.js"; export type { Palette, Theme } from "./theme.js"; export type { ChatMessage, ForwardHeader, Loose, LoosePoll, Reaction, RenderOptions, ReplyQuote, Side, TickStatus, WebpagePreview, } from "./types.js"; /** truncate by grapheme cluster, not UTF-16 code unit โ€” a plain `.slice()` can cut a long run * of emoji in half, leaving a lone surrogate in the ``. */ function truncateChars(s: string, n: number): string { const g = graphemes(s); return g.length > n ? `${g.slice(0, n - 1).join("")}โ€ฆ` : s; } /** mask any spoiler-covered text before it can reach the auto-generated `` โ€” the visible * bubble hides spoilers behind block glyphs, so the accessibility metadata must too. */ function maskSpoilers(text: string, entities: MessageEntity[] | undefined): string { const spoilers = entities?.filter((e) => e.type === "spoiler"); if (!spoilers?.length) return text; let out = ""; let i = 0; // UTF-16 code-unit offset, matching MessageEntity.offset/length for (const ch of text) { out += spoilers.some((e) => i >= e.offset && i < e.offset + e.length) ? "โ€ข" : ch; i += ch.length; } return out; } /** render a telegram-style chat to an SVG string. */ export function renderChat(messages: ChatMessage[], options: RenderOptions = {}): string { const W = Math.max(240, Math.round(options.width ?? 380)); const p = resolvePalette(options.theme, options.palette); const PAD = 14; const GAP = 10; const GROUP_GAP = 3; // vertical gap between consecutive same-sender messages const AV = 30; const AVGAP = 8; const RAD = 16; const TAIL = 5; const BTN_H = 34; const BTN_GAP = 4; const maxBubbleW = Math.round(W * 0.76); const body: string[] = []; const ids = new IdScope(options.idPrefix); const groups = computeGroups(messages); let y = PAD; for (let idx = 0; idx < messages.length; idx++) { const m = messages[idx] as ChatMessage; const grp = groups[idx] ?? { first: true, last: true }; // every rendered message is wrapped in so consumers can // target messages individually (e.g. the docs playground animates them in) const msgStart = body.length; const wrapMessage = () => { if (body.length === msgStart) return; body.splice(msgStart, 0, ``); body.push(""); }; const out = m.from === "user"; const indent = out ? 0 : AV + AVGAP; const base = out ? p.outText : p.inText; const time = m.time ?? (m.messageId === undefined ? "" : `#${m.messageId}`); const trailingGap = grp.last ? GAP : GROUP_GAP; if (m.from === "system") { const text = m.text ?? ""; if (!text) continue; const maxW = W - PAD * 4; const lines = wrapPlainText(text, Math.max(18, Math.floor(maxW / 6.2))); const textW = Math.max(...lines.map((line) => line.length * 6.2)); const bw = clamp(textW + 22, 80, W - PAD * 2); const bh = lines.length * 15 + 10; const bx = (W - bw) / 2; body.push( ``, ); lines.forEach((line, i) => { body.push( `${esc(line)}`, ); }); y += bh + GAP; wrapMessage(); continue; } // sticker with nothing else attached: standalone image/emoji, no bubble (matches telegram). // a sticker with text/caption/buttons/reply/forward falls through to the normal bubble // pipeline below instead of silently dropping that content. if (m.sticker) { const needsBubble = !!(m.text || m.caption || m.buttons?.length || m.reply || m.forward); if (!needsBubble) { const sz = 116; const sx = out ? W - PAD - sz : PAD + indent; if (m.src) { const c = ids.next("c"); body.push( ``, ); } else { body.push( `${esc(m.sticker.emoji ?? "๐ŸŽˆ")}`, ); } body.push(meta(out ? W - PAD : sx + sz, y + sz, time, m.status, out, p, false, m.edited)); y += sz; if (m.reactions?.length) { const rx = out ? W - PAD - sz : sx; const rr_ = reactionsRow(rx, y + 6, m.reactions, p); body.push(rr_.s); y += rr_.h ? rr_.h + 6 : 0; } y += trailingGap; wrapMessage(); continue; } } // build content blocks โ€” reply/forward/webpage decorations first, then media, then text. // they're all plain Blocks, so the bubble-sizing math below treats them uniformly. // `header: true` marks a block that (like text) renders a single tight line that can reach // the bubble's right edge, so it needs the same inline meta-width reservation as text does // โ€” plain reply/webpage cards are deliberately full-width already and never need it. const blocks: { block: Block; bleed: boolean; header?: boolean }[] = []; if (m.forward) blocks.push({ block: forwardBlock(m.forward, p), bleed: false, header: true }); if (m.reply) blocks.push({ block: replyBlock(m.reply, MW - PADX * 2, p), bleed: false }); if (m.webpage) blocks.push({ block: webpageBlock( m.webpage.site, m.webpage.title, m.webpage.description, m.webpage.src, MW - PADX * 2, base, p, ), bleed: false, }); const pic = ( natW: number | undefined, natH: number | undefined, seed: string | undefined, ov?: (x: number, y: number, w: number, h: number) => string, ) => { const clip = ids.next("c"); return picture(m.src, natW, natH, clip, seed ?? clip, !!m.spoiler, ov ?? (() => "")); }; if (m.photo) { const largest = m.photo.at(-1); blocks.push({ block: pic(largest?.width, largest?.height, largest?.file_unique_id ?? largest?.file_id), bleed: true, }); } else if (m.animation) { const a = m.animation; blocks.push({ block: pic(a.width, a.height, a.file_unique_id ?? a.file_id, (x, y2) => badge(x, y2, "GIF"), ), bleed: true, }); } else if (m.video) { const v = m.video; blocks.push({ block: pic( v.width, v.height, v.file_unique_id ?? v.file_id, (x, y2, w, h) => playGlyph(x + w / 2, y2 + h / 2) + durPill(x, y2 + h, dur(v.duration)), ), bleed: true, }); } else if (m.location) { const h = 132; blocks.push({ block: { w: MW, h, render: (x, y2) => mapTile(x, y2, MW, h, p, ids.next("c")) }, bleed: true, }); } else if (m.venue) { const mh = 120; const v = m.venue; blocks.push({ block: { w: MW, h: mh + 44, render: (x, y2) => { let s = mapTile(x, y2, MW, mh, p, ids.next("c")); s += `${esc(ellipsize(v.title ?? "venue", MW))}`; s += `${esc(ellipsize(v.address ?? "", MW))}`; return s; }, }, bleed: true, }); } else if (m.sticker) { // only reached when the sticker also carries text/caption/buttons/reply/forward โ€” // render it as a boxed block instead of silently dropping the rest of the message. const size = MW; const emoji = m.sticker.emoji; blocks.push({ block: { w: size, h: size, render: (x, y2) => { if (m.src) { const c = ids.next("c"); return ``; } return `${esc(emoji ?? "๐ŸŽˆ")}`; }, }, bleed: true, }); } if (m.voice) { const d = m.voice.duration; blocks.push({ block: card(40, (x, y2) => { const cy = y2 + 20; return `${waveform(x + 44, cy, p.bar, p.barTrack)}${dur(d)}`; }), bleed: false, }); } else if (m.audio) { const a = m.audio; blocks.push({ block: card(44, (x, y2) => { const cy = y2 + 22; const title = ellipsize(a.title ?? a.file_name ?? "audio", 180); const sub = ellipsize(a.performer ?? dur(a.duration), 180); return `${esc(title)}${esc(sub)}`; }), bleed: false, }); } else if (m.document) { const d = m.document; const kb = d.file_size ? `${Math.max(1, Math.round(d.file_size / 1024))} KB` : (d.mime_type ?? "file"); blocks.push({ block: card(44, (x, y2) => { const cy = y2 + 22; return `${esc(ellipsize(d.file_name ?? "document", 180))}${esc(kb)}`; }), bleed: false, }); } else if (m.contact) { const c = m.contact; const nm = `${c.first_name ?? "Contact"}${c.last_name ? ` ${c.last_name}` : ""}`; const col = AVATAR_COLORS[hash(nm) % AVATAR_COLORS.length] ?? "#65aadd"; blocks.push({ block: card(44, (x, y2) => { const cy = y2 + 22; return `${esc(initialOf(nm))}${esc(ellipsize(nm, 180))}${esc(c.phone_number ?? "")}`; }), bleed: false, }); } else if (m.poll) { blocks.push({ block: pollBlock(m.poll, MW - PADX * 2, base, p), bleed: false }); } const hasBleed = blocks.some((b) => b.bleed); // text/caption const tText = m.text ?? ""; const cText = m.caption ?? ""; const ents = m.entities ?? []; const cEnts = m.captionEntities ?? []; const innerMax = hasBleed || blocks.length ? MW - PADX * 2 : maxBubbleW - PADX * 2; if (tText) blocks.push({ block: layoutText(tText, ents, blocks.length ? innerMax : maxBubbleW - PADX * 2, base, p), bleed: false, }); if (cText) blocks.push({ block: layoutText(cText, cEnts, innerMax, base, p), bleed: false }); if (!blocks.length && !m.buttons?.length && !m.reactions?.length) continue; // nothing to render โ€” no gap consumed let bubbleW = 0; if (blocks.length) { // bubble width: bleed media โ†’ media width; else widest padded block const bleedW = hasBleed ? MW : 0; const padW = Math.max(0, ...blocks.filter((b) => !b.bleed).map((b) => b.block.w)) + (blocks.some((b) => !b.bleed) ? PADX * 2 : 0); const lastBlockEntry = blocks[blocks.length - 1]; const lastIsText = !lastBlockEntry?.bleed && (!!tText || !!cText || !!lastBlockEntry?.header); const metaInline = lastIsText && (time || m.edited) ? metaWidth(time, out, !!m.status, m.edited) + 8 : 0; bubbleW = Math.max(bleedW, padW); if (lastIsText) { const lastW = blocks[blocks.length - 1]?.block.w ?? 0; bubbleW = Math.max(bubbleW, Math.min(maxBubbleW, lastW + PADX * 2 + metaInline)); } bubbleW = clamp(bubbleW, 60, maxBubbleW); // an inline keyboard spans at least media width โ€” widen the bubble to match, so the // bubble and its keyboard sit flush like one telegram message if (m.buttons?.length) bubbleW = clamp(Math.max(bubbleW, MW), 60, maxBubbleW); // stack height let inner = 0; blocks.forEach((b, i) => { inner += b.block.h; if (i < blocks.length - 1) inner += b.bleed && !blocks[i + 1]?.bleed ? PADY : 6; }); // name sits on its own row above the first non-bleed block โ€” count its height, and only // on the first message of a grouped series (telegram shows the sender name once per group) const showName = grp.first && !out && !!m.name && !blocks[0]?.bleed; const nameH = showName ? LH : 0; const padTop = blocks[0]?.bleed ? 0 : PADY; const padBot = lastIsText ? PADY : blocks[blocks.length - 1]?.bleed ? 0 : PADY; const bubbleH = padTop + nameH + inner + padBot; const debugLines = [m.debug].flat().filter((line): line is string => !!line); const debugH = debugLines.length ? debugLines.length * 14 + 4 : 0; const bx = out ? W - PAD - bubbleW : PAD + indent; const by = y + debugH; if (debugLines.length) { const tx = out ? bx + bubbleW : bx; const anchor = out ? "end" : "start"; debugLines.forEach((line, i) => { body.push( `${esc(line)}`, ); }); } // only the last bubble of a grouped series gets the pointed "tail" corner; earlier // messages in the series round that corner the same as the others. const tailCorner = grp.last ? TAIL : RAD; const path = out ? rr(bx, by, bubbleW, bubbleH, RAD, RAD, tailCorner, RAD) : rr(bx, by, bubbleW, bubbleH, RAD, RAD, RAD, tailCorner); body.push(``); // avatar โ€” only on the last message of a grouped series (telegram aligns it to the // bottom of the group instead of repeating it on every bubble) if (!out && grp.last) { const who = m.name ?? ""; const glyph = m.avatar ?? options.avatar ?? (who ? initialOf(who) : undefined) ?? "๐Ÿค–"; const ac = AVATAR_COLORS[hash(who || "bot") % AVATAR_COLORS.length] ?? "#7bc862"; const cy = by + bubbleH - AV / 2; body.push( `${esc(glyph)}`, ); } // name (incoming, above first padded block, first message of the group only) let cursor = by + padTop; if (showName) { body.push( `${esc(m.name ?? "")}`, ); cursor += LH; } // blocks blocks.forEach((b, i) => { const bxr = b.bleed ? bx : bx + PADX; body.push(b.block.render(bxr, cursor)); cursor += b.block.h; if (i < blocks.length - 1) cursor += b.bleed && !blocks[i + 1]?.bleed ? PADY : 6; }); // meta const onScrim = !lastIsText && hasBleed; const metaRight = onScrim ? bx + bubbleW - 8 : bx + bubbleW - PADX; // sit time/ticks on the last text line's baseline (bubble bottom โˆ’ padBot โˆ’ descent) const metaBottom = onScrim ? by + (blocks[0]?.block.h ?? bubbleH) : by + bubbleH - padBot - (LH - 13) + 4; body.push(meta(metaRight, metaBottom, time, m.status, out, p, onScrim, m.edited)); y = by + bubbleH; } // under-bubble row width/x shared by the buttons-only meta shim, the buttons row, and the reactions row const rowW = Math.max(bubbleW, MW); const rowX = out ? W - PAD - rowW : PAD + indent; // buttons-only messages still show time/status (previously dropped entirely) if (!blocks.length && m.buttons?.length && (time || m.status || m.edited)) { body.push(meta(rowX + rowW - 4, y + 14, time, m.status, out, p, false, m.edited)); y += 16; } // buttons if (m.buttons?.length) { let byy = blocks.length ? y + 6 : y; for (const row of m.buttons) { const n = Math.max(1, row.length); const cw = (rowW - (n - 1) * BTN_GAP) / n; row.forEach((label, i) => { const ux = rowX + i * (cw + BTN_GAP); body.push( `${esc(label)}`, ); }); byy += BTN_H + BTN_GAP; } y = byy - BTN_GAP; } // reactions if (m.reactions?.length) { const ry = y + (blocks.length || m.buttons?.length ? 6 : 0); const { s, h } = reactionsRow(rowX, ry, m.reactions, p); body.push(s); y = h ? ry + h : y; } y += trailingGap; wrapMessage(); } const H = Math.round(y - GAP + PAD); const scale = options.scale ?? 1; const bgId = ids.next("bg"); const defs = options.wallpaper ? "" : ``; const bgFill = options.wallpaper ? esc(options.wallpaper) : `url(#${bgId})`; const title = options.a11yTitle ?? "Telegram-style chat preview"; const descSource = messages .filter((m) => m.text || m.caption) .slice(0, 2) .map((m) => m.text ? maskSpoilers(m.text, m.entities) : maskSpoilers(m.caption ?? "", m.captionEntities), ) .join(" ยท "); const desc = options.a11yDesc ?? (descSource ? truncateChars(descSource, 200) : undefined); const open = ``; const a11y = `${esc(title)}${desc ? `${esc(desc)}` : ""}`; return `${open}${a11y}${defs}${body.join("")}`; } /** a chainable message-list builder over {@link renderChat} โ€” nicer for docs/examples than a raw array literal. */ export interface ChatBuilder { /** append an outgoing ("user") message. */ user(text: string, extra?: Omit): ChatBuilder; /** append an incoming ("bot") message. */ bot(text: string, extra?: Omit): ChatBuilder; /** append a centered system notice (e.g. a date divider). */ system(text: string): ChatBuilder; /** append any raw `ChatMessage` (for media, polls, etc. that don't fit `.user`/`.bot`). */ push(message: ChatMessage): ChatBuilder; /** render the accumulated messages to an SVG string. */ render(): string; } /** `chat({ theme: "dark" }).user("/start").bot("hi", { name: "yaebal" }).render()` */ export function chat(options: RenderOptions = {}): ChatBuilder { const messages: ChatMessage[] = []; const builder: ChatBuilder = { user(text, extra) { messages.push({ from: "user", text, ...extra }); return builder; }, bot(text, extra) { messages.push({ from: "bot", text, ...extra }); return builder; }, system(text) { messages.push({ from: "system", text }); return builder; }, push(message) { messages.push(message); return builder; }, render() { return renderChat(messages, options); }, }; return builder; }