/** self-contained media blocks: pictures, audio/voice/document/contact cards, maps, polls, link-preview cards. */ import { clamp, esc, hash, round } from "./svg.js"; import type { Block } from "./text.js"; import { wrapPlainText } from "./text.js"; import type { Palette } from "./theme.js"; import { charW, FONT } from "./theme.js"; import type { LoosePoll } from "./types.js"; import { clusterWidth, graphemes, textWidthUnits } from "./unicode.js"; /** picture media (photo/video/animation) width — every bleed block shares this. */ export const MW = 252; /** truncate `text` with an ellipsis so it fits `maxW` px at the given px-per-unit width — cuts * on grapheme-cluster boundaries so it never splits a surrogate pair or emoji sequence. */ export function ellipsize(text: string, maxW: number, unitW = charW): string { if (textWidthUnits(text) * unitW <= maxW) return text; const budget = Math.max(1, maxW / unitW - 1); // leave room for the ellipsis glyph let out = ""; let w = 0; for (const g of graphemes(text)) { const cw = clusterWidth(g); if (w + cw > budget) break; out += g; w += cw; } return `${out}…`; } export function picture( src: string | undefined, natW: number | undefined, natH: number | undefined, clip: string, seedKey: string, spoiler: boolean, overlay: (x: number, y: number, w: number, h: number) => string, ): Block { const ar = natW && natH ? natH / natW : 0.62; const w = MW; const h = clamp(Math.round(w * ar), 120, 320); return { w, h, render: (x, y) => { let s = ``; if (src && !spoiler) s += ``; else { // no real pixels (a file_id has none) — paint a vivid, deterministic "photo" seeded // from the media's own identity (falls back to the clip id) so it never reads as an // empty grey box, and stays stable when messages are reordered. const seed = hash(seedKey); const h1 = seed % 360; const h2 = (h1 + 35 + (seed % 70)) % 360; s += ``; s += ``; const cx = x + w / 2; const cy = y + h / 2; if (spoiler) s += `👁️‍🗨️`; else s += ``; } s += ""; s += overlay(x, y, w, h); return s; }, }; } export const playGlyph = (cx: number, cy: number): string => ``; export const badge = (x: number, y: number, label: string): string => `${esc(label)}`; export const durPill = (x: number, bottomY: number, text: string): string => `${esc(text)}`; /** a 252-wide single-row card (audio/voice/document/contact). */ export function card(h: number, draw: (x: number, y: number) => string): Block { return { w: MW, h, render: (x, y) => draw(x, y) }; } export function waveform(x: number, y: number, color: string, track: string): string { const heights = [5, 9, 14, 8, 16, 11, 6, 13, 9, 17, 7, 12, 10, 15, 6, 11, 8, 5]; let s = ""; heights.forEach((hh, i) => { const bx = x + i * 5; const col = i < 7 ? color : track; s += ``; }); return s; } export function mapTile( x: number, y: number, w: number, h: number, p: Palette, clip: string, ): string { let s = ``; s += ``; for (let i = 1; i < 5; i++) s += ``; s += ``; const px = x + w / 2; const py = y + h / 2; s += ``; return s; } /** poll question + options with percentage bars; shows a quiz/closed marker on the winning option. */ export function pollBlock(poll: LoosePoll, w: number, base: string, p: Palette): Block { const opts = poll.options; const total = Math.max( 1, poll.total_voter_count || opts.reduce((a, o) => a + (o.voter_count ?? 0), 0), ); const rowH = 34; const h = 26 + opts.length * rowH + 16; const closed = poll.is_closed; const isQuiz = poll.type === "quiz"; return { w, h, render: (x, y) => { let s = `${esc(poll.question ?? "")}`; const kind = closed ? "Final results" : isQuiz ? "Quiz" : poll.is_anonymous === false ? "Public" : "Anonymous"; s += `${esc(kind)}`; // round each option down, then hand the leftover percentage points to the largest // remainders so the row always reads a clean 100% total (not e.g. 33/33/33). const raw = opts.map((o) => ((o.voter_count ?? 0) / total) * 100); const pct = raw.map(Math.floor); let leftover = 100 - pct.reduce((a, b) => a + b, 0); raw .map((v, i) => ({ i, frac: v - Math.floor(v) })) .sort((a, b) => b.frac - a.frac) .forEach(({ i }) => { if (leftover <= 0) return; pct[i] = (pct[i] ?? 0) + 1; leftover--; }); opts.forEach((o, i) => { const oy = y + 26 + i * rowH; const p_ = pct[i] ?? 0; const winning = (closed || isQuiz) && (o.voter_count ?? 0) > 0 && p_ === Math.max(...pct); s += `${esc(ellipsize(o.text ?? "", w - 40))}`; if (winning) s += ``; s += `${p_}%`; s += ``; s += ``; }); return s; }, }; } /** link-preview card — a left accent bar + site/title/description, like telegram's own webpage preview. */ export function webpageBlock( site: string | undefined, title: string | undefined, description: string | undefined, src: string | undefined, w: number, base: string, p: Palette, ): Block { const innerW = w - 12; const descLines = description ? wrapPlainText(description, Math.max(10, Math.floor(innerW / 6.2))).slice(0, 3) : []; const thumb = src ? 56 : 0; const textW = innerW - (thumb ? thumb + 8 : 0); const siteH = site ? 15 : 0; const titleH = title ? 17 : 0; const descH = descLines.length * 15; const contentH = siteH + titleH + descH; const h = Math.max(contentH, thumb) + 4; return { w, h, render: (x, y) => { let s = ``; let ty = y; if (site) { s += `${esc(ellipsize(site, textW))}`; ty += siteH; } if (title) { s += `${esc(ellipsize(title, textW))}`; ty += titleH; } descLines.forEach((line, i) => { s += `${esc(line)}`; }); if (thumb) { const tx = x + w - thumb; const clip = `wp${Math.abs(hash(src ?? "")) % 100000}`; s += ``; } return s; }, }; }