| index 352dee8..562c9ac 100644 |
| --- a/web/static/apps/chat.js |
| +++ b/web/static/apps/chat.js |
| @@ -25,6 +25,7 @@ |
| import { defineApp, keyedList, runCommand, runAppMention, messageActionsFor, renderSurfaces, commandCatalog, buildLocalNotice, renderApp, deliverAnswer, parseMeCommand, defaultCommandFor, appChatReadOnly, setCommandPanelHost, notifyCommandPanel, motionOK } from "../apps.js"; |
| import { replySnippet, formatBytes, emojiOnlyCount, firstLink, youtubeId, typingSummary } from "../format.js"; |
| import { parseMessage, renderTree } from "../markdown.js"; |
| +import { createComposer } from "../composer.js"; |
| import { renderCard, CARD_CSS } from "../cards.js"; |
| import { emojify } from "../emoji.js"; |
| import { fileIcon, shouldSend, composerToken, ownMessage, sameSender } from "../ui.js"; |
| @@ -2646,7 +2647,6 @@ function buildSkeleton(el, ctx) { |
| fieldLabel.className = "composer-label"; |
| fieldLabel.setAttribute("aria-hidden", "true"); |
| fieldLabel.hidden = true; |
| - const input = document.createElement("textarea"); |
| // When plain text runs the app's default command (typing into To-do adds a |
| // to-do — see defaultCommandFor), the placeholder must SAY so: the implicit |
| // routing is only honest if the composer advertises it before the first send. |
| @@ -2655,28 +2655,48 @@ function buildSkeleton(el, ctx) { |
| const dc = defaultCmd(); |
| return (dc && dc.app.composerPlaceholder) || "Message"; |
| }; |
| + // The message input itself lives behind the composer facade (composer.js) — |
| + // chat.js never touches an element's .value directly, so the rich backend |
| + // can slot in behind the same handle. The handler bodies are function |
| + // declarations further down (hoisted; they run long after the skeleton is |
| + // fully built, so the consts they close over are all live by then). |
| + const composer = createComposer({ |
| + placeholder: composerHints.get(ctx.threadId) || basePlaceholder(), |
| + onInput: () => onComposerInput(), |
| + onKeydown: (e) => onComposerKeydown(e), |
| + // Caret moves (click / arrows) can enter or leave a token too — re-check. |
| + onKeyup: (e) => { |
| + if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) updateTypeahead(); |
| + }, |
| + onClick: () => updateTypeahead(), |
| + // The original keyboard-inset rule (.composer.editing on coarse pointers) |
| + // keys off focus — mirror it so the composer sits on the keyboard. |
| + onFocus: () => compose.classList.add("editing"), |
| + onBlur: () => { |
| + compose.classList.remove("editing"); |
| + setTimeout(closeTypeahead, 100); |
| + }, |
| + onPaste: (e) => onComposerPaste(e), |
| + }); |
| const applyFieldLabel = () => { |
| const label = composerLabels.get(ctx.threadId) || ""; |
| fieldLabel.textContent = label; |
| fieldLabel.hidden = !label; |
| - input.setAttribute("aria-label", label ? label.replace(/[::]\s*$/, "") : "Message"); |
| + composer.setAriaLabel(label ? label.replace(/[::]\s*$/, "") : "Message"); |
| if (!label) { |
| - input.style.paddingLeft = ""; |
| + composer.setIndent(0); |
| return; |
| } |
| // Indent the text past the pill — measured after layout so the width is real. |
| requestAnimationFrame(() => { |
| - if (!fieldLabel.hidden) input.style.paddingLeft = `${fieldLabel.offsetWidth + 12 + 8}px`; |
| + if (!fieldLabel.hidden) composer.setIndent(fieldLabel.offsetWidth + 12 + 8); |
| }); |
| }; |
| - input.placeholder = composerHints.get(ctx.threadId) || basePlaceholder(); |
| refreshComposerHint.set(ctx.threadId, () => { |
| const attach = pending.length ? composerAttachHints.get(ctx.threadId) : ""; |
| - input.placeholder = attach || composerHints.get(ctx.threadId) || basePlaceholder(); |
| + composer.setPlaceholder(attach || composerHints.get(ctx.threadId) || basePlaceholder()); |
| applyFieldLabel(); |
| }); |
| - input.rows = 1; |
| - input.autocomplete = "off"; |
| const send = document.createElement("button"); |
| send.type = "submit"; |
| send.className = "send-btn"; |
| @@ -2684,7 +2704,7 @@ function buildSkeleton(el, ctx) { |
| send.hidden = true; |
| send.innerHTML = |
| '<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>'; |
| - field.append(fieldLabel, input, send); |
| + field.append(fieldLabel, composer.el, send); |
| compose.append(attach, fileInput, field); |
| // A default-command thread takes text only — with no one to read a file, an |
| // attachment is a dead end, so the affordance goes (stageFiles gates paste and |
| @@ -2878,7 +2898,7 @@ function buildSkeleton(el, ctx) { |
| refreshSend(); |
| // Removing a chip can resolve a queued send either way: nothing left to |
| // send cancels it; removing the last still-uploading chip releases it. |
| - if (sendQueued && !input.value.trim() && !pending.length) setSendQueued(false); |
| + if (sendQueued && composer.isEmpty() && !pending.length) setSendQueued(false); |
| else fireQueuedSend(); |
| }; |
| chip.appendChild(x); |
| @@ -2889,7 +2909,7 @@ function buildSkeleton(el, ctx) { |
| // upload is in flight — the original rules exactly. It also greys while the |
| // socket is down (a send would be dropped), mirroring the original composer. |
| const refreshSend = () => { |
| - send.hidden = input.value.trim() === "" && pending.length === 0; |
| + send.hidden = composer.isEmpty() && pending.length === 0; |
| send.disabled = pending.some((p) => !p.att) || !connected; |
| }; |
| // Enter during an upload queues the send instead of dropping it: the button |
| @@ -2918,16 +2938,15 @@ function buildSkeleton(el, ctx) { |
| // Embedded: the native shell draws the reply banner and focuses its own |
| // composer (this one is hidden). |
| if (inEmbed) host.native({ replying: { name: replyTo.name || "", snippet: replyTo.snippet || "" } }); |
| - else input.focus(); |
| + else composer.focus(); |
| }, |
| startEdit(m) { |
| replyTo = null; |
| editing = m; |
| - input.value = m.text; |
| - input.dispatchEvent(new Event("input")); // resize + reveal send |
| + composer.setText(m.text, { notify: true }); // resize + reveal send |
| refreshBanners(); |
| if (inEmbed) host.native({ editing: { text: m.text } }); |
| - if (!inEmbed) input.focus(); |
| + if (!inEmbed) composer.focus(); |
| }, |
| }); |
| |
| @@ -3030,12 +3049,7 @@ function buildSkeleton(el, ctx) { |
| // Embedded: the native shell owns the visible composer — forward the |
| // insertion (a shell that doesn't handle composerInsert ignores it). |
| if (inEmbed) return host.native({ composerInsert: text }); |
| - const start = input.selectionStart == null ? input.value.length : input.selectionStart; |
| - const end = input.selectionEnd == null ? start : input.selectionEnd; |
| - input.value = input.value.slice(0, start) + text + input.value.slice(end); |
| - input.selectionStart = input.selectionEnd = start + text.length; |
| - input.dispatchEvent(new Event("input", { bubbles: true })); |
| - input.focus(); |
| + composer.insertAtCaret(text); |
| }, |
| attachFiles: (files) => stageFiles([...(files || [])]), |
| }; |
| @@ -3178,7 +3192,7 @@ function buildSkeleton(el, ctx) { |
| }; |
| // Programmatic closes (ctx.closePanel) take the same eased path as ✕/Escape. |
| cmdPanels.set(ctx.threadId, (spec) => (spec ? showPanel(spec) : hidePanel())); |
| - input.addEventListener("paste", (e) => { |
| + function onComposerPaste(e) { |
| const files = [...((e.clipboardData && e.clipboardData.files) || [])]; |
| if (files.length) { |
| e.preventDefault(); |
| @@ -3190,24 +3204,19 @@ function buildSkeleton(el, ctx) { |
| if (!files.length) { |
| const textItem = [...(e.clipboardData?.items || [])].find((i) => i.type === "text/plain"); |
| if (textItem) { |
| - const start = input.selectionStart; |
| - const beforeCursor = input.value.substring(0, start); |
| + const start = composer.getCaret(); |
| + const beforeCursor = composer.getText().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 = input.selectionEnd; |
| - const before = input.value.substring(0, start); |
| - const after = input.value.substring(end); |
| - input.value = before + blockquoted + after; |
| - input.selectionStart = input.selectionEnd = start + blockquoted.length; |
| - input.dispatchEvent(new Event("input", { bubbles: true })); |
| + composer.replaceRange(start, composer.getSelectionEnd(), blockquoted); |
| }); |
| } |
| } |
| } |
| - }); |
| + } |
| // Drag-drop overlay affordance (.drop-overlay — the same class the original |
| // pane uses, so it looks identical). The overlay element exists in *both* modes; |
| // only the web drop *listeners* are web-only (see below). |
| @@ -3285,15 +3294,12 @@ function buildSkeleton(el, ctx) { |
| const acceptTypeahead = () => { |
| if (!typeahead) return; |
| const it = typeahead.items[typeahead.index]; |
| - const caret = input.selectionStart; |
| - const before = input.value.slice(0, typeahead.start); |
| - const after = input.value.slice(caret); |
| - input.value = before + it.insert + after; |
| - const pos = before.length + it.insert.length; |
| - input.setSelectionRange(pos, pos); |
| + const caret = composer.getCaret(); |
| + const start = typeahead.start; |
| closeTypeahead(); |
| - input.dispatchEvent(new Event("input")); // resize + send-visibility + re-check token |
| - input.focus(); |
| + // replaceRange notifies (resize + send-visibility + token re-check) and |
| + // restores focus — the original splice, behind the facade. |
| + composer.replaceRange(start, caret, it.insert); |
| }; |
| const renderTa = () => { |
| if (!typeahead) return; |
| @@ -3355,7 +3361,7 @@ function buildSkeleton(el, ctx) { |
| taBox.children[typeahead.index]?.scrollIntoView({ block: "nearest" }); |
| }; |
| const updateTypeahead = () => { |
| - const tok = composerToken(input.value, input.selectionStart); |
| + const tok = composerToken(composer.getText(), composer.getCaret()); |
| if (!tok || !host || !host.typeaheadItems) return closeTypeahead(); |
| const items = host.typeaheadItems(tok); |
| if (!items.length) return closeTypeahead(); |
| @@ -3403,34 +3409,25 @@ function buildSkeleton(el, ctx) { |
| else if (now - lastEmit >= TYPING_INTERVAL_MS) sendBeat(TYPING_EXPIRY_S); // sustain the indicator |
| }; |
| composers.get(ctx.threadId).tickTyping = tickTyping; // render() re-arms the ticker each draw |
| - input.addEventListener("input", () => { |
| - input.style.height = "auto"; |
| - input.style.height = Math.min(input.scrollHeight, 140) + "px"; |
| - // The composer just grew, shrinking the flex feed. Re-pin to the newest |
| - // message *here*, synchronously in the same layout — the ResizeObserver below |
| - // also re-pins, but only on a later frame, which leaves one painted frame |
| - // stranded a line short of the bottom (the visible "jump" on WebKit / the Mac |
| - // app). Guard on feedAtBottom so typing never yanks a reader who scrolled up. |
| + function onComposerInput() { |
| + // (Auto-grow already ran inside the facade.) The composer just grew, |
| + // shrinking the flex feed. Re-pin to the newest message *here*, |
| + // synchronously in the same layout — the ResizeObserver below also |
| + // re-pins, but only on a later frame, which leaves one painted frame |
| + // stranded a line short of the bottom (the visible "jump" on WebKit / the |
| + // Mac app). Guard on feedAtBottom so typing never yanks a reader who |
| + // scrolled up. |
| if (feedAtBottom) feed.scrollTop = feed.scrollHeight; |
| if (sendQueued) setSendQueued(false); // still composing — don't auto-send under them |
| refreshSend(); |
| updateTypeahead(); |
| // A "/command" in progress is private — never announce typing for it (and |
| // clear any beat already sent if the text just became a command). |
| - if (input.value && input.value[0] !== "/") beatTyping(); |
| + const v = composer.getText(); |
| + if (v && v[0] !== "/") beatTyping(); |
| else if (lastBeat) stopTypingBeat(); |
| - }); |
| - // The original keyboard-inset rule (.composer.editing on coarse pointers) |
| - // keys off focus — mirror it so the composer sits on the keyboard while typing. |
| - input.addEventListener("focus", () => compose.classList.add("editing")); |
| - input.addEventListener("blur", () => compose.classList.remove("editing")); |
| - // Caret moves (click / arrows) can enter or leave a token too — re-check. |
| - input.addEventListener("keyup", (e) => { |
| - if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) updateTypeahead(); |
| - }); |
| - input.addEventListener("click", updateTypeahead); |
| - input.addEventListener("blur", () => setTimeout(closeTypeahead, 100)); |
| - input.onkeydown = (e) => { |
| + } |
| + function onComposerKeydown(e) { |
| // The typeahead grabs the nav keys first when it's open (original order). |
| if (typeahead) { |
| if (e.key === "ArrowDown") return e.preventDefault(), moveTypeahead(1); |
| @@ -3466,12 +3463,12 @@ function buildSkeleton(el, ctx) { |
| cancelReply(); |
| cancelEdit(); |
| if (wasEditing) { |
| - input.value = ""; |
| + composer.setText(""); |
| refreshSend(); |
| } |
| } |
| // ↑ on an empty composer edits your latest still-editable message. |
| - if (e.key === "ArrowUp" && !input.value && !editing) { |
| + if (e.key === "ArrowUp" && !composer.getText() && !editing) { |
| const state = ctx.getState(); |
| const mine = ((state && state.msgs) || []) |
| .filter((m) => ownMessage({ uid: m.uid, name: m.name }, ctx.me, ctx.meName) && m.text.trim() && Date.now() - m.ts < EDIT_WINDOW_MS) |
| @@ -3484,15 +3481,15 @@ function buildSkeleton(el, ctx) { |
| // ↓ undoes an accidental ↑-to-edit: only while unchanged and with no line |
| // below the cursor to navigate to (the original rule). |
| if (e.key === "ArrowDown" && editing) { |
| - const afterCursor = input.value.slice(input.selectionEnd); |
| - if (input.value === editing.text && !afterCursor.includes("\n")) { |
| + const afterCursor = composer.getText().slice(composer.getSelectionEnd()); |
| + if (composer.getText() === editing.text && !afterCursor.includes("\n")) { |
| e.preventDefault(); |
| - input.value = ""; |
| + composer.setText(""); |
| cancelEdit(); |
| refreshSend(); |
| } |
| } |
| - }; |
| + } |
| |
| compose.onsubmit = (ev) => { |
| ev.preventDefault(); |
| @@ -3500,10 +3497,9 @@ function buildSkeleton(el, ctx) { |
| setSendQueued(true); // still uploading — send as soon as it lands |
| return; |
| } |
| - const text = emojify(input.value.trim()); // :tada: → 🎉, like the original composer |
| + const text = emojify(composer.getText().trim()); // :tada: → 🎉, like the original composer |
| const resetInput = () => { |
| - input.value = ""; |
| - input.style.height = "auto"; |
| + composer.clear(); |
| refreshSend(); |
| stopTypingBeat(); // command / edit / send all clear the field — end the beat now |
| clearComposerHint(ctx.threadId); // the question was answered — back to "Message" now, not at the reply |
| @@ -3552,8 +3548,8 @@ function buildSkeleton(el, ctx) { |
| // A reply with no `short` is the command PUSHING BACK ("when should |
| // I remind you?"), not a confirmation — put the typed text back so |
| // it can be fixed, not retyped (matches the native .rejected path). |
| - if (r && !r.short && !input.value) { |
| - input.value = typed; |
| + if (r && !r.short && !composer.getText()) { |
| + composer.setText(typed); |
| refreshSend(); |
| } |
| }); |
| @@ -3598,7 +3594,7 @@ function buildSkeleton(el, ctx) { |
| if (threadSid) stageThreadReply(); |
| else cancelReply(); |
| refreshStaged(); |
| - input.focus(); |
| + composer.focus(); |
| // A scripted conversation awaiting an answer in this thread is told what |
| // was sent — text AND attachments (the setup photo step takes a picture as |
| // its answer). The message itself already sent normally; it's in the |
| @@ -3651,7 +3647,7 @@ function buildSkeleton(el, ctx) { |
| : { id: threadSid, name: (threadFallback && threadFallback.name) || "", snippet: (threadFallback && threadFallback.snippet) || "" }; |
| refreshBanners(); |
| if (inEmbed) host.native({ replying: { name: replyTo.name || "", snippet: replyTo.snippet || "" } }); |
| - else if (!coarse) input.focus(); |
| + else if (!coarse) composer.focus(); |
| }; |
| |
| const closeThread = () => { |
| @@ -3835,48 +3831,48 @@ function buildSkeleton(el, ctx) { |
| // re-attaches this parked root without rebuilding the skeleton. |
| root._hotkeys = (e) => { |
| if (!root.isConnected) return; |
| - if ((e.metaKey || e.ctrlKey) && e.key === "t" && !input.value) { |
| + if ((e.metaKey || e.ctrlKey) && e.key === "t" && !composer.getText()) { |
| e.preventDefault(); |
| const m = latest(true); |
| if (m) openMessagePicker(m, ctx, feed.querySelector(`.msg[data-id="${m.sid}"]`) || feed); |
| } |
| - if ((e.metaKey || e.ctrlKey) && e.key === "r" && !input.value) { |
| + if ((e.metaKey || e.ctrlKey) && e.key === "r" && !composer.getText()) { |
| e.preventDefault(); |
| const m = latest(false); |
| if (m) composers.get(ctx.threadId).startReply(m); |
| } |
| // Escape dismisses an open command panel when the composer isn't focused |
| - // (the focused case is in input.onkeydown), with the same layer checks as |
| + // (the focused case is in onComposerKeydown), with the same layer checks as |
| // the thread-view close below — one Escape peels one layer, never two. |
| - if (e.key === "Escape" && panelOpen() && !e.defaultPrevented && document.activeElement !== input && !document.querySelector(".emoji-picker, .msg-sheet, .lightbox")) { |
| + if (e.key === "Escape" && panelOpen() && !e.defaultPrevented && !composer.hasFocus() && !document.querySelector(".emoji-picker, .msg-sheet, .lightbox")) { |
| e.preventDefault(); |
| hidePanel(); |
| return; |
| } |
| // Escape closes an open thread view when the composer isn't focused (the |
| - // focused case is handled in input.onkeydown). Skipped when a layer that |
| + // focused case is handled in onComposerKeydown). Skipped when a layer that |
| // owns Escape is up — the picker preventDefaults before us (capture phase), |
| // the sheet/lightbox are still in the DOM when we run — so one Escape peels |
| // one layer, never two. |
| - if (e.key === "Escape" && threadSid && !editing && !e.defaultPrevented && document.activeElement !== input && !document.querySelector(".emoji-picker, .msg-sheet, .lightbox")) { |
| + if (e.key === "Escape" && threadSid && !editing && !e.defaultPrevented && !composer.hasFocus() && !document.querySelector(".emoji-picker, .msg-sheet, .lightbox")) { |
| e.preventDefault(); |
| closeThread(); |
| return; |
| } |
| - if (e.key === "Escape" && document.activeElement !== input && (replyTo || editing) && !document.querySelector(".emoji-picker")) { |
| + if (e.key === "Escape" && !composer.hasFocus() && (replyTo || editing) && !document.querySelector(".emoji-picker")) { |
| e.preventDefault(); |
| const wasEditing = !!editing; |
| cancelReply(); |
| cancelEdit(); |
| if (wasEditing) { |
| - input.value = ""; |
| + composer.setText(""); |
| refreshSend(); |
| } |
| } |
| }; |
| |
| // Desktop opens ready to type, like the original feed. |
| - if (!coarse && !inEmbed) input.focus(); |
| + if (!coarse && !inEmbed) composer.focus(); |
| |
| // Embedded: the native composer drives THIS composer through the shell's |
| // window.lchatNative bridge (see installNativeBridge in chat.js) — text, |
| @@ -3891,8 +3887,8 @@ function buildSkeleton(el, ctx) { |
| root._native = { |
| sendText(text) { |
| if (pending.some((p) => !p.att)) return false; // still uploading |
| - input.value = text || ""; |
| - if (!input.value.trim() && !pending.length) return false; |
| + composer.setText(text || ""); |
| + if (composer.isEmpty() && !pending.length) return false; |
| compose.requestSubmit(); |
| stopTypingBeat(); |
| return true; |