web: extract buildComposerBlock — composer as an instantiable unit (#1462)
Pure refactor of buildSkeleton (apps/chat.js), zero behaviour change: the
inline composer — banners, attach/upload staging, typeahead, the plain/rich
input facade, send, and the submit handler — moves into a new
buildComposerBlock(ctx, host, cfg) factory, so the docked thread pane (next
PR) can instantiate a second, root-pinned composer beside the main one.
buildSkeleton is the factory's only caller ({ fixedReply: null, commands:
true, inEmbed }). Everything per-pane stays in buildSkeleton, wired through
cfg hooks and the returned api: the composers.set registration (still one
entry), the command panel, the thread view (stageThreadReply / closeThread /
pageInRoot now drive the block via api.stageReply / stagedReply / cancelReply),
root._hotkeys, root._native, the feed-follow pins (onFieldGrow /
onStripChange), and the post-send pinNext + thread re-stage (onSent — today's
`if (threadSid) stageThreadReply(); else cancelReply();` verbatim). The
labelled-ask/hint machinery and deliverAnswer stay primary-surface-only,
gated on fixedReply == null, so the single caller's behaviour is byte-
identical. The block's four top-level nodes return as a fragment (siblings,
never wrapped) because the .embed/.readonly rules match them with
`.chatapp-root > .composer` child combinators — no CSS touched.
No exported signatures change; npm test (event vectors included) green.
Claude-Session: https://claude.ai/code/session_01EXy6kzmYE9rChy3KgciJvZ
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+743
−572
web/static/apps/chat.js+743 −572
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js| index 37417d2..fdfa5b2 100644 |
| --- a/web/static/apps/chat.js |
| +++ b/web/static/apps/chat.js |
| @@ -2389,43 +2389,53 @@ function addLinkPreview(content, url) { |
| }); |
| } |
| -// ---- skeleton: feed, banners, staging, composer (original markup) ----------- |
| - |
| -// buildSkeleton constructs the once-per-mount frame with the original composer |
| -// structure and class names — .composer-reply / .composer-edit banners, the |
| -// floating .composer-preview staging strip, and the .composer form — so |
| -// style.css styles them identically to the original ones (which are hidden in |
| -// app-mode). Send emits the original untyped payload; editing re-sends the full |
| -// original payload with new text through the host bridge. |
| -function buildSkeleton(el, ctx) { |
| - const root = document.createElement("div"); |
| - root.className = "chatapp-root"; |
| - |
| - |
| - const feed = document.createElement("div"); |
| - feed.className = "feed chatapp-feed"; // .feed for original descendant rules; .chatapp-feed mirrors #feed's layout |
| - // The wrapper bounds the reply-thread overlay to exactly the feed's box (it |
| - // dims the transcript, never the composer). Feed selectors everywhere are |
| - // depth-independent (.chatapp-feed querySelectors, no child combinators), so |
| - // the extra level is invisible to the rest of the shell + native probes. |
| - const feedWrap = document.createElement("div"); |
| - feedWrap.className = "chatapp-feedwrap"; |
| - feedWrap.appendChild(feed); |
| - |
| - // Jump-to-latest: the classic chat affordance for getting back to the |
| - // newest message without hunting for it — shown/hidden by the feed's |
| - // "scroll" listener below (and re-checked after each render, since new |
| - // messages can widen the gap without a scroll event). |
| - const jumpBtn = document.createElement("button"); |
| - jumpBtn.type = "button"; |
| - jumpBtn.className = "feed-jump-btn hidden"; |
| - // aria-label carries the accessible name; title mirrors it as a mouse-hover |
| - // tooltip (icon-only button — see docs/accessibility.md's icon-label rule). |
| - jumpBtn.setAttribute("aria-label", "Jump to latest"); |
| - jumpBtn.title = "Jump to latest"; |
| - jumpBtn.innerHTML = icon("chevronDown"); |
| - jumpBtn.onclick = () => feedGlideToBottom(feed, false); |
| - feedWrap.appendChild(jumpBtn); |
| +// ---- composer block: banners, staging, typeahead, input, send --------------- |
| + |
| +// buildComposerBlock builds one complete composer — the reply/edit banners, |
| +// the floating preview/typeahead anchor, attach + upload staging, the |
| +// plain/rich input facade, and send — as an instantiable unit. It exists so |
| +// the docked reply-thread pane (next PR) can mount a SECOND composer beside |
| +// the main one; today buildSkeleton is its only caller, and the extraction |
| +// changes no behaviour. The block owns its own reply/edit/staging state; |
| +// everything per-PANE — the command panel, the thread view, the feed and its |
| +// follow behaviour, the composers registry, hotkeys, and the native bridge — |
| +// stays with the caller, wired through the cfg hooks and the returned api. |
| +// |
| +// cfg: |
| +// fixedReply null = the main composer: replies stage freely and banners |
| +// show. Or () => {id,name,snippet}: the block is pinned to a |
| +// thread root — the target derives fresh per send, no reply |
| +// banner or cancel ever shows, and the block stands down from |
| +// the primary-surface machinery (labelled asks, composer |
| +// hints, deliverAnswer), which targets the main composer only. |
| +// inEmbed the native shell draws the visible composer (host.native |
| +// mirrors banners/staging; this block is hidden by CSS). |
| +// commands false = no command routing at all (slash, /me, @app |
| +// mentions, default commands) — text sends verbatim. |
| +// onSent(payload) a send reached the server (the caller's scroll pin + |
| +// thread re-stage — see compose.onsubmit). |
| +// onEscape(hasEdit) pane-owned Escape layers; return true when consumed. |
| +// onReplyCancel() the reply banner's ✕; return true when the pane |
| +// consumed it (closing an open thread view) — otherwise |
| +// the block just drops its staged reply. |
| +// onEditCancelled() an edit ended — the pane may re-stage a thread reply. |
| +// onFieldGrow() the input grew this keystroke (caller re-pins its feed). |
| +// onStripChange() the staging strip appeared/changed (caller keeps the |
| +// feed's newest content in view). |
| +// |
| +// Returns { el, float, composer, api }. `el` is a fragment of the block's |
| +// four top-level nodes — kept as SIBLINGS under the caller's root, never |
| +// wrapped, because the .embed/.readonly rules hide composer surfaces with |
| +// `.chatapp-root > .composer` child combinators. `float` is the zero-height |
| +// anchor command panels insert before; `composer` is the input facade; `api` |
| +// carries the verbs the caller wires outward (the composers registry, the |
| +// thread view, hotkeys, the native bridge). |
| +function buildComposerBlock(ctx, host, cfg) { |
| + const inEmbed = !!cfg.inEmbed; |
| + // The primary (main) composer is the one surface the labelled-ask/hint |
| + // machinery and scripted answers (deliverAnswer) target; a root-pinned |
| + // block never is. |
| + const primary = !cfg.fixedReply; |
| const float = document.createElement("div"); |
| float.className = "composer-float"; |
| @@ -2488,7 +2498,7 @@ function buildSkeleton(el, ctx) { |
| }; |
| labelDismiss.addEventListener("click", dismissComposerAsk); |
| const refreshLabelDismiss = () => { |
| - labelDismiss.hidden = !(composerLabels.get(ctx.threadId) || "") || !send.hidden; |
| + labelDismiss.hidden = !primary || !(composerLabels.get(ctx.threadId) || "") || !send.hidden; |
| }; |
| // 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 |
| @@ -2503,16 +2513,15 @@ function buildSkeleton(el, ctx) { |
| // 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 inEmbed = !!(host && host.embed); |
| const composerOpts = { |
| - placeholder: composerHints.get(ctx.threadId) || basePlaceholder(), |
| + placeholder: primary ? composerHints.get(ctx.threadId) || basePlaceholder() : "Reply…", |
| formatAsYouType: formatAsYouTypePref(), |
| onInput: () => onComposerInput(), |
| onKeydown: (e) => { |
| // Escape dismisses an active labelled ask (#1375) — the keyboard twin |
| // of the X. Only while the pill shows; otherwise Escape keeps its |
| // usual meanings (close typeahead, cancel edit…) downstream. |
| - if (e.key === "Escape" && (composerLabels.get(ctx.threadId) || "")) { |
| + if (e.key === "Escape" && primary && (composerLabels.get(ctx.threadId) || "")) { |
| e.preventDefault(); |
| dismissComposerAsk(); |
| return; |
| @@ -2541,6 +2550,7 @@ function buildSkeleton(el, ctx) { |
| pmModule && !inEmbed && !appChatReadOnly(ctx.threadId) && !classicComposerPref() |
| ? adoptComposer(pmModule.createRichComposer(composerOpts)) |
| : createComposer(composerOpts); |
| + |
| const applyFieldLabel = () => { |
| const label = composerLabels.get(ctx.threadId) || ""; |
| fieldLabel.textContent = label; |
| @@ -2556,11 +2566,17 @@ function buildSkeleton(el, ctx) { |
| if (!fieldLabel.hidden) composer.setIndent(fieldLabel.offsetWidth + 12 + 8); |
| }); |
| }; |
| - refreshComposerHint.set(ctx.threadId, () => { |
| - const attach = pending.length ? composerAttachHints.get(ctx.threadId) : ""; |
| - composer.setPlaceholder(attach || composerHints.get(ctx.threadId) || basePlaceholder()); |
| - applyFieldLabel(); |
| - }); |
| + // The labelled-ask / composer-hint machinery targets the PRIMARY surface |
| + // only — a second (root-pinned) block never registers, so a scripted |
| + // question can't relabel a thread pane's box. |
| + if (primary) { |
| + refreshComposerHint.set(ctx.threadId, () => { |
| + const attach = pending.length ? composerAttachHints.get(ctx.threadId) : ""; |
| + composer.setPlaceholder(attach || composerHints.get(ctx.threadId) || basePlaceholder()); |
| + applyFieldLabel(); |
| + }); |
| + } |
| + |
| const send = document.createElement("button"); |
| send.type = "submit"; |
| send.className = "send-btn"; |
| @@ -2575,59 +2591,20 @@ function buildSkeleton(el, ctx) { |
| // drop the same way). |
| attach.hidden = !!defaultCmd(); |
| - |
| - root.append(feedWrap, float, replyBanner, editBanner, compose); |
| - el.appendChild(root); |
| - |
| - // Keep the feed pinned to the newest message when the composer, its staged |
| - // reply/edit banner, or the preview strip grows and steals vertical space — |
| - // they're in-flow siblings, so the flex feed shrinks and this observer fires. |
| - // (The original re-pins the same way; render() alone can't, since a resize |
| - // isn't a state change.) Track "at bottom" from real scrolls so the decision |
| - // reflects the state *before* the feed shrank — reading the gap inside the |
| - // callback would already show it past the threshold and wrongly bail. The |
| - // `< 60` matches render()'s pin check. |
| - let feedAtBottom = true; |
| - const updateFeedScrollState = () => { |
| - const gap = feed.scrollHeight - feed.scrollTop - feed.clientHeight; |
| - feedAtBottom = gap < 60; |
| - jumpBtn.classList.toggle("hidden", gap < JUMP_BUTTON_GAP); |
| - }; |
| - feed.addEventListener("scroll", updateFeedScrollState, { passive: true }); |
| - // render() re-checks after every message-list update too — new messages |
| - // widen the gap (or a glide-to-bottom closes it) without firing "scroll". |
| - root._updateJumpBtn = updateFeedScrollState; |
| - new ResizeObserver(() => { |
| - if (!feedAtBottom) return; |
| - // WebKit restores the pre-resize scrollTop at *layout* time — which lands |
| - // after this callback AND after a single rAF — so one re-assert loses the |
| - // race and strands the feed the growth-delta (~one composer line) above the |
| - // bottom (Chrome doesn't do this). Re-pin across a few frames so a |
| - // correction always lands after the restoration, whenever it happens. The |
| - // scroll listener keeps feedAtBottom true through the restoration (its gap |
| - // stays under the 60px threshold), so this bails only if the reader |
| - // genuinely scrolls up mid-window. |
| - let frame = 0; |
| - const pin = () => { |
| - if (!feedAtBottom) return; // reader scrolled away — stop fighting them |
| - feed.scrollTop = feed.scrollHeight; |
| - if (++frame < 4) requestAnimationFrame(pin); |
| - }; |
| - pin(); |
| - }).observe(feed); |
| - |
| - // ---- composer state (per mount; a fresh open rebuilds it clean) ---- |
| + // ---- composer state (per block; a fresh open rebuilds it clean) ---- |
| let replyTo = null; // { id, name, snippet } — the original payload shape |
| let editing = null; // the item being edited |
| let editBaseline = ""; // getMarkdown() right after edit-load (the ↓-undo comparator) |
| let pending = []; // staged attachments: { att | null (uploading), name, isImage } |
| let sendQueued = false; // Enter landed mid-upload — auto-send once the uploads finish |
| let staging = 0; // stageFiles batches in flight — a queued send waits for the whole batch |
| - // A remount mid-conversation must restore the inset label; it needs layout |
| - // (the width measure) and `pending` above, so it runs here, post-append. |
| - applyComposerHint(ctx.threadId); |
| + // A remount mid-conversation must restore the inset label. The caller |
| + // appends the block in the same tick, and the one part that needs layout |
| + // (the width measure in applyFieldLabel) is rAF-deferred past that insert. |
| + if (primary) applyComposerHint(ctx.threadId); |
| const refreshBanners = () => { |
| + if (cfg.fixedReply) return; // a root-pinned block derives its reply per send — no banner |
| replyBanner.textContent = ""; |
| replyBanner.classList.add("hidden"); |
| editBanner.textContent = ""; |
| @@ -2661,21 +2638,20 @@ function buildSkeleton(el, ctx) { |
| x.className = "reply-bar-cancel"; |
| x.innerHTML = icon("x"); |
| x.setAttribute("aria-label", "Cancel reply"); |
| - // While the thread view is open its staged reply IS the view's composer |
| - // state — cancelling it means leaving the thread, so close the whole view |
| - // (which clears the reply) rather than stranding an open thread whose |
| - // sends would land outside it. |
| - x.onclick = () => (threadSid ? closeThread() : cancelReply()); |
| + // The pane may own this gesture (an open thread view treats its staged |
| + // reply as the view's own state — see buildSkeleton's onReplyCancel); |
| + // otherwise it just drops the staged reply. |
| + x.onclick = () => { |
| + if (!(cfg.onReplyCancel && cfg.onReplyCancel())) cancelReply(); |
| + }; |
| bar.append(ic, body, x); |
| replyBanner.appendChild(bar); |
| replyBanner.classList.remove("hidden"); |
| } |
| }; |
| - if (inEmbed) root.classList.add("embed"); // the native shell draws the composer |
| - // The owning app declared its chat a read-only stream — no composer to draw. |
| - if (appChatReadOnly(ctx.threadId)) root.classList.add("readonly"); |
| const cancelReply = () => { |
| + if (cfg.fixedReply) return; // nothing staged to cancel — the reply derives per send |
| replyTo = null; |
| refreshBanners(); |
| if (inEmbed) host.native({ replying: null }); |
| @@ -2684,19 +2660,19 @@ function buildSkeleton(el, ctx) { |
| editing = null; |
| refreshBanners(); |
| if (inEmbed) host.native({ editing: null }); |
| - // An edit inside an open thread view borrowed the composer (startEdit clears |
| - // replyTo); hand it back so the next send still continues the thread. |
| - if (threadSid) stageThreadReply(); |
| + // The pane may need to re-arm state the edit displaced (an open thread |
| + // view's staged reply — see buildSkeleton's onEditCancelled). |
| + if (cfg.onEditCancelled) cfg.onEditCancelled(); |
| }; |
| // Staged attachments as original .preview-chip tiles: a spinner while |
| // uploading, then a thumbnail (image) or a file chip, each with its remove. |
| const refreshStaged = () => { |
| if (inEmbed) host.native({ staged: pending.length > 0 }); // enables the native send button |
| - applyComposerHint(ctx.threadId); // an attach-gated hint appears/disappears with the staged files |
| - // The strip takes real layout space, so the feed shrinks when it appears — |
| - // keep the newest messages (and any action chips) in view, not under the fold. |
| - if (feedNearBottom(feed, 160)) requestAnimationFrame(() => feedGlideToBottom(feed, true)); |
| + if (primary) applyComposerHint(ctx.threadId); // an attach-gated hint appears/disappears with the staged files |
| + // The strip takes real layout space — the caller keeps its feed pinned |
| + // (see buildSkeleton's onStripChange). |
| + if (cfg.onStripChange) cfg.onStripChange(); |
| preview.textContent = ""; |
| preview.classList.toggle("hidden", pending.length === 0); |
| for (const p of pending) { |
| @@ -2795,29 +2771,6 @@ function buildSkeleton(el, ctx) { |
| compose.requestSubmit(); |
| }; |
| - composers.set(ctx.threadId, { |
| - refreshSend, |
| - startReply(m) { |
| - editing = null; |
| - // Replying to a reply continues its thread: target the TOP message. |
| - replyTo = replyTarget(((ctx.getState() || {}).msgs) || [], m); |
| - refreshBanners(); |
| - // 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 composer.focus(); |
| - }, |
| - startEdit(m) { |
| - replyTo = null; |
| - editing = m; |
| - composer.setMarkdown(m.text, { notify: true }); // resize + reveal send |
| - editBaseline = composer.getMarkdown(); // ↓-undo compares against this, not raw bytes |
| - refreshBanners(); |
| - if (inEmbed) host.native({ editing: { text: m.text } }); |
| - if (!inEmbed) composer.focus(); |
| - }, |
| - }); |
| - |
| // Matches maxUploadBytes (main.go) / the original composer's MAX_UPLOAD — used |
| // only to word the "too big" message; the host bridge does the real limiting. |
| const MAX_UPLOAD = 25 * 1024 * 1024; |
| @@ -2889,327 +2842,93 @@ function buildSkeleton(el, ctx) { |
| stageFiles(files); |
| }; |
| - // ---- command panel: the transient results surface a command opens ---- |
| - // (ctx.panel — see the command-panels section of apps.js.) In-flow, never |
| - // floating: it sits above the composer stack by default, or pinned above the |
| - // feed with position: "top", taking layout space either way. One per thread — |
| - // opening another replaces it; its ✕, Escape, or the app's panel.close() |
| - // dismisses it. Client-only chrome: nothing here is sent or folded. |
| - const panelBox = document.createElement("section"); |
| - panelBox.className = "cmd-panel hidden"; |
| - panelBox.setAttribute("role", "region"); |
| - let panelSpec = null; |
| - const panelOpen = () => !!panelSpec; |
| - const renderPanelBody = (spec, body) => { |
| - body.replaceChildren(); |
| - // The handle a panel renders against: dismiss it, redraw it, and act on |
| - // the composer it sits above — insert text at the caret, or stage |
| - // attachments through the same path the attach button / paste / drop use. |
| - const panel = { |
| - threadId: ctx.threadId, |
| - close: () => hidePanel(), |
| - refresh: () => { |
| - if (panelSpec === spec && body.isConnected) renderPanelBody(spec, body); |
| - }, |
| - insertText: (t) => { |
| - const text = String(t == null ? "" : t); |
| - if (!text) return; |
| - // 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 }); |
| - composer.insertAtCaret(text); |
| - }, |
| - attachFiles: (files) => stageFiles([...(files || [])]), |
| - }; |
| - try { |
| - spec.render(body, panel); |
| - } catch (e) { |
| - // A panel must never take down the pane — contain the error in place. |
| - console.error(`command panel (${(spec.sender && spec.sender.name) || "app"}) render failed:`, e); |
| - body.replaceChildren(); |
| - const p = document.createElement("p"); |
| - p.textContent = "This panel hit an error and couldn't load."; |
| - body.appendChild(p); |
| + function onComposerPaste(e) { |
| + const files = [...((e.clipboardData && e.clipboardData.files) || [])]; |
| + if (files.length) { |
| + e.preventDefault(); |
| + stageFiles(files); |
| + } |
| + // Blockquote continuation: pasting multi-line text onto a bare "> " line |
| + // prefixes every line after the first with "> " so the whole quote stays a |
| + // quote — matches the original composer's paste behaviour. |
| + if (!files.length && composer.kind === "plain") { |
| + const textItem = [...(e.clipboardData?.items || [])].find((i) => i.type === "text/plain"); |
| + if (textItem) { |
| + 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"); |
| + composer.replaceRange(start, composer.getSelectionEnd(), blockquoted); |
| + }); |
| + } |
| + } |
| } |
| + } |
| + |
| + // ---- :emoji / @mention typeahead — the original menu, verbatim ---- |
| + // composerToken (pure) detects the in-progress token at the caret; the host |
| + // ranks the candidates (host.typeaheadItems — the same function that feeds |
| + // the original menu and the native strip, so all three rank identically). |
| + let typeahead = null; // { kind, start, items, index } or null |
| + const closeTypeahead = () => { |
| + typeahead = null; |
| + taBox.classList.add("hidden"); |
| + taBox.innerHTML = ""; |
| }; |
| - // Whether the panel eases open/shut: the app's call (animate: false opts |
| - // out), gated on reduced-motion — and skipped in the command-host embed, |
| - // where the panel is the whole page and the NATIVE shell animates the |
| - // frame instead (an in-page ease inside a just-revealed frame reads as a |
| - // flash, not a slide). |
| - // (The side column skips the ease outright — its reveal is horizontal, and |
| - // a height roll inside a full-height column reads as a glitch.) |
| - const panelEases = (spec) => |
| - spec && spec.animate !== false && spec.position !== "side" && motionOK() && !document.body.classList.contains("command-host"); |
| - const clearPanelEase = () => { |
| - panelBox.style.removeProperty("height"); |
| - panelBox.style.removeProperty("overflow"); |
| - panelBox.style.removeProperty("transition"); |
| + const acceptTypeahead = () => { |
| + if (!typeahead) return; |
| + const it = typeahead.items[typeahead.index]; |
| + const caret = composer.getCaret(); |
| + const start = typeahead.start; |
| + closeTypeahead(); |
| + // replaceRange notifies (resize + send-visibility + token re-check) and |
| + // restores focus — the original splice, behind the facade. |
| + composer.replaceRange(start, caret, it.insert); |
| }; |
| - const showPanel = (spec) => { |
| - const prev = panelSpec; |
| - panelSpec = spec && typeof spec.render === "function" ? spec : null; |
| - clearPanelEase(); // a mid-animation replace starts clean |
| - // Replacing (or closing) a panel tells the outgoing one — best-effort. |
| - if (prev && prev.onClose) { |
| - try { |
| - prev.onClose(); |
| - } catch (e) { |
| - console.error(e); |
| - } |
| - } |
| - panelBox.replaceChildren(); |
| - panelBox.classList.toggle("hidden", !panelSpec); |
| - panelBox.classList.toggle("cmd-panel--top", !!(panelSpec && panelSpec.position === "top")); |
| - panelBox.classList.toggle("cmd-panel--side", !!(panelSpec && panelSpec.position === "side")); |
| - // The side placement reserves a right-hand gutter on the root (padding, |
| - // so the column occupies real layout space and nothing renders under it). |
| - root.classList.toggle("has-side-panel", !!(panelSpec && panelSpec.position === "side")); |
| - if (!panelSpec) { |
| - notifyCommandPanel(ctx.threadId, null); |
| - return; |
| - } |
| - // Every placement is in-flow: "top" pins it above the feed (under the |
| - // shared-app surface); "side" is a full-height right column (a vertical |
| - // sidebar — list-shaped content wants a tall, narrow box, not a wide, |
| - // short band; on narrow screens its CSS falls back to the bottom band); |
| - // the default sits at the bottom of the pane, above the composer stack |
| - // (before the zero-height .composer-float anchor). |
| - root.insertBefore(panelBox, panelSpec.position === "top" ? feedWrap : float); |
| - const head = document.createElement("div"); |
| - head.className = "cmd-panel-head"; |
| - const s = panelSpec.sender || {}; |
| - if (s.glyph) { |
| - const g = document.createElement("span"); |
| - g.textContent = s.glyph; |
| - head.appendChild(g); |
| - } |
| - const title = document.createElement("span"); |
| - title.className = "cmd-panel-title"; |
| - title.textContent = panelSpec.title || s.name || ""; |
| - const x = document.createElement("button"); |
| - x.type = "button"; |
| - x.className = "cmd-panel-x"; |
| - x.setAttribute("aria-label", "Dismiss"); |
| - x.innerHTML = icon("x"); |
| - x.onclick = () => hidePanel(); |
| - // headExtra: a spec-provided slot in the title row, between the title and |
| - // the dismiss — for a panel's one standing control (Memory's "Forget…"), |
| - // so it sits inline with the name instead of eating body space. |
| - if (typeof panelSpec.headExtra === "function") { |
| - const extra = document.createElement("span"); |
| - extra.className = "cmd-panel-extra"; |
| - try { |
| - panelSpec.headExtra(extra); |
| - } catch (e) { |
| - console.error(e); |
| - } |
| - head.append(title, extra, x); |
| - } else { |
| - head.append(title, x); |
| - } |
| - const body = document.createElement("div"); |
| - body.className = "cmd-panel-body"; |
| - panelBox.append(head, body); |
| - renderPanelBody(panelSpec, body); |
| - notifyCommandPanel(ctx.threadId, panelSpec); |
| - // Ease up from nothing to the panel's natural height (see panelEases). |
| - if (panelEases(panelSpec)) { |
| - const h = panelBox.offsetHeight; |
| - if (h) { |
| - panelBox.style.overflow = "hidden"; |
| - panelBox.style.height = "0px"; |
| - void panelBox.offsetHeight; // commit the start height |
| - panelBox.style.transition = "height 0.22s ease"; |
| - panelBox.style.height = h + "px"; |
| - panelBox.addEventListener("transitionend", clearPanelEase, { once: true }); |
| - setTimeout(clearPanelEase, 350); // backstop if transitionend never fires |
| - } |
| - } |
| - // A bottom panel takes its space from the feed — keep the newest in view. |
| - if (feedNearBottom(feed, 160)) requestAnimationFrame(() => feedGlideToBottom(feed, true)); |
| - }; |
| - // Dismissal rolls the panel down before tearing it out, unless the app (or |
| - // the OS) asked for instant. panelClosing keeps a second Escape (or the ✕) |
| - // from re-arming the roll-up mid-flight; a NEW panel opening mid-animation |
| - // wins — showPanel resets the inline styles, and the captured spec check |
| - // keeps the stale finish() from tearing the newcomer down. |
| - let panelClosing = false; |
| - const hidePanel = () => { |
| - if (!panelSpec || panelClosing) return; |
| - if (!panelEases(panelSpec) || !panelBox.isConnected) return showPanel(null); |
| - panelClosing = true; |
| - const closing = panelSpec; |
| - let finished = false; |
| - const finish = () => { |
| - if (finished) return; |
| - finished = true; |
| - panelClosing = false; |
| - clearPanelEase(); |
| - if (panelSpec === closing) showPanel(null); |
| - }; |
| - const startH = panelBox.offsetHeight; |
| - if (!startH) return finish(); |
| - panelBox.style.overflow = "hidden"; |
| - panelBox.style.height = startH + "px"; |
| - void panelBox.offsetHeight; |
| - panelBox.style.transition = "height 0.2s ease"; |
| - panelBox.style.height = "0px"; |
| - panelBox.addEventListener("transitionend", finish, { once: true }); |
| - setTimeout(finish, 320); |
| - }; |
| - // Programmatic closes (ctx.closePanel) take the same eased path as ✕/Escape. |
| - cmdPanels.set(ctx.threadId, (spec) => (spec ? showPanel(spec) : hidePanel())); |
| - function onComposerPaste(e) { |
| - const files = [...((e.clipboardData && e.clipboardData.files) || [])]; |
| - if (files.length) { |
| - e.preventDefault(); |
| - stageFiles(files); |
| - } |
| - // Blockquote continuation: pasting multi-line text onto a bare "> " line |
| - // prefixes every line after the first with "> " so the whole quote stays a |
| - // quote — matches the original composer's paste behaviour. |
| - if (!files.length && composer.kind === "plain") { |
| - const textItem = [...(e.clipboardData?.items || [])].find((i) => i.type === "text/plain"); |
| - if (textItem) { |
| - 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"); |
| - 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). |
| - const dropOverlay = document.createElement("div"); |
| - dropOverlay.className = "drop-overlay hidden"; |
| - dropOverlay.textContent = "Drop a photo or file to send"; |
| - root.appendChild(dropOverlay); |
| - // The native macOS shell (Eleven) drives the overlay during a Finder drag: |
| - // WebKit swallows the drag events natively (see the listener note below), so the |
| - // page's own dragenter/leave never fire in the app. Selector-based so it always |
| - // targets the connected root's overlay — survives thread switches / the |
| - // instant-restore path. (DropWebView in ChatFeedWebView.swift calls this.) |
| - window.__lchatDropOverlay = (show) => { |
| - const o = document.querySelector(".chatapp-root .drop-overlay"); |
| - if (o) o.classList.toggle("hidden", !show); |
| - }; |
| - // The native wrapper intercepts Finder file-drops itself — a WKWebView doesn't |
| - // hand the page the dropped files on macOS, and merely *registering* a web drop |
| - // handler makes WebKit claim the drop, bypassing the native path, so nothing |
| - // uploads. So skip the web drop listeners in the native app (html.native); there |
| - // the shell delivers files via __lchatAddFiles and shows the overlay via |
| - // __lchatDropOverlay above. |
| - if (!document.documentElement.classList.contains("native")) { |
| - let dragDepth = 0; |
| - root.addEventListener("dragenter", (e) => { |
| - if (!e.dataTransfer || ![...e.dataTransfer.types].includes("Files")) return; |
| - dragDepth++; |
| - dropOverlay.classList.remove("hidden"); |
| - }); |
| - root.addEventListener("dragleave", () => { |
| - if (--dragDepth <= 0) { |
| - dragDepth = 0; |
| - dropOverlay.classList.add("hidden"); |
| - } |
| - }); |
| - root.addEventListener("dragover", (e) => { |
| - if (e.dataTransfer && [...e.dataTransfer.types].includes("Files")) e.preventDefault(); |
| - }); |
| - root.addEventListener("drop", (e) => { |
| - dragDepth = 0; |
| - dropOverlay.classList.add("hidden"); |
| - const files = [...((e.dataTransfer && e.dataTransfer.files) || [])]; |
| - if (files.length) { |
| - e.preventDefault(); |
| - stageFiles(files); |
| - } |
| - }); |
| - } |
| - // The native macOS shell (LChatMac) can't hand Finder file-drops to the page — |
| - // a WKWebView doesn't deliver them and the web drop listeners above are skipped |
| - // in native mode — so it fills the page's static #file-input and dispatches |
| - // `change` (see ChatFeedWebView's __lchatAddFiles). The live composer uses its |
| - // own input, so expose a handler that pulls the files off that static input |
| - // into this thread's staging path; render() wires it to #file-input for the |
| - // thread on screen. (A plain browser never reaches this — its drops land on the |
| - // web listeners above.) |
| - root._dropFiles = () => { |
| - const el = document.getElementById("file-input"); |
| - if (!el) return; |
| - const files = [...(el.files || [])]; |
| - el.value = ""; |
| - if (files.length) stageFiles(files); |
| - }; |
| - |
| - // ---- :emoji / @mention typeahead — the original menu, verbatim ---- |
| - // composerToken (pure) detects the in-progress token at the caret; the host |
| - // ranks the candidates (host.typeaheadItems — the same function that feeds |
| - // the original menu and the native strip, so all three rank identically). |
| - let typeahead = null; // { kind, start, items, index } or null |
| - const closeTypeahead = () => { |
| - typeahead = null; |
| - taBox.classList.add("hidden"); |
| - taBox.innerHTML = ""; |
| - }; |
| - const acceptTypeahead = () => { |
| - if (!typeahead) return; |
| - const it = typeahead.items[typeahead.index]; |
| - const caret = composer.getCaret(); |
| - const start = typeahead.start; |
| - closeTypeahead(); |
| - // 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; |
| - taBox.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.append(g, c); |
| - } else { |
| - // A group-added app in the @ typeahead wears its glyph avatar (there's |
| - // no roster photo to load); people keep the original avatar element. |
| - if (it.glyph) { |
| - const g = document.createElement("span"); |
| - g.className = "avatar app-avatar"; |
| - g.textContent = it.glyph; |
| - row.appendChild(g); |
| - } else if (host && host.taAvatar) row.appendChild(host.taAvatar(it.name, it.userId)); |
| - const n = document.createElement("span"); |
| - n.className = "ta-name"; |
| - n.textContent = it.name; |
| - row.appendChild(n); |
| + const renderTa = () => { |
| + if (!typeahead) return; |
| + taBox.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.append(g, c); |
| + } else { |
| + // A group-added app in the @ typeahead wears its glyph avatar (there's |
| + // no roster photo to load); people keep the original avatar element. |
| + if (it.glyph) { |
| + const g = document.createElement("span"); |
| + g.className = "avatar app-avatar"; |
| + g.textContent = it.glyph; |
| + row.appendChild(g); |
| + } else if (host && host.taAvatar) row.appendChild(host.taAvatar(it.name, it.userId)); |
| + const n = document.createElement("span"); |
| + n.className = "ta-name"; |
| + n.textContent = it.name; |
| + row.appendChild(n); |
| } |
| // pointerdown fires before the textarea blurs, so the tap isn't lost. |
| row.addEventListener("pointerdown", (e) => { |
| @@ -3276,16 +2995,12 @@ function buildSkeleton(el, ctx) { |
| if (now - lastBeat >= TYPING_INTERVAL_MS) stopTypingBeat(); // idle since the last keystroke |
| 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 |
| + |
| 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; |
| + // shrinking the caller's flex feed — let it re-pin synchronously in this |
| + // same layout (see buildSkeleton's onFieldGrow for the WebKit why). |
| + if (cfg.onFieldGrow) cfg.onFieldGrow(); |
| if (sendQueued) setSendQueued(false); // still composing — don't auto-send under them |
| refreshSend(); |
| updateTypeahead(); |
| @@ -3317,17 +3032,10 @@ function buildSkeleton(el, ctx) { |
| setSendQueued(false); // un-queue first; a second Escape cancels reply/edit |
| return; |
| } |
| - // An open command panel is the most recently summoned layer — Escape peels |
| - // it before the thread view / reply / edit below. |
| - if (e.key === "Escape" && panelOpen()) { |
| - hidePanel(); |
| - return; |
| - } |
| - // An open thread view closes on Escape (dropping its staged reply) — unless |
| - // an edit is in flight, which the branch below cancels first (cancelEdit |
| - // re-stages the thread reply, so a second Escape then closes the view). |
| - if (e.key === "Escape" && threadSid && !editing) { |
| - closeThread(); |
| + // Pane-owned layers (the command panel, the thread view) peel before the |
| + // block's own reply/edit below — the caller supplies the order and the |
| + // edit-in-flight exception (see buildSkeleton's onEscape). |
| + if (e.key === "Escape" && cfg.onEscape && cfg.onEscape(!!editing)) { |
| return; |
| } |
| if (e.key === "Escape" && (editing || replyTo)) { |
| @@ -3347,7 +3055,10 @@ function buildSkeleton(el, ctx) { |
| .pop(); |
| if (mine) { |
| e.preventDefault(); |
| - composers.get(ctx.threadId).startEdit(mine); |
| + // This block's own verb — the registry entry is the same function for |
| + // the main composer, and a second block must edit in ITS box, not the |
| + // registered one's. |
| + api.startEdit(mine); |
| } |
| } |
| // ↓ undoes an accidental ↑-to-edit: only while unchanged and with no line |
| @@ -3378,12 +3089,14 @@ function buildSkeleton(el, ctx) { |
| 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 |
| + if (primary) clearComposerHint(ctx.threadId); // the question was answered — back to "Message" now, not at the reply |
| }; |
| + // Command routing below is gated on cfg.commands — a block with commands |
| + // off sends every line verbatim, "/" text included (the pane composer). |
| // /me is the one command that BROADCASTS a real message — an action-style |
| // line ("Alice waves"), not a bubble — instead of answering privately, so |
| // it's handled here rather than through runCommand (see parseMeCommand). |
| - if (!editing) { |
| + if (cfg.commands && !editing) { |
| const action = parseMeCommand(plain); |
| if (action !== null) { |
| if (!action) return resetInput(); // bare "/me" — nothing to say |
| @@ -3395,88 +3108,546 @@ function buildSkeleton(el, ctx) { |
| return; |
| } |
| } |
| - // A slash command (mini-app command, /help, or an unknown "/foo") is handled |
| - // instead of sending — it answers in place with a private "only visible to you" |
| - // reply that stacks in the feed and persists (addLocalNotice). Skipped while |
| - // editing, like the original composer. |
| - if (!editing && runCommand(plain, ctx.threadId, (r) => addLocalNotice(ctx, r))) { |
| - resetInput(); |
| + // A slash command (mini-app command, /help, or an unknown "/foo") is handled |
| + // instead of sending — it answers in place with a private "only visible to you" |
| + // reply that stacks in the feed and persists (addLocalNotice). Skipped while |
| + // editing, like the original composer. |
| + if (cfg.commands && !editing && runCommand(plain, ctx.threadId, (r) => addLocalNotice(ctx, r))) { |
| + resetInput(); |
| + return; |
| + } |
| + // "@To-do buy milk" in a chat with apps added: the mention addresses the |
| + // app, the text runs its default command — an EVENT into this thread (shown |
| + // as an action line), not a message. Leading-@ only; a mention mid-text is |
| + // ordinary conversation. See runAppMention. |
| + if (cfg.commands && !editing && !replyTo && !pending.length && plain && plain[0] === "@" && runAppMention(plain, ctx.threadId, (r) => addLocalNotice(ctx, r))) { |
| + resetInput(); |
| + return; |
| + } |
| + // Plain text in a default-command thread runs the app's default command |
| + // instead of sending a message nobody reads — typing "buy milk" into To-do |
| + // just adds it (defaultCommandFor gates this to app-threads with no bot; |
| + // slash text never falls through here, a "/" always means command). |
| + if (cfg.commands && !editing && !replyTo && plain && plain[0] !== "/" && !pending.length) { |
| + const dc = defaultCmd(); |
| + if (dc) { |
| + const typed = plain; |
| + runCommand(dc.name + " " + typed, ctx.threadId, (r) => { |
| + addLocalNotice(ctx, r); |
| + // 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 && !composer.getText()) { |
| + composer.setText(typed); |
| + refreshSend(); |
| + } |
| + }); |
| + resetInput(); |
| + return; |
| + } |
| + } |
| + if (editing) { |
| + // An empty edit just cancels; otherwise only clear once the edit actually |
| + // reaches the server — host.edit resolves false on a down socket, so the |
| + // typed changes survive to be resent after reconnecting. |
| + if (!text || !host || !host.edit) { |
| + resetInput(); |
| + cancelEdit(); |
| + return; |
| + } |
| + Promise.resolve(host.edit(ctx.threadId, editing.sid, { ...editing.raw, text })).then((sent) => { |
| + if (!sent) return; |
| + resetInput(); |
| + cancelEdit(); |
| + }); |
| + return; |
| + } |
| + if (!text && !pending.length) return; |
| + const payload = { text }; |
| + if (pending.length) payload.attachments = pending.map((p) => p.att); |
| + // A root-pinned block derives its reply target fresh at send time (the |
| + // root may have loaded since); the main composer sends what's staged. |
| + if (cfg.fixedReply) { |
| + const target = cfg.fixedReply(); |
| + if (target) payload.replyTo = target; |
| + } else if (replyTo) payload.replyTo = replyTo; |
| + // Only clear the composer once the send actually reaches the server: ctx.emit |
| + // resolves false when the socket is down/reconnecting, so keep the text, staged |
| + // files, and reply target intact to resend — never silently drop what was typed. |
| + ctx.emit(payload).then((sent) => { |
| + if (!sent) return; |
| + resetInput(); |
| + pending = []; |
| + // The caller's post-send policy runs here — arming the next render's |
| + // scroll pin, re-staging an open thread view's reply — exactly where |
| + // the staged-reply/thread handoff always sat (before the strip repaint |
| + // and the refocus below). |
| + if (cfg.onSent) cfg.onSent(payload); |
| + refreshStaged(); |
| + 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 |
| + // transcript either way. Primary-surface machinery, like the asks it |
| + // answers. |
| + if (primary) deliverAnswer(ctx.threadId, payload, (r) => addLocalNotice(ctx, r)); |
| + }); |
| + }; |
| + |
| + // The block's outward face. startReply/startEdit/refreshSend/tickTyping are |
| + // the registry verbs (buildSkeleton publishes them in `composers`); the |
| + // rest are the narrow seams the pane chrome — thread view, hotkeys, native |
| + // bridge, command panel, drop targets — needs to drive this composer |
| + // without owning its state. |
| + const api = { |
| + refreshSend, |
| + startReply(m) { |
| + editing = null; |
| + // Replying to a reply continues its thread: target the TOP message. |
| + replyTo = replyTarget(((ctx.getState() || {}).msgs) || [], m); |
| + refreshBanners(); |
| + // 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 composer.focus(); |
| + }, |
| + startEdit(m) { |
| + replyTo = null; |
| + editing = m; |
| + composer.setMarkdown(m.text, { notify: true }); // resize + reveal send |
| + editBaseline = composer.getMarkdown(); // ↓-undo compares against this, not raw bytes |
| + refreshBanners(); |
| + if (inEmbed) host.native({ editing: { text: m.text } }); |
| + if (!inEmbed) composer.focus(); |
| + }, |
| + // stageReply arms a reply target the caller derived itself (the thread |
| + // view's root, quoted-snapshot fallback included) — startReply's |
| + // resolve-and-focus stays for bubble actions; focus is the caller's call. |
| + stageReply(target) { |
| + editing = null; |
| + replyTo = target; |
| + refreshBanners(); |
| + if (inEmbed) host.native({ replying: { name: replyTo.name || "", snippet: replyTo.snippet || "" } }); |
| + }, |
| + cancelReply, |
| + cancelEdit, |
| + stagedReply: () => replyTo, |
| + editing: () => !!editing, |
| + focus: () => composer.focus(), |
| + submit: () => compose.requestSubmit(), |
| + stageFiles, |
| + uploading: () => pending.some((p) => !p.att), // an upload still in flight |
| + staged: () => pending.length > 0, |
| + typing: (hasText) => (hasText ? beatTyping() : stopTypingBeat()), |
| + tickTyping, // render() re-arms the outbound-beat ticker each draw |
| + }; |
| + // Four sibling nodes, not a wrapper: the .embed/.readonly rules hide the |
| + // composer surfaces with `.chatapp-root > .composer` child combinators, so |
| + // the block must land as direct children of the caller's root. |
| + const el = document.createDocumentFragment(); |
| + el.append(float, replyBanner, editBanner, compose); |
| + return { el, float, composer, api }; |
| +} |
| + |
| +// ---- skeleton: feed, panels, thread view, composer (original markup) -------- |
| + |
| +// buildSkeleton constructs the once-per-mount frame — the feed, the command |
| +// panel, the reply-thread view, and (via buildComposerBlock above) the main |
| +// composer with the original structure and class names: .composer-reply / |
| +// .composer-edit banners, the floating .composer-preview staging strip, the |
| +// .composer form — so style.css styles them identically to the original ones |
| +// (which are hidden in app-mode). Send emits the original untyped payload; |
| +// editing re-sends the full original payload with new text through the host |
| +// bridge. |
| +function buildSkeleton(el, ctx) { |
| + const root = document.createElement("div"); |
| + root.className = "chatapp-root"; |
| + |
| + |
| + const feed = document.createElement("div"); |
| + feed.className = "feed chatapp-feed"; // .feed for original descendant rules; .chatapp-feed mirrors #feed's layout |
| + // The wrapper bounds the reply-thread overlay to exactly the feed's box (it |
| + // dims the transcript, never the composer). Feed selectors everywhere are |
| + // depth-independent (.chatapp-feed querySelectors, no child combinators), so |
| + // the extra level is invisible to the rest of the shell + native probes. |
| + const feedWrap = document.createElement("div"); |
| + feedWrap.className = "chatapp-feedwrap"; |
| + feedWrap.appendChild(feed); |
| + |
| + // Jump-to-latest: the classic chat affordance for getting back to the |
| + // newest message without hunting for it — shown/hidden by the feed's |
| + // "scroll" listener below (and re-checked after each render, since new |
| + // messages can widen the gap without a scroll event). |
| + const jumpBtn = document.createElement("button"); |
| + jumpBtn.type = "button"; |
| + jumpBtn.className = "feed-jump-btn hidden"; |
| + // aria-label carries the accessible name; title mirrors it as a mouse-hover |
| + // tooltip (icon-only button — see docs/accessibility.md's icon-label rule). |
| + jumpBtn.setAttribute("aria-label", "Jump to latest"); |
| + jumpBtn.title = "Jump to latest"; |
| + jumpBtn.innerHTML = icon("chevronDown"); |
| + jumpBtn.onclick = () => feedGlideToBottom(feed, false); |
| + feedWrap.appendChild(jumpBtn); |
| + |
| + // The composer is built by buildComposerBlock so the docked thread pane |
| + // (next PR) can instantiate a second, root-pinned composer beside this one. |
| + // The cfg hooks below wire the block back into pane-owned chrome — the |
| + // command panel, the reply-thread view, the feed's follow behaviour — which |
| + // stays out here (per-pane, not per-composer). Hook closures reference |
| + // consts declared further down (panelOpen, closeThread, …): they only run |
| + // on user input, long after buildSkeleton completes, so every binding is |
| + // live by then (the same hoisted-handlers pattern the block itself uses). |
| + const inEmbed = !!(host && host.embed); |
| + const block = buildComposerBlock(ctx, host, { |
| + fixedReply: null, // the main composer stages replies freely (and owns the labelled-ask surface) |
| + inEmbed, |
| + commands: true, |
| + // Escape peels pane layers before the composer cancels its own reply/edit: |
| + // an open command panel is the most recently summoned layer, then the |
| + // thread view — unless an edit is in flight, which Escape must cancel |
| + // first (cancelEdit re-stages the thread reply, so a second Escape then |
| + // closes the view). |
| + onEscape: (hasEdit) => { |
| + if (panelOpen()) { |
| + hidePanel(); |
| + return true; |
| + } |
| + if (threadSid && !hasEdit) { |
| + closeThread(); |
| + return true; |
| + } |
| + return false; |
| + }, |
| + // While the thread view is open its staged reply IS the view's composer |
| + // state — cancelling it means leaving the thread, so close the whole view |
| + // (which clears the reply) rather than stranding an open thread whose |
| + // sends would land outside it. |
| + onReplyCancel: () => { |
| + if (!threadSid) return false; |
| + closeThread(); |
| + return true; |
| + }, |
| + // An edit inside an open thread view borrowed the composer (startEdit |
| + // clears the staged reply); hand it back so the next send still continues |
| + // the thread. |
| + onEditCancelled: () => { |
| + if (threadSid) stageThreadReply(); |
| + }, |
| + // The composer just grew, shrinking the flex feed. Re-pin to the newest |
| + // message 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. |
| + onFieldGrow: () => { |
| + if (feedAtBottom) feed.scrollTop = feed.scrollHeight; |
| + }, |
| + // The staging strip takes real layout space, so the feed shrinks when it |
| + // appears — keep the newest messages (and any action chips) in view, not |
| + // under the fold. |
| + onStripChange: () => { |
| + if (feedNearBottom(feed, 160)) requestAnimationFrame(() => feedGlideToBottom(feed, true)); |
| + }, |
| + // Post-send: snap the echo's re-render to the newest message (set now, |
| + // well before the server round-trip; render() clears it), and keep an open |
| + // thread view's reply staged — sending from it continues the same thread, |
| + // like iMessage — else the armed reply is spent. |
| + onSent: () => { |
| + pinNext.add(ctx.threadId); |
| + if (threadSid) stageThreadReply(); |
| + else block.api.cancelReply(); |
| + }, |
| + }); |
| + const { composer, api } = block; |
| + |
| + root.append(feedWrap, block.el); |
| + el.appendChild(root); |
| + |
| + // Keep the feed pinned to the newest message when the composer, its staged |
| + // reply/edit banner, or the preview strip grows and steals vertical space — |
| + // they're in-flow siblings, so the flex feed shrinks and this observer fires. |
| + // (The original re-pins the same way; render() alone can't, since a resize |
| + // isn't a state change.) Track "at bottom" from real scrolls so the decision |
| + // reflects the state *before* the feed shrank — reading the gap inside the |
| + // callback would already show it past the threshold and wrongly bail. The |
| + // `< 60` matches render()'s pin check. |
| + let feedAtBottom = true; |
| + const updateFeedScrollState = () => { |
| + const gap = feed.scrollHeight - feed.scrollTop - feed.clientHeight; |
| + feedAtBottom = gap < 60; |
| + jumpBtn.classList.toggle("hidden", gap < JUMP_BUTTON_GAP); |
| + }; |
| + feed.addEventListener("scroll", updateFeedScrollState, { passive: true }); |
| + // render() re-checks after every message-list update too — new messages |
| + // widen the gap (or a glide-to-bottom closes it) without firing "scroll". |
| + root._updateJumpBtn = updateFeedScrollState; |
| + new ResizeObserver(() => { |
| + if (!feedAtBottom) return; |
| + // WebKit restores the pre-resize scrollTop at *layout* time — which lands |
| + // after this callback AND after a single rAF — so one re-assert loses the |
| + // race and strands the feed the growth-delta (~one composer line) above the |
| + // bottom (Chrome doesn't do this). Re-pin across a few frames so a |
| + // correction always lands after the restoration, whenever it happens. The |
| + // scroll listener keeps feedAtBottom true through the restoration (its gap |
| + // stays under the 60px threshold), so this bails only if the reader |
| + // genuinely scrolls up mid-window. |
| + let frame = 0; |
| + const pin = () => { |
| + if (!feedAtBottom) return; // reader scrolled away — stop fighting them |
| + feed.scrollTop = feed.scrollHeight; |
| + if (++frame < 4) requestAnimationFrame(pin); |
| + }; |
| + pin(); |
| + }).observe(feed); |
| + |
| + if (inEmbed) root.classList.add("embed"); // the native shell draws the composer |
| + // The owning app declared its chat a read-only stream — no composer to draw. |
| + if (appChatReadOnly(ctx.threadId)) root.classList.add("readonly"); |
| + |
| + // Bubble actions (Reply, Edit) resolve their composer through this registry |
| + // — still exactly one entry per thread: the main composer's verbs, straight |
| + // off the block's api (tickTyping rides along for render()'s beat ticker). |
| + composers.set(ctx.threadId, { |
| + refreshSend: api.refreshSend, |
| + startReply: api.startReply, |
| + startEdit: api.startEdit, |
| + tickTyping: api.tickTyping, |
| + }); |
| + |
| + // ---- command panel: the transient results surface a command opens ---- |
| + // (ctx.panel — see the command-panels section of apps.js.) In-flow, never |
| + // floating: it sits above the composer stack by default, or pinned above the |
| + // feed with position: "top", taking layout space either way. One per thread — |
| + // opening another replaces it; its ✕, Escape, or the app's panel.close() |
| + // dismisses it. Client-only chrome: nothing here is sent or folded. |
| + const panelBox = document.createElement("section"); |
| + panelBox.className = "cmd-panel hidden"; |
| + panelBox.setAttribute("role", "region"); |
| + let panelSpec = null; |
| + const panelOpen = () => !!panelSpec; |
| + const renderPanelBody = (spec, body) => { |
| + body.replaceChildren(); |
| + // The handle a panel renders against: dismiss it, redraw it, and act on |
| + // the composer it sits above — insert text at the caret, or stage |
| + // attachments through the same path the attach button / paste / drop use. |
| + const panel = { |
| + threadId: ctx.threadId, |
| + close: () => hidePanel(), |
| + refresh: () => { |
| + if (panelSpec === spec && body.isConnected) renderPanelBody(spec, body); |
| + }, |
| + insertText: (t) => { |
| + const text = String(t == null ? "" : t); |
| + if (!text) return; |
| + // 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 }); |
| + composer.insertAtCaret(text); |
| + }, |
| + attachFiles: (files) => api.stageFiles([...(files || [])]), |
| + }; |
| + try { |
| + spec.render(body, panel); |
| + } catch (e) { |
| + // A panel must never take down the pane — contain the error in place. |
| + console.error(`command panel (${(spec.sender && spec.sender.name) || "app"}) render failed:`, e); |
| + body.replaceChildren(); |
| + const p = document.createElement("p"); |
| + p.textContent = "This panel hit an error and couldn't load."; |
| + body.appendChild(p); |
| + } |
| + }; |
| + // Whether the panel eases open/shut: the app's call (animate: false opts |
| + // out), gated on reduced-motion — and skipped in the command-host embed, |
| + // where the panel is the whole page and the NATIVE shell animates the |
| + // frame instead (an in-page ease inside a just-revealed frame reads as a |
| + // flash, not a slide). |
| + // (The side column skips the ease outright — its reveal is horizontal, and |
| + // a height roll inside a full-height column reads as a glitch.) |
| + const panelEases = (spec) => |
| + spec && spec.animate !== false && spec.position !== "side" && motionOK() && !document.body.classList.contains("command-host"); |
| + const clearPanelEase = () => { |
| + panelBox.style.removeProperty("height"); |
| + panelBox.style.removeProperty("overflow"); |
| + panelBox.style.removeProperty("transition"); |
| + }; |
| + const showPanel = (spec) => { |
| + const prev = panelSpec; |
| + panelSpec = spec && typeof spec.render === "function" ? spec : null; |
| + clearPanelEase(); // a mid-animation replace starts clean |
| + // Replacing (or closing) a panel tells the outgoing one — best-effort. |
| + if (prev && prev.onClose) { |
| + try { |
| + prev.onClose(); |
| + } catch (e) { |
| + console.error(e); |
| + } |
| + } |
| + panelBox.replaceChildren(); |
| + panelBox.classList.toggle("hidden", !panelSpec); |
| + panelBox.classList.toggle("cmd-panel--top", !!(panelSpec && panelSpec.position === "top")); |
| + panelBox.classList.toggle("cmd-panel--side", !!(panelSpec && panelSpec.position === "side")); |
| + // The side placement reserves a right-hand gutter on the root (padding, |
| + // so the column occupies real layout space and nothing renders under it). |
| + root.classList.toggle("has-side-panel", !!(panelSpec && panelSpec.position === "side")); |
| + if (!panelSpec) { |
| + notifyCommandPanel(ctx.threadId, null); |
| return; |
| } |
| - // "@To-do buy milk" in a chat with apps added: the mention addresses the |
| - // app, the text runs its default command — an EVENT into this thread (shown |
| - // as an action line), not a message. Leading-@ only; a mention mid-text is |
| - // ordinary conversation. See runAppMention. |
| - if (!editing && !replyTo && !pending.length && plain && plain[0] === "@" && runAppMention(plain, ctx.threadId, (r) => addLocalNotice(ctx, r))) { |
| - resetInput(); |
| - return; |
| + // Every placement is in-flow: "top" pins it above the feed (under the |
| + // shared-app surface); "side" is a full-height right column (a vertical |
| + // sidebar — list-shaped content wants a tall, narrow box, not a wide, |
| + // short band; on narrow screens its CSS falls back to the bottom band); |
| + // the default sits at the bottom of the pane, above the composer stack |
| + // (before the zero-height .composer-float anchor). |
| + root.insertBefore(panelBox, panelSpec.position === "top" ? feedWrap : block.float); |
| + const head = document.createElement("div"); |
| + head.className = "cmd-panel-head"; |
| + const s = panelSpec.sender || {}; |
| + if (s.glyph) { |
| + const g = document.createElement("span"); |
| + g.textContent = s.glyph; |
| + head.appendChild(g); |
| } |
| - // Plain text in a default-command thread runs the app's default command |
| - // instead of sending a message nobody reads — typing "buy milk" into To-do |
| - // just adds it (defaultCommandFor gates this to app-threads with no bot; |
| - // slash text never falls through here, a "/" always means command). |
| - if (!editing && !replyTo && plain && plain[0] !== "/" && !pending.length) { |
| - const dc = defaultCmd(); |
| - if (dc) { |
| - const typed = plain; |
| - runCommand(dc.name + " " + typed, ctx.threadId, (r) => { |
| - addLocalNotice(ctx, r); |
| - // 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 && !composer.getText()) { |
| - composer.setText(typed); |
| - refreshSend(); |
| - } |
| - }); |
| - resetInput(); |
| - return; |
| + const title = document.createElement("span"); |
| + title.className = "cmd-panel-title"; |
| + title.textContent = panelSpec.title || s.name || ""; |
| + const x = document.createElement("button"); |
| + x.type = "button"; |
| + x.className = "cmd-panel-x"; |
| + x.setAttribute("aria-label", "Dismiss"); |
| + x.innerHTML = icon("x"); |
| + x.onclick = () => hidePanel(); |
| + // headExtra: a spec-provided slot in the title row, between the title and |
| + // the dismiss — for a panel's one standing control (Memory's "Forget…"), |
| + // so it sits inline with the name instead of eating body space. |
| + if (typeof panelSpec.headExtra === "function") { |
| + const extra = document.createElement("span"); |
| + extra.className = "cmd-panel-extra"; |
| + try { |
| + panelSpec.headExtra(extra); |
| + } catch (e) { |
| + console.error(e); |
| } |
| + head.append(title, extra, x); |
| + } else { |
| + head.append(title, x); |
| } |
| - if (editing) { |
| - // An empty edit just cancels; otherwise only clear once the edit actually |
| - // reaches the server — host.edit resolves false on a down socket, so the |
| - // typed changes survive to be resent after reconnecting. |
| - if (!text || !host || !host.edit) { |
| - resetInput(); |
| - cancelEdit(); |
| - return; |
| + const body = document.createElement("div"); |
| + body.className = "cmd-panel-body"; |
| + panelBox.append(head, body); |
| + renderPanelBody(panelSpec, body); |
| + notifyCommandPanel(ctx.threadId, panelSpec); |
| + // Ease up from nothing to the panel's natural height (see panelEases). |
| + if (panelEases(panelSpec)) { |
| + const h = panelBox.offsetHeight; |
| + if (h) { |
| + panelBox.style.overflow = "hidden"; |
| + panelBox.style.height = "0px"; |
| + void panelBox.offsetHeight; // commit the start height |
| + panelBox.style.transition = "height 0.22s ease"; |
| + panelBox.style.height = h + "px"; |
| + panelBox.addEventListener("transitionend", clearPanelEase, { once: true }); |
| + setTimeout(clearPanelEase, 350); // backstop if transitionend never fires |
| } |
| - Promise.resolve(host.edit(ctx.threadId, editing.sid, { ...editing.raw, text })).then((sent) => { |
| - if (!sent) return; |
| - resetInput(); |
| - cancelEdit(); |
| - }); |
| - return; |
| } |
| - if (!text && !pending.length) return; |
| - const payload = { text }; |
| - if (pending.length) payload.attachments = pending.map((p) => p.att); |
| - if (replyTo) payload.replyTo = replyTo; |
| - // Only clear the composer once the send actually reaches the server: ctx.emit |
| - // resolves false when the socket is down/reconnecting, so keep the text, staged |
| - // files, and reply target intact to resend — never silently drop what was typed. |
| - ctx.emit(payload).then((sent) => { |
| - if (!sent) return; |
| - // Snap to the newest on the echo's re-render — your message should land in |
| - // view even if you'd scrolled up to reply. Set now (well before the server |
| - // round-trip); render() clears it. |
| - pinNext.add(ctx.threadId); |
| - resetInput(); |
| - pending = []; |
| - // Sending from an open thread view keeps it open and re-stages the reply |
| - // to its root — the new reply folds in live and the next send continues |
| - // the same thread, like iMessage. |
| - if (threadSid) stageThreadReply(); |
| - else cancelReply(); |
| - refreshStaged(); |
| - 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 |
| - // transcript either way. |
| - deliverAnswer(ctx.threadId, payload, (r) => addLocalNotice(ctx, r)); |
| + // A bottom panel takes its space from the feed — keep the newest in view. |
| + if (feedNearBottom(feed, 160)) requestAnimationFrame(() => feedGlideToBottom(feed, true)); |
| + }; |
| + // Dismissal rolls the panel down before tearing it out, unless the app (or |
| + // the OS) asked for instant. panelClosing keeps a second Escape (or the ✕) |
| + // from re-arming the roll-up mid-flight; a NEW panel opening mid-animation |
| + // wins — showPanel resets the inline styles, and the captured spec check |
| + // keeps the stale finish() from tearing the newcomer down. |
| + let panelClosing = false; |
| + const hidePanel = () => { |
| + if (!panelSpec || panelClosing) return; |
| + if (!panelEases(panelSpec) || !panelBox.isConnected) return showPanel(null); |
| + panelClosing = true; |
| + const closing = panelSpec; |
| + let finished = false; |
| + const finish = () => { |
| + if (finished) return; |
| + finished = true; |
| + panelClosing = false; |
| + clearPanelEase(); |
| + if (panelSpec === closing) showPanel(null); |
| + }; |
| + const startH = panelBox.offsetHeight; |
| + if (!startH) return finish(); |
| + panelBox.style.overflow = "hidden"; |
| + panelBox.style.height = startH + "px"; |
| + void panelBox.offsetHeight; |
| + panelBox.style.transition = "height 0.2s ease"; |
| + panelBox.style.height = "0px"; |
| + panelBox.addEventListener("transitionend", finish, { once: true }); |
| + setTimeout(finish, 320); |
| + }; |
| + // Programmatic closes (ctx.closePanel) take the same eased path as ✕/Escape. |
| + cmdPanels.set(ctx.threadId, (spec) => (spec ? showPanel(spec) : hidePanel())); |
| + |
| + // 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). |
| + const dropOverlay = document.createElement("div"); |
| + dropOverlay.className = "drop-overlay hidden"; |
| + dropOverlay.textContent = "Drop a photo or file to send"; |
| + root.appendChild(dropOverlay); |
| + // The native macOS shell (Eleven) drives the overlay during a Finder drag: |
| + // WebKit swallows the drag events natively (see the listener note below), so the |
| + // page's own dragenter/leave never fire in the app. Selector-based so it always |
| + // targets the connected root's overlay — survives thread switches / the |
| + // instant-restore path. (DropWebView in ChatFeedWebView.swift calls this.) |
| + window.__lchatDropOverlay = (show) => { |
| + const o = document.querySelector(".chatapp-root .drop-overlay"); |
| + if (o) o.classList.toggle("hidden", !show); |
| + }; |
| + // The native wrapper intercepts Finder file-drops itself — a WKWebView doesn't |
| + // hand the page the dropped files on macOS, and merely *registering* a web drop |
| + // handler makes WebKit claim the drop, bypassing the native path, so nothing |
| + // uploads. So skip the web drop listeners in the native app (html.native); there |
| + // the shell delivers files via __lchatAddFiles and shows the overlay via |
| + // __lchatDropOverlay above. |
| + if (!document.documentElement.classList.contains("native")) { |
| + let dragDepth = 0; |
| + root.addEventListener("dragenter", (e) => { |
| + if (!e.dataTransfer || ![...e.dataTransfer.types].includes("Files")) return; |
| + dragDepth++; |
| + dropOverlay.classList.remove("hidden"); |
| + }); |
| + root.addEventListener("dragleave", () => { |
| + if (--dragDepth <= 0) { |
| + dragDepth = 0; |
| + dropOverlay.classList.add("hidden"); |
| + } |
| }); |
| + root.addEventListener("dragover", (e) => { |
| + if (e.dataTransfer && [...e.dataTransfer.types].includes("Files")) e.preventDefault(); |
| + }); |
| + root.addEventListener("drop", (e) => { |
| + dragDepth = 0; |
| + dropOverlay.classList.add("hidden"); |
| + const files = [...((e.dataTransfer && e.dataTransfer.files) || [])]; |
| + if (files.length) { |
| + e.preventDefault(); |
| + api.stageFiles(files); |
| + } |
| + }); |
| + } |
| + // The native macOS shell (LChatMac) can't hand Finder file-drops to the page — |
| + // a WKWebView doesn't deliver them and the web drop listeners above are skipped |
| + // in native mode — so it fills the page's static #file-input and dispatches |
| + // `change` (see ChatFeedWebView's __lchatAddFiles). The live composer uses its |
| + // own input, so expose a handler that pulls the files off that static input |
| + // into this thread's staging path; render() wires it to #file-input for the |
| + // thread on screen. (A plain browser never reaches this — its drops land on the |
| + // web listeners above.) |
| + root._dropFiles = () => { |
| + const el = document.getElementById("file-input"); |
| + if (!el) return; |
| + const files = [...(el.files || [])]; |
| + el.value = ""; |
| + if (files.length) api.stageFiles(files); |
| }; |
| // Scroll-back pagination: nearing the top folds the previous history page |
| @@ -3510,20 +3681,19 @@ function buildSkeleton(el, ctx) { |
| let threadPaging = false; // paging history backward looking for the root |
| // Stage the composer's reply on the thread's root — the whole point of the |
| - // view's composer contract ("Replying to <root author>"). Mirrors startReply |
| - // (a reply displaces an in-flight edit; native shells mirror the banner) but |
| - // skips the focus on touch, where popping the keyboard would cover the |
| - // thread the reader just opened. |
| + // view's composer contract ("Replying to <root author>"). api.stageReply |
| + // mirrors startReply (a reply displaces an in-flight edit; native shells |
| + // mirror the banner); the focus is skipped on touch, where popping the |
| + // keyboard would cover the thread the reader just opened. |
| const stageThreadReply = () => { |
| - editing = null; |
| const msgs = ((ctx.getState() || {}).msgs) || []; |
| const rootMsg = msgs.find((m) => m.sid === threadSid); |
| - replyTo = rootMsg |
| - ? replyTarget(msgs, rootMsg) |
| - : { 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) composer.focus(); |
| + api.stageReply( |
| + rootMsg |
| + ? replyTarget(msgs, rootMsg) |
| + : { id: threadSid, name: (threadFallback && threadFallback.name) || "", snippet: (threadFallback && threadFallback.snippet) || "" } |
| + ); |
| + if (!inEmbed && !coarse) composer.focus(); |
| }; |
| const closeThread = () => { |
| @@ -3536,8 +3706,9 @@ function buildSkeleton(el, ctx) { |
| threadEl = null; |
| } |
| // Leaving the thread drops its staged reply — never an unrelated one staged |
| - // since (an edit clears replyTo itself, so that survives untouched). |
| - if (sid && replyTo && replyTo.id === sid) cancelReply(); |
| + // since (an edit clears the staged reply itself, so that survives untouched). |
| + const staged = api.stagedReply(); |
| + if (sid && staged && staged.id === sid) api.cancelReply(); |
| }; |
| const buildThread = () => { |
| @@ -3668,7 +3839,8 @@ function buildSkeleton(el, ctx) { |
| refreshThread(); |
| // The root may have just loaded — refresh the banner to its live snapshot, |
| // but only while the staged reply is still this thread's (not an edit). |
| - if (!editing && replyTo && replyTo.id === sid) stageThreadReply(); |
| + const staged = api.stagedReply(); |
| + if (!api.editing() && staged && staged.id === sid) stageThreadReply(); |
| }; |
| const openThread = (rootSid, ref) => { |
| @@ -3730,19 +3902,19 @@ function buildSkeleton(el, ctx) { |
| // 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 && !composer.hasFocus() && !document.querySelector(".emoji-picker, .msg-sheet, .lightbox")) { |
| + if (e.key === "Escape" && threadSid && !api.editing() && !e.defaultPrevented && !composer.hasFocus() && !document.querySelector(".emoji-picker, .msg-sheet, .lightbox")) { |
| e.preventDefault(); |
| closeThread(); |
| return; |
| } |
| - if (e.key === "Escape" && !composer.hasFocus() && (replyTo || editing) && !document.querySelector(".emoji-picker")) { |
| + if (e.key === "Escape" && !composer.hasFocus() && (api.stagedReply() || api.editing()) && !document.querySelector(".emoji-picker")) { |
| e.preventDefault(); |
| - const wasEditing = !!editing; |
| - cancelReply(); |
| - cancelEdit(); |
| + const wasEditing = api.editing(); |
| + api.cancelReply(); |
| + api.cancelEdit(); |
| if (wasEditing) { |
| composer.setText(""); |
| - refreshSend(); |
| + api.refreshSend(); |
| } |
| } |
| }; |
| @@ -3762,28 +3934,27 @@ function buildSkeleton(el, ctx) { |
| if (inEmbed && host.registerNative) { |
| root._native = { |
| sendText(text) { |
| - if (pending.some((p) => !p.att)) return false; // still uploading |
| + if (api.uploading()) return false; // still uploading |
| composer.setText(text || ""); |
| - if (composer.isEmpty() && !pending.length) return false; |
| - compose.requestSubmit(); |
| - stopTypingBeat(); |
| + if (composer.isEmpty() && !api.staged()) return false; |
| + api.submit(); |
| + api.typing(false); |
| return true; |
| }, |
| typing(hasText) { |
| - if (hasText) beatTyping(); |
| - else stopTypingBeat(); |
| + api.typing(hasText); |
| }, |
| stage(file) { |
| - stageFiles([file]); |
| + api.stageFiles([file]); |
| }, |
| cancelReply() { |
| // The native banner's ✕ means the same as the web one: leaving an open |
| // thread view, not just dropping its staged reply. |
| if (threadSid) closeThread(); |
| - else cancelReply(); |
| + else api.cancelReply(); |
| }, |
| cancelEdit() { |
| - cancelEdit(); |
| + api.cancelEdit(); |
| }, |
| didAttach(force) { |
| const atBottom = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 60; |