| 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. |