| index fdfa5b2..2369580 100644 |
| --- a/web/static/apps/chat.js |
| +++ b/web/static/apps/chat.js |
| @@ -272,7 +272,10 @@ const composers = new Map(); // threadId → { refreshSend(), startReply(item), |
| // The reply-thread view controller for each mounted thread, so bubble taps (a |
| // quote card, a "N Replies" tally) can lift that thread over the pane they live |
| // in. Built alongside the composer in buildSkeleton, overwritten per mount. |
| -const threads = new Map(); // threadId → { open(rootSid, ref), close(), isOpen() } |
| +// paneComposer() hands out the docked pane's composer api while one is mounted |
| +// (null otherwise) — how setConnected and bubble actions reach the second |
| +// composer without a second registry. |
| +const threads = new Map(); // threadId → { open(rootSid, ref, opener), close(), isOpen(), paneComposer() } |
| |
| // Threads whose next render should snap to the newest message even if the reader |
| // had scrolled up — set when you send from the composer (a reply included), since |
| @@ -289,6 +292,13 @@ let connected = true; |
| export function setConnected(v) { |
| connected = v; |
| for (const c of composers.values()) c.refreshSend(); |
| + // The docked thread pane's composer isn't in `composers` (one entry per |
| + // thread, the main block's) — reach it through the threads registry so its |
| + // send button greys with the socket too. |
| + for (const t of threads.values()) { |
| + const pane = t.paneComposer && t.paneComposer(); |
| + if (pane) pane.refreshSend(); |
| + } |
| } |
| |
| // Successful unfurl results cached by URL so a bubble re-render doesn't |
| @@ -633,6 +643,11 @@ export const app = defineApp({ |
| .chatapp-root.readonly .typeahead, |
| .chatapp-root.readonly > .composer-reply, |
| .chatapp-root.readonly > .composer-edit { display: none; } |
| +/* The docked thread pane's composer is nested (not a root child), so the two |
| + child-combinator sweeps above miss it — same rule, same reasons: embedded, |
| + the shell draws the composer; read-only, nobody replies. */ |
| +.chatapp-root.embed .chatapp-thread-composer, |
| +.chatapp-root.readonly .chatapp-thread-composer { display: none; } |
| /* Embedded: keep the attachment preview visible (a staged photo shows just above |
| the native composer) and give the feed the native safe-area insets — mirrors |
| the original feed's .native-embed rules. */ |
| @@ -877,6 +892,15 @@ export const app = defineApp({ |
| // composer, so re-registering the timer each draw is harmless). |
| const composer = composers.get(ctx.threadId); |
| if (composer && composer.tickTyping) ctx.interval(composer.tickTyping, 1000); |
| + // The docked thread pane's composer beats typing too. Resolved lazily on |
| + // each tick, not captured: the pane opens and closes between renders (no |
| + // re-render marks either), so a captured handle would go stale — and with |
| + // no pane this is a 1s no-op, the same cost as the ticker above. |
| + ctx.interval(() => { |
| + const t = threads.get(ctx.threadId); |
| + const pane = t && t.paneComposer && t.paneComposer(); |
| + if (pane && pane.tickTyping) pane.tickTyping(); |
| + }, 1000); |
| }, |
| }); |
| |
| @@ -950,17 +974,22 @@ export function repaintMentionsOf(names) { |
| // slot — inline, never floating over the feed. |
| function paintClosedState(root, ctx) { |
| const closed = !!(host && host.dmClosed && host.dmClosed(ctx.threadId)); |
| - const compose = root.querySelector(".composer"); |
| - const float = root.querySelector(".composer-float"); |
| + // Direct children only: a docked thread pane nests its own ".composer", |
| + // and this paint must never grab that one by first match. |
| + const compose = root.querySelector(":scope > .composer"); |
| + const float = root.querySelector(":scope > .composer-float"); |
| + const paneCompose = root.querySelector(".chatapp-thread-composer"); |
| let bar = root.querySelector(":scope > .dm-closed-bar"); |
| if (!closed) { |
| if (bar) bar.remove(); |
| if (compose) compose.classList.remove("hidden"); |
| if (float) float.classList.remove("hidden"); |
| + if (paneCompose) paneCompose.classList.remove("hidden"); |
| return; |
| } |
| if (compose) compose.classList.add("hidden"); |
| if (float) float.classList.add("hidden"); |
| + if (paneCompose) paneCompose.classList.add("hidden"); |
| if (bar) return; // already painted; keep any inline error it shows |
| bar = document.createElement("div"); |
| bar.className = "dm-closed-bar"; |
| @@ -2604,7 +2633,9 @@ function buildComposerBlock(ctx, host, cfg) { |
| if (primary) applyComposerHint(ctx.threadId); |
| |
| const refreshBanners = () => { |
| - if (cfg.fixedReply) return; // a root-pinned block derives its reply per send — no banner |
| + // A root-pinned block derives its reply per send — no reply banner. The |
| + // EDIT banner still renders: entering edit mode without saying so would |
| + // let Enter silently overwrite an earlier message. |
| replyBanner.textContent = ""; |
| replyBanner.classList.add("hidden"); |
| editBanner.textContent = ""; |
| @@ -2618,7 +2649,7 @@ function buildComposerBlock(ctx, host, cfg) { |
| x.onclick = cancelEdit; |
| editBanner.appendChild(x); |
| editBanner.classList.remove("hidden"); |
| - } else if (replyTo) { |
| + } else if (replyTo && !cfg.fixedReply) { |
| const bar = document.createElement("div"); |
| bar.className = "reply-bar"; |
| const ic = document.createElement("span"); |
| @@ -2950,6 +2981,10 @@ function buildComposerBlock(ctx, host, cfg) { |
| const updateTypeahead = () => { |
| const tok = composerToken(composer.getText(), composer.getCaret()); |
| if (!tok || !host || !host.typeaheadItems) return closeTypeahead(); |
| + // No command routing (cfg.commands off) means no command typeahead either: |
| + // a leading "/" in this block is ordinary text that sends verbatim (the |
| + // thread pane's composer), so offering completions would be a lie. |
| + if (tok.kind === "command" && !cfg.commands) return closeTypeahead(); |
| const items = host.typeaheadItems(tok); |
| if (!items.length) return closeTypeahead(); |
| typeahead = { kind: tok.kind, start: tok.start, items, index: 0 }; |
| @@ -3030,15 +3065,21 @@ function buildComposerBlock(ctx, host, cfg) { |
| } |
| if (e.key === "Escape" && sendQueued) { |
| setSendQueued(false); // un-queue first; a second Escape cancels reply/edit |
| + e.preventDefault(); // handled — the document dispatcher must not also peel a layer |
| return; |
| } |
| // 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). |
| + // edit-in-flight exception (see buildSkeleton's onEscape). A consumed |
| + // Escape is marked handled: with two composer blocks live (the docked |
| + // thread pane), the document dispatcher can't tell "this block dealt with |
| + // it" from focus alone, and one Escape must peel exactly one layer. |
| if (e.key === "Escape" && cfg.onEscape && cfg.onEscape(!!editing)) { |
| + e.preventDefault(); |
| return; |
| } |
| if (e.key === "Escape" && (editing || replyTo)) { |
| + e.preventDefault(); // same one-layer rule as above |
| const wasEditing = !!editing; |
| cancelReply(); |
| cancelEdit(); |
| @@ -3048,7 +3089,7 @@ function buildComposerBlock(ctx, host, cfg) { |
| } |
| } |
| // ↑ on an empty composer edits your latest still-editable message. |
| - if (e.key === "ArrowUp" && !composer.getText() && !editing) { |
| + if (e.key === "ArrowUp" && !composer.getText() && !editing && !cfg.fixedReply) { |
| 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) |
| @@ -3312,7 +3353,11 @@ function buildSkeleton(el, ctx) { |
| hidePanel(); |
| return true; |
| } |
| - if (threadSid && !hasEdit) { |
| + // Docked, the main composer's staged reply is its OWN state (never the |
| + // thread's) — let the block cancel it below before an Escape reaches |
| + // for the pane; the next Escape then closes the pane. Overlay, the |
| + // staged reply IS the thread's, so closing the view clears it in one. |
| + if (threadSid && !hasEdit && !(threadDocked && block.api.stagedReply())) { |
| closeThread(); |
| return true; |
| } |
| @@ -3321,17 +3366,18 @@ function buildSkeleton(el, ctx) { |
| // 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. |
| + // sends would land outside it. Docked, the pane carries its own composer |
| + // and the main composer's staged reply is unrelated — just drop it. |
| onReplyCancel: () => { |
| - if (!threadSid) return false; |
| + if (!threadSid || threadDocked) 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. |
| + // the thread. Docked, the main composer was never staged to the thread. |
| onEditCancelled: () => { |
| - if (threadSid) stageThreadReply(); |
| + if (threadSid && !threadDocked) stageThreadReply(); |
| }, |
| // The composer just grew, shrinking the flex feed. Re-pin to the newest |
| // message synchronously in the same layout — the ResizeObserver below also |
| @@ -3350,11 +3396,12 @@ function buildSkeleton(el, ctx) { |
| }, |
| // 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. |
| + // OVERLAY thread view's reply staged — sending from it continues the same |
| + // thread, like iMessage — else the armed reply is spent. Docked, the pane |
| + // owns the thread's composer, so the main one always just spends its reply. |
| onSent: () => { |
| pinNext.add(ctx.threadId); |
| - if (threadSid) stageThreadReply(); |
| + if (threadSid && !threadDocked) stageThreadReply(); |
| else block.api.cancelReply(); |
| }, |
| }); |
| @@ -3472,6 +3519,13 @@ function buildSkeleton(el, ctx) { |
| panelBox.style.removeProperty("transition"); |
| }; |
| const showPanel = (spec) => { |
| + // A docked thread and a command panel can't share the right dock (and a |
| + // bottom band under an open thread reads as the thread's) — opening a |
| + // panel closes the thread, the mirror of openThread's hidePanel(). Gated |
| + // on `spec`: hidePanel's eased teardown lands here as showPanel(null), |
| + // possibly AFTER openThread already re-set threadSid (the 200ms roll-up), |
| + // and that must never close the thread it was cleared for. |
| + if (spec && threadSid && threadDocked) closeThread(); |
| const prev = panelSpec; |
| panelSpec = spec && typeof spec.render === "function" ? spec : null; |
| clearPanelEase(); // a mid-animation replace starts clean |
| @@ -3669,16 +3723,22 @@ function buildSkeleton(el, ctx) { |
| }); |
| |
| // ---- reply-thread view: tap a quote or a "N Replies" tally to lift that |
| - // thread over the transcript, iMessage-style ---- |
| - // The overlay occupies the feed's box only (see .chatapp-feedwrap), so the |
| + // thread over the transcript (narrow), or dock it as a right-hand column |
| + // beside it (desktop widths), Slack-style ---- |
| + // Overlay: occupies the feed's box only (see .chatapp-feedwrap), so the |
| // real composer below stays live with its reply state staged to the thread's |
| - // root — sending from it continues the thread, and the new reply folds into |
| - // the open view (render() → root._refreshThread re-derives from current |
| - // state each fold; the view never caches its list). |
| + // root — sending from it continues the thread. Docked: the panel parks in |
| + // the root's right gutter (the command panel's side-dock technique) and |
| + // carries its OWN root-pinned composer, leaving the main one alone. Either |
| + // way the new reply folds into the open view (render() → root._refreshThread |
| + // re-derives from current state each fold; the view never caches its list). |
| let threadSid = 0; // the open thread's root sid (0 = closed) |
| let threadFallback = null; // { id, name, snippet } — header stand-in while the root isn't loaded |
| - let threadEl = null; // { ov, count, rootBox, note, body } while open |
| + let threadEl = null; // { ov, panel, count, rootBox, note, body } while open (ov null when docked) |
| let threadPaging = false; // paging history backward looking for the root |
| + let threadDocked = false; // this open used the docked presentation (decided at open time, like every width check) |
| + let threadOpener = null; // the element that opened the view — focus returns there on close |
| + let paneBlock = null; // the docked pane's composer block (buildComposerBlock), docked only |
| |
| // Stage the composer's reply on the thread's root — the whole point of the |
| // view's composer contract ("Replying to <root author>"). api.stageReply |
| @@ -3699,27 +3759,48 @@ function buildSkeleton(el, ctx) { |
| const closeThread = () => { |
| const sid = threadSid; |
| if (!sid && !threadEl) return; |
| + const docked = threadDocked; |
| + const opener = threadOpener; |
| threadSid = 0; |
| threadFallback = null; |
| + threadDocked = false; |
| + threadOpener = null; |
| if (threadEl) { |
| - threadEl.ov.remove(); |
| + if (docked) { |
| + threadEl.panel.remove(); |
| + root.classList.remove("has-thread-pane"); |
| + } else threadEl.ov.remove(); |
| threadEl = null; |
| } |
| - // Leaving the thread drops its staged reply — never an unrelated one staged |
| - // since (an edit clears the staged reply itself, so that survives untouched). |
| - const staged = api.stagedReply(); |
| - if (sid && staged && staged.id === sid) api.cancelReply(); |
| + paneBlock = null; // the pane composer dies with its panel — a re-open builds fresh |
| + // Overlay only: leaving the thread drops its staged reply — never an |
| + // unrelated one staged since (an edit clears the staged reply itself, so |
| + // that survives untouched). Docked, the main composer was never staged to |
| + // the thread, so a same-id reply the user armed themselves is theirs to keep. |
| + if (!docked) { |
| + const staged = api.stagedReply(); |
| + if (sid && staged && staged.id === sid) api.cancelReply(); |
| + } |
| + // Hand focus back where the view came from (the tally/quote that opened |
| + // it), the standard dialog return — but never to a node a re-render has |
| + // since replaced. Docked with no usable opener, fall back to the main |
| + // composer: closing removed the focused pane, and keyboard/AT users must |
| + // not be dropped onto <body>. |
| + if (opener && opener.isConnected && typeof opener.focus === "function") opener.focus(); |
| + else if (docked && !inEmbed && !coarse) composer.focus(); |
| + // The ▤ Views menu lists an open thread as a view (PR 3) — same repaint |
| + // signal the bot views use. |
| + document.dispatchEvent(new CustomEvent("lchat:views-changed")); |
| }; |
| |
| - const buildThread = () => { |
| - const ov = document.createElement("div"); |
| - ov.className = "chatapp-thread"; |
| - const dim = document.createElement("div"); |
| - dim.className = "chatapp-thread-dim"; |
| - dim.onclick = () => closeThread(); // tapping the dimmed transcript leaves the thread |
| + const buildThread = (docked) => { |
| const panel = document.createElement("div"); |
| - panel.className = "chatapp-thread-panel"; |
| - panel.setAttribute("role", "dialog"); |
| + panel.className = "chatapp-thread-panel" + (docked ? " chatapp-thread-panel--docked" : ""); |
| + // Docked it's an ordinary non-modal region beside the chat (a landmark, |
| + // no focus trap); the overlay presents as a dialog over the transcript. |
| + // No aria-modal on the overlay: its reply surface is the MAIN composer |
| + // below the dialog, and aria-modal would tell AT to ignore it. |
| + panel.setAttribute("role", docked ? "complementary" : "dialog"); |
| panel.setAttribute("aria-label", "Reply thread"); |
| const head = document.createElement("div"); |
| head.className = "chatapp-thread-head"; |
| @@ -3742,8 +3823,60 @@ function buildSkeleton(el, ctx) { |
| const body = document.createElement("div"); |
| body.className = "chatapp-thread-msgs"; |
| panel.append(head, rootBox, note, body); |
| + if (docked) { |
| + // The pane's own composer, pinned to the thread root: a SECOND |
| + // buildComposerBlock instance. fixedReply derives the target fresh per |
| + // send (the root may load mid-thread — same derivation stageThreadReply |
| + // uses), so no banner ever shows and nothing is staged on the main |
| + // composer. commands off: "/" text sends verbatim, Slack-style (v1 — |
| + // a pane-summoned command panel has nowhere to dock; see the spec's |
| + // follow-ups). The whole 4-node fragment mounts INSIDE the wrapper — |
| + // including the block's float, which carries the attachment staging |
| + // strip and the emoji/@ typeahead drop-up (without it the typeahead |
| + // would arm invisibly and swallow Enter). That float is harmless here: |
| + // command panels anchor on the MAIN block's float captured in showPanel, |
| + // never this one — and nesting keeps the `.chatapp-root >` child |
| + // combinators (embed/readonly sweeps) deliberately blind to the pane. |
| + const composerWrap = document.createElement("div"); |
| + composerWrap.className = "chatapp-thread-composer"; |
| + paneBlock = buildComposerBlock(ctx, host, { |
| + fixedReply: () => { |
| + const msgs = ((ctx.getState() || {}).msgs) || []; |
| + const rootMsg = msgs.find((m) => m.sid === threadSid); |
| + return rootMsg |
| + ? replyTarget(msgs, rootMsg) |
| + : { id: threadSid, name: (threadFallback && threadFallback.name) || "", snippet: (threadFallback && threadFallback.snippet) || "" }; |
| + }, |
| + commands: false, |
| + // Escape in the pane's box: an in-flight edit cancels first (return |
| + // false — the block's own cancel path runs, then a second Escape |
| + // lands back here); otherwise the pane is the layer, so close it. |
| + onEscape: (hasEdit) => { |
| + if (hasEdit) return false; |
| + closeThread(); |
| + return true; |
| + }, |
| + // Refocus the pane's box so the next reply types straight away (the |
| + // factory's own post-send focus covers the common path; this keeps |
| + // the contract explicit). Deliberately NO pinNext: the reply shows in |
| + // the pane (whose body follows), and snapping the main transcript |
| + // would yank a reader who scrolled it while working the thread — |
| + // the overlay never had that problem because the dim covers the feed. |
| + onSent: () => { |
| + if (paneBlock) paneBlock.api.focus(); |
| + }, |
| + }); |
| + composerWrap.appendChild(paneBlock.el); |
| + panel.appendChild(composerWrap); |
| + return { ov: null, panel, count, rootBox, note, body }; |
| + } |
| + const ov = document.createElement("div"); |
| + ov.className = "chatapp-thread"; |
| + const dim = document.createElement("div"); |
| + dim.className = "chatapp-thread-dim"; |
| + dim.onclick = () => closeThread(); // tapping the dimmed transcript leaves the thread |
| ov.append(dim, panel); |
| - return { ov, count, rootBox, note, body }; |
| + return { ov, panel, count, rootBox, note, body }; |
| }; |
| |
| // Thread rows reuse the feed's exact bubble machinery — renderItem + |
| @@ -3843,23 +3976,68 @@ function buildSkeleton(el, ctx) { |
| if (!api.editing() && staged && staged.id === sid) stageThreadReply(); |
| }; |
| |
| - const openThread = (rootSid, ref) => { |
| + const openThread = (rootSid, ref, opener) => { |
| if (!rootSid) return; |
| - if (threadSid === rootSid && threadEl) return; // already open on this root |
| + if (threadSid === rootSid && threadEl) { |
| + // Already open on this root — just put the caret back in the acting |
| + // composer (the pane's when docked, the main one under the overlay). |
| + if (threadDocked && paneBlock) paneBlock.api.focus(); |
| + else if (!inEmbed && !coarse) composer.focus(); |
| + return; |
| + } |
| closeThread(); |
| + // Presentation is decided per open, at open time — the codebase's width |
| + // idiom (point-in-time matchMedia, no live resize listeners). 721px is |
| + // the command panel side-dock's breakpoint; below it the overlay is |
| + // byte-for-byte today's. |
| + threadDocked = matchMedia("(min-width: 721px)").matches; |
| + // One right dock: an open command panel yields to a DOCKED thread |
| + // (showPanel does the mirror). The overlay never shared a dock with the |
| + // panel, so narrow widths keep both — exactly as before. |
| + if (threadDocked) hidePanel(); |
| threadSid = rootSid; |
| threadFallback = ref || null; |
| - threadEl = buildThread(); |
| - feedWrap.appendChild(threadEl.ov); |
| - stageThreadReply(); |
| + threadOpener = opener || null; |
| + threadEl = buildThread(threadDocked); |
| + if (threadDocked) { |
| + // The side-dock technique: the root reserves the gutter with padding |
| + // (real layout space), the panel parks absolutely in it — same slot the |
| + // command panel's side column uses. Appended LAST deliberately: the |
| + // pane carries its own ".composer", and every first-match query in the |
| + // tree (closed-DM paint, shell/search/onboarding focus hops) must keep |
| + // finding the MAIN composer first; last also puts the pane after the |
| + // transcript and composer in Tab order. |
| + root.classList.add("has-thread-pane"); |
| + root.appendChild(threadEl.panel); |
| + } else { |
| + feedWrap.appendChild(threadEl.ov); |
| + // Overlay only: the main composer below IS the thread's reply box, so |
| + // stage it to the root. The docked pane's composer needs no staging — |
| + // it derives its fixedReply target per send. |
| + stageThreadReply(); |
| + } |
| // Render before any paging: the view must show what's loaded even when |
| // pageInRoot bails (e.g. a scroll-back already in flight). When paging does |
| // run, its own refresh follows synchronously, so no wrong-note flash. |
| refreshThread(); |
| const msgs = ((ctx.getState() || {}).msgs) || []; |
| if (!msgs.some((m) => m.sid === rootSid)) pageInRoot(); |
| + // Move focus into the pane once it's laid out (the rAF lets the insert |
| + // paint first). Same touch guard as stageThreadReply's focus: popping the |
| + // keyboard over a just-opened pane helps nobody on coarse pointers. |
| + if (threadDocked && !inEmbed && !coarse) requestAnimationFrame(() => { |
| + if (paneBlock) paneBlock.api.focus(); |
| + }); |
| + document.dispatchEvent(new CustomEvent("lchat:views-changed")); |
| }; |
| - threads.set(ctx.threadId, { open: openThread, close: closeThread, isOpen: () => !!threadSid }); |
| + threads.set(ctx.threadId, { |
| + open: openThread, |
| + close: closeThread, |
| + isOpen: () => !!threadSid, |
| + // The docked pane's composer api, while one is mounted — how setConnected |
| + // (and PR 3's composerFor routing) reach the second composer. |
| + paneComposer: () => (threadDocked && paneBlock ? paneBlock.api : null), |
| + }); |
| |
| // Keyboard shortcuts, mirroring the original feed: with an empty composer, |
| // cmd/ctrl-T opens the message menu on the latest message from someone else |
| @@ -3898,16 +4076,19 @@ function buildSkeleton(el, ctx) { |
| return; |
| } |
| // Escape closes an open thread view when the composer isn't focused (the |
| - // 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 && !api.editing() && !e.defaultPrevented && !composer.hasFocus() && !document.querySelector(".emoji-picker, .msg-sheet, .lightbox")) { |
| + // focused cases — main box and the docked pane's — are handled in each |
| + // block's onComposerKeydown, which marks a consumed Escape handled). |
| + // 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. Docked, a staged |
| + // reply the user armed on the main composer is the newer layer: cancel it |
| + // (the branch below) before an Escape reaches the pane. |
| + if (e.key === "Escape" && threadSid && !api.editing() && !(threadDocked && api.stagedReply()) && !e.defaultPrevented && !composer.hasFocus() && !document.querySelector(".emoji-picker, .msg-sheet, .lightbox")) { |
| e.preventDefault(); |
| closeThread(); |
| return; |
| } |
| - if (e.key === "Escape" && !composer.hasFocus() && (api.stagedReply() || api.editing()) && !document.querySelector(".emoji-picker")) { |
| + if (e.key === "Escape" && !e.defaultPrevented && !composer.hasFocus() && (api.stagedReply() || api.editing()) && !document.querySelector(".emoji-picker")) { |
| e.preventDefault(); |
| const wasEditing = api.editing(); |
| api.cancelReply(); |