eleven / ocho Public

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>
James Adam def812f408a08f60bd7d6ac889a8824b6c24d5f5 parent 5519820
4 files changed, +64 −3
  • test/format.test.mjs +35 −1
  • web/static/chat.js +7 −2
  • web/static/format.js +19 −0
  • web/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