eleven / messenger Public

web: retire the hidden legacy composer — #text, its staging chain, and the DOM typeahead (#931)

The classic composer's send path died with the classic feed; what
remained was a hidden form (#text/#send-btn/#attach-btn), a staging
chain whose uploads could never send, an edit/reply banner nothing
could trigger (editing had no writers), and the #text-bound typeahead
DOM (updateTypeahead had zero callers). All deleted, with the live
tenants relocated: typeaheadItems + openMentionMenu (both bridge-fed)
stay, processImage untouched. The native bridge's pre-mount stage()
fallback — which staged into the black hole — now queues files and
flushes them when the chat app registers.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 44e276e8b9d68e67c6d42650a98864e653487dd4 parent 23917e1
2 files changed, +11 −290
  • web/pages/index.html +0 −24
  • web/static/shell.js +11 −266
diff --git a/web/pages/index.html b/web/pages/index.html
index 0e811b2..37301bc 100644
--- a/web/pages/index.html
+++ b/web/pages/index.html
@@ -227,30 +227,6 @@
<div class="app-pane interface-pane" id="interface-pane"></div>
</div>
<div class="drop-overlay hidden" id="drop-overlay">Drop a photo or file to send</div>
- <!-- Zero-height anchor at the top of the composer stack: the staged-
- attachment chips paint upward from here, floating over the feed
- instead of occupying their own band (see .composer-float). -->
- <div class="composer-float">
- <div class="composer-preview hidden" id="composer-preview"></div>
- </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>
- <form class="composer" id="composer" autocomplete="off">
- <button type="button" class="attach-btn" id="attach-btn" aria-label="Add a photo or file">+</button>
- <input type="file" id="file-input" multiple
- accept="image/*,.pdf,.txt,.md,.markdown,.csv,.json,.doc,.docx,.xls,.xlsx,.ppt,.pptx" hidden />
- <div class="composer-field">
- <textarea id="text" placeholder="Message" rows="1" autocomplete="off"></textarea>
- <button type="submit" class="send-btn" id="send-btn" aria-label="Send" hidden>
- <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor"
- stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
- <line x1="12" y1="19" x2="12" y2="5"></line>
- <polyline points="6 11 12 5 18 11"></polyline>
- </svg>
- </button>
- </div>
- </form>
</div>
</section>
</div>
diff --git a/web/static/shell.js b/web/static/shell.js
index 7df5f3b..5d5ccc0 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -508,8 +508,7 @@ function showThemePicker() {
card.appendChild(foot);
}
-let pending = []; // attachments staged in the composer, to send with the next message
-let attachSeq = 0; // local id counter for staged attachments (drives per-chip removal)
+const preMountStaged = []; // native-staged files awaiting the chat app's composer (flushed by registerNative)
const blobURLs = {}; // blob id -> decrypted object URL (cache)
let instanceKey = null; // shared key for all-members content (profile photos)
let instanceKeyB64 = null;
@@ -757,9 +756,6 @@ const TIME_SEPARATOR_GAP_MS = 15 * 60 * 1000;
// The UI icon set (web/static/icons.js — Lucide idiom, docs/icons.md).
import { ICON_REPLY, ICON_X, ICON_EDIT, ICON_REACT, ICON_TRASH, ICON_PLAY, ICON_PIN, ICON_MUTE, ICON_REPORT, ICON_COPY, ICON_CHEVRON_LEFT, ICON_MORE_VERTICAL, ICON_BRAIN, ICON_WRENCH } from "/static/icons.js";
-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
const app = document.getElementById("app");
// The native shell (iOS/macOS) injects window.__lchatEmbed at documentStart with
@@ -1286,7 +1282,10 @@ function installNativeBridge() {
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
const file = new File([bytes], att.name || "photo.jpg", { type: att.type });
if (chatAppNative) chatAppNative.stage(file);
- else addAttachment(file);
+ // Pre-mount: hold the file until the chat app registers — the legacy
+ // staging this used to fall back to had no send path left (a black
+ // hole). registerNative flushes the queue.
+ else preMountStaged.push(file);
} catch {
nativePost({ error: "Couldn't attach that photo." });
}
@@ -1299,8 +1298,7 @@ function installNativeBridge() {
// The native edit banner's ✕ — drops edit mode (cancelEdit posts
// {editing: null} back so the native banner + prefilled composer clear).
cancelEdit() {
- if (chatAppNative) return chatAppNative.cancelEdit();
- cancelEdit();
+ if (chatAppNative) chatAppNative.cancelEdit();
},
// Switch a companion app-thread between its Chat and Interface views —
// the native header's toggle drives this (the web's own header toggle
@@ -2672,15 +2670,12 @@ function isAdmin() {
// True when a composer holds unsent text — reload paths check this so picking up
// an update never eats someone's draft. Covers every mounted chat-app composer
-// (Split view shows two) plus the legacy #text box.
+// (Split view shows two).
function hasUnsentDraft() {
// The chat composer(s) live behind the composer.js facade — Split view can
// mount two, and the rich backend isn't a textarea. Same semantics as the
// old DOM query: only MOUNTED composers block (parked panes never did).
- if (anyComposerHasText()) return true;
- // The retired classic feed's #text input, until its deletion.
- const t = document.getElementById("text");
- return !!(t && t.value && t.value.trim());
+ return anyComposerHasText();
}
// On returning to the app, ask the server for the current build hash; if it shipped
@@ -4984,6 +4979,7 @@ setChatAppHost({
native: (msg) => nativePost(msg),
registerNative: (handlers) => {
chatAppNative = handlers;
+ for (const f of preMountStaged.splice(0)) handlers.stage(f);
},
// Moderation (issue #578): the muted-people set lives here in the shell
// (sealed prefs blob, synced across devices); the app folds a muted member's
@@ -7194,25 +7190,9 @@ function renderActiveSurfaces() {
} else renderSurfaces(activeId, document.getElementById("app-surface"));
}
-function cancelEdit() {
- editing = null;
- const banner = document.getElementById("composer-edit");
- if (banner) {
- banner.textContent = "";
- banner.classList.add("hidden");
- }
- const text = document.getElementById("text");
- if (text && document.activeElement === text) {
- text.value = "";
- text.dispatchEvent(new Event("input"));
- }
- if (embed) nativePost({ editing: null });
-}
-
// Unsend a message (server deletes it for everyone if we're the author).
function deleteMessage(msgId) {
if (!ws || ws.readyState !== WebSocket.OPEN || !activeId) return;
- if (editing === msgId) cancelEdit(); // don't keep editing a message we're deleting
ws.send(JSON.stringify({ type: "delete", thread_id: activeId, message_id: msgId }));
}
@@ -7318,139 +7298,6 @@ async function processImage(file, maxDim = MAX_IMAGE_DIM) {
return { bytes: new Uint8Array(await out.arrayBuffer()), type: "image/jpeg", w, h, bh };
}
-// Stage one attachment. Several can ride a single message, so each gets its own
-// entry in `pending` with a local uid; the entry shows a loading chip until its
-// upload resolves, then becomes a thumbnail (image) or a file chip.
-async function addAttachment(file) {
- if (!activeId || !file) return;
- const isImage = !!(file.type && file.type.startsWith("image/"));
- // Images take the downscale/re-encode path; other files must pass the
- // extension allowlist (the safety guard — executables/scripts have no entry).
- if (!isImage && !fileAllowed(file.name)) {
- alert("That file type isn't supported. You can share images, PDFs, text, CSV, JSON, and Office documents.");
- return;
- }
- const entry = { uid: ++attachSeq, isImage, loading: true, name: file.name || (isImage ? "photo" : "file") };
- pending.push(entry);
- renderPreview();
- updateSendVisibility();
- try {
- if (isImage) {
- const { bytes, type, w, h } = await processImage(file);
- if (bytes.length > MAX_UPLOAD) throw new Error("too big");
- entry.id = await uploadBytes(bytes);
- Object.assign(entry, { type, w, h, name: (file.name || "photo").replace(/\.[^.]+$/, "") + ".jpg" });
- } else {
- const bytes = new Uint8Array(await file.arrayBuffer());
- if (bytes.length > MAX_UPLOAD) throw new Error("too big");
- entry.id = await uploadBytes(bytes);
- // The name/type/size ride inside the encrypted message, never as server
- // metadata — the blob is opaque ciphertext.
- Object.assign(entry, { type: fileMime(file.name, file.type), size: bytes.length });
- }
- entry.loading = false;
- renderPreview();
- updateSendVisibility();
- } catch (e) {
- console.error(e);
- pending = pending.filter((p) => p !== entry);
- renderPreview();
- updateSendVisibility();
- const msg = isImage ? "Couldn't add that photo. Try a smaller one." : `Couldn't add that file (max ${formatBytes(MAX_UPLOAD)}).`;
- // alert() is invisible inside the native shell's webview — post it instead.
- if (embed) nativePost({ error: msg });
- else alert(msg);
- }
-}
-
-// Encrypt bytes with the active thread key and upload them; returns the blob id.
-async function uploadBytes(bytes) {
- const enc = await encryptBytes(threadKeys[activeId], bytes);
- const res = await fetch(`/api/upload?token=${encodeURIComponent(session.token)}`, { method: "POST", body: enc });
- if (!res.ok) throw new Error("upload");
- const { id } = await res.json();
- return id;
-}
-
-function renderPreview() {
- // Tell the native shell an attachment is (un)staged, so its send button enables.
- if (embed) nativePost({ staged: pending.length > 0 });
- const preview = document.getElementById("composer-preview");
- preview.textContent = "";
- if (!pending.length) {
- preview.classList.add("hidden");
- return;
- }
- preview.classList.remove("hidden");
- for (const entry of pending) preview.appendChild(buildPreviewTile(entry));
-}
-
-// One staged attachment as a removable chip: a spinner while uploading, then an
-// image thumbnail or a file chip. Each chip carries its own remove button.
-function buildPreviewTile(entry) {
- const chip = document.createElement("div");
- chip.className = "preview-chip";
- if (entry.loading) {
- const spin = document.createElement("span");
- spin.className = "preview-spinner";
- spin.setAttribute("aria-hidden", "true");
- const label = document.createElement("span");
- label.className = "preview-name";
- label.textContent = entry.isImage ? "Adding photo…" : "Adding file…";
- chip.append(spin, label);
- } else if (entry.isImage) {
- const img = document.createElement("img");
- img.alt = entry.name || "photo";
- loadBlob(entry.id).then((url) => url && (img.src = url));
- chip.appendChild(img);
- } else {
- const icon = document.createElement("span");
- icon.className = "preview-file";
- icon.textContent = fileIcon(entry.name);
- const name = document.createElement("span");
- name.className = "preview-name";
- name.textContent = entry.name || "file";
- chip.append(icon, name);
- }
- const x = document.createElement("button");
- x.className = "preview-remove";
- x.type = "button";
- x.setAttribute("aria-label", "Remove attachment");
- x.innerHTML = ICON_X; // static in-code SVG
- x.onclick = () => removeAttachment(entry.uid);
- chip.appendChild(x);
- return chip;
-}
-
-function removeAttachment(uid) {
- pending = pending.filter((p) => p.uid !== uid);
- renderPreview();
- updateSendVisibility();
-}
-
-function clearPending() {
- pending = [];
- if (embed) nativePost({ staged: false });
- const preview = document.getElementById("composer-preview");
- if (preview) {
- preview.textContent = "";
- preview.classList.add("hidden");
- }
-}
-
-// The send button shows when there's text or any staged attachment, but stays
-// disabled until every attachment has finished uploading — so a still-uploading
-// file can't be silently dropped from the message.
-function updateSendVisibility() {
- const text = document.getElementById("text");
- const sendBtn = document.getElementById("send-btn");
- if (!text || !sendBtn) return;
- sendBtn.hidden = text.value.trim() === "" && pending.length === 0;
- // Disabled while an attachment is still uploading, or while the socket is down
- // (a send would be silently dropped) — greyed via .send-btn:disabled in CSS.
- sendBtn.disabled = pending.some((p) => p.loading) || !wsConnected;
-}
-
// Fetch + decrypt a blob to raw bytes (no object URL) — for consumers that
// need the plaintext itself (the setup flow re-posting a sent photo as the
// branding image; fetch(blob:) is blocked by the page CSP's connect-src).
@@ -7583,7 +7430,6 @@ function connectWS() {
}
showConnStatus("");
wsConnected = true;
- updateSendVisibility(); // re-enable the send button now the socket's back
setChatAppConnected(true); // and the mini-app composer's send button
if (pingTimer) clearInterval(pingTimer);
// Idle proxies drop WebSockets after ~60s; a tiny ping every 25s keeps it open.
@@ -8038,7 +7884,6 @@ function connectWS() {
};
ws.onclose = () => {
wsConnected = false;
- updateSendVisibility(); // grey out send — a message now would be dropped
setChatAppConnected(false); // and grey the mini-app composer's send button too
if (pingTimer) {
clearInterval(pingTimer);
@@ -8683,7 +8528,7 @@ function updateRenameBanner(t) {
// the composer so keyboard users aren't dropped onto <body>.
const choose = async (keepMine) => {
await resolveRename(other, keepMine);
- const box = view.querySelector(".chatapp-root .composer .composer-input") || document.getElementById("text");
+ const box = view.querySelector(".chatapp-root .composer .composer-input");
if (box) box.focus();
};
for (const b of renameChoices(notice, choose)) bar.appendChild(b);
@@ -11078,20 +10923,7 @@ 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();
- const items = typeaheadItems(tok);
- if (!items.length) return closeTypeahead();
- typeahead = { kind: tok.kind, start: tok.start, items, index: 0 };
- renderTypeahead();
-}
+// ---- @mention: candidate ranking + the tap-a-mention menu ---------------
// Candidates for a composer token — shared by the web menu above and the native
// shell's strip (window.lchatNative.typeahead), so both rank identically.
@@ -11131,93 +10963,6 @@ function typeaheadItems(tok) {
].slice(0, 8);
}
-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 === "command") {
- const n = document.createElement("span");
- n.className = "ta-name";
- n.textContent = it.command + (it.args ? " " + it.args : "");
- row.appendChild(n);
- if (it.desc) {
- const d = document.createElement("span");
- d.className = "ta-desc";
- d.textContent = it.desc;
- row.appendChild(d);
- }
- } else 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);
- // I call them something else — say whose @name will insert.
- if (it.goesBy) {
- const d = document.createElement("span");
- d.className = "ta-desc";
- d.textContent = "@" + it.goesBy;
- row.appendChild(d);
- }
- }
- // 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 = "";
- }
-}
// 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.