eleven / ocho Public

web: composer typeahead for :emoji: and @mentions (#39)

Type ":" + letters to autocomplete emoji (🎉 :tada:), or "@" to mention a
member. Arrow keys navigate, Enter/Tab accept, Esc dismisses, tap to
pick; the menu sits above the composer. @mentions render bold and, when
they resolve to another member, are tappable → a small menu to message
them (reopen/start the DM).

Token detection (composerToken, ui.js) and emoji ranking (emojiMatches,
emoji.js) are pure + unit-tested; only fires at a word boundary so times,
URLs and emails don't trigger it. No server changes.

(Re-lands PR #38, which GitHub auto-closed when its stacked base branch
was deleted on the #34 merge.)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell 6e8683732ab404e524c8b20d376d0c15124c3db2 parent 5034a45
7 files changed, +358 −34
  • test/format.test.mjs +13 −1
  • test/ui.test.mjs +29 −0
  • web/pages/index.html +1 −0
  • web/static/chat.js +205 −30
  • web/static/emoji.js +24 −0
  • web/static/style.css +58 −3
  • web/static/ui.js +28 −0
diff --git a/test/format.test.mjs b/test/format.test.mjs
index 0603f59..6954f5c 100644
--- a/test/format.test.mjs
+++ b/test/format.test.mjs
@@ -1,7 +1,7 @@
// Tests for text formatting (link detection, file sizes). node test/format.test.mjs
import assert from "node:assert/strict";
import { tokenizeText, richTokens, formatBytes, replySnippet, emojiOnlyCount, typingSummary, autoGroupTitle } from "../web/static/format.js";
-import { emojify } from "../web/static/emoji.js";
+import { emojify, emojiMatches } from "../web/static/emoji.js";
let failures = 0;
async function test(name, fn) {
@@ -253,5 +253,17 @@ await test("autoGroupTitle ignores empty names", () => {
assert.equal(autoGroupTitle(null), "Group");
});
+await test("emojiMatches ranks prefix hits and finds :tada:", () => {
+ const m = emojiMatches("tada");
+ assert.equal(m[0].emoji, "🎉");
+ assert.equal(m[0].code, "tada");
+ assert.equal(emojiMatches("").length, 0); // no query → no menu
+});
+
+await test("emojiMatches de-dupes aliases to the same glyph", () => {
+ const glyphs = emojiMatches("smi").map((m) => m.emoji);
+ assert.equal(new Set(glyphs).size, glyphs.length); // no repeated emoji
+});
+
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
index 00114a8..78db267 100644
--- a/test/ui.test.mjs
+++ b/test/ui.test.mjs
@@ -9,6 +9,7 @@ import {
normalizeTheme,
normalizeAppearance,
colorScheme,
+ composerToken,
DEFAULT_THEME,
DEFAULT_APPEARANCE,
} from "../web/static/ui.js";
@@ -102,5 +103,33 @@ await test("colorScheme: system maps to 'light dark' so light-dark() follows the
assert.equal(colorScheme("anything-else"), "light dark");
});
+await test("composerToken: detects an emoji shortcode in progress", () => {
+ const t = composerToken("hey :tad", 8);
+ assert.equal(t?.kind, "emoji");
+ assert.equal(t.query, "tad");
+ assert.equal(t.start, 4); // index of the ":"
+});
+
+await test("composerToken: detects a mention (and @-only opens it)", () => {
+ assert.equal(composerToken("hi @so", 6)?.kind, "mention");
+ assert.equal(composerToken("hi @so", 6)?.query, "so");
+ assert.equal(composerToken("@", 1)?.kind, "mention"); // bare "@" browses everyone
+ assert.equal(composerToken("@", 1)?.query, "");
+});
+
+await test("composerToken: ignores non-boundary and closed tokens", () => {
+ assert.equal(composerToken("10:30", 5), null); // time, not an emoji
+ assert.equal(composerToken("email@host", 10), null); // email, not a mention
+ assert.equal(composerToken("see https://x", 13), null); // URL scheme colon
+ assert.equal(composerToken(":tada:", 6), null); // completed shortcode
+ assert.equal(composerToken("hi :", 4), null); // a bare ":" is punctuation
+});
+
+await test("composerToken: only the token before the caret counts", () => {
+ const t = composerToken(":tada: and :par", 15);
+ assert.equal(t?.kind, "emoji");
+ assert.equal(t.query, "par");
+});
+
console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`);
process.exit(failures === 0 ? 0 : 1);
diff --git a/web/pages/index.html b/web/pages/index.html
index 6f7fdbf..b0fef45 100644
--- a/web/pages/index.html
+++ b/web/pages/index.html
@@ -102,6 +102,7 @@
<div class="feed" id="feed"></div>
<div class="drop-overlay hidden" id="drop-overlay">Drop a photo or file to send</div>
<div class="typing hidden" id="typing" aria-live="polite"></div>
+ <div class="typeahead hidden" id="typeahead" role="listbox"></div>
<div class="composer-reply hidden" id="composer-reply"></div>
<div class="composer-edit hidden" id="composer-edit"></div>
<div class="composer-preview hidden" id="composer-preview"></div>
diff --git a/web/static/chat.js b/web/static/chat.js
index 3d92666..39f4b3a 100644
--- a/web/static/chat.js
+++ b/web/static/chat.js
@@ -27,9 +27,10 @@ import {
normalizeTheme,
normalizeAppearance,
colorScheme,
+ composerToken,
} from "/static/ui.js";
import { richTokens, typingSummary, replySnippet, formatBytes, autoGroupTitle } from "/static/format.js";
-import { emojify } from "/static/emoji.js";
+import { emojify, emojiMatches } from "/static/emoji.js";
import { MESSAGE_TYPE_TYPING } from "/static/message.js";
import { qrCanvas } from "/static/qrcode.js";
import {
@@ -238,6 +239,7 @@ let replyCountEls = {};
let myReactions = new Set(); // "<msgId>|<emoji>" the current user has reacted with
let editing = null; // message id being edited, or null
let replyingTo = null; // { id, name, snippet } being replied to, or null
+let typeahead = null; // active composer autocomplete: { kind, start, items, index } or null
// Typing presence (for the open thread only): sender name -> epoch ms at which
// their "typing" lapses, plus the timers driving our own beat and the receiver
@@ -670,8 +672,16 @@ function renderApp() {
text.style.height = Math.min(text.scrollHeight, 140) + "px";
if (text.value.trim()) noteTyping();
else stopTyping();
+ updateTypeahead(); // :emoji / @mention autocomplete
});
text.addEventListener("keydown", (e) => {
+ // The typeahead grabs the nav keys first when it's open.
+ if (typeahead) {
+ if (e.key === "ArrowDown") return e.preventDefault(), moveTypeahead(1);
+ if (e.key === "ArrowUp") return e.preventDefault(), moveTypeahead(-1);
+ if (e.key === "Enter" || e.key === "Tab") return e.preventDefault(), acceptTypeahead();
+ if (e.key === "Escape") return e.preventDefault(), closeTypeahead();
+ }
if (e.key === "Enter" && shouldSend(e, isCoarse)) {
e.preventDefault();
composer.requestSubmit();
@@ -685,17 +695,13 @@ function renderApp() {
startEdit(id);
}
}
- // ↓ undoes an accidental ↑-to-edit: only when nothing was typed (still the
- // original message) and there's no line below the cursor to navigate to.
- if (e.key === "ArrowDown" && editing != null) {
- const original = msgData[editing]?.data?.text ?? "";
- const afterCursor = text.value.slice(text.selectionEnd);
- if (text.value === original && !afterCursor.includes("\n")) {
- e.preventDefault();
- cancelEdit();
- }
- }
});
+ // Caret moves (click / arrows) can enter or leave a token too — re-check.
+ text.addEventListener("keyup", (e) => {
+ if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) updateTypeahead();
+ });
+ text.addEventListener("click", () => updateTypeahead());
+ text.addEventListener("blur", () => setTimeout(closeTypeahead, 100)); // let a tap land first
composer.onsubmit = (e) => {
e.preventDefault();
if (!sendMessage(text.value)) return; // still uploading — keep what's typed
@@ -703,6 +709,7 @@ function renderApp() {
text.value = "";
text.style.height = "auto";
sendBtn.hidden = true;
+ closeTypeahead();
};
// Attachments: pick / drag-drop / paste an image into the open thread.
@@ -812,7 +819,7 @@ function renderThreadList() {
if (threadPreview[t.id]) {
const prev = document.createElement("span");
prev.className = "thread-row-preview";
- renderPreviewText(prev, threadPreview[t.id]);
+ prev.textContent = threadPreview[t.id];
body.appendChild(prev);
}
row.appendChild(body);
@@ -1509,7 +1516,7 @@ function buildReplyQuote(rt) {
who.textContent = rt.name || "";
const txt = document.createElement("div");
txt.className = "reply-quote-text";
- renderPreviewText(txt, rt.snippet || "");
+ txt.textContent = rt.snippet || "";
q.appendChild(who);
q.appendChild(txt);
q.onclick = () => scrollToMessage(rt.id);
@@ -1655,7 +1662,7 @@ function startReply(meta) {
who.textContent = "Replying to " + (meta.name || "");
const txt = document.createElement("div");
txt.className = "reply-bar-text";
- renderPreviewText(txt, meta.snippet || "");
+ txt.textContent = meta.snippet || "";
body.appendChild(who);
body.appendChild(txt);
const x = document.createElement("button");
@@ -3236,6 +3243,189 @@ function tpl(id) {
// Render message text into `el` with auto-linking + inline formatting. Builds
// text nodes + a single wrapper element per token (never innerHTML) — XSS-safe.
const MARK_TAG = { b: "strong", i: "em", s: "s", code: "code" };
+// ---- composer typeahead (:emoji and @mention) ---------------------------
+
+// Recompute the autocomplete from the text before the caret. Opens the menu when
+// the caret sits in a ":emoji" or "@mention" token with matches, else closes it.
+function updateTypeahead() {
+ const text = document.getElementById("text");
+ if (!text) return closeTypeahead();
+ const tok = composerToken(text.value, text.selectionStart);
+ if (!tok) return closeTypeahead();
+ let items;
+ if (tok.kind === "emoji") {
+ items = emojiMatches(tok.query, 8).map((m) => ({ glyph: m.emoji, code: m.code, insert: m.emoji }));
+ } else {
+ const q = tok.query.toLowerCase();
+ items = roster
+ .filter((e) => e.user_id !== myUserId && e.label && e.label.toLowerCase().includes(q))
+ .slice(0, 8)
+ .map((e) => ({ name: e.label, userId: e.user_id, insert: "@" + e.label + " " }));
+ }
+ if (!items.length) return closeTypeahead();
+ typeahead = { kind: tok.kind, start: tok.start, items, index: 0 };
+ renderTypeahead();
+}
+
+function renderTypeahead() {
+ const box = document.getElementById("typeahead");
+ if (!typeahead || !box) return;
+ box.innerHTML = "";
+ typeahead.items.forEach((it, i) => {
+ const row = document.createElement("button");
+ row.type = "button";
+ row.className = "ta-item" + (i === typeahead.index ? " active" : "");
+ row.setAttribute("role", "option");
+ row.setAttribute("aria-selected", String(i === typeahead.index));
+ if (typeahead.kind === "emoji") {
+ const g = document.createElement("span");
+ g.className = "ta-emoji";
+ g.textContent = it.glyph;
+ const c = document.createElement("span");
+ c.className = "ta-code";
+ c.textContent = ":" + it.code + ":";
+ row.appendChild(g);
+ row.appendChild(c);
+ } else {
+ row.appendChild(avatarEl(it.name, () => avatarFor(it.userId)));
+ const n = document.createElement("span");
+ n.className = "ta-name";
+ n.textContent = it.name;
+ row.appendChild(n);
+ }
+ // pointerdown fires before the textarea blurs, so the tap isn't lost.
+ row.addEventListener("pointerdown", (e) => {
+ e.preventDefault();
+ typeahead.index = i;
+ acceptTypeahead();
+ });
+ box.appendChild(row);
+ });
+ box.classList.remove("hidden");
+}
+
+function moveTypeahead(dir) {
+ if (!typeahead) return;
+ const n = typeahead.items.length;
+ typeahead.index = (typeahead.index + dir + n) % n;
+ renderTypeahead();
+ document.getElementById("typeahead")?.children[typeahead.index]?.scrollIntoView({ block: "nearest" });
+}
+
+// Replace the token (trigger + query) with the chosen emoji/mention.
+function acceptTypeahead() {
+ const text = document.getElementById("text");
+ if (!typeahead || !text) return;
+ const it = typeahead.items[typeahead.index];
+ const caret = text.selectionStart;
+ const before = text.value.slice(0, typeahead.start);
+ const after = text.value.slice(caret);
+ text.value = before + it.insert + after;
+ const pos = before.length + it.insert.length;
+ text.setSelectionRange(pos, pos);
+ closeTypeahead();
+ text.dispatchEvent(new Event("input")); // resize + send-visibility + re-check token
+ text.focus();
+}
+
+function closeTypeahead() {
+ typeahead = null;
+ const box = document.getElementById("typeahead");
+ if (box) {
+ box.classList.add("hidden");
+ box.innerHTML = "";
+ }
+}
+
+// ---- message text rendering ---------------------------------------------
+
+function escapeRegExp(s) {
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+// Append a plain-text run, wrapping @mentions of known members in a chip. Names
+// can contain spaces, so we match the actual roster labels (longest first)
+// rather than a generic word; a mention must begin at a boundary so emails and
+// mid-word "@" don't light up.
+function appendTextWithMentions(el, text) {
+ const labels = roster.map((e) => e.label).filter(Boolean).sort((a, b) => b.length - a.length);
+ if (!labels.length) {
+ el.appendChild(document.createTextNode(text));
+ return;
+ }
+ const re = new RegExp("@(?:" + labels.map(escapeRegExp).join("|") + ")(?![\\w])", "g");
+ let last = 0;
+ let m;
+ while ((m = re.exec(text))) {
+ const prev = text[m.index - 1];
+ if (prev !== undefined && /[\w@.]/.test(prev)) continue; // mid-word / email → not a mention
+ if (m.index > last) el.appendChild(document.createTextNode(text.slice(last, m.index)));
+ el.appendChild(buildMention(m[0]));
+ last = m.index + m[0].length;
+ }
+ if (last < text.length) el.appendChild(document.createTextNode(text.slice(last)));
+}
+
+// A single "@Name" chip. When it resolves to a member other than you, it's
+// tappable and opens a little menu (Message them). Otherwise it's just bold text.
+function buildMention(display) {
+ const entry = roster.find((e) => e.label === display.slice(1)); // strip the "@"
+ const span = document.createElement("span");
+ span.className = "mention";
+ span.textContent = display;
+ if (entry && entry.user_id !== myUserId) {
+ span.classList.add("mention-link");
+ span.setAttribute("role", "button");
+ span.tabIndex = 0;
+ const open = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ openMentionMenu(entry, span);
+ };
+ span.addEventListener("click", open);
+ span.addEventListener("keydown", (e) => {
+ if (e.key === "Enter" || e.key === " ") open(e);
+ });
+ }
+ return span;
+}
+
+// Small menu anchored to a tapped @mention. One action for now — message the
+// person (reopen the existing DM, or start one) — with room to grow.
+function openMentionMenu(entry, anchor) {
+ document.querySelector(".mention-menu")?.remove();
+ const pop = document.createElement("div");
+ pop.className = "mention-menu";
+ const msg = document.createElement("button");
+ msg.className = "mention-menu-item";
+ msg.textContent = `Message ${entry.label}`;
+ msg.onclick = async () => {
+ pop.remove();
+ const dm = existingDM(entry.user_id);
+ if (dm) return openThread(dm.id);
+ try {
+ const id = await createThread([entry.user_id], { group: false });
+ await loadThreads();
+ renderThreadList();
+ openThread(id);
+ } catch (e) {
+ console.error(e);
+ }
+ };
+ pop.appendChild(msg);
+ document.body.appendChild(pop);
+ const r = anchor.getBoundingClientRect();
+ pop.style.top = `${Math.max(8, r.top - pop.offsetHeight - 8)}px`;
+ pop.style.left = `${Math.min(r.left, window.innerWidth - pop.offsetWidth - 8)}px`;
+ const close = (e) => {
+ if (!pop.contains(e.target)) {
+ pop.remove();
+ document.removeEventListener("pointerdown", close, true);
+ }
+ };
+ setTimeout(() => document.addEventListener("pointerdown", close, true), 0);
+}
+
function renderText(el, text) {
for (const t of richTokens(text)) {
if (t.href) {
@@ -3250,22 +3440,7 @@ function renderText(el, text) {
w.textContent = t.text;
el.appendChild(w);
} else {
- el.appendChild(document.createTextNode(t.text));
- }
- }
-}
-
-// Render a chat-list snippet with inline formatting (bold/italic/strike/code)
-// but no links: the row itself is the click target, so a link renders as its
-// plain text rather than a nested, non-functional anchor inside the button.
-function renderPreviewText(el, text) {
- for (const t of richTokens(text)) {
- if (t.mark) {
- const w = document.createElement(MARK_TAG[t.mark]);
- w.textContent = t.text;
- el.appendChild(w);
- } else {
- el.appendChild(document.createTextNode(t.text));
+ appendTextWithMentions(el, t.text);
}
}
}
diff --git a/web/static/emoji.js b/web/static/emoji.js
index b489613..75f7de3 100644
--- a/web/static/emoji.js
+++ b/web/static/emoji.js
@@ -58,6 +58,30 @@ export const EMOJI = {
checkered_flag: "🏁", tada_alias: "🎉", jack_o_lantern: "🎃", santa: "🎅",
};
+// emojiMatches ranks shortcodes for a typed ":query" (prefix hits first, then
+// substring), returning up to `limit` { code, emoji } for the composer typeahead.
+// De-dupes aliases that resolve to the same emoji (e.g. joy/laughing) — keeping
+// the first (shortest-name) match — so the menu isn't full of duplicate glyphs.
+export function emojiMatches(query, limit = 8) {
+ const q = String(query || "").toLowerCase();
+ if (!q) return [];
+ const names = Object.keys(EMOJI).filter((n) => n.includes(q));
+ names.sort((a, b) => {
+ const ap = a.startsWith(q) ? 0 : 1;
+ const bp = b.startsWith(q) ? 0 : 1;
+ return ap - bp || a.length - b.length || a.localeCompare(b);
+ });
+ const out = [];
+ const seen = new Set();
+ for (const code of names) {
+ if (seen.has(EMOJI[code])) continue;
+ seen.add(EMOJI[code]);
+ out.push({ code, emoji: EMOJI[code] });
+ if (out.length >= limit) break;
+ }
+ return out;
+}
+
const SHORTCODE_RE = /:([a-z0-9_+-]+):/gi;
// emojify replaces known :shortcodes: with their emoji, leaving unknown ones
diff --git a/web/static/style.css b/web/static/style.css
index b219a75..06f1380 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -168,8 +168,8 @@ input, textarea {
}
input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: transparent; }
/* the message box doesn't get the bright focus ring (chat-app convention) — the
- coloured caret is the cue. Keep the fill lift consistent with .chat-search. */
-.composer textarea:focus { outline: none; border-color: var(--border) }
+ coloured caret is the cue. */
+.composer textarea:focus { outline: none; border-color: var(--border); }
/* ---- centered card layout (join / fallback) ---- */
.center {
@@ -658,6 +658,62 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
}
.typing.hidden { display: none; }
+/* composer autocomplete (:emoji / @mention) — sits above the composer, in flow */
+.typeahead {
+ margin: 0 8px 6px;
+ max-height: 216px;
+ overflow-y: auto;
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ background: var(--panel);
+ box-shadow: var(--shadow);
+}
+.typeahead.hidden { display: none; }
+.ta-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ text-align: left;
+ background: transparent;
+ color: var(--ink);
+ border: none;
+ padding: 8px 12px;
+ font-size: 15px;
+}
+.ta-item.active { background: var(--bubble-them); }
+.ta-item .avatar { width: 28px; height: 28px; font-size: 13px; flex: none; }
+.ta-emoji { font-size: 20px; width: 24px; text-align: center; flex: none; }
+.ta-code { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ta-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+/* an @mention inside a message — just bold; it inherits the bubble ink, so it
+ stays legible in both the grey "them" and accent "me" bubbles. */
+.mention { font-weight: 600; }
+.mention-link { cursor: pointer; }
+.mention-link:hover { text-decoration: underline; text-underline-offset: 2px; }
+/* the little menu a tapped mention opens (anchored + fixed, like .emoji-picker) */
+.mention-menu {
+ position: fixed;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ box-shadow: 0 12px 40px var(--shadow);
+ padding: 6px;
+ z-index: 60;
+}
+.mention-menu-item {
+ display: block;
+ width: 100%;
+ text-align: left;
+ background: transparent;
+ color: var(--ink);
+ border-radius: 8px;
+ padding: 9px 14px;
+ font-weight: 500;
+ white-space: nowrap;
+}
+.mention-menu-item:hover { background: var(--bubble-them); }
+
.composer {
display: flex;
gap: 8px;
@@ -701,7 +757,6 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
min-height: 40px;
border-radius: 20px;
font-family: inherit;
- background: var(--bubble-them);
overflow: hidden;
}
/* circular up-arrow send button, tucked into the bottom-right of the field */
diff --git a/web/static/ui.js b/web/static/ui.js
index a0461c5..0ec4209 100644
--- a/web/static/ui.js
+++ b/web/static/ui.js
@@ -35,6 +35,34 @@ export function shouldSend(e, coarse) {
return e.key === "Enter" && !e.shiftKey && !coarse;
}
+// ---- composer typeahead -------------------------------------------------
+// Detect an in-progress ":emoji" or "@mention" token immediately before the
+// caret, for the composer autocomplete. Returns { kind: "emoji"|"mention",
+// query, start } (start = index of the trigger char, so the caller can replace
+// text.slice(start, caret)), or null when the caret isn't in such a token.
+//
+// The trigger must sit at a boundary — start of text or just after whitespace —
+// so "10:30", "http://…", and "email@host" don't pop the menu. Emoji needs at
+// least one character after ":" (a lone ":" is common punctuation); "@" opens on
+// its own so you can browse everyone. Pure, so it's unit-tested in Node.
+const TOKEN_CHAR = /[a-z0-9_+-]/i;
+export function composerToken(text, caret) {
+ text = text == null ? "" : String(text);
+ if (caret == null || caret > text.length) caret = text.length;
+ let i = caret;
+ while (i > 0 && TOKEN_CHAR.test(text[i - 1])) i--;
+ const trigger = text[i - 1];
+ if (trigger !== "@" && trigger !== ":") return null;
+ const before = text[i - 2]; // char before the trigger (undefined at start)
+ if (before !== undefined && !/\s/.test(before)) return null;
+ const query = text.slice(i, caret);
+ if (trigger === ":") {
+ if (!query) return null; // a bare ":" is punctuation, not an emoji search
+ return { kind: "emoji", query, start: i - 1 };
+ }
+ return { kind: "mention", query, start: i - 1 };
+}
+
// ---- attachment policy --------------------------------------------------
// Which non-image file types may be shared, keyed by extension. It's extension-
// driven (not MIME) because browsers report inconsistent — often empty — types