| 1 | // Text formatting helpers. Pure functions so they can be unit-tested in Node; |
| 2 | // the DOM rendering that consumes these lives in shell.js. |
| 3 | |
| 4 | // Matches http(s):// URLs, bare www. hosts, and scheme-less "host.tld" domains. |
| 5 | // The bare case is validated against a TLD allowlist (see linkHref) so file names |
| 6 | // and abbreviations (node.js, index.html, e.g., main.go) aren't turned into links. |
| 7 | // The lookbehind on the bare case is the mid-token/email rule ("x_a.com", |
| 8 | // "b@a.com", "a-b.com" never link from the middle — hyphens count as part of |
| 9 | // the token, like the match's own charset) AND the performance guard: without |
| 10 | // it, every position inside a long unbroken run of those characters is a fresh |
| 11 | // match attempt that scans to the run's end before failing — quadratic, and a |
| 12 | // pasted 45k-char key/hash/log line locks the main thread for seconds per |
| 13 | // render. With it, a mid-run start fails in one lookbehind check, keeping the |
| 14 | // scan linear. |
| 15 | const URL_RE = /https?:\/\/[^\s<]+|www\.[^\s<]+|(?<![\w@.-])[a-z0-9][a-z0-9-]*(?:\.[a-z0-9-]+)+(?:\/[^\s<]*)?/gi; |
| 16 | |
| 17 | // Trailing punctuation that's almost certainly sentence punctuation, not URL — |
| 18 | // including emphasis markers: a bot writing "**github.com/apps/x**" must not |
| 19 | // get the closing ** swallowed into the href. (The stars still render as |
| 20 | // literal text rather than bolding across the link — emphasis can't span a |
| 21 | // link token in this hand-rolled pass; the honest fix is the real Markdown |
| 22 | // parser, tracked separately.) |
| 23 | const TRAILING = /[.,!?;:'")\]}*_~`]+$/; |
| 24 | |
| 25 | const SCHEME_RE = /^https?:\/\//i; |
| 26 | const WWW_RE = /^www\./i; |
| 27 | // TLDs we treat a scheme-less "host.tld" as a link for: common gTLDs + ccTLDs. |
| 28 | // File extensions (js, html, css, py, go, md, sh, so, txt, json…) are deliberately |
| 29 | // excluded so "node.js" / "index.html" don't become links. |
| 30 | const BARE_TLDS = new Set( |
| 31 | ("com org net edu gov int io ie co dev app ai me tv fm gg news info biz xyz " + |
| 32 | "eu uk us ca au nz de fr es it nl se no fi dk pl pt be ch at gr jp kr cn in " + |
| 33 | "br mx za ru").split(" ") |
| 34 | ); |
| 35 | |
| 36 | // The http(s) href for a matched token, or null if it isn't really a link. |
| 37 | function linkHref(token) { |
| 38 | if (SCHEME_RE.test(token)) return token; |
| 39 | if (WWW_RE.test(token)) return "https://" + token; |
| 40 | const host = token.split(/[/?#]/)[0]; |
| 41 | const labels = host.split("."); |
| 42 | if (labels.length >= 2 && BARE_TLDS.has(labels[labels.length - 1].toLowerCase())) { |
| 43 | return "https://" + token; |
| 44 | } |
| 45 | return null; |
| 46 | } |
| 47 | |
| 48 | // tokenizeText splits a message into an ordered list of tokens: |
| 49 | // { text } → plain text (render as a text node) |
| 50 | // { text, href } → a link (render as an <a>, href is always http(s)://) |
| 51 | // Splitting (rather than building HTML) keeps rendering XSS-safe: callers only |
| 52 | // ever create text nodes and anchors with a validated href. |
| 53 | export function tokenizeText(text) { |
| 54 | text = text == null ? "" : String(text); |
| 55 | const tokens = []; |
| 56 | let last = 0; |
| 57 | for (const m of text.matchAll(URL_RE)) { |
| 58 | const match = m[0]; |
| 59 | const offset = m.index; |
| 60 | let url = match; |
| 61 | let trail = ""; |
| 62 | const tm = url.match(TRAILING); |
| 63 | if (tm) { |
| 64 | trail = tm[0]; |
| 65 | url = url.slice(0, url.length - trail.length); |
| 66 | } |
| 67 | // (A scheme-less candidate glued to a preceding word char, '@', '.' or '-' |
| 68 | // is mid-token or an email's domain — URL_RE's lookbehind already rejects it.) |
| 69 | const href = linkHref(url); |
| 70 | if (!href) continue; // e.g. "node.js" / "index.html" — not a link |
| 71 | if (offset > last) tokens.push({ text: text.slice(last, offset) }); |
| 72 | tokens.push({ text: url, href }); |
| 73 | if (trail) tokens.push({ text: trail }); |
| 74 | last = offset + match.length; |
| 75 | } |
| 76 | if (last < text.length) tokens.push({ text: text.slice(last) }); |
| 77 | if (tokens.length === 0) tokens.push({ text: "" }); |
| 78 | return tokens; |
| 79 | } |
| 80 | |
| 81 | // Inline formatting markers (single level, no nesting — except the one |
| 82 | // combined form, ***bold italic***, which is its own marker, not nesting): |
| 83 | // `code` ***bold italic*** **bold** *italic* _italic_ ~strike~ |
| 84 | // Underscore italics require word boundaries so file_names don't italicize. |
| 85 | // (Hyphens are deliberately NOT a marker — they appear too often in normal text, |
| 86 | // e.g. dates and "well-known", to be a safe delimiter.) |
| 87 | const FORMAT_RE = /`([^`\n]+)`|\*\*\*([^*\n]+)\*\*\*|\*\*([^*\n]+)\*\*|\*([^*\n]+)\*|(?<![\w])_([^_\n]+)_(?![\w])|~([^~\n]+)~/g; |
| 88 | |
| 89 | function formatSegment(text, out) { |
| 90 | let last = 0; |
| 91 | for (const m of text.matchAll(FORMAT_RE)) { |
| 92 | if (m.index > last) out.push({ text: text.slice(last, m.index) }); |
| 93 | let mark, content; |
| 94 | if (m[1] != null) [mark, content] = ["code", m[1]]; |
| 95 | else if (m[2] != null) [mark, content] = ["bi", m[2]]; |
| 96 | else if (m[3] != null) [mark, content] = ["b", m[3]]; |
| 97 | else if (m[4] != null) [mark, content] = ["i", m[4]]; |
| 98 | else if (m[5] != null) [mark, content] = ["i", m[5]]; |
| 99 | else [mark, content] = ["s", m[6]]; |
| 100 | out.push({ text: content, mark }); |
| 101 | last = m.index + m[0].length; |
| 102 | } |
| 103 | if (last < text.length) out.push({ text: text.slice(last) }); |
| 104 | } |
| 105 | |
| 106 | // richTokens splits a message into ordered render tokens, combining links and |
| 107 | // inline formatting: |
| 108 | // { text } → plain text |
| 109 | // { text, href } → a link (http(s):// only) — never formatted inside |
| 110 | // { text, mark } → formatted run; mark ∈ b | i | bi | s | code |
| 111 | // Links take precedence (we don't format inside a URL). Pure + flat (no nesting) |
| 112 | // so shell.js builds DOM with text nodes + a single wrapper element each — no |
| 113 | // innerHTML, so it stays XSS-safe. |
| 114 | export function richTokens(text) { |
| 115 | const tokens = []; |
| 116 | for (const t of tokenizeText(text)) { |
| 117 | if (t.href) tokens.push(t); |
| 118 | else formatSegment(t.text, tokens); |
| 119 | } |
| 120 | return tokens.filter((t) => t.text !== "" || t.href); |
| 121 | } |
| 122 | |
| 123 | // Block-level structure, parsed BEFORE inline formatting so markers inside a |
| 124 | // fence stay verbatim. Splits a message into ordered blocks: |
| 125 | // { type: "p", text } → normal text (linkify + inline-format) |
| 126 | // { type: "code", text, lang } → ``` fenced block — render verbatim |
| 127 | // { type: "quote", text } → run of "> " lines, markers stripped |
| 128 | // (still linkified + inline-formatted) |
| 129 | // Blank lines butting against a code/quote block are dropped — the blocks |
| 130 | // carry their own spacing, so the delimiter newlines shouldn't render too. |
| 131 | // Pure + flat so shell.js builds <pre>/<blockquote> wrappers with no innerHTML. |
| 132 | const FENCE_RE = /^\s*```(.*)$/; |
| 133 | const QUOTE_RE = /^\s*> ?(.*)$/; |
| 134 | |
| 135 | export function blockTokens(text) { |
| 136 | text = text == null ? "" : String(text); |
| 137 | const lines = text.split("\n"); |
| 138 | // Fast path: no block markers anywhere — one plain block, text untouched. |
| 139 | if (!lines.some((l) => FENCE_RE.test(l) || QUOTE_RE.test(l))) { |
| 140 | return [{ type: "p", text }]; |
| 141 | } |
| 142 | const blocks = []; |
| 143 | let plain = []; |
| 144 | const flush = (beforeBlock) => { |
| 145 | // Trim blank lines that only exist to separate text from a block. |
| 146 | if (blocks.length) while (plain.length && plain[0].trim() === "") plain.shift(); |
| 147 | if (beforeBlock) while (plain.length && plain[plain.length - 1].trim() === "") plain.pop(); |
| 148 | if (plain.length) blocks.push({ type: "p", text: plain.join("\n") }); |
| 149 | plain = []; |
| 150 | }; |
| 151 | let i = 0; |
| 152 | while (i < lines.length) { |
| 153 | const fence = lines[i].match(FENCE_RE); |
| 154 | // An opening fence's info string never contains a backtick — that's an |
| 155 | // inline-code line (e.g. "```js``` opens a fence"), not a block. |
| 156 | if (fence && !fence[1].includes("`")) { |
| 157 | flush(true); |
| 158 | const body = []; |
| 159 | i++; |
| 160 | while (i < lines.length && lines[i].trim() !== "```") body.push(lines[i++]); |
| 161 | i++; // skip the closing fence; an unterminated fence runs to the end |
| 162 | blocks.push({ type: "code", text: body.join("\n"), lang: fence[1].trim() }); |
| 163 | continue; |
| 164 | } |
| 165 | const quote = lines[i].match(QUOTE_RE); |
| 166 | if (quote) { |
| 167 | flush(true); |
| 168 | const body = [quote[1]]; |
| 169 | i++; |
| 170 | for (let q; i < lines.length && (q = lines[i].match(QUOTE_RE)); i++) body.push(q[1]); |
| 171 | blocks.push({ type: "quote", text: body.join("\n") }); |
| 172 | continue; |
| 173 | } |
| 174 | plain.push(lines[i++]); |
| 175 | } |
| 176 | flush(false); |
| 177 | if (blocks.length === 0) blocks.push({ type: "p", text: "" }); |
| 178 | return blocks; |
| 179 | } |
| 180 | |
| 181 | // The first http(s) link in a message, skipping fenced code blocks (a URL |
| 182 | // pasted as code shouldn't sprout a preview card). Used by shell.js to decide |
| 183 | // which URL, if any, gets an OpenGraph unfurl. |
| 184 | export function firstLink(text) { |
| 185 | for (const b of blockTokens(text)) { |
| 186 | if (b.type === "code") continue; |
| 187 | const hit = tokenizeText(b.text).find((t) => t.href); |
| 188 | if (hit) return hit.href; |
| 189 | } |
| 190 | return null; |
| 191 | } |
| 192 | |
| 193 | // Parse a YouTube URL into { id, start } (start in seconds, 0 if none), or null |
| 194 | // for anything that isn't a YouTube video link. Covers the shapes people paste: |
| 195 | // watch?v=, youtu.be/, /shorts/, /live/, /embed/, with t= / start= offsets |
| 196 | // (plain seconds, "90s", or "1h2m3s"). Pure so shell.js's click-to-play embed |
| 197 | // card stays unit-testable; the 11-char id alphabet is YouTube's own. |
| 198 | const YT_ID = /^[A-Za-z0-9_-]{11}$/; |
| 199 | export function youtubeId(url) { |
| 200 | let u; |
| 201 | try { |
| 202 | u = new URL(url); |
| 203 | } catch { |
| 204 | return null; |
| 205 | } |
| 206 | if (!/^https?:$/.test(u.protocol)) return null; |
| 207 | const host = u.hostname.replace(/^www\.|^m\./, ""); |
| 208 | let id = ""; |
| 209 | if (host === "youtu.be") { |
| 210 | id = u.pathname.split("/")[1] || ""; |
| 211 | } else if (host === "youtube.com" || host === "youtube-nocookie.com" || host === "music.youtube.com") { |
| 212 | const seg = u.pathname.split("/").filter(Boolean); |
| 213 | if (u.pathname === "/watch") id = u.searchParams.get("v") || ""; |
| 214 | else if (["shorts", "live", "embed", "v"].includes(seg[0])) id = seg[1] || ""; |
| 215 | } else { |
| 216 | return null; |
| 217 | } |
| 218 | if (!YT_ID.test(id)) return null; |
| 219 | // t=90, t=90s, t=1h2m3s, start=90 — normalise to whole seconds. |
| 220 | const t = u.searchParams.get("t") || u.searchParams.get("start") || ""; |
| 221 | let start = 0; |
| 222 | if (/^\d+s?$/.test(t)) start = parseInt(t, 10); |
| 223 | else { |
| 224 | const m = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/.exec(t); |
| 225 | if (m && (m[1] || m[2] || m[3])) start = (+m[1] || 0) * 3600 + (+m[2] || 0) * 60 + (+m[3] || 0); |
| 226 | } |
| 227 | return { id, start }; |
| 228 | } |
| 229 | |
| 230 | // Build a short, plain-text preview of a message for a reply quote. Collapses |
| 231 | // whitespace and truncates long text; when a message has no text (e.g. a photo |
| 232 | // only), falls back to a label for its first attachment so the quote still says |
| 233 | // something. Pure so the reply UI in shell.js stays testable. |
| 234 | export function replySnippet(payload, max = 140) { |
| 235 | const clamp = (s) => (s.length > max ? s.slice(0, max - 1).trimEnd() + "…" : s); |
| 236 | const text = (payload && payload.text ? String(payload.text) : "").replace(/\s+/g, " ").trim(); |
| 237 | if (text) return clamp(text); |
| 238 | const atts = payload && Array.isArray(payload.attachments) ? payload.attachments : []; |
| 239 | if (atts.length) { |
| 240 | const a = atts[0] || {}; |
| 241 | if (a.type && a.type.startsWith("image/")) return "📷 Photo"; |
| 242 | return clamp("📎 " + (a.name || "Attachment")); |
| 243 | } |
| 244 | return ""; |
| 245 | } |
| 246 | |
| 247 | // The chat-list line for a reaction event — "James reacted 👍 to “buy milk”". |
| 248 | // A reaction is an ordinary encrypted message, so it can be the newest thing in |
| 249 | // a thread; before this the row simply went blank (previewText saw a payload |
| 250 | // with no text). `who` is resolved by the caller, so it can say "You". |
| 251 | // The quoted snippet rides inside the ciphertext as `targetText`, stamped by |
| 252 | // the reacting client — reactions sent before that shipped (and by older |
| 253 | // clients) carry none, so the bare "James reacted 👍" is the fallback, not an |
| 254 | // error. Pure so both the sidebar and its tests can use it. |
| 255 | export function reactionLine(who, payload, max = 40) { |
| 256 | const actor = String(who || "").trim() || "Someone"; |
| 257 | const emoji = (payload && payload.emoji ? String(payload.emoji) : "").trim(); |
| 258 | // on === false is an un-react. It still speaks: the list has to say the same |
| 259 | // thing after a reload as it did live, and on reload all we have is this event. |
| 260 | const off = !!payload && payload.on === false; |
| 261 | const head = `${actor} ${off ? "removed" : "reacted"}${emoji ? " " + emoji : ""}`; |
| 262 | const snip = replySnippet({ text: payload && payload.targetText }, max); |
| 263 | return snip ? `${head} ${off ? "from" : "to"} “${snip}”` : head; |
| 264 | } |
| 265 | |
| 266 | // The chat-list line for a conversion — "James moved 3 replies into a thread". |
| 267 | // Same reasoning as reactionLine: a conversion is an ordinary encrypted message |
| 268 | // with no text of its own, so it can be the newest thing in a thread, and a row |
| 269 | // that said nothing would go blank. `who` is resolved by the caller (so it can |
| 270 | // say "You"); the count pluralises and groups for the locale. Pure, and mirrored |
| 271 | // natively by ThreadStore's preview so every platform reads the same line. |
| 272 | export function convertLine(who, n) { |
| 273 | const actor = String(who || "").trim() || "Someone"; |
| 274 | const count = Number(n) || 0; |
| 275 | return `${actor} moved ${count === 1 ? "a reply" : `${count.toLocaleString()} replies`} into a thread`; |
| 276 | } |
| 277 | |
| 278 | // Counts the emoji in a message whose only content is emoji (plus whitespace), |
| 279 | // returning 0 for anything else. Used to render short all-emoji messages large |
| 280 | // and bubble-less (à la Messages). Grapheme-segmented so ZWJ families (👨👩👧👦), |
| 281 | // skin-tone modifiers (👍🏽) and flags (🇬🇧) each count as one. Any non-emoji, |
| 282 | // non-whitespace grapheme (a letter, a digit, punctuation) means it's normal |
| 283 | // text, not pure emoji. Pure so the render path in shell.js stays testable. |
| 284 | const EMOJI_GRAPHEME = /\p{Extended_Pictographic}|\p{Regional_Indicator}/u; |
| 285 | export function emojiOnlyCount(text) { |
| 286 | const s = (text == null ? "" : String(text)).trim(); |
| 287 | if (!s) return 0; |
| 288 | let count = 0; |
| 289 | for (const { segment } of new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(s)) { |
| 290 | if (/^\s+$/u.test(segment)) continue; // whitespace between emoji is allowed |
| 291 | if (!EMOJI_GRAPHEME.test(segment)) return 0; // a non-emoji grapheme → it's text |
| 292 | if (++count > 3) return 0; // more than 3 → render as a normal message |
| 293 | } |
| 294 | return count; |
| 295 | } |
| 296 | |
| 297 | // Summarise who's currently typing into a subtle one-liner. Past two names it |
| 298 | // collapses to "Several people…" so the line never grows unbounded. Pure so the |
| 299 | // presence UI in shell.js stays testable; the caller renders it with textContent |
| 300 | // (the names are attacker-controlled). |
| 301 | export function typingSummary(names) { |
| 302 | const list = Array.isArray(names) ? names.filter(Boolean) : []; |
| 303 | if (list.length === 0) return ""; |
| 304 | if (list.length === 1) return `${list[0]} is typing…`; |
| 305 | if (list.length === 2) return `${list[0]} and ${list[1]} are typing…`; |
| 306 | return "Several people are typing…"; |
| 307 | } |
| 308 | |
| 309 | // Auto-title for a group with no explicit name: the member names (pass the |
| 310 | // OTHER members — the viewer excludes themselves), joined naturally and |
| 311 | // collapsed past three into "A, B & N more" so the header never grows unbounded: |
| 312 | // [] → "Group" (fallback: just you / unknown) |
| 313 | // [A] → "A" |
| 314 | // [A, B] → "A & B" |
| 315 | // [A, B, C] → "A, B & C" |
| 316 | // [A, B, C, D, …] → "A, B & N more" (N = names.length - 2) |
| 317 | // Pure so the header/list rendering in shell.js stays testable. |
| 318 | export function autoGroupTitle(names) { |
| 319 | const list = Array.isArray(names) ? names.filter(Boolean) : []; |
| 320 | const n = list.length; |
| 321 | if (n === 0) return "Group"; |
| 322 | if (n === 1) return list[0]; |
| 323 | if (n === 2) return `${list[0]} & ${list[1]}`; |
| 324 | if (n === 3) return `${list[0]}, ${list[1]} & ${list[2]}`; |
| 325 | return `${list[0]}, ${list[1]} & ${n - 2} more`; |
| 326 | } |
| 327 | |
| 328 | // Join names into a natural English list — "Alice" / "Alice and Bob" / |
| 329 | // "Alice, Bob and Dave". Used to announce who was just added to a group (the |
| 330 | // centered .chatapp-action line; see addPeople in shell.js). Pure so it stays |
| 331 | // unit-testable; the names are user-controlled, so the caller renders the |
| 332 | // result with textContent. |
| 333 | export function nameList(names) { |
| 334 | const list = Array.isArray(names) ? names.filter(Boolean) : []; |
| 335 | const n = list.length; |
| 336 | if (n === 0) return ""; |
| 337 | if (n === 1) return list[0]; |
| 338 | if (n === 2) return `${list[0]} and ${list[1]}`; |
| 339 | return `${list.slice(0, -1).join(", ")} and ${list[n - 1]}`; |
| 340 | } |
| 341 | |
| 342 | // Render a group membership announcement (someone added people, removed people, or |
| 343 | // left) from the *current viewer's* point of view: "you"/"You" is substituted for |
| 344 | // the reader and every id is resolved to a live name via labelOf, so one stored |
| 345 | // message reads "Paul added you and Bob" for a newcomer, "You added Alice and Bob" |
| 346 | // for the person who did it, and "Paul added Alice and Bob" for a bystander. The |
| 347 | // announcement carries user *ids* (not baked names) precisely so each client can |
| 348 | // do this locally — see announceJoin/announceRemove/announceLeave in shell.js. |
| 349 | // Returns null when the payload isn't a membership event. Pure (ids in, string out) |
| 350 | // so it's unit-testable; the caller renders the result with textContent (names are |
| 351 | // user-controlled). |
| 352 | export function membershipLine(payload, meId, labelOf) { |
| 353 | if (!payload || typeof payload !== "object") return null; |
| 354 | const actor = payload.uid; |
| 355 | const actorName = actor ? (actor === meId ? "You" : labelOf(actor)) : payload.name || "Someone"; |
| 356 | const names = (ids) => nameList(ids.map((uid) => (uid === meId ? "you" : labelOf(uid)))); |
| 357 | if (Array.isArray(payload.join) && payload.join.length) { |
| 358 | // A self-serve join (an open group's Discover): the joiner announces |
| 359 | // themselves — "Paul joined" / "You joined", never "Paul added Paul". |
| 360 | if (actor && payload.join.length === 1 && payload.join[0] === actor) return `${actorName} joined`; |
| 361 | return `${actorName} added ${names(payload.join)}`; |
| 362 | } |
| 363 | if (Array.isArray(payload.removed) && payload.removed.length) return `${actorName} removed ${names(payload.removed)}`; |
| 364 | if (payload.left) return `${actorName} left`; |
| 365 | return null; |
| 366 | } |
| 367 | |
| 368 | // Human-readable file size, e.g. 1536 → "1.5 KB". |
| 369 | export function formatBytes(n) { |
| 370 | if (!Number.isFinite(n) || n < 0) return ""; |
| 371 | if (n < 1024) return n + " B"; |
| 372 | const units = ["KB", "MB", "GB"]; |
| 373 | let v = n / 1024; |
| 374 | let i = 0; |
| 375 | while (v >= 1024 && i < units.length - 1) { |
| 376 | v /= 1024; |
| 377 | i++; |
| 378 | } |
| 379 | return (v < 10 ? v.toFixed(1) : Math.round(v)) + " " + units[i]; |
| 380 | } |
| 381 | |
| 382 | // Release notes ship inside the binary as prose that (a) opens with an |
| 383 | // authorship marker (🤖 / 👨 …) meant for the repo, not readers, and (b) is |
| 384 | // hard-wrapped at ~72 columns. changelogParagraphs cleans both for display — |
| 385 | // the Admin app posts each note into its update stream through this: it drops |
| 386 | // a leading marker, then returns display paragraphs — blank lines split |
| 387 | // paragraphs, and the single newlines inside one (the hard wraps) collapse |
| 388 | // back to spaces. Pure, so it's unit-tested. |
| 389 | export function changelogParagraphs(text) { |
| 390 | if (typeof text !== "string") return []; |
| 391 | const clean = text.replace(/^\s*(?:🤖|👨|👤|🧑|👩)\s+/u, ""); |
| 392 | return clean |
| 393 | .split(/\n[ \t]*\n/) |
| 394 | .map((p) => p.replace(/[ \t]*\n[ \t]*/g, " ").trim()) |
| 395 | .filter(Boolean); |
| 396 | } |
| 397 | |
| 398 | // A compact "last activity" label: time today, "Yesterday", a weekday this week, |
| 399 | // else a short date. |
| 400 | export function relTime(sec) { |
| 401 | if (!sec) return ""; |
| 402 | const d = new Date(sec * 1000); |
| 403 | const now = new Date(); |
| 404 | if (d.toDateString() === now.toDateString()) { |
| 405 | return d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); |
| 406 | } |
| 407 | const y = new Date(now); |
| 408 | y.setDate(now.getDate() - 1); |
| 409 | if (d.toDateString() === y.toDateString()) return "Yesterday"; |
| 410 | if ((now - d) / 86400000 < 7) return d.toLocaleDateString([], { weekday: "short" }); |
| 411 | return d.toLocaleDateString([], { day: "2-digit", month: "2-digit" }); |
| 412 | } |
| 413 | |
| 414 | // formatCountdown renders a remaining duration as a compact whole-unit |
| 415 | // countdown for the demo banner: "23h 40m" above an hour, "40m" under one, |
| 416 | // "under a minute" at the end. Minutes are ceiled so the promise is never |
| 417 | // optimistic — the space outlives the label, not the other way round. |
| 418 | export function formatCountdown(ms) { |
| 419 | const mins = Math.ceil(ms / 60000); |
| 420 | if (mins <= 1) return "under a minute"; |
| 421 | if (mins < 60) return mins + "m"; |
| 422 | const h = Math.floor(mins / 60); |
| 423 | const m = mins % 60; |
| 424 | return m ? h + "h " + m + "m" : h + "h"; |
| 425 | } |
| 426 | |