| 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); |
| } |
| } |
| } |