web: render short all-emoji messages large and bubble-less (#10)
A message whose entire content is up to 3 emoji now renders large with no surrounding bubble (ร la Messages), making single reactions like ๐ or ๐๐๐ read as the expressive beats they are rather than tiny text in a chat bubble. Detection lives in a new pure `emojiOnlyCount` helper in format.js so it stays unit-testable: it grapheme-segments the text via Intl.Segmenter (so ZWJ families ๐จโ๐ฉโ๐งโ๐ฆ, skin-tone modifiers ๐๐ฝ and flags ๐ฌ๐ง each count as one), permits whitespace between emoji, and returns 0 for anything that isn't purely 1โ3 emoji. fillContent applies the jumbo style only when there are no attachments โ a photo with a ๐ caption is a normal captioned message and keeps its bubble.
Co-authored-by: Claude <noreply@anthropic.com>
4 files changed,
+64
−3
test/format.test.mjs+35 −1web/static/chat.js+7 −2web/static/format.js+19 −0web/static/style.css+3 −0
diff --git a/test/format.test.mjs b/test/format.test.mjs| index 8b96448..e728155 100644 |
| --- a/test/format.test.mjs |
| +++ b/test/format.test.mjs |
| @@ -1,6 +1,6 @@ |
| // Tests for text formatting (link detection, file sizes). node test/format.test.mjs |
| import assert from "node:assert/strict"; |
| -import { tokenizeText, formatBytes, replySnippet } from "../web/static/format.js"; |
| +import { tokenizeText, formatBytes, replySnippet, emojiOnlyCount } from "../web/static/format.js"; |
| let failures = 0; |
| async function test(name, fn) { |
| @@ -104,5 +104,39 @@ await test("replySnippet is empty for an empty message", () => { |
| assert.equal(replySnippet(null), ""); |
| }); |
| +await test("emojiOnlyCount counts 1โ3 all-emoji messages", () => { |
| + assert.equal(emojiOnlyCount("๐"), 1); |
| + assert.equal(emojiOnlyCount("๐๐"), 2); |
| + assert.equal(emojiOnlyCount("๐๐๐"), 3); |
| +}); |
| + |
| +await test("emojiOnlyCount treats a ZWJ family / skin tone as one emoji", () => { |
| + assert.equal(emojiOnlyCount("๐จโ๐ฉโ๐งโ๐ฆ"), 1); |
| + assert.equal(emojiOnlyCount("๐๐ฝ"), 1); |
| +}); |
| + |
| +await test("emojiOnlyCount handles a flag (regional indicators) as one emoji", () => { |
| + assert.equal(emojiOnlyCount("๐ฌ๐ง"), 1); |
| +}); |
| + |
| +await test("emojiOnlyCount allows whitespace between emoji", () => { |
| + assert.equal(emojiOnlyCount("๐ ๐"), 2); |
| +}); |
| + |
| +await test("emojiOnlyCount is 0 for more than 3 emoji", () => { |
| + assert.equal(emojiOnlyCount("๐๐๐๐"), 0); |
| +}); |
| + |
| +await test("emojiOnlyCount is 0 when any text accompanies the emoji", () => { |
| + assert.equal(emojiOnlyCount("hi ๐"), 0); |
| + assert.equal(emojiOnlyCount("๐ nice"), 0); |
| +}); |
| + |
| +await test("emojiOnlyCount is 0 for empty or whitespace-only text", () => { |
| + assert.equal(emojiOnlyCount(""), 0); |
| + assert.equal(emojiOnlyCount(" "), 0); |
| + assert.equal(emojiOnlyCount(null), 0); |
| +}); |
| + |
| console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`); |
| process.exit(failures === 0 ? 0 : 1); |
diff --git a/web/static/chat.js b/web/static/chat.js| index 03fb22e..8ad202c 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -1,6 +1,6 @@ |
| import { importKey, encrypt, decrypt, encryptBytes, decryptBytes } from "/static/crypto.js"; |
| import { shouldSend } from "/static/ui.js"; |
| -import { tokenizeText, formatBytes, replySnippet } from "/static/format.js"; |
| +import { tokenizeText, formatBytes, replySnippet, emojiOnlyCount } from "/static/format.js"; |
| import { MESSAGE_TYPE_CHAT, acceptsMessageType } from "/static/message.js"; |
| const MAX_UPLOAD = 10 * 1024 * 1024; // keep in step with maxUploadBytes (main.go) |
| @@ -496,9 +496,14 @@ async function start(session) { |
| function fillContent(content, payload) { |
| content.innerHTML = ""; |
| if (payload.replyTo) content.appendChild(buildReplyQuote(payload.replyTo)); |
| + const hasAttachments = Array.isArray(payload.attachments) && payload.attachments.length > 0; |
| if (payload.text) { |
| const bubble = document.createElement("div"); |
| - bubble.className = "bubble"; |
| + // A message that's nothing but a few emoji renders large and bubble-less |
| + // (ร la Messages). Anything alongside it โ an attachment โ makes it a |
| + // normal captioned message, so it keeps its bubble. |
| + const jumbo = !hasAttachments && emojiOnlyCount(payload.text) > 0; |
| + bubble.className = jumbo ? "bubble jumbo-emoji" : "bubble"; |
| appendText(bubble, payload.text); |
| content.appendChild(bubble); |
| } |
diff --git a/web/static/format.js b/web/static/format.js| index d876fc3..cdef433 100644 |
| --- a/web/static/format.js |
| +++ b/web/static/format.js |
| @@ -56,6 +56,25 @@ export function replySnippet(payload, max = 140) { |
| return ""; |
| } |
| +// 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 chat.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; |
| +} |
| + |
| // Human-readable file size, e.g. 1536 โ "1.5 KB". |
| export function formatBytes(n) { |
| if (!Number.isFinite(n) || n < 0) return ""; |
diff --git a/web/static/style.css b/web/static/style.css| index 8eefd6b..fe2691e 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -220,6 +220,9 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| .msg.me .bubble { background: var(--bubble-me); color: #fff; border-bottom-right-radius: 6px; } |
| .msg.them .bubble { border-bottom-left-radius: 6px; } |
| .msg .bubble a { color: inherit; text-decoration: underline; text-underline-offset: 2px; } |
| +/* a message of just a few emoji: large and bubble-less, no background or padding (see fillContent) */ |
| +.msg .bubble.jumbo-emoji, |
| +.msg.me .bubble.jumbo-emoji { background: none; color: inherit; padding: 2px 0; font-size: 44px; line-height: 1.15; } |
| .msg .time { font-size: 11px; color: var(--muted); margin: 3px 6px 0; } |
| /* desktop hover reply/edit icons โ absolutely positioned just outside the bubble |