// Text formatting helpers. Pure functions so they can be unit-tested in Node; // the DOM rendering that consumes these lives in shell.js. // Matches http(s):// URLs, bare www. hosts, and scheme-less "host.tld" domains. // The bare case is validated against a TLD allowlist (see linkHref) so file names // and abbreviations (node.js, index.html, e.g., main.go) aren't turned into links. // The lookbehind on the bare case is the mid-token/email rule ("x_a.com", // "b@a.com", "a-b.com" never link from the middle — hyphens count as part of // the token, like the match's own charset) AND the performance guard: without // it, every position inside a long unbroken run of those characters is a fresh // match attempt that scans to the run's end before failing — quadratic, and a // pasted 45k-char key/hash/log line locks the main thread for seconds per // render. With it, a mid-run start fails in one lookbehind check, keeping the // scan linear. const URL_RE = /https?:\/\/[^\s<]+|www\.[^\s<]+|(?= 2 && BARE_TLDS.has(labels[labels.length - 1].toLowerCase())) { return "https://" + token; } return null; } // tokenizeText splits a message into an ordered list of tokens: // { text } → plain text (render as a text node) // { text, href } → a link (render as an , href is always http(s)://) // Splitting (rather than building HTML) keeps rendering XSS-safe: callers only // ever create text nodes and anchors with a validated href. export function tokenizeText(text) { text = text == null ? "" : String(text); const tokens = []; let last = 0; for (const m of text.matchAll(URL_RE)) { const match = m[0]; const offset = m.index; let url = match; let trail = ""; const tm = url.match(TRAILING); if (tm) { trail = tm[0]; url = url.slice(0, url.length - trail.length); } // (A scheme-less candidate glued to a preceding word char, '@', '.' or '-' // is mid-token or an email's domain — URL_RE's lookbehind already rejects it.) const href = linkHref(url); if (!href) continue; // e.g. "node.js" / "index.html" — not a link if (offset > last) tokens.push({ text: text.slice(last, offset) }); tokens.push({ text: url, href }); if (trail) tokens.push({ text: trail }); last = offset + match.length; } if (last < text.length) tokens.push({ text: text.slice(last) }); if (tokens.length === 0) tokens.push({ text: "" }); return tokens; } // Inline formatting markers (single level, no nesting — except the one // combined form, ***bold italic***, which is its own marker, not nesting): // `code` ***bold italic*** **bold** *italic* _italic_ ~strike~ // Underscore italics require word boundaries so file_names don't italicize. // (Hyphens are deliberately NOT a marker — they appear too often in normal text, // e.g. dates and "well-known", to be a safe delimiter.) const FORMAT_RE = /`([^`\n]+)`|\*\*\*([^*\n]+)\*\*\*|\*\*([^*\n]+)\*\*|\*([^*\n]+)\*|(? last) out.push({ text: text.slice(last, m.index) }); let mark, content; if (m[1] != null) [mark, content] = ["code", m[1]]; else if (m[2] != null) [mark, content] = ["bi", m[2]]; else if (m[3] != null) [mark, content] = ["b", m[3]]; else if (m[4] != null) [mark, content] = ["i", m[4]]; else if (m[5] != null) [mark, content] = ["i", m[5]]; else [mark, content] = ["s", m[6]]; out.push({ text: content, mark }); last = m.index + m[0].length; } if (last < text.length) out.push({ text: text.slice(last) }); } // richTokens splits a message into ordered render tokens, combining links and // inline formatting: // { text } → plain text // { text, href } → a link (http(s):// only) — never formatted inside // { text, mark } → formatted run; mark ∈ b | i | bi | s | code // Links take precedence (we don't format inside a URL). Pure + flat (no nesting) // so shell.js builds DOM with text nodes + a single wrapper element each — no // innerHTML, so it stays XSS-safe. export function richTokens(text) { const tokens = []; for (const t of tokenizeText(text)) { if (t.href) tokens.push(t); else formatSegment(t.text, tokens); } return tokens.filter((t) => t.text !== "" || t.href); } // Block-level structure, parsed BEFORE inline formatting so markers inside a // fence stay verbatim. Splits a message into ordered blocks: // { type: "p", text } → normal text (linkify + inline-format) // { type: "code", text, lang } → ``` fenced block — render verbatim // { type: "quote", text } → run of "> " lines, markers stripped // (still linkified + inline-formatted) // Blank lines butting against a code/quote block are dropped — the blocks // carry their own spacing, so the delimiter newlines shouldn't render too. // Pure + flat so shell.js builds
/
wrappers with no innerHTML. const FENCE_RE = /^\s*```(.*)$/; const QUOTE_RE = /^\s*> ?(.*)$/; export function blockTokens(text) { text = text == null ? "" : String(text); const lines = text.split("\n"); // Fast path: no block markers anywhere — one plain block, text untouched. if (!lines.some((l) => FENCE_RE.test(l) || QUOTE_RE.test(l))) { return [{ type: "p", text }]; } const blocks = []; let plain = []; const flush = (beforeBlock) => { // Trim blank lines that only exist to separate text from a block. if (blocks.length) while (plain.length && plain[0].trim() === "") plain.shift(); if (beforeBlock) while (plain.length && plain[plain.length - 1].trim() === "") plain.pop(); if (plain.length) blocks.push({ type: "p", text: plain.join("\n") }); plain = []; }; let i = 0; while (i < lines.length) { const fence = lines[i].match(FENCE_RE); // An opening fence's info string never contains a backtick — that's an // inline-code line (e.g. "```js``` opens a fence"), not a block. if (fence && !fence[1].includes("`")) { flush(true); const body = []; i++; while (i < lines.length && lines[i].trim() !== "```") body.push(lines[i++]); i++; // skip the closing fence; an unterminated fence runs to the end blocks.push({ type: "code", text: body.join("\n"), lang: fence[1].trim() }); continue; } const quote = lines[i].match(QUOTE_RE); if (quote) { flush(true); const body = [quote[1]]; i++; for (let q; i < lines.length && (q = lines[i].match(QUOTE_RE)); i++) body.push(q[1]); blocks.push({ type: "quote", text: body.join("\n") }); continue; } plain.push(lines[i++]); } flush(false); if (blocks.length === 0) blocks.push({ type: "p", text: "" }); return blocks; } // The first http(s) link in a message, skipping fenced code blocks (a URL // pasted as code shouldn't sprout a preview card). Used by shell.js to decide // which URL, if any, gets an OpenGraph unfurl. export function firstLink(text) { for (const b of blockTokens(text)) { if (b.type === "code") continue; const hit = tokenizeText(b.text).find((t) => t.href); if (hit) return hit.href; } return null; } // Parse a YouTube URL into { id, start } (start in seconds, 0 if none), or null // for anything that isn't a YouTube video link. Covers the shapes people paste: // watch?v=, youtu.be/, /shorts/, /live/, /embed/, with t= / start= offsets // (plain seconds, "90s", or "1h2m3s"). Pure so shell.js's click-to-play embed // card stays unit-testable; the 11-char id alphabet is YouTube's own. const YT_ID = /^[A-Za-z0-9_-]{11}$/; export function youtubeId(url) { let u; try { u = new URL(url); } catch { return null; } if (!/^https?:$/.test(u.protocol)) return null; const host = u.hostname.replace(/^www\.|^m\./, ""); let id = ""; if (host === "youtu.be") { id = u.pathname.split("/")[1] || ""; } else if (host === "youtube.com" || host === "youtube-nocookie.com" || host === "music.youtube.com") { const seg = u.pathname.split("/").filter(Boolean); if (u.pathname === "/watch") id = u.searchParams.get("v") || ""; else if (["shorts", "live", "embed", "v"].includes(seg[0])) id = seg[1] || ""; } else { return null; } if (!YT_ID.test(id)) return null; // t=90, t=90s, t=1h2m3s, start=90 — normalise to whole seconds. const t = u.searchParams.get("t") || u.searchParams.get("start") || ""; let start = 0; if (/^\d+s?$/.test(t)) start = parseInt(t, 10); else { const m = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/.exec(t); if (m && (m[1] || m[2] || m[3])) start = (+m[1] || 0) * 3600 + (+m[2] || 0) * 60 + (+m[3] || 0); } return { id, start }; } // Build a short, plain-text preview of a message for a reply quote. Collapses // whitespace and truncates long text; when a message has no text (e.g. a photo // only), falls back to a label for its first attachment so the quote still says // something. Pure so the reply UI in shell.js stays testable. export function replySnippet(payload, max = 140) { const clamp = (s) => (s.length > max ? s.slice(0, max - 1).trimEnd() + "…" : s); const text = (payload && payload.text ? String(payload.text) : "").replace(/\s+/g, " ").trim(); if (text) return clamp(text); const atts = payload && Array.isArray(payload.attachments) ? payload.attachments : []; if (atts.length) { const a = atts[0] || {}; if (a.type && a.type.startsWith("image/")) return "📷 Photo"; return clamp("📎 " + (a.name || "Attachment")); } return ""; } // The chat-list line for a reaction event — "James reacted 👍 to “buy milk”". // A reaction is an ordinary encrypted message, so it can be the newest thing in // a thread; before this the row simply went blank (previewText saw a payload // with no text). `who` is resolved by the caller, so it can say "You". // The quoted snippet rides inside the ciphertext as `targetText`, stamped by // the reacting client — reactions sent before that shipped (and by older // clients) carry none, so the bare "James reacted 👍" is the fallback, not an // error. Pure so both the sidebar and its tests can use it. export function reactionLine(who, payload, max = 40) { const actor = String(who || "").trim() || "Someone"; const emoji = (payload && payload.emoji ? String(payload.emoji) : "").trim(); // on === false is an un-react. It still speaks: the list has to say the same // thing after a reload as it did live, and on reload all we have is this event. const off = !!payload && payload.on === false; const head = `${actor} ${off ? "removed" : "reacted"}${emoji ? " " + emoji : ""}`; const snip = replySnippet({ text: payload && payload.targetText }, max); return snip ? `${head} ${off ? "from" : "to"} “${snip}”` : head; } // Counts the emoji in a message whose only content is emoji (plus whitespace), // returning 0 for anything else. Used to render short all-emoji messages large // and bubble-less (à la Messages). Grapheme-segmented so ZWJ families (👨‍👩‍👧‍👦), // skin-tone modifiers (👍🏽) and flags (🇬🇧) each count as one. Any non-emoji, // non-whitespace grapheme (a letter, a digit, punctuation) means it's normal // text, not pure emoji. Pure so the render path in shell.js stays testable. const EMOJI_GRAPHEME = /\p{Extended_Pictographic}|\p{Regional_Indicator}/u; export function emojiOnlyCount(text) { const s = (text == null ? "" : String(text)).trim(); if (!s) return 0; let count = 0; for (const { segment } of new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(s)) { if (/^\s+$/u.test(segment)) continue; // whitespace between emoji is allowed if (!EMOJI_GRAPHEME.test(segment)) return 0; // a non-emoji grapheme → it's text if (++count > 3) return 0; // more than 3 → render as a normal message } return count; } // Summarise who's currently typing into a subtle one-liner. Past two names it // collapses to "Several people…" so the line never grows unbounded. Pure so the // presence UI in shell.js stays testable; the caller renders it with textContent // (the names are attacker-controlled). export function typingSummary(names) { const list = Array.isArray(names) ? names.filter(Boolean) : []; if (list.length === 0) return ""; if (list.length === 1) return `${list[0]} is typing…`; if (list.length === 2) return `${list[0]} and ${list[1]} are typing…`; return "Several people are typing…"; } // Auto-title for a group with no explicit name: the member names (pass the // OTHER members — the viewer excludes themselves), joined naturally and // collapsed past three into "A, B & N more" so the header never grows unbounded: // [] → "Group" (fallback: just you / unknown) // [A] → "A" // [A, B] → "A & B" // [A, B, C] → "A, B & C" // [A, B, C, D, …] → "A, B & N more" (N = names.length - 2) // Pure so the header/list rendering in shell.js stays testable. export function autoGroupTitle(names) { const list = Array.isArray(names) ? names.filter(Boolean) : []; const n = list.length; if (n === 0) return "Group"; if (n === 1) return list[0]; if (n === 2) return `${list[0]} & ${list[1]}`; if (n === 3) return `${list[0]}, ${list[1]} & ${list[2]}`; return `${list[0]}, ${list[1]} & ${n - 2} more`; } // Join names into a natural English list — "Alice" / "Alice and Bob" / // "Alice, Bob and Dave". Used to announce who was just added to a group (the // centered .chatapp-action line; see addPeople in shell.js). Pure so it stays // unit-testable; the names are user-controlled, so the caller renders the // result with textContent. export function nameList(names) { const list = Array.isArray(names) ? names.filter(Boolean) : []; const n = list.length; if (n === 0) return ""; if (n === 1) return list[0]; if (n === 2) return `${list[0]} and ${list[1]}`; return `${list.slice(0, -1).join(", ")} and ${list[n - 1]}`; } // Render a group membership announcement (someone added people, removed people, or // left) from the *current viewer's* point of view: "you"/"You" is substituted for // the reader and every id is resolved to a live name via labelOf, so one stored // message reads "Paul added you and Bob" for a newcomer, "You added Alice and Bob" // for the person who did it, and "Paul added Alice and Bob" for a bystander. The // announcement carries user *ids* (not baked names) precisely so each client can // do this locally — see announceJoin/announceRemove/announceLeave in shell.js. // Returns null when the payload isn't a membership event. Pure (ids in, string out) // so it's unit-testable; the caller renders the result with textContent (names are // user-controlled). export function membershipLine(payload, meId, labelOf) { if (!payload || typeof payload !== "object") return null; const actor = payload.uid; const actorName = actor ? (actor === meId ? "You" : labelOf(actor)) : payload.name || "Someone"; const names = (ids) => nameList(ids.map((uid) => (uid === meId ? "you" : labelOf(uid)))); if (Array.isArray(payload.join) && payload.join.length) { // A self-serve join (an open group's Discover): the joiner announces // themselves — "Paul joined" / "You joined", never "Paul added Paul". if (actor && payload.join.length === 1 && payload.join[0] === actor) return `${actorName} joined`; return `${actorName} added ${names(payload.join)}`; } if (Array.isArray(payload.removed) && payload.removed.length) return `${actorName} removed ${names(payload.removed)}`; if (payload.left) return `${actorName} left`; return null; } // Human-readable file size, e.g. 1536 → "1.5 KB". export function formatBytes(n) { if (!Number.isFinite(n) || n < 0) return ""; if (n < 1024) return n + " B"; const units = ["KB", "MB", "GB"]; let v = n / 1024; let i = 0; while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } return (v < 10 ? v.toFixed(1) : Math.round(v)) + " " + units[i]; } // Release notes ship inside the binary as prose that (a) opens with an // authorship marker (🤖 / 👨 …) meant for the repo, not readers, and (b) is // hard-wrapped at ~72 columns. changelogParagraphs cleans both for display — // the Admin app posts each note into its update stream through this: it drops // a leading marker, then returns display paragraphs — blank lines split // paragraphs, and the single newlines inside one (the hard wraps) collapse // back to spaces. Pure, so it's unit-tested. export function changelogParagraphs(text) { if (typeof text !== "string") return []; const clean = text.replace(/^\s*(?:🤖|👨|👤|🧑|👩)\s+/u, ""); return clean .split(/\n[ \t]*\n/) .map((p) => p.replace(/[ \t]*\n[ \t]*/g, " ").trim()) .filter(Boolean); } // A compact "last activity" label: time today, "Yesterday", a weekday this week, // else a short date. export function relTime(sec) { if (!sec) return ""; const d = new Date(sec * 1000); const now = new Date(); if (d.toDateString() === now.toDateString()) { return d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); } const y = new Date(now); y.setDate(now.getDate() - 1); if (d.toDateString() === y.toDateString()) return "Yesterday"; if ((now - d) / 86400000 < 7) return d.toLocaleDateString([], { weekday: "short" }); return d.toLocaleDateString([], { day: "2-digit", month: "2-digit" }); } // formatCountdown renders a remaining duration as a compact whole-unit // countdown for the demo banner: "23h 40m" above an hour, "40m" under one, // "under a minute" at the end. Minutes are ceiled so the promise is never // optimistic — the space outlives the label, not the other way round. export function formatCountdown(ms) { const mins = Math.ceil(ms / 60000); if (mins <= 1) return "under a minute"; if (mins < 60) return mins + "m"; const h = Math.floor(mins / 60); const m = mins % 60; return m ? h + "h " + m + "m" : h + "h"; }