| similarity index 74% |
| rename from web/static/chat.js |
| rename to web/static/shell.js |
| index 5883949..829001f 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/shell.js |
| @@ -493,9 +493,6 @@ async function boot() { |
| // Reveal only once the initial feed is loaded + scrolled — so the first paint |
| // is the finished layout, not an empty feed that then jumps to the bottom. |
| await opened; |
| - // With the open thread in place, warm the other visible chats in the background |
| - // so the first tap into any of them is instant (no fetch flash). |
| - prebuildVisibleThreads(); |
| } catch (e) { |
| console.error(e); |
| app.innerHTML = |
| @@ -558,7 +555,7 @@ async function bootEmbed() { |
| } |
| |
| // The bridge the native composer drives: forward typed text and staged photos into |
| -// the one crypto/WebSocket path (chat.js). Mirrors the hooks ChatView's WebBridge |
| +// the one crypto/WebSocket path (shell.js). Mirrors the hooks ChatView's WebBridge |
| // calls; staged-attachment state + errors are posted back on the "lchat" handler. |
| function installNativeBridge() { |
| window.lchatNative = { |
| @@ -568,11 +565,7 @@ function installNativeBridge() { |
| sendText(text) { |
| if (!ws || ws.readyState !== WebSocket.OPEN) return false; |
| if (chatAppNative) return chatAppNative.sendText(text); |
| - const t = (text || "").trim(); |
| - if (!t && !pending.length) return false; |
| - sendMessage(t); |
| - stopTyping(); // sending means you've stopped typing (mirrors the web submit) |
| - return true; |
| + return false; |
| }, |
| // The native composer's keystrokes: beat "typing" to the thread while there's |
| // text, stop when it empties (mirrors the web composer's input handler). |
| @@ -603,7 +596,6 @@ function installNativeBridge() { |
| // (cancelReply posts {replying: null} back). |
| cancelReply() { |
| if (chatAppNative) return chatAppNative.cancelReply(); |
| - cancelReply(); |
| }, |
| // The native edit banner's ✕ — drops edit mode (cancelEdit posts |
| // {editing: null} back so the native banner + prefilled composer clear). |
| @@ -1167,257 +1159,6 @@ function renderApp() { |
| leaveGroup(); |
| }; |
| |
| - const text = document.getElementById("text"); |
| - const sendBtn = document.getElementById("send-btn"); |
| - const composer = document.getElementById("composer"); |
| - // Touch: while the keyboard is up the composer sits above it, so the home-indicator |
| - // safe-area inset below would just be dead space — drop it while focused. |
| - text.addEventListener("focus", () => composer.classList.add("editing")); |
| - text.addEventListener("blur", () => composer.classList.remove("editing")); |
| - // Tapping the conversation dismisses the keyboard (it otherwise stays up across sends). |
| - const feedForBlur = document.getElementById("feed"); |
| - if (feedForBlur) |
| - feedForBlur.addEventListener("pointerdown", () => { |
| - if (document.activeElement === text) text.blur(); |
| - }); |
| - // Keep the feed pinned to the bottom when its viewport resizes. The stack |
| - // below it — autogrowing textarea, :emoji/@mention typeahead, reply/edit |
| - // banners, the keyboard inset — sits in normal flow, so any of it growing |
| - // shrinks the feed *without firing a scroll event*, leaving the chat wedged |
| - // not-quite-at-bottom while you type. `feedAtBottom` is refreshed on scroll, |
| - // so at resize time it reflects where the user was before the change; the |
| - // re-pin fires its own scroll event, which keeps the flag true. |
| - if (feedForBlur) { |
| - feedForBlur.addEventListener( |
| - "scroll", |
| - () => { |
| - feedAtBottom = feedForBlur.scrollHeight - feedForBlur.scrollTop - feedForBlur.clientHeight < 80; |
| - }, |
| - { passive: true }, |
| - ); |
| - new ResizeObserver(() => { |
| - if (!feedAtBottom) return; |
| - feedForBlur.scrollTop = feedForBlur.scrollHeight; |
| - // WebKit restores the pre-resize scroll position *after* observers run, |
| - // undoing the line above (Chrome doesn't). Re-assert on the next frame, |
| - // once its restoration has happened — unconditionally: the was-at-bottom |
| - // decision was made above, and the restoration's own scroll event may |
| - // have already pushed the gap past the threshold (e.g. the 216px |
| - // typeahead), flipping feedAtBottom false. A user can't scroll within |
| - // the same frame, so this never fights a real scroll-away. |
| - requestAnimationFrame(() => { |
| - feedForBlur.scrollTop = feedForBlur.scrollHeight; |
| - }); |
| - }).observe(feedForBlur); |
| - } |
| - // A tap on the composer's own blank padding must never close the keyboard. |
| - // The .send-btn::after hit extension is clipped by the field's overflow:hidden, |
| - // so the gutter just right of Send falls through to here — treat a near-miss |
| - // at Send's height as a send; anything else only keeps focus where it is. |
| - composer.addEventListener("pointerdown", (e) => { |
| - if (e.target !== composer) return; |
| - e.preventDefault(); // don't blur the field |
| - if (sendBtn.hidden || sendBtn.disabled) return; |
| - const r = sendBtn.getBoundingClientRect(); |
| - if (e.clientX >= r.left - 8 && Math.abs(e.clientY - (r.top + r.height / 2)) <= r.height) { |
| - sendBtn.click(); |
| - } |
| - }); |
| - text.addEventListener("input", () => { |
| - updateSendVisibility(); |
| - // Autogrow, keeping the feed pinned. The height:auto + scrollHeight-read |
| - // dance forces an intermediate layout with a *shorter* textarea — the feed |
| - // is momentarily taller there, and WebKit clamps its scrollTop down during |
| - // that layout (every keystroke), which is what used to strand the chat a |
| - // line short of the bottom while typing. Judge "was at bottom" before the |
| - // dance, re-pin after: the last write in the turn wins. |
| - const feedEl = document.getElementById("feed"); |
| - const atBottom = feedEl && feedEl.scrollHeight - feedEl.scrollTop - feedEl.clientHeight < 80; |
| - text.style.height = "auto"; |
| - text.style.height = Math.min(text.scrollHeight, 140) + "px"; |
| - if (atBottom) feedEl.scrollTop = feedEl.scrollHeight; |
| - // A "/command" in progress is private — don't announce typing for it. |
| - if (text.value.trim() && text.value[0] !== "/") 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(); |
| - } |
| - // ↑ on an empty composer edits your most recent still-editable message (à la |
| - // iMessage/Slack). Only when empty so it doesn't fight cursor movement. |
| - if (e.key === "ArrowUp" && editing == null && text.value === "") { |
| - const id = lastEditableMine(); |
| - if (id != null) { |
| - e.preventDefault(); |
| - 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 |
| - stopTyping(); // sending means you've stopped typing this message |
| - text.value = ""; |
| - text.style.height = "auto"; |
| - sendBtn.hidden = true; |
| - closeTypeahead(); |
| - if (isCoarse) text.focus(); // keep the on-screen keyboard up after sending |
| - }; |
| - |
| - // Keyboard shortcuts: cmd-T opens message menu, cmd-R starts quick reply. |
| - // When the menu is open, number keys trigger reactions or actions. |
| - let reactionSheetKeyListener = null; |
| - |
| - function getLatestMessage(othersOnly = false) { |
| - const feed = document.getElementById("feed"); |
| - if (!feed) return null; |
| - const messages = feed.querySelectorAll(".msg"); |
| - if (!messages.length) return null; |
| - // Find the latest message, optionally filtering to exclude own messages |
| - for (let i = messages.length - 1; i >= 0; i--) { |
| - const msgId = messages[i].dataset.id; |
| - if (!msgId) continue; |
| - if (othersOnly && msgData[msgId]?.mine) continue; |
| - return { id: msgId, el: messages[i] }; |
| - } |
| - return null; |
| - } |
| - |
| - function attachReactionSheetKeyHandler(msgId, wrap) { |
| - // Wait a tick for the sheet to be in the DOM |
| - setTimeout(() => { |
| - const sheet = document.querySelector(".msg-sheet"); |
| - if (!sheet) return; |
| - |
| - if (reactionSheetKeyListener) { |
| - document.removeEventListener("keydown", reactionSheetKeyListener, true); |
| - reactionSheetKeyListener = null; |
| - } |
| - |
| - reactionSheetKeyListener = (e) => { |
| - const num = parseInt(e.key, 10); |
| - if (isNaN(num) || num < 1 || num > 6) return; |
| - |
| - e.preventDefault(); |
| - e.stopImmediatePropagation(); |
| - const emoji = REACTIONS[num - 1]; |
| - if (emoji) { |
| - toggleReaction(msgId, emoji); |
| - const currentSheet = document.querySelector(".msg-sheet"); |
| - if (currentSheet) currentSheet.remove(); |
| - wrap.style.visibility = ""; |
| - document.removeEventListener("keydown", reactionSheetKeyListener, true); |
| - reactionSheetKeyListener = null; |
| - } |
| - }; |
| - |
| - // Attach to document with capture, but also to the sheet itself |
| - document.addEventListener("keydown", reactionSheetKeyListener, true); |
| - sheet.addEventListener("keydown", reactionSheetKeyListener); |
| - }, 0); |
| - } |
| - |
| - document.addEventListener("keydown", (e) => { |
| - // Cmd/Ctrl+T: open message menu on latest message from someone else |
| - if ((e.metaKey || e.ctrlKey) && e.key === "t" && !text.value) { |
| - e.preventDefault(); |
| - const msg = getLatestMessage(true); // othersOnly=true |
| - if (msg) { |
| - openMessageSheet(msg.id, msg.el); |
| - attachReactionSheetKeyHandler(msg.id, msg.el); |
| - } |
| - } |
| - // Cmd/Ctrl+R: start reply to latest message (including own) |
| - if ((e.metaKey || e.ctrlKey) && e.key === "r" && !text.value) { |
| - e.preventDefault(); |
| - const msg = getLatestMessage(false); // othersOnly=false (all messages) |
| - if (msg && msgData[msg.id]?.replyMeta) { |
| - startReply(msgData[msg.id].replyMeta); |
| - } |
| - } |
| - }); |
| - |
| - // Attachments: pick / drag-drop / paste an image into the open thread. |
| - const fileInput = document.getElementById("file-input"); |
| - document.getElementById("attach-btn").onclick = () => fileInput.click(); |
| - fileInput.onchange = () => { |
| - for (const f of fileInput.files) addAttachment(f); |
| - fileInput.value = ""; |
| - }; |
| - const pane = document.getElementById("thread-view"); |
| - const dropOverlay = document.getElementById("drop-overlay"); |
| - // The native macOS wrapper (Eleven) intercepts Finder file-drops itself — a |
| - // WKWebView doesn't hand the page the dropped files on macOS. Merely *registering* |
| - // a web drop handler here makes WebKit claim the drop, bypassing the native path, |
| - // so nothing uploads. Skip the web drop listeners in the native app (html.native). |
| - if (!document.documentElement.classList.contains("native")) { |
| - pane.addEventListener("dragover", (e) => { |
| - e.preventDefault(); |
| - if (dropOverlay) dropOverlay.classList.remove("hidden"); |
| - }); |
| - pane.addEventListener("dragleave", (e) => { |
| - // Hide only when the cursor truly leaves the pane — dragleave also fires when |
| - // crossing onto a child element, so check relatedTarget. |
| - if (dropOverlay && !pane.contains(e.relatedTarget)) dropOverlay.classList.add("hidden"); |
| - }); |
| - pane.addEventListener("drop", (e) => { |
| - e.preventDefault(); |
| - if (dropOverlay) dropOverlay.classList.add("hidden"); |
| - for (const f of e.dataTransfer.files || []) addAttachment(f); |
| - }); |
| - } |
| - text.addEventListener("paste", (e) => { |
| - const item = [...(e.clipboardData?.items || [])].find((i) => i.type.startsWith("image/")); |
| - if (item) { |
| - addAttachment(item.getAsFile()); |
| - return; |
| - } |
| - const textItem = [...(e.clipboardData?.items || [])].find((i) => i.type === "text/plain"); |
| - if (textItem) { |
| - const start = text.selectionStart; |
| - const beforeCursor = text.value.substring(0, start); |
| - const currentLine = beforeCursor.split("\n").pop(); |
| - if (/^>\s*$/.test(currentLine)) { |
| - e.preventDefault(); |
| - textItem.getAsString((pastedText) => { |
| - const blockquoted = pastedText.split("\n").map((line, i) => i === 0 ? line : "> " + line).join("\n"); |
| - const end = text.selectionEnd; |
| - const before = text.value.substring(0, start); |
| - const after = text.value.substring(end); |
| - text.value = before + blockquoted + after; |
| - text.selectionStart = text.selectionEnd = start + blockquoted.length; |
| - text.dispatchEvent(new Event("input", { bubbles: true })); |
| - }); |
| - } |
| - } |
| - }); |
| - |
| renderThreadList(); |
| // Embed mode opens its one thread explicitly (bootEmbed); don't auto-pick here. |
| if (embed) return Promise.resolve(); |
| @@ -1490,7 +1231,7 @@ function renderThreadList() { |
| for (const t of shown) { |
| const row = document.createElement("button"); |
| row.className = "thread-row" + (t.id === activeId ? " active" : ""); |
| - row.dataset.id = t.id; // lets prebuildVisibleThreads map a visible row to its thread |
| + row.dataset.id = t.id; |
| |
| // DM/self rows show the person's profile photo; groups use initials (group |
| // photos are thread-scoped — phase 2). An app-thread shows the app's glyph. |
| @@ -1945,68 +1686,6 @@ async function openThread(id) { |
| await openAppThreadView(id, myOpen, renderApp, threadApp[id] || null); |
| return; |
| } |
| - setAppThreadMode(false); // a normal chat: restore feed + composer |
| - |
| - // The composer is never carried between threads: a staged photo is encrypted to |
| - // the old thread's key, and a half-typed line belongs to the thread you left. |
| - clearPending(); |
| - cancelEdit(); |
| - cancelReply(); |
| - const composerText = document.getElementById("text"); |
| - if (composerText) { |
| - composerText.value = ""; |
| - composerText.dispatchEvent(new Event("input")); |
| - } |
| - |
| - const feed = document.getElementById("feed"); |
| - const cached = feedCache[id]; |
| - if (cached && cached.loaded) { |
| - // Instant restore: re-attach the parked nodes, no fetch, no flash, scroll kept. |
| - bindThreadState(cached); |
| - feed.innerHTML = ""; |
| - if (cached.nodes) feed.appendChild(cached.nodes); |
| - cached.nodes = null; |
| - feed.scrollTop = cached.scrollTop != null ? cached.scrollTop : feed.scrollHeight; |
| - // Judge at-bottom from the restored offset right away — the scroll event the |
| - // assignment fires is async, so without this the *previous* thread's flag |
| - // could leak into this thread's catch-up pin decision below. |
| - feedAtBottom = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 80; |
| - if (!isCoarse) composerText.focus(); |
| - renderActiveSurfaces(); // repaint shared-app panels for THIS thread — #app-surface is one |
| - // persistent element reused across threads, so without this it would keep showing |
| - // whatever the previous thread last rendered until a new event happened to arrive. |
| - catchUpActiveMessages(); // background: append anything that arrived while it was away |
| - return; |
| - } |
| - |
| - // First open of this thread: build the feed from scratch. |
| - const cache = (feedCache[id] = newFeedCache()); |
| - bindThreadState(cache); |
| - feed.innerHTML = ""; |
| - try { |
| - const r = await ( |
| - await fetch(`/api/messages?token=${encodeURIComponent(session.token)}&thread=${encodeURIComponent(id)}`) |
| - ).json(); |
| - if (myOpen !== openSeq) return; // switched threads while loading — abandon this load |
| - // Build the history off-DOM, then attach it in one shot. Appending live (one |
| - // node per awaited decrypt) reflowed #feed on every message and left it pinned |
| - // to the top until the whole load finished, then snapped to the bottom — a |
| - // cascade of visible layout shift on first open. A detached fragment never |
| - // paints mid-build, so the feed appears complete, already at the latest message. |
| - const frag = document.createDocumentFragment(); |
| - resetThread(id); // fold this thread's app state fresh from the loaded window |
| - await seedAppState(id); // snapshot + catch-up first, so state predates the window |
| - if (myOpen !== openSeq) return; // switched threads while seeding — abandon |
| - for (const m of r.messages || []) await appendMessage(id, m, false, frag, cache); |
| - if (myOpen !== openSeq) return; // switched away mid-build — don't attach a stale feed |
| - feed.appendChild(frag); |
| - cache.loaded = true; |
| - if (id === activeId) renderActiveSurfaces(); // paint app panels from the folded state |
| - } catch (e) { |
| - console.error("load messages", e); |
| - } |
| - if (myOpen === openSeq) pinFeedToBottom(feed, myOpen); |
| - if (!isCoarse) document.getElementById("text").focus(); |
| } |
| |
| // Pin the feed to the bottom, and keep it pinned for a short settle window as |
| @@ -2037,548 +1716,6 @@ function pinFeedToBottom(feed, gen) { |
| requestAnimationFrame(tick); |
| } |
| |
| -const PREBUILD_MAX = 10; // cap on chats warmed on load (a safety net over "visible") |
| - |
| -// Build a thread's feed off-screen into its feedCache entry, so the *first* tap |
| -// into it is the instant-restore path in openThread — no fetch round-trip, no |
| -// flash. Idempotent, and isolated from the open thread: it writes only into this |
| -// thread's own cache entry (the explicit `cache` arg threaded through |
| -// appendMessage), never the active-thread globals, so it's safe to run in the |
| -// background while another chat is open. |
| -async function prebuildThread(id) { |
| - if (id === activeId || feedCache[id] || !threadKeys[id]) return; |
| - const cache = (feedCache[id] = newFeedCache()); // claim the slot so nothing double-builds |
| - await seedAppState(id); // app state from snapshot + catch-up (fold-only, no DOM) |
| - try { |
| - const r = await ( |
| - await fetch(`/api/messages?token=${encodeURIComponent(session.token)}&thread=${encodeURIComponent(id)}`) |
| - ).json(); |
| - if (feedCache[id] !== cache) return; // opened for real meanwhile — let that build win |
| - const frag = document.createDocumentFragment(); |
| - for (const m of r.messages || []) await appendMessage(id, m, false, frag, cache); |
| - if (feedCache[id] !== cache) return; // superseded while decrypting |
| - cache.nodes = frag; // parked; openThread re-attaches it instantly |
| - cache.scrollTop = null; // null → pin to bottom on first show |
| - cache.loaded = true; |
| - } catch { |
| - if (feedCache[id] === cache) delete feedCache[id]; // failed — let a real open retry cleanly |
| - } |
| -} |
| - |
| -// After first paint, warm the chats visible in the list so tapping any of them is |
| -// instant. Only above-the-fold rows (the flash the user sees is the initial fetch), |
| -// one at a time at idle so it never competes with the open thread or first paint. |
| -function prebuildVisibleThreads() { |
| - const run = async () => { |
| - for (const id of visibleThreadIds()) await prebuildThread(id); |
| - }; |
| - if (typeof requestIdleCallback === "function") requestIdleCallback(() => run()); |
| - else setTimeout(run, 200); |
| -} |
| - |
| -// Thread ids whose list rows are on-screen (capped). Falls back to the top rows |
| -// when the list isn't laid out — e.g. mobile currently showing a thread. |
| -function visibleThreadIds() { |
| - const list = document.getElementById("thread-list"); |
| - if (!list) return []; |
| - const rows = [...list.querySelectorAll(".thread-row")]; |
| - const vh = window.innerHeight || document.documentElement.clientHeight || 0; |
| - const onScreen = []; |
| - for (const row of rows) { |
| - const b = row.getBoundingClientRect(); |
| - if (b.width === 0 && b.height === 0) continue; // not laid out (hidden pane) |
| - if (b.bottom > 0 && b.top < vh) onScreen.push(row.dataset.id); |
| - } |
| - const ids = onScreen.length ? onScreen : rows.map((r) => r.dataset.id); |
| - return ids.filter(Boolean).slice(0, PREBUILD_MAX); |
| -} |
| - |
| -// ============================================================================ |
| -// RETIRED — original-chat feed. DEAD, UNREACHABLE CODE (pending deletion). |
| -// The app engine (web/static/apps/chat.js) is the sole chat; `chatAppFor` never |
| -// routes here anymore. Everything from here to the "mini-apps" section below is |
| -// the classic `#feed` renderer + composer + message actions. |
| -// DO NOT edit, extend, or copy from this — all chat behaviour lives in |
| -// apps/chat.js. (See CLAUDE.md.) Shared helpers still used by the app host |
| -// (processImage, loadBlob, encryptBytes, unfurl fetch) live outside this block. |
| -// ============================================================================ |
| -// `container` lets the first open build the whole history into a detached fragment |
| -// (see openThread) so there's no per-message reflow; live appends omit it and |
| -// target the real #feed. Scroll/query still resolve against #feed either way. |
| -async function appendMessage(threadId, m, scroll = true, container = null, cache = null) { |
| - // A live append (no container) must target the open thread. Off-DOM builds — the |
| - // first-open fragment and prebuildThread — pass a container and write into an |
| - // explicit cache entry, so they're allowed to run for any thread. |
| - if (!container && threadId !== activeId) return; |
| - const key = threadKeys[threadId]; |
| - if (!key) return; |
| - let data; |
| - try { |
| - data = await decrypt(key, m.blob); |
| - } catch { |
| - return; // can't decrypt — skip |
| - } |
| - if (!container && threadId !== activeId) return; // switched mid-decrypt — don't bleed into the new feed |
| - // A mini-app event, not a chat message: fold it into the app's state and give |
| - // the owning app an optional line in the stream — never a chat bubble. Runs for |
| - // both history (folds the loaded window) and live appends. |
| - if (isAppType(data.type)) { |
| - const app = foldEvent(threadId, data, { id: m.id, ts: data.ts }); |
| - const line = renderAppLine(app, data, { id: m.id, ts: data.ts }); |
| - if (line) { |
| - const dest = container || document.getElementById("feed"); |
| - if (dest) dest.appendChild(line); |
| - if (!container && scroll) { |
| - const f = document.getElementById("feed"); |
| - if (f) f.scrollTop = f.scrollHeight; |
| - } |
| - } |
| - if (threadId === activeId) renderActiveSurfaces(); |
| - return; |
| - } |
| - // The state maps this message registers into: an explicit entry for off-DOM |
| - // builds, else the active thread's — whose maps ARE the globals (bindThreadState), |
| - // so live/catch-up appends behave exactly as before. |
| - const st = cache || feedCache[threadId]; |
| - if (!st) return; |
| - // Their message just arrived → they're no longer typing. Clear it immediately |
| - // rather than waiting out the typing-beat expiry (the guaranteed signal). A |
| - // background prebuild (cache set) must never touch the open thread's typing. |
| - if (!cache && data.name && typingUntil.delete(data.name)) renderTyping(); |
| - const feed = document.getElementById("feed"); |
| - const target = container || feed; // where the node lands; feed for live appends |
| - const mine = ownMessage(data, myUserId, session.name); |
| - const t = threads.find((x) => x.id === threadId); |
| - const wrap = document.createElement("div"); |
| - wrap.className = "msg " + (mine ? "me" : "them"); |
| - wrap.dataset.id = m.id; // lets a reply quote scroll back to this message |
| - wrap.dataset.t = data.ts || m.created_at * 1000; // drives the time separators |
| - wrap.dataset.sender = data.name || ""; // drives sender-name collapse |
| - if (data.replyTo && data.replyTo.id) wrap.dataset.replyTo = data.replyTo.id; |
| - if (!mine && t && t.kind === "group") { |
| - const nm = document.createElement("div"); |
| - nm.className = "name"; |
| - nm.style.display = "flex"; // center the name text with its AI/BOT badge |
| - nm.style.alignItems = "center"; |
| - nm.textContent = data.name || ""; |
| - // Badge bot senders on the name label itself, so "who's a bot" is visible |
| - // on every message, not just in the picker. Sender names aren't signed, so |
| - // this is matched against the roster's agent labels (same trust level as |
| - // the name shown beside it). |
| - const ag = roster.find((e) => e.is_agent && e.label === data.name); |
| - if (ag) nm.appendChild(agentTag(true, ag.kind)); |
| - wrap.appendChild(nm); |
| - } |
| - // The bubble + its quote/image/preview + hover icons + exact time live in |
| - // .content, which is the positioning context for the hover affordances. |
| - // Reactions sit OUTSIDE it (below), so the reply/edit icons and the hover time |
| - // center on the bubble, not on the bubble-plus-reactions. |
| - const content = document.createElement("div"); |
| - content.className = "content"; |
| - // A reply shows a tappable quote of its parent above everything else. |
| - if (data.replyTo) content.appendChild(buildReplyQuote(data.replyTo)); |
| - // Attachments render above the text bubble: images inline, other files as a |
| - // download chip. A text-less attachment has no bubble. |
| - for (const a of data.attachments || []) { |
| - if (a.type && a.type.startsWith("image/")) { |
| - const img = document.createElement("img"); |
| - img.className = "msg-image"; |
| - img.alt = a.name || "photo"; |
| - // Reserve the image's final box *before* the blob decrypts. aspect-ratio |
| - // alone can't do it: an unloaded <img> has no intrinsic width, so with only |
| - // a max-width it lays out 0×0 and the bubble collapses until the photo pops |
| - // in — the "bubbles run into each other then jump" shift on first open. Give |
| - // it a definite width (the same 260×320 cap as .msg-image, scaled to the |
| - // real dimensions, never upscaled) so the height is reserved up front. |
| - if (a.w && a.h) { |
| - img.style.aspectRatio = `${a.w} / ${a.h}`; |
| - const scale = Math.min(260 / a.w, 320 / a.h, 1); |
| - img.style.width = Math.round(a.w * scale) + "px"; |
| - } |
| - loadBlob(a.id, threadId).then((url) => { |
| - if (url) { |
| - img.src = url; |
| - img.onclick = () => openLightbox(url, img); |
| - if (scroll) feed.scrollTop = feed.scrollHeight; |
| - } |
| - }); |
| - content.appendChild(img); |
| - } else { |
| - content.appendChild(buildFileCard(a, threadId)); |
| - } |
| - } |
| - if (data.text) { |
| - const bubble = document.createElement("div"); |
| - // 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 hasAttachments = Array.isArray(data.attachments) && data.attachments.length > 0; |
| - const jumbo = !hasAttachments && emojiOnlyCount(data.text) > 0; |
| - bubble.className = jumbo ? "bubble jumbo-emoji" : "bubble"; |
| - renderText(bubble, data.text); |
| - content.appendChild(bubble); |
| - const link = firstLink(data.text); |
| - // A YouTube link gets a click-to-play video card instead of the OG unfurl — |
| - // built purely from the URL, so the server never sees it (unlike unfurl). |
| - const yt = link && youtubeId(link); |
| - if (yt) content.appendChild(buildYouTubeCard(yt)); |
| - else if (link) addLinkPreview(content, link); |
| - } |
| - // Helper-proposed one-tap actions render as chips under the bubble (both the |
| - // history and live paths come through here). sanitizeActions whitelists. |
| - const chips = sanitizeActions(data.actions); |
| - if (chips.length) content.appendChild(buildActionChips(chips)); |
| - // Reply/edit affordances: desktop hover icons (reply on any message; edit on |
| - // your own in-window ones), touch swipe-right to reply. The press-hold / |
| - // double-tap picker also offers both, via the replyMeta stored in msgData. |
| - const replyMeta = { id: m.id, name: data.name, snippet: replySnippet(data) }; |
| - if (isCoarse) { |
| - attachSwipeReply(wrap, replyMeta); |
| - } else { |
| - // Desktop: a full-height strip beside the bubble with the action icons and the |
| - // exact time, all revealed on hover (see .msg-actions). Full height so the |
| - // pointer can travel from anywhere in the bubble straight to an icon. |
| - const actions = document.createElement("div"); |
| - actions.className = "msg-actions"; |
| - actions.appendChild(buildHoverReact(m.id)); |
| - actions.appendChild(buildHoverReply(replyMeta)); |
| - const remaining = m.created_at * 1000 + EDIT_WINDOW_MS - Date.now(); |
| - if (mine && data.text && remaining > 0) { |
| - const editBtn = buildHoverEdit(m.id); |
| - actions.appendChild(editBtn); |
| - setTimeout(() => editBtn.remove(), remaining); // drop it when the window lapses |
| - } |
| - const timeEl = document.createElement("span"); |
| - timeEl.className = "msg-time"; |
| - timeEl.textContent = formatTime(data.ts || m.created_at * 1000); |
| - actions.appendChild(timeEl); |
| - content.appendChild(actions); |
| - } |
| - wrap.appendChild(content); |
| - // Reactions: track which are mine (server's per-message flag), render the chips |
| - // below the bubble (outside .content), and wire the press-hold / double-tap picker. |
| - for (const r of m.reactions || []) { |
| - if (r.mine) st.myReactions.add(`${m.id}|${r.emoji}`); |
| - } |
| - renderReactions(wrap, m.id, m.reactions || [], st.myReactions); |
| - attachReactionGesture(wrap, m.id); |
| - // Count this reply against its parent, so the parent's tag can show "N Replies". |
| - if (data.replyTo && data.replyTo.id) bumpReplyCount(data.replyTo.id, st); |
| - // A persistent "N Replies" tag below the message, tappable to jump to the first |
| - // reply. Hidden until a reply arrives. |
| - const replyCount = document.createElement("div"); |
| - replyCount.className = "reply-count"; |
| - replyCount.hidden = true; |
| - replyCount.onclick = () => { |
| - const first = feed.querySelector(`.msg[data-reply-to="${m.id}"]`); |
| - if (first) scrollToMessage(first.dataset.id); |
| - }; |
| - wrap.appendChild(replyCount); |
| - st.replyCountEls[m.id] = replyCount; |
| - paintReplyCount(m.id, st); // reflect replies that may already have arrived |
| - if (m.edited_at) markEdited(wrap); // persistent "Edited" label below the bubble |
| - st.msgEls[m.id] = wrap; |
| - st.msgData[m.id] = { mine, createdAt: m.created_at, data, replyMeta }; |
| - if (scroll) wrap.classList.add("appear"); // live send/receive eases in; history doesn't |
| - // A live append lands above the in-feed typing row (someone else may still be |
| - // typing) so the indicator stays glued below the newest message. |
| - if (target === feed && typingEl && typingEl.parentNode === feed) feed.insertBefore(wrap, typingEl); |
| - else target.appendChild(wrap); |
| - refreshSeparator(wrap); // centered time separator above, if the gap warrants |
| - refreshSenderName(wrap); // hide a repeated "them" name within a run |
| - if (scroll) { |
| - // A live send/receive glides the feed up so the new bubble eases into view. |
| - // Reduced-motion falls back to an instant jump. |
| - if (matchMedia("(prefers-reduced-motion: reduce)").matches) { |
| - feed.scrollTop = feed.scrollHeight; |
| - } else { |
| - feed.scrollTo({ top: feed.scrollHeight, behavior: "smooth" }); |
| - } |
| - } |
| -} |
| - |
| -// A click-to-play YouTube card: thumbnail + play button, swapped for the real |
| -// player only on tap — so nothing loads from YouTube (beyond the thumbnail |
| -// image) unless someone chooses to play. The embed uses youtube-nocookie.com |
| -// (YouTube's reduced-tracking domain) with rel=0 (related videos limited to the |
| -// same channel). yt is youtubeId()'s validated { id, start } — an 11-char id |
| -// and integer seconds — so the URL is safe by construction. |
| -function buildYouTubeCard(yt) { |
| - const card = document.createElement("div"); |
| - card.className = "yt-card"; |
| - const btn = document.createElement("button"); |
| - btn.type = "button"; |
| - btn.className = "yt-poster"; |
| - btn.setAttribute("aria-label", "Play video"); |
| - const img = document.createElement("img"); |
| - img.className = "yt-thumb"; |
| - img.alt = ""; |
| - img.loading = "lazy"; |
| - img.src = `https://i.ytimg.com/vi/${yt.id}/hqdefault.jpg`; |
| - // A dead/private video serves a grey 120px placeholder; drop to a plain card. |
| - img.onload = () => { |
| - if (img.naturalWidth <= 120) card.classList.add("yt-bare"); |
| - }; |
| - img.onerror = () => card.classList.add("yt-bare"); |
| - const play = document.createElement("span"); |
| - play.className = "yt-play"; |
| - play.innerHTML = ICON_PLAY; // static in-code SVG (safe) |
| - btn.appendChild(img); |
| - btn.appendChild(play); |
| - btn.onclick = () => { |
| - const frame = document.createElement("iframe"); |
| - frame.className = "yt-frame"; |
| - frame.src = |
| - `https://www.youtube-nocookie.com/embed/${yt.id}?autoplay=1&rel=0` + |
| - (yt.start ? `&start=${yt.start}` : ""); |
| - frame.allow = "autoplay; encrypted-media; fullscreen; picture-in-picture"; |
| - frame.allowFullscreen = true; |
| - frame.title = "YouTube video"; |
| - btn.replaceWith(frame); |
| - }; |
| - card.appendChild(btn); |
| - return card; |
| -} |
| - |
| -// Fetch an OpenGraph preview for a URL and append a card below the bubble. The |
| -// server briefly sees the URL to build the card (the one documented exception); |
| -// the card image loads from its origin (no proxy). |
| -async function addLinkPreview(wrap, url) { |
| - let pv; |
| - try { |
| - pv = await ( |
| - await fetch(`/api/unfurl?token=${encodeURIComponent(session.token)}&url=${encodeURIComponent(url)}`) |
| - ).json(); |
| - } catch { |
| - return; |
| - } |
| - if (!pv || !pv.ok || (!pv.title && !pv.image)) return; |
| - const card = document.createElement("a"); |
| - card.className = "link-preview"; |
| - card.href = url; |
| - card.target = "_blank"; |
| - card.rel = "noopener noreferrer"; |
| - if (pv.image) { |
| - const img = document.createElement("img"); |
| - img.className = "lp-image"; |
| - img.alt = ""; |
| - img.src = pv.image; |
| - img.onerror = () => img.remove(); |
| - card.appendChild(img); |
| - } |
| - const body = document.createElement("div"); |
| - body.className = "lp-body"; |
| - if (pv.title) { |
| - const t = document.createElement("div"); |
| - t.className = "lp-title"; |
| - t.textContent = pv.title; |
| - body.appendChild(t); |
| - } |
| - if (pv.description) { |
| - const d = document.createElement("div"); |
| - d.className = "lp-desc"; |
| - d.textContent = pv.description; |
| - body.appendChild(d); |
| - } |
| - const host = document.createElement("div"); |
| - host.className = "lp-host"; |
| - try { |
| - host.textContent = new URL(url).host; |
| - } catch {} |
| - body.appendChild(host); |
| - card.appendChild(body); |
| - const reactions = wrap.querySelector(".reactions"); |
| - if (reactions) wrap.insertBefore(card, reactions); |
| - else wrap.appendChild(card); |
| -} |
| - |
| -// Append a persistent "Edited" label below a message (once). |
| -function markEdited(wrap) { |
| - if (wrap.querySelector(".edited-tag")) return; |
| - const e = document.createElement("div"); |
| - e.className = "edited-tag"; |
| - e.textContent = "Edited"; |
| - wrap.appendChild(e); |
| -} |
| - |
| -// Bump (and repaint) the reply tally for a parent message as replies to it arrive. |
| -// `st` targets a specific thread's cache entry (prebuild / first-open); omitted, it |
| -// falls back to the active thread's globals. |
| -function bumpReplyCount(parentId, st) { |
| - const counts = st ? st.replyCounts : replyCounts; |
| - counts[parentId] = (counts[parentId] || 0) + 1; |
| - paintReplyCount(parentId, st); |
| -} |
| - |
| -function paintReplyCount(parentId, st) { |
| - const el = (st ? st.replyCountEls : replyCountEls)[parentId]; |
| - if (!el) return; // parent older than the loaded window — nothing to mark |
| - const n = (st ? st.replyCounts : replyCounts)[parentId] || 0; |
| - el.textContent = n === 1 ? "1 Reply" : n + " Replies"; |
| - el.hidden = n === 0; |
| -} |
| - |
| -// Render a message's reaction chips (rebuilds the row in place). "Mine" chips are |
| -// highlighted; tapping a chip toggles that reaction. `mineSet` targets a specific |
| -// thread's reaction set (prebuild / first-open); omitted, it uses the active one. |
| -function renderReactions(wrap, msgId, reactions, mineSet) { |
| - const mine = mineSet || myReactions; |
| - const old = wrap.querySelector(".reactions"); |
| - if (old) old.remove(); |
| - if (!reactions || !reactions.length) return; |
| - const row = document.createElement("div"); |
| - row.className = "reactions"; |
| - for (const r of reactions) { |
| - if (!r.count) continue; |
| - const chip = document.createElement("button"); |
| - chip.className = "reaction-chip" + (mine.has(`${msgId}|${r.emoji}`) ? " mine" : ""); |
| - chip.textContent = `${r.emoji} ${r.count}`; |
| - chip.onclick = (e) => { |
| - e.stopPropagation(); |
| - toggleReaction(msgId, r.emoji); |
| - }; |
| - row.appendChild(chip); |
| - } |
| - // Keep chips above the reply/edited tags even when re-rendered on a live update. |
| - const anchor = wrap.querySelector(".reply-count"); |
| - if (anchor) wrap.insertBefore(row, anchor); |
| - else wrap.appendChild(row); |
| -} |
| - |
| -function toggleReaction(msgId, emoji) { |
| - if (!ws || ws.readyState !== WebSocket.OPEN) return; |
| - const key = `${msgId}|${emoji}`; |
| - const mine = myReactions.has(key); |
| - if (mine) myReactions.delete(key); |
| - else myReactions.add(key); |
| - ws.send(JSON.stringify({ type: mine ? "unreact" : "react", thread_id: activeId, message_id: Number(msgId), emoji })); |
| -} |
| - |
| -// Open the emoji picker over a message; press-and-hold (desktop) or double-tap |
| -// (touch) trigger it. Tapping an emoji toggles that reaction. |
| -function openReactionPicker(msgId, anchor) { |
| - document.querySelector(".emoji-picker")?.remove(); |
| - const pop = document.createElement("div"); |
| - pop.className = "emoji-picker"; |
| - for (const emoji of REACTIONS) { |
| - const b = document.createElement("button"); |
| - b.className = "emoji-opt"; |
| - b.textContent = emoji; |
| - b.onclick = () => { |
| - toggleReaction(msgId, emoji); |
| - pop.remove(); |
| - }; |
| - pop.appendChild(b); |
| - } |
| - const d = msgData[msgId]; |
| - // Reply to any message (icon, set off from the emoji row). |
| - if (d && d.replyMeta) { |
| - const reply = document.createElement("button"); |
| - reply.className = "picker-action"; |
| - reply.innerHTML = ICON_REPLY; // static in-code SVG (safe) |
| - reply.setAttribute("aria-label", "Reply"); |
| - reply.title = "Reply"; |
| - reply.onclick = () => { |
| - startReply(d.replyMeta); |
| - pop.remove(); |
| - }; |
| - pop.appendChild(reply); |
| - } |
| - // Edit, for your own messages still within the edit window (server enforces it). |
| - if (d && d.mine && d.data && d.data.text && Date.now() - d.createdAt * 1000 < EDIT_WINDOW_MS) { |
| - const edit = document.createElement("button"); |
| - edit.className = "picker-action"; |
| - edit.innerHTML = ICON_EDIT; // static in-code SVG (safe) |
| - edit.setAttribute("aria-label", "Edit"); |
| - edit.title = "Edit"; |
| - edit.onclick = () => { |
| - startEdit(msgId); |
| - pop.remove(); |
| - }; |
| - pop.appendChild(edit); |
| - } |
| - // Delete (unsend), for your own messages — any time. |
| - if (d && d.mine) { |
| - const del = document.createElement("button"); |
| - del.className = "picker-action"; |
| - del.innerHTML = ICON_TRASH; // static in-code SVG (safe) |
| - del.setAttribute("aria-label", "Delete"); |
| - del.title = "Delete"; |
| - del.onclick = () => { |
| - pop.remove(); |
| - if (confirm("Delete this message for everyone? This can't be undone.")) deleteMessage(msgId); |
| - }; |
| - pop.appendChild(del); |
| - } |
| - // Actions mini-apps contribute (e.g. "Save link"). Each carries an emoji glyph |
| - // rendered as textContent — no innerHTML for anything an app supplies. |
| - for (const act of appMessageActions(msgId)) { |
| - const b = document.createElement("button"); |
| - b.className = "picker-action"; |
| - b.textContent = act.glyph; |
| - b.setAttribute("aria-label", act.label); |
| - b.title = act.label; |
| - b.onclick = () => { |
| - act.run(); |
| - pop.remove(); |
| - }; |
| - pop.appendChild(b); |
| - } |
| - 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); |
| -} |
| - |
| -// Actions mini-apps contribute for this message (e.g. Bookmarks' "Save link" on |
| -// any message with a link). Each: { label, glyph, run } — an emoji glyph instead |
| -// of an SVG icon, so the app stays self-contained. Empty unless an app opts in. |
| -function appMessageActions(msgId) { |
| - const d = msgData[msgId]; |
| - if (!d || !d.data) return []; |
| - return messageActionsFor({ text: d.data.text || "", from: d.data.name || "" }); |
| -} |
| - |
| -// The actions in the mobile long-press menu, in a sensible order: Reply, Copy, then |
| -// app actions (Save link…), then your-own Edit (in-window) and Delete. Each is |
| -// { label, icon | glyph, danger?, run }. |
| -function messageActions(msgId) { |
| - const d = msgData[msgId]; |
| - if (!d) return []; |
| - const acts = []; |
| - if (d.replyMeta) acts.push({ label: "Reply", icon: ICON_REPLY, run: () => startReply(d.replyMeta) }); |
| - if (d.data && d.data.text) acts.push({ label: "Copy", icon: ICON_COPY, run: () => copyMessageText(d.data.text) }); |
| - for (const a of appMessageActions(msgId)) acts.push({ label: a.label, glyph: a.glyph, run: a.run }); |
| - if (d.mine && d.data && d.data.text && Date.now() - d.createdAt * 1000 < EDIT_WINDOW_MS) |
| - acts.push({ label: "Edit", icon: ICON_EDIT, run: () => startEdit(msgId) }); |
| - if (d.mine) |
| - acts.push({ |
| - label: "Delete", |
| - icon: ICON_TRASH, |
| - danger: true, |
| - run: () => { |
| - if (confirm("Delete this message for everyone? This can't be undone.")) deleteMessage(msgId); |
| - }, |
| - }); |
| - return acts; |
| -} |
| - |
| -async function copyMessageText(text) { |
| - try { |
| - await navigator.clipboard.writeText(text); |
| - toast("Copied"); |
| - } catch { |
| - toast("Couldn't copy"); |
| - } |
| -} |
| - |
| // A brief status pill (reuses the #toast element). |
| let toastTimer; |
| function toast(msg) { |
| @@ -2590,532 +1727,6 @@ function toast(msg) { |
| toastTimer = setTimeout(() => t.classList.remove("show"), 1600); |
| } |
| |
| -// Mobile long-press menu (WhatsApp-style): a dim backdrop with the emoji reactions |
| -// floating above the message and a labelled action menu below it. |
| -function openMessageSheet(msgId, wrap) { |
| - document.querySelector(".msg-sheet")?.remove(); |
| - const bubble = wrap.querySelector(".bubble") || wrap; |
| - const brect = bubble.getBoundingClientRect(); // for horizontal alignment |
| - const rect = wrap.getBoundingClientRect(); // the whole message (what we lift) |
| - const mine = !!(msgData[msgId] && msgData[msgId].mine); |
| - |
| - const backdrop = document.createElement("div"); |
| - backdrop.className = "msg-sheet"; |
| - |
| - // A non-interactive copy of the message, lifted above the dim so it stays legible |
| - // while the reactions float above it and the menu below — the whole cluster is |
| - // repositioned vertically so both fit even when the message is at the screen edge. |
| - const clone = wrap.cloneNode(true); |
| - clone.classList.add("msg-sheet-clone"); |
| - clone.classList.remove("appear"); |
| - clone.style.left = rect.left + "px"; |
| - clone.style.width = rect.width + "px"; |
| - |
| - const reactions = document.createElement("div"); |
| - reactions.className = "msg-sheet-reactions"; |
| - for (const emoji of REACTIONS) { |
| - const b = document.createElement("button"); |
| - b.className = "sheet-emoji"; |
| - b.textContent = emoji; |
| - b.onclick = () => { |
| - toggleReaction(msgId, emoji); |
| - close(); |
| - }; |
| - reactions.appendChild(b); |
| - } |
| - |
| - const menu = document.createElement("div"); |
| - menu.className = "msg-sheet-menu"; |
| - for (const a of messageActions(msgId)) { |
| - const item = document.createElement("button"); |
| - item.className = "msg-sheet-item" + (a.danger ? " danger" : ""); |
| - const label = document.createElement("span"); |
| - label.textContent = a.label; |
| - const icon = document.createElement("span"); |
| - icon.className = "msg-sheet-icon"; |
| - if (a.glyph) icon.textContent = a.glyph; // app action: emoji glyph (textContent) |
| - else icon.innerHTML = a.icon; // static in-code SVG (safe) |
| - item.appendChild(label); |
| - item.appendChild(icon); |
| - item.onclick = () => { |
| - a.run(); |
| - close(); |
| - }; |
| - menu.appendChild(item); |
| - } |
| - |
| - backdrop.appendChild(clone); |
| - backdrop.appendChild(reactions); |
| - backdrop.appendChild(menu); |
| - document.body.appendChild(backdrop); |
| - |
| - const M = 8; |
| - const GAP = 10; |
| - const vw = window.innerWidth; |
| - const vh = window.innerHeight; |
| - const rH = reactions.offsetHeight; |
| - const mH = menu.offsetHeight; |
| - // Cap the lifted message so the reactions + menu always fit around it. |
| - const maxMsgH = Math.max(60, vh - 2 * M - rH - mH - 2 * GAP); |
| - const bH = Math.min(rect.height, maxMsgH); |
| - if (rect.height > bH) { |
| - clone.style.maxHeight = bH + "px"; |
| - clone.style.overflow = "hidden"; |
| - } |
| - // Keep the message near where it was, but slide it just enough that the reactions |
| - // fit above and the menu below. |
| - const minTop = M + rH + GAP; |
| - const maxTop = Math.max(minTop, vh - M - mH - GAP - bH); |
| - const msgTop = Math.max(minTop, Math.min(rect.top, maxTop)); |
| - clone.style.top = msgTop + "px"; |
| - |
| - const placeX = (el) => { |
| - const w = el.offsetWidth; |
| - const left = mine ? brect.right - w : brect.left; |
| - return Math.max(M, Math.min(left, vw - w - M)); |
| - }; |
| - reactions.style.top = msgTop - GAP - rH + "px"; |
| - reactions.style.left = placeX(reactions) + "px"; |
| - menu.style.top = msgTop + bH + GAP + "px"; |
| - menu.style.left = placeX(menu) + "px"; |
| - |
| - // Hide the original while its lifted copy is shown; restore on close. |
| - wrap.style.visibility = "hidden"; |
| - |
| - function close() { |
| - wrap.style.visibility = ""; |
| - backdrop.remove(); |
| - document.removeEventListener("pointerdown", onDoc, true); |
| - document.removeEventListener("keydown", onEsc); |
| - } |
| - const onDoc = (e) => { |
| - if (!reactions.contains(e.target) && !menu.contains(e.target)) close(); |
| - }; |
| - const onEsc = (e) => { |
| - if (e.key === "Escape") { |
| - e.preventDefault(); |
| - close(); |
| - } |
| - }; |
| - // Deferred so the press that opened the sheet doesn't immediately close it. |
| - setTimeout(() => { |
| - document.addEventListener("pointerdown", onDoc, true); |
| - document.addEventListener("keydown", onEsc); |
| - }, 0); |
| -} |
| - |
| -// Double-tap (touch) → a quick emoji reactions bar. Just the emojis: the actions |
| -// (reply/copy/edit/delete) live in the long-press menu. Reuses the compact picker. |
| -function openReactionBar(msgId, anchor) { |
| - document.querySelector(".emoji-picker")?.remove(); |
| - const pop = document.createElement("div"); |
| - pop.className = "emoji-picker"; |
| - for (const emoji of REACTIONS) { |
| - const b = document.createElement("button"); |
| - b.className = "emoji-opt"; |
| - b.textContent = emoji; |
| - b.onclick = () => { |
| - toggleReaction(msgId, emoji); |
| - pop.remove(); |
| - }; |
| - pop.appendChild(b); |
| - } |
| - 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); |
| -} |
| - |
| -// Touch: long-press → the message menu (reactions + reply/copy/edit/delete); a quick |
| -// double-tap → the reactions bar. Desktop keeps press-and-hold (≈450ms) opening the |
| -// emoji/action picker + the hover strip. |
| -function attachReactionGesture(wrap, msgId) { |
| - if (isCoarse) { |
| - let timer = null; |
| - let sx = 0; |
| - let sy = 0; |
| - let lastTap = 0; |
| - let longFired = false; |
| - const cancel = () => { |
| - if (timer) { |
| - clearTimeout(timer); |
| - timer = null; |
| - } |
| - }; |
| - wrap.addEventListener( |
| - "touchstart", |
| - (e) => { |
| - cancel(); |
| - longFired = false; |
| - if (e.touches.length !== 1) return; |
| - sx = e.touches[0].clientX; |
| - sy = e.touches[0].clientY; |
| - timer = setTimeout(() => { |
| - timer = null; |
| - longFired = true; |
| - openMessageSheet(msgId, wrap); |
| - }, 500); |
| - }, |
| - { passive: true } |
| - ); |
| - wrap.addEventListener( |
| - "touchmove", |
| - (e) => { |
| - if (!timer) return; |
| - const t = e.touches[0]; |
| - // A drag (swipe-to-reply or a scroll) isn't a long-press. |
| - if (Math.abs(t.clientX - sx) > 10 || Math.abs(t.clientY - sy) > 10) cancel(); |
| - }, |
| - { passive: true } |
| - ); |
| - wrap.addEventListener("touchend", () => { |
| - const wasLong = longFired; |
| - cancel(); |
| - if (wasLong) { |
| - lastTap = 0; // the long-press already opened the menu — not a tap |
| - return; |
| - } |
| - const now = Date.now(); |
| - if (now - lastTap < 300) { |
| - lastTap = 0; |
| - openReactionBar(msgId, wrap); // second quick tap |
| - } else { |
| - lastTap = now; |
| - } |
| - }); |
| - wrap.addEventListener("touchcancel", () => { |
| - cancel(); |
| - lastTap = 0; |
| - }); |
| - } else { |
| - let timer = null; |
| - const cancel = () => { |
| - clearTimeout(timer); |
| - timer = null; |
| - }; |
| - wrap.addEventListener("mousedown", () => { |
| - timer = setTimeout(() => openReactionPicker(msgId, wrap), 450); |
| - }); |
| - wrap.addEventListener("mouseup", cancel); |
| - wrap.addEventListener("mouseleave", cancel); |
| - wrap.addEventListener("mousemove", cancel); |
| - } |
| -} |
| - |
| -// ---- replies ------------------------------------------------------------ |
| -// The reply link rides inside the encrypted payload (payload.replyTo = |
| -// {id, name, snippet}); the server never learns who replied to whom. |
| - |
| -// Scroll to the replied-to message and flash it, if it's still loaded. |
| -function scrollToMessage(id) { |
| - const target = document.querySelector(`.msg[data-id="${id}"]`); |
| - if (!target) return; |
| - target.scrollIntoView({ behavior: "smooth", block: "center" }); |
| - target.classList.remove("flash"); |
| - void target.offsetWidth; // restart the animation if it's still mid-flash |
| - target.classList.add("flash"); |
| -} |
| - |
| -// A tappable quote of the message being replied to, shown above the reply. |
| -function buildReplyQuote(rt) { |
| - const q = document.createElement("button"); |
| - q.type = "button"; |
| - q.className = "reply-quote"; |
| - const who = document.createElement("div"); |
| - who.className = "reply-quote-name"; |
| - who.textContent = rt.name || ""; |
| - const txt = document.createElement("div"); |
| - txt.className = "reply-quote-text"; |
| - txt.textContent = rt.snippet || ""; |
| - q.appendChild(who); |
| - q.appendChild(txt); |
| - q.onclick = () => scrollToMessage(rt.id); |
| - return q; |
| -} |
| - |
| -// Desktop-only: a reply icon revealed beside the bubble on hover, so reply is |
| -// discoverable without the press-and-hold. (Touch uses swipe-right instead.) |
| -function buildHoverReply(meta) { |
| - const b = document.createElement("button"); |
| - b.type = "button"; |
| - b.className = "reply-hover"; |
| - b.innerHTML = ICON_REPLY; |
| - b.setAttribute("aria-label", "Reply"); |
| - b.onclick = (e) => { |
| - e.stopPropagation(); |
| - startReply(meta); |
| - }; |
| - return b; |
| -} |
| - |
| -// Desktop-only edit icon, mirroring buildHoverReply — only for your own in-window |
| -// messages, so editing is discoverable without the press-and-hold picker. |
| -function buildHoverEdit(id) { |
| - const b = document.createElement("button"); |
| - b.type = "button"; |
| - b.className = "edit-hover"; |
| - b.innerHTML = ICON_EDIT; |
| - b.setAttribute("aria-label", "Edit"); |
| - b.onclick = (e) => { |
| - e.stopPropagation(); |
| - startEdit(id); |
| - }; |
| - return b; |
| -} |
| - |
| -// Desktop-only react icon beside the reply icon; opens the emoji picker anchored |
| -// to itself. |
| -function buildHoverReact(id) { |
| - const b = document.createElement("button"); |
| - b.type = "button"; |
| - b.className = "react-hover"; |
| - b.innerHTML = ICON_REACT; |
| - b.setAttribute("aria-label", "React"); |
| - b.onclick = (e) => { |
| - e.stopPropagation(); |
| - openReactionPicker(id, b); |
| - }; |
| - return b; |
| -} |
| - |
| -// Swipe a message to the right (touch) to reply — à la Messages/WhatsApp. The |
| -// message follows the finger; release past the threshold opens the reply. |
| -// touch-action: pan-y (CSS) keeps vertical scrolling native while we take the |
| -// horizontal drag; a small reply arrow fades in as you drag. |
| -function attachSwipeReply(el, meta) { |
| - const TRIGGER = 56; |
| - const MAX = 80; |
| - let startX = 0, |
| - startY = 0, |
| - dx = 0, |
| - mode = "", // "" undecided, "swipe", or "no" (vertical/other) |
| - icon = null, |
| - raf = 0; |
| - |
| - // Write the transform once per frame (rAF-batched) using translate3d, which |
| - // keeps the bubble on a GPU compositor layer — no per-move repaint of the |
| - // subtree, so the drag stays smooth even in the iOS webview. |
| - const render = () => { |
| - raf = 0; |
| - el.style.transform = "translate3d(" + dx + "px,0,0)"; |
| - if (icon) icon.style.opacity = Math.min(1, dx / TRIGGER); |
| - }; |
| - const schedule = () => { |
| - if (!raf) raf = requestAnimationFrame(render); |
| - }; |
| - |
| - const cleanup = (animate) => { |
| - if (raf) { |
| - cancelAnimationFrame(raf); |
| - raf = 0; |
| - } |
| - if (icon) { |
| - icon.remove(); |
| - icon = null; |
| - } |
| - if (animate) { |
| - el.style.transition = "transform 0.18s ease"; |
| - el.style.transform = "translate3d(0,0,0)"; |
| - const done = () => { |
| - el.style.transition = ""; |
| - el.style.transform = ""; |
| - el.style.willChange = ""; |
| - el.removeEventListener("transitionend", done); |
| - }; |
| - el.addEventListener("transitionend", done); |
| - } else { |
| - el.style.transition = ""; |
| - el.style.transform = ""; |
| - el.style.willChange = ""; |
| - } |
| - }; |
| - |
| - el.addEventListener("pointerdown", (e) => { |
| - if (e.pointerType !== "touch") return; |
| - startX = e.clientX; |
| - startY = e.clientY; |
| - dx = 0; |
| - mode = ""; |
| - }); |
| - el.addEventListener("pointermove", (e) => { |
| - if (e.pointerType !== "touch") return; |
| - const ax = e.clientX - startX; |
| - const ay = e.clientY - startY; |
| - if (mode === "") { |
| - if (Math.abs(ax) < 10 && Math.abs(ay) < 10) return; |
| - mode = ax > 0 && Math.abs(ax) > Math.abs(ay) * 1.3 ? "swipe" : "no"; |
| - if (mode === "swipe") { |
| - el.style.willChange = "transform"; // promote the bubble to its own layer for the drag |
| - icon = document.createElement("div"); |
| - icon.className = "swipe-reply-icon"; |
| - icon.innerHTML = ICON_REPLY; |
| - el.appendChild(icon); |
| - } |
| - } |
| - if (mode !== "swipe") return; |
| - dx = Math.max(0, Math.min(ax, MAX)); |
| - schedule(); |
| - }); |
| - const onEnd = (e) => { |
| - if (e.pointerType && e.pointerType !== "touch") return; |
| - if (mode === "swipe") { |
| - const trigger = dx >= TRIGGER; |
| - cleanup(true); |
| - if (trigger) startReply(meta); |
| - } |
| - mode = ""; |
| - }; |
| - el.addEventListener("pointerup", onEnd); |
| - el.addEventListener("pointercancel", onEnd); |
| -} |
| - |
| -// Begin replying: stage the parent and show a cancelable banner above the |
| -// composer. Replying and editing are mutually exclusive. |
| -function startReply(meta) { |
| - if (!meta) return; |
| - cancelEdit(); |
| - replyingTo = meta; |
| - const feed = document.getElementById("feed"); |
| - const atBottom = feed && feed.scrollHeight - feed.scrollTop - feed.clientHeight < 80; |
| - const box = document.getElementById("composer-reply"); |
| - box.innerHTML = ""; |
| - const bar = document.createElement("div"); |
| - bar.className = "reply-bar"; |
| - const icon = document.createElement("span"); |
| - icon.className = "reply-bar-icon"; |
| - icon.innerHTML = ICON_REPLY; |
| - const body = document.createElement("div"); |
| - body.className = "reply-bar-body"; |
| - const who = document.createElement("div"); |
| - who.className = "reply-bar-name"; |
| - who.textContent = "Replying to " + (meta.name || ""); |
| - const txt = document.createElement("div"); |
| - txt.className = "reply-bar-text"; |
| - txt.textContent = meta.snippet || ""; |
| - body.appendChild(who); |
| - body.appendChild(txt); |
| - const x = document.createElement("button"); |
| - x.type = "button"; |
| - x.className = "reply-bar-cancel"; |
| - x.innerHTML = ICON_X; |
| - x.setAttribute("aria-label", "Cancel reply"); |
| - x.onclick = cancelReply; |
| - bar.appendChild(icon); |
| - bar.appendChild(body); |
| - bar.appendChild(x); |
| - box.appendChild(bar); |
| - box.classList.remove("hidden"); |
| - if (atBottom && feed) feed.scrollTop = feed.scrollHeight; |
| - // The native shell draws its own reply banner (this one is display:none in |
| - // embed mode) and focuses its own composer. |
| - if (embed) nativePost({ replying: { name: meta.name || "", snippet: meta.snippet || "" } }); |
| - else document.getElementById("text").focus(); |
| - // ESC key cancels reply but preserves text |
| - const replyEscHandler = (e) => { |
| - if (e.key === "Escape" && replyingTo) { |
| - e.preventDefault(); |
| - cancelReply(); |
| - } |
| - }; |
| - document.addEventListener("keydown", replyEscHandler); |
| - // Store handler so it can be removed if a new reply starts or edit begins |
| - replyEscHandler._bound = true; |
| - cancelReply._escHandler = replyEscHandler; |
| -} |
| - |
| -function cancelReply() { |
| - if (cancelReply._escHandler) { |
| - document.removeEventListener("keydown", cancelReply._escHandler); |
| - cancelReply._escHandler = null; |
| - } |
| - replyingTo = null; |
| - const box = document.getElementById("composer-reply"); |
| - if (box) { |
| - box.innerHTML = ""; |
| - box.classList.add("hidden"); |
| - } |
| - if (embed) nativePost({ replying: null }); |
| -} |
| - |
| -// Returns true when the composer should be reset (message sent, or nothing to |
| -// send), and false when it should be left intact — i.e. an attachment is still |
| -// uploading, so the caller keeps the typed text rather than wiping it. |
| -// Render a command's private "only visible to you" reply into the original feed |
| -// (a client-only row — never sent, gone on reload). The chat mini-app has its |
| -// own copy against .chatapp-feed. |
| -function showLocalNotice(response) { |
| - const feed = document.getElementById("feed"); |
| - if (!feed) return; |
| - feed.appendChild(buildLocalNotice(response)); |
| - feed.scrollTop = feed.scrollHeight; |
| -} |
| - |
| -function sendMessage(raw) { |
| - const text = emojify((raw || "").trim()); // :tada: → 🎉 before encrypting |
| - // Can't send right now (no open thread, or the socket is down / reconnecting) — |
| - // return false so the composer keeps what's typed rather than clearing it. The |
| - // send button is greyed meanwhile, but Enter can still land here. |
| - if (!activeId || !ws || ws.readyState !== WebSocket.OPEN) return false; |
| - |
| - // A slash command (e.g. "/todo buy milk", or an unknown "/foo"): handle it |
| - // instead of sending a chat message, answering in place with a private "only |
| - // visible to you" reply appended to this feed. Skipped while editing so you can |
| - // still edit a message whose text starts with "/". |
| - if (editing == null && runCommand(text, activeId, showLocalNotice)) return true; |
| - |
| - // Editing an existing message: re-encrypt the original payload with new text |
| - // (preserving its name + any attachments) and send an edit. |
| - if (editing != null) { |
| - // Capture the id before cancelEdit() (below) resets `editing` to null — the |
| - // encrypt().then() send is async, so reading `editing` inside it would race |
| - // and ship message_id: null, which the server drops. |
| - const id = editing; |
| - const orig = msgData[id] && msgData[id].data; |
| - if (text && orig) { |
| - encrypt(threadKeys[activeId], { ...orig, text }).then((blob) => { |
| - ws.send(JSON.stringify({ type: "edit", thread_id: activeId, message_id: id, blob })); |
| - }); |
| - } |
| - cancelEdit(); |
| - return true; |
| - } |
| - |
| - // Don't send while any attachment is still uploading — the send button is |
| - // disabled meanwhile, but Enter can still reach here. Bail so the typed text |
| - // is kept; the user sends once every chip resolves. |
| - if (pending.some((p) => p.loading)) return false; |
| - if (!text && !pending.length) return true; |
| - // The sender's user id rides inside the ciphertext (never as server metadata): |
| - // it's what lets your other devices recognise the message as yours. |
| - const payload = { name: session.name, text, ts: Date.now() }; |
| - if (myUserId) payload.uid = myUserId; |
| - if (pending.length) { |
| - // The name/type/size ride inside the encrypted payload; strip local-only fields. |
| - payload.attachments = pending.map((a) => { |
| - const out = { id: a.id, name: a.name, type: a.type }; |
| - if (a.w) out.w = a.w; |
| - if (a.h) out.h = a.h; |
| - if (a.size) out.size = a.size; |
| - return out; |
| - }); |
| - } |
| - // Carry the reply link inside the encrypted payload (the server stays blind). |
| - if (replyingTo) payload.replyTo = replyingTo; |
| - clearPending(); |
| - cancelReply(); |
| - encrypt(threadKeys[activeId], payload).then((blob) => { |
| - ws.send(JSON.stringify({ type: "msg", thread_id: activeId, blob })); |
| - }); |
| - return true; |
| -} |
| - |
| // ---- mini-apps ---------------------------------------------------------- |
| // Mini-app events ride the exact same encrypted-message path as chat: the app |
| // only produces a typed payload, and the transport wraps it with the same |
| @@ -3764,32 +2375,6 @@ function lastEditableMine() { |
| return best; |
| } |
| |
| -// Enter edit mode: load the message text into the composer with an "Editing" banner. |
| -function startEdit(msgId) { |
| - const d = msgData[msgId]; |
| - if (!d || !d.data) return; |
| - cancelReply(); // editing and replying are mutually exclusive |
| - editing = msgId; |
| - const text = document.getElementById("text"); |
| - text.value = d.data.text || ""; |
| - text.dispatchEvent(new Event("input")); // resize + reveal send button |
| - text.focus(); |
| - const banner = document.getElementById("composer-edit"); |
| - banner.textContent = "Editing message"; |
| - const x = document.createElement("button"); |
| - x.className = "banner-x"; |
| - x.type = "button"; |
| - x.textContent = "×"; |
| - x.onclick = cancelEdit; |
| - banner.appendChild(x); |
| - banner.classList.remove("hidden"); |
| - // Native shell: the web composer + banner above are display:none in embed mode, |
| - // so hand the original text to the native composer and let it show its own |
| - // "Editing" banner (mirrors the reply bridge). A native send then hits the |
| - // editing path in sendMessage just like the web composer would. |
| - if (embed) nativePost({ editing: { text: d.data.text || "" } }); |
| -} |
| - |
| function cancelEdit() { |
| editing = null; |
| const banner = document.getElementById("composer-edit"); |
| @@ -3980,41 +2565,6 @@ async function loadBlob(id, threadId = activeId, type) { |
| } |
| } |
| |
| -// A download chip for a non-image attachment: icon + name + size. The bytes are |
| -// fetched + decrypted lazily; clicking downloads the file under its real name. |
| -// textContent only — never innerHTML on the attacker-controlled name. |
| -function buildFileCard(a, threadId) { |
| - const card = document.createElement("a"); |
| - card.className = "file-chip"; |
| - card.href = "#"; |
| - card.download = a.name || "file"; |
| - const icon = document.createElement("span"); |
| - icon.className = "file-icon"; |
| - icon.textContent = fileIcon(a.name); |
| - const meta = document.createElement("span"); |
| - meta.className = "file-meta"; |
| - const nm = document.createElement("span"); |
| - nm.className = "file-name"; |
| - nm.textContent = a.name || "file"; |
| - meta.appendChild(nm); |
| - if (a.size) { |
| - const sz = document.createElement("span"); |
| - sz.className = "file-sub"; |
| - sz.textContent = formatBytes(a.size); |
| - meta.appendChild(sz); |
| - } |
| - card.appendChild(icon); |
| - card.appendChild(meta); |
| - // Don't follow the placeholder "#" href if tapped before the blob is ready. |
| - card.addEventListener("click", (e) => { |
| - if (card.getAttribute("href") === "#") e.preventDefault(); |
| - }); |
| - loadBlob(a.id, threadId, a.type).then((url) => { |
| - if (url) card.href = url; |
| - }); |
| - return card; |
| -} |
| - |
| function openLightbox(url, source) { |
| const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches; |
| const back = document.createElement("div"); |
| @@ -4444,11 +2994,11 @@ function showConnStatus(text) { |
| async function catchUpActiveMessages() { |
| const id = activeId; |
| if (!id || !threadKeys[id]) return; |
| - // A chat-app thread catches up through the fold: re-fetch the window, fold |
| - // anything unseen (per-message-id dedupe makes the rest no-ops), re-render. |
| - // Without this, messages missed while the socket was down would only appear |
| - // after leaving and re-entering the thread. |
| - if (chatAppFor(id)) { |
| + // An app-rendered thread (the chat app, or a personal app-thread) catches up |
| + // through the fold: re-fetch the window, fold anything unseen (per-message-id |
| + // dedupe makes the rest no-ops), re-render. Without this, messages missed while |
| + // the socket was down would only appear after leaving and re-entering the thread. |
| + if (activeAppFor(id)) { |
| try { |
| const r = await ( |
| await fetch(`/api/messages?token=${encodeURIComponent(session.token)}&thread=${encodeURIComponent(id)}`) |
| @@ -4461,27 +3011,6 @@ async function catchUpActiveMessages() { |
| } |
| return; |
| } |
| - try { |
| - const r = await ( |
| - await fetch(`/api/messages?token=${encodeURIComponent(session.token)}&thread=${encodeURIComponent(id)}`) |
| - ).json(); |
| - if (id !== activeId) return; // switched threads while fetching — abandon |
| - const feed = document.getElementById("feed"); |
| - let added = 0; |
| - for (const m of r.messages || []) { |
| - if (msgEls[m.id]) continue; // already rendered (live or from the initial load) |
| - await appendMessage(id, m, false); |
| - added++; |
| - } |
| - // Jump to the newest when we appended something — and also when the user |
| - // was last at the bottom: a message that live-appended while rendering was |
| - // suspended (hidden tab, parked webview) never finished its smooth scroll, |
| - // leaving the feed shy of the newest with nothing left to append. Someone |
| - // reading history is never yanked down (feedAtBottom is false for them). |
| - if ((added || feedAtBottom) && id === activeId && feed) pinFeedToBottom(feed, openSeq); |
| - } catch (e) { |
| - console.error("catch up messages", e); |
| - } |
| } |
| |
| // Catch up on messages missed while we were offline. The WebSocket only relays |
| @@ -4597,9 +3126,6 @@ function connectWS() { |
| foldHostEvent(msg.thread_id, "chat", { type: "chat.react", msgId: msg.message_id, reactions: msg.reactions || [] }); |
| if (msg.thread_id === activeId) renderActiveSurfaces(); |
| } |
| - if (msg.thread_id === activeId && msgEls[msg.message_id]) { |
| - renderReactions(msgEls[msg.message_id], msg.message_id, msg.reactions || []); |
| - } |
| return; |
| } |
| if (msg.type === "edit") { |
| @@ -4612,21 +3138,6 @@ function connectWS() { |
| if (msg.thread_id === activeId) renderActiveSurfaces(); |
| } catch {} |
| } |
| - if (msg.thread_id === activeId && msgEls[msg.id]) { |
| - try { |
| - const nd = await decrypt(threadKeys[msg.thread_id], msg.blob); |
| - // Re-validate after the await: a fast thread switch could have cleared |
| - // msgEls or changed the active thread while we were decrypting. |
| - if (msg.thread_id !== activeId || !msgEls[msg.id]) return; |
| - const bubble = msgEls[msg.id].querySelector(".bubble"); |
| - if (bubble) { |
| - bubble.textContent = ""; |
| - renderText(bubble, nd.text || ""); |
| - markEdited(msgEls[msg.id]); |
| - } |
| - if (msgData[msg.id]) msgData[msg.id].data = nd; |
| - } catch {} |
| - } |
| return; |
| } |
| if (msg.type === "delete") { |
| @@ -4638,24 +3149,6 @@ function connectWS() { |
| } |
| // Hard-deleted upstream: drop the bubble + its tracked state. (A separator |
| // above it may briefly linger; harmless and gone on next thread open.) |
| - if (msg.thread_id === activeId && msgEls[msg.id]) { |
| - const next = nextMsgOf(msgEls[msg.id]); |
| - // Keep reply tallies honest: drop this message's tag, and if it was itself a |
| - // reply, decrement its parent's count. |
| - const parent = msgData[msg.id]?.data?.replyTo?.id; |
| - delete replyCountEls[msg.id]; |
| - if (parent && replyCounts[parent]) { |
| - replyCounts[parent]--; |
| - paintReplyCount(parent); |
| - } |
| - msgEls[msg.id].remove(); |
| - delete msgEls[msg.id]; |
| - delete msgData[msg.id]; |
| - if (next) { |
| - refreshSeparator(next); |
| - refreshSenderName(next); |
| - } |
| - } |
| return; |
| } |
| if (msg.type === "typing") { |
| @@ -4721,10 +3214,9 @@ function connectWS() { |
| foldEvent(tid, payload, { id: msg.id, ts: payload.ts }); |
| foldAppEventBubble(tid, owner, payload, { id: msg.id, ts: payload.ts }); |
| if (tid === activeId) renderActiveSurfaces(); |
| - } else if (tid === activeId) { |
| - await appendMessage(tid, msg, true); |
| } else { |
| foldEvent(tid, payload, { id: msg.id, ts: payload.ts }); |
| + if (tid === activeId) renderActiveSurfaces(); |
| } |
| notifyLive(tid, payload); |
| const pv = appPreview(payload); |
| @@ -4745,12 +3237,7 @@ function connectWS() { |
| foldHostEvent(tid, "chat", { ...payload, type: "chat.msg" }, { id: msg.id, ts: payload.ts }); |
| if (tid === activeId) renderActiveSurfaces(); |
| } |
| - if (tid === activeId) { |
| - // A chat-app thread already drew this message via renderActiveSurfaces() |
| - // above; the classic-feed appendMessage would only re-decrypt the same blob |
| - // and paint a phantom bubble into the hidden #feed, so skip it there. |
| - if (!chatAppFor(tid)) await appendMessage(tid, msg, true); |
| - } else { |
| + if (tid !== activeId) { |
| unread[tid] = !ownMessage(payload, myUserId, session.name); |
| bumpThread(tid); |
| } |
| @@ -6358,59 +4845,6 @@ function closeTypeahead() { |
| } |
| } |
| |
| -// ---- 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) { |
| @@ -6447,45 +4881,6 @@ function openMentionMenu(entry, anchor) { |
| setTimeout(() => document.addEventListener("pointerdown", close, true), 0); |
| } |
| |
| -function renderInline(el, text) { |
| - for (const t of richTokens(text)) { |
| - if (t.href) { |
| - const a = document.createElement("a"); |
| - a.href = t.href; |
| - a.target = "_blank"; |
| - a.rel = "noopener noreferrer"; |
| - a.textContent = t.text; |
| - el.appendChild(a); |
| - } else if (t.mark) { |
| - const w = document.createElement(MARK_TAG[t.mark]); |
| - w.textContent = t.text; |
| - el.appendChild(w); |
| - } else { |
| - appendTextWithMentions(el, t.text); |
| - } |
| - } |
| -} |
| - |
| -// Block structure first (``` fences, > quotes), then inline formatting inside |
| -// everything except code, which renders verbatim. Still no innerHTML anywhere. |
| -function renderText(el, text) { |
| - for (const b of blockTokens(text)) { |
| - if (b.type === "code") { |
| - const pre = document.createElement("pre"); |
| - const code = document.createElement("code"); |
| - code.textContent = b.text; |
| - pre.appendChild(code); |
| - el.appendChild(pre); |
| - } else if (b.type === "quote") { |
| - const q = document.createElement("blockquote"); |
| - renderInline(q, b.text); |
| - el.appendChild(q); |
| - } else { |
| - renderInline(el, b.text); |
| - } |
| - } |
| -} |
| - |
| // A centered modal sheet. Click the backdrop or Esc to dismiss. |
| function modal() { |
| const prevFocus = document.activeElement; // restored when the sheet closes |