eleven / messenger Public

web: composer-key + text-formatting helpers, run all JS suites

ui.js (shouldSend) and format.js (tokenizeText, formatBytes), each with tests; npm test now runs all three suites.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell a55db48296d2b8c503b5aad51a2c07b809be1c6b parent ffa0d3f
5 files changed, +182 −1
  • package.json +1 −1
  • test/format.test.mjs +79 −0
  • test/ui.test.mjs +38 −0
  • web/static/format.js +54 −0
  • web/static/ui.js +10 −0
diff --git a/package.json b/package.json
index 4bafcfb..6117162 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,6 @@
"private": true,
"type": "module",
"scripts": {
- "test": "node test/crypto.test.mjs"
+ "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs"
}
}
diff --git a/test/format.test.mjs b/test/format.test.mjs
new file mode 100644
index 0000000..1eee543
--- /dev/null
+++ b/test/format.test.mjs
@@ -0,0 +1,79 @@
+// Tests for text formatting (link detection, file sizes). node test/format.test.mjs
+import assert from "node:assert/strict";
+import { tokenizeText, formatBytes } from "../web/static/format.js";
+
+let failures = 0;
+async function test(name, fn) {
+ try {
+ await fn();
+ console.log("PASS " + name);
+ } catch (e) {
+ console.log("FAIL " + name + " — " + (e && e.message));
+ failures++;
+ }
+}
+
+// Reconstructing the token texts must always equal the input (no data lost).
+function roundtrip(text) {
+ return tokenizeText(text)
+ .map((t) => t.text)
+ .join("");
+}
+
+await test("plain text has no links and round-trips", () => {
+ const toks = tokenizeText("just a normal message");
+ assert.equal(toks.length, 1);
+ assert.equal(toks[0].href, undefined);
+ assert.equal(roundtrip("just a normal message"), "just a normal message");
+});
+
+await test("https URL becomes a link", () => {
+ const toks = tokenizeText("see https://example.com/page now");
+ const link = toks.find((t) => t.href);
+ assert.equal(link.text, "https://example.com/page");
+ assert.equal(link.href, "https://example.com/page");
+});
+
+await test("bare www. gets an https href", () => {
+ const link = tokenizeText("go to www.example.com").find((t) => t.href);
+ assert.equal(link.text, "www.example.com");
+ assert.equal(link.href, "https://www.example.com");
+});
+
+await test("trailing punctuation is not part of the link", () => {
+ const toks = tokenizeText("look at https://example.com.");
+ const link = toks.find((t) => t.href);
+ assert.equal(link.href, "https://example.com");
+ assert.equal(roundtrip("look at https://example.com."), "look at https://example.com.");
+});
+
+await test("a URL in parentheses keeps the paren out of the link", () => {
+ const toks = tokenizeText("(https://example.com)");
+ const link = toks.find((t) => t.href);
+ assert.equal(link.href, "https://example.com");
+ assert.equal(roundtrip("(https://example.com)"), "(https://example.com)");
+});
+
+await test("javascript: scheme is NOT linkified", () => {
+ const toks = tokenizeText("javascript:alert(1)");
+ assert.ok(!toks.some((t) => t.href));
+});
+
+await test("multiple links in one message", () => {
+ const links = tokenizeText("a https://one.com b http://two.com c").filter((t) => t.href);
+ assert.equal(links.length, 2);
+});
+
+await test("newlines and text are preserved around links", () => {
+ const input = "line1\nhttps://x.com\nline3";
+ assert.equal(roundtrip(input), input);
+});
+
+await test("formatBytes is human readable", () => {
+ assert.equal(formatBytes(500), "500 B");
+ assert.equal(formatBytes(1536), "1.5 KB");
+ assert.equal(formatBytes(5 * 1024 * 1024), "5.0 MB");
+});
+
+console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`);
+process.exit(failures === 0 ? 0 : 1);
diff --git a/test/ui.test.mjs b/test/ui.test.mjs
new file mode 100644
index 0000000..e058042
--- /dev/null
+++ b/test/ui.test.mjs
@@ -0,0 +1,38 @@
+// Tests for composer key handling. node test/ui.test.mjs
+import assert from "node:assert/strict";
+import { shouldSend } from "../web/static/ui.js";
+
+let failures = 0;
+async function test(name, fn) {
+ try {
+ await fn();
+ console.log("PASS " + name);
+ } catch (e) {
+ console.log("FAIL " + name + " — " + (e && e.message));
+ failures++;
+ }
+}
+
+await test("desktop: Enter sends", () => {
+ assert.equal(shouldSend({ key: "Enter", shiftKey: false }, false), true);
+});
+
+await test("desktop: Shift+Enter is a newline (does not send)", () => {
+ assert.equal(shouldSend({ key: "Enter", shiftKey: true }, false), false);
+});
+
+await test("mobile: Enter is a newline (does not send)", () => {
+ assert.equal(shouldSend({ key: "Enter", shiftKey: false }, true), false);
+});
+
+await test("mobile: Shift+Enter is a newline (does not send)", () => {
+ assert.equal(shouldSend({ key: "Enter", shiftKey: true }, true), false);
+});
+
+await test("other keys never send", () => {
+ assert.equal(shouldSend({ key: "a", shiftKey: false }, false), false);
+ assert.equal(shouldSend({ key: "Tab", shiftKey: false }, false), false);
+});
+
+console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`);
+process.exit(failures === 0 ? 0 : 1);
diff --git a/web/static/format.js b/web/static/format.js
new file mode 100644
index 0000000..08908a2
--- /dev/null
+++ b/web/static/format.js
@@ -0,0 +1,54 @@
+// Text formatting helpers. Pure functions so they can be unit-tested in Node;
+// the DOM rendering that consumes these lives in chat.js.
+
+// Matches http(s):// URLs and bare www. hosts. Deliberately narrow: only these
+// become links, so things like "javascript:" or "mailto:" are never linkified.
+const URL_RE = /(https?:\/\/[^\s<]+|www\.[^\s<]+)/gi;
+
+// Trailing punctuation that's almost certainly sentence punctuation, not URL.
+const TRAILING = /[.,!?;:'")\]}]+$/;
+
+// 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 <a>, 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;
+ if (offset > last) tokens.push({ text: text.slice(last, offset) });
+
+ let url = match;
+ let trail = "";
+ const tm = url.match(TRAILING);
+ if (tm) {
+ trail = tm[0];
+ url = url.slice(0, url.length - trail.length);
+ }
+ const href = /^https?:\/\//i.test(url) ? url : "https://" + url;
+ 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;
+}
+
+// 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];
+}
diff --git a/web/static/ui.js b/web/static/ui.js
new file mode 100644
index 0000000..c4e9c66
--- /dev/null
+++ b/web/static/ui.js
@@ -0,0 +1,10 @@
+// Small, framework-free UI helpers, kept here so they can be unit-tested in Node.
+
+// shouldSend decides whether a keydown in the composer should send the message.
+//
+// Desktop (precise pointer): Enter sends, Shift+Enter inserts a newline.
+// Mobile/touch (coarse pointer): Enter always inserts a newline; you send with
+// the on-screen button instead. `coarse` is matchMedia('(pointer: coarse)').
+export function shouldSend(e, coarse) {
+ return e.key === "Enter" && !e.shiftKey && !coarse;
+}