eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

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.
15const 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.)
23const TRAILING = /[.,!?;:'")\]}*_~`]+$/;
24
25const SCHEME_RE = /^https?:\/\//i;
26const 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.
30const 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.
37function 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.
53export 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.)
87const FORMAT_RE = /`([^`\n]+)`|\*\*\*([^*\n]+)\*\*\*|\*\*([^*\n]+)\*\*|\*([^*\n]+)\*|(?<![\w])_([^_\n]+)_(?![\w])|~([^~\n]+)~/g;
88
89function 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.
114export 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.
132const FENCE_RE = /^\s*```(.*)$/;
133const QUOTE_RE = /^\s*> ?(.*)$/;
134
135export 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.
184export 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.
198const YT_ID = /^[A-Za-z0-9_-]{11}$/;
199export 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.
234export 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.
255export 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// Counts the emoji in a message whose only content is emoji (plus whitespace),
267// returning 0 for anything else. Used to render short all-emoji messages large
268// and bubble-less (à la Messages). Grapheme-segmented so ZWJ families (👨‍👩‍👧‍👦),
269// skin-tone modifiers (👍🏽) and flags (🇬🇧) each count as one. Any non-emoji,
270// non-whitespace grapheme (a letter, a digit, punctuation) means it's normal
271// text, not pure emoji. Pure so the render path in shell.js stays testable.
272const EMOJI_GRAPHEME = /\p{Extended_Pictographic}|\p{Regional_Indicator}/u;
273export function emojiOnlyCount(text) {
274 const s = (text == null ? "" : String(text)).trim();
275 if (!s) return 0;
276 let count = 0;
277 for (const { segment } of new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(s)) {
278 if (/^\s+$/u.test(segment)) continue; // whitespace between emoji is allowed
279 if (!EMOJI_GRAPHEME.test(segment)) return 0; // a non-emoji grapheme → it's text
280 if (++count > 3) return 0; // more than 3 → render as a normal message
281 }
282 return count;
283}
284
285// Summarise who's currently typing into a subtle one-liner. Past two names it
286// collapses to "Several people…" so the line never grows unbounded. Pure so the
287// presence UI in shell.js stays testable; the caller renders it with textContent
288// (the names are attacker-controlled).
289export function typingSummary(names) {
290 const list = Array.isArray(names) ? names.filter(Boolean) : [];
291 if (list.length === 0) return "";
292 if (list.length === 1) return `${list[0]} is typing…`;
293 if (list.length === 2) return `${list[0]} and ${list[1]} are typing…`;
294 return "Several people are typing…";
295}
296
297// Auto-title for a group with no explicit name: the member names (pass the
298// OTHER members — the viewer excludes themselves), joined naturally and
299// collapsed past three into "A, B & N more" so the header never grows unbounded:
300// [] → "Group" (fallback: just you / unknown)
301// [A] → "A"
302// [A, B] → "A & B"
303// [A, B, C] → "A, B & C"
304// [A, B, C, D, …] → "A, B & N more" (N = names.length - 2)
305// Pure so the header/list rendering in shell.js stays testable.
306export function autoGroupTitle(names) {
307 const list = Array.isArray(names) ? names.filter(Boolean) : [];
308 const n = list.length;
309 if (n === 0) return "Group";
310 if (n === 1) return list[0];
311 if (n === 2) return `${list[0]} & ${list[1]}`;
312 if (n === 3) return `${list[0]}, ${list[1]} & ${list[2]}`;
313 return `${list[0]}, ${list[1]} & ${n - 2} more`;
314}
315
316// Join names into a natural English list — "Alice" / "Alice and Bob" /
317// "Alice, Bob and Dave". Used to announce who was just added to a group (the
318// centered .chatapp-action line; see addPeople in shell.js). Pure so it stays
319// unit-testable; the names are user-controlled, so the caller renders the
320// result with textContent.
321export function nameList(names) {
322 const list = Array.isArray(names) ? names.filter(Boolean) : [];
323 const n = list.length;
324 if (n === 0) return "";
325 if (n === 1) return list[0];
326 if (n === 2) return `${list[0]} and ${list[1]}`;
327 return `${list.slice(0, -1).join(", ")} and ${list[n - 1]}`;
328}
329
330// Render a group membership announcement (someone added people, removed people, or
331// left) from the *current viewer's* point of view: "you"/"You" is substituted for
332// the reader and every id is resolved to a live name via labelOf, so one stored
333// message reads "Paul added you and Bob" for a newcomer, "You added Alice and Bob"
334// for the person who did it, and "Paul added Alice and Bob" for a bystander. The
335// announcement carries user *ids* (not baked names) precisely so each client can
336// do this locally — see announceJoin/announceRemove/announceLeave in shell.js.
337// Returns null when the payload isn't a membership event. Pure (ids in, string out)
338// so it's unit-testable; the caller renders the result with textContent (names are
339// user-controlled).
340export function membershipLine(payload, meId, labelOf) {
341 if (!payload || typeof payload !== "object") return null;
342 const actor = payload.uid;
343 const actorName = actor ? (actor === meId ? "You" : labelOf(actor)) : payload.name || "Someone";
344 const names = (ids) => nameList(ids.map((uid) => (uid === meId ? "you" : labelOf(uid))));
345 if (Array.isArray(payload.join) && payload.join.length) {
346 // A self-serve join (an open group's Discover): the joiner announces
347 // themselves — "Paul joined" / "You joined", never "Paul added Paul".
348 if (actor && payload.join.length === 1 && payload.join[0] === actor) return `${actorName} joined`;
349 return `${actorName} added ${names(payload.join)}`;
350 }
351 if (Array.isArray(payload.removed) && payload.removed.length) return `${actorName} removed ${names(payload.removed)}`;
352 if (payload.left) return `${actorName} left`;
353 return null;
354}
355
356// Human-readable file size, e.g. 1536 → "1.5 KB".
357export function formatBytes(n) {
358 if (!Number.isFinite(n) || n < 0) return "";
359 if (n < 1024) return n + " B";
360 const units = ["KB", "MB", "GB"];
361 let v = n / 1024;
362 let i = 0;
363 while (v >= 1024 && i < units.length - 1) {
364 v /= 1024;
365 i++;
366 }
367 return (v < 10 ? v.toFixed(1) : Math.round(v)) + " " + units[i];
368}
369
370// Release notes ship inside the binary as prose that (a) opens with an
371// authorship marker (🤖 / 👨 …) meant for the repo, not readers, and (b) is
372// hard-wrapped at ~72 columns. changelogParagraphs cleans both for display —
373// the Admin app posts each note into its update stream through this: it drops
374// a leading marker, then returns display paragraphs — blank lines split
375// paragraphs, and the single newlines inside one (the hard wraps) collapse
376// back to spaces. Pure, so it's unit-tested.
377export function changelogParagraphs(text) {
378 if (typeof text !== "string") return [];
379 const clean = text.replace(/^\s*(?:🤖|👨|👤|🧑|👩)\s+/u, "");
380 return clean
381 .split(/\n[ \t]*\n/)
382 .map((p) => p.replace(/[ \t]*\n[ \t]*/g, " ").trim())
383 .filter(Boolean);
384}
385
386// A compact "last activity" label: time today, "Yesterday", a weekday this week,
387// else a short date.
388export function relTime(sec) {
389 if (!sec) return "";
390 const d = new Date(sec * 1000);
391 const now = new Date();
392 if (d.toDateString() === now.toDateString()) {
393 return d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
394 }
395 const y = new Date(now);
396 y.setDate(now.getDate() - 1);
397 if (d.toDateString() === y.toDateString()) return "Yesterday";
398 if ((now - d) / 86400000 < 7) return d.toLocaleDateString([], { weekday: "short" });
399 return d.toLocaleDateString([], { day: "2-digit", month: "2-digit" });
400}
401
402// formatCountdown renders a remaining duration as a compact whole-unit
403// countdown for the demo banner: "23h 40m" above an hour, "40m" under one,
404// "under a minute" at the end. Minutes are ceiled so the promise is never
405// optimistic — the space outlives the label, not the other way round.
406export function formatCountdown(ms) {
407 const mins = Math.ceil(ms / 60000);
408 if (mins <= 1) return "under a minute";
409 if (mins < 60) return mins + "m";
410 const h = Math.floor(mins / 60);
411 const m = mins % 60;
412 return m ? h + "h " + m + "m" : h + "h";
413}
414