/** 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;
},
};
}