| index 2369580..a16b77c 100644 |
| --- a/web/static/apps/chat.js |
| +++ b/web/static/apps/chat.js |
| @@ -277,6 +277,46 @@ const composers = new Map(); // threadId → { refreshSend(), startReply(item), |
| // composer without a second registry. |
| const threads = new Map(); // threadId → { open(rootSid, ref, opener), close(), isOpen(), paneComposer() } |
| |
| +// composerFor: the composer a bubble action (Reply, Edit, swipe-reply) lands |
| +// in — the docked thread pane's own when the acting bubble lives inside that |
| +// pane, else the thread's main composer. Decided on the DOM (is the anchor |
| +// inside `.chatapp-thread-panel--docked`?), not on state: the pane's rows come |
| +// from the same renderItem as the transcript's, so WHERE the bubble is is the |
| +// only thing that tells the two apart. The overlay has no pane composer |
| +// (paneComposer() is null), so it resolves to the main one — as before. |
| +function composerFor(anchor, ctx) { |
| + const t = threads.get(ctx.threadId); |
| + const pane = t && t.paneComposer && t.paneComposer(); |
| + if (pane && anchor && anchor.closest && anchor.closest(".chatapp-thread-panel--docked")) return pane; |
| + return composers.get(ctx.threadId); |
| +} |
| + |
| +// openReplyThread: the "Reply in thread" verb — open the thread `m` belongs |
| +// to, anchored on its resolved ROOT (replyTarget: a reply's thread is its |
| +// root's, never the tapped reply's own), docked or lifted per openThread's |
| +// width check. From a bubble inside the pane itself this is harmless: same |
| +// root, so openThread's already-open branch just refocuses. The opener is |
| +// where focus returns on close, so only a node that can take focus is worth |
| +// recording: press-and-hold and the cmd/ctrl-T hotkey anchor the picker on |
| +// the bubble (a plain div), and handing closeThread an unfocusable node would |
| +// swallow its main-composer fallback. |
| +function openReplyThread(m, ctx, anchor) { |
| + const t = threads.get(ctx.threadId); |
| + if (!t) return; |
| + const target = replyTarget(((ctx.getState() || {}).msgs) || [], m); |
| + if (!target.id) return; |
| + t.open(target.id, target, anchor && anchor.tabIndex >= 0 ? anchor : null); |
| +} |
| + |
| +// activeThreadPane: the open reply-thread controller for a mounted thread |
| +// (null when none is open) — how the shell's ▤ Views menu lists an open |
| +// thread as a view and puts focus back in it. openThread/closeThread dispatch |
| +// lchat:views-changed, so that menu repaints as the pane comes and goes. |
| +export function activeThreadPane(threadId) { |
| + const t = threads.get(threadId); |
| + return t && t.isOpen() ? t : null; |
| +} |
| + |
| // 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 |
| // there's no optimistic echo: your message only lands on the server round-trip's |
| @@ -1150,11 +1190,12 @@ function renderItem(m, ctx) { |
| // A quote tap opens the whole thread, anchored on its resolved ROOT (the |
| // quoted ref may name a mid-chain parent on a legacy reply). replyTarget |
| // also hands us the root's name/snippet — the header fallback if the root |
| - // has scrolled out of the loaded window. Old scroll-jump kept as a belt |
| + // has scrolled out of the loaded window. The card is the opener — focus |
| + // returns to it when the view closes. Old scroll-jump kept as a belt |
| // for a mount without thread controls. |
| const t = threads.get(ctx.threadId); |
| const ref = replyTarget(((ctx.getState() || {}).msgs) || [], m); |
| - if (t && ref.id) t.open(ref.id, ref); |
| + if (t && ref.id) t.open(ref.id, ref, q); |
| else scrollToMessage(wrap, m.reply.id); |
| }; |
| content.appendChild(q); |
| @@ -1288,14 +1329,14 @@ function attachActions(wrap, content, m, ctx) { |
| actions.appendChild(hoverBtn("react-hover", "React", icon("react"), (e) => openMessagePicker(m, ctx, e.currentTarget))); |
| actions.appendChild( |
| hoverBtn("reply-hover", "Reply", icon("reply"), () => { |
| - const ctl = composers.get(ctx.threadId); |
| + const ctl = composerFor(wrap, ctx); |
| if (ctl) ctl.startReply(m); |
| }), |
| ); |
| const remaining = m.ts + EDIT_WINDOW_MS - Date.now(); |
| if (mine && m.text.trim() && remaining > 0) { |
| const editBtn = hoverBtn("edit-hover", "Edit", icon("edit"), () => { |
| - const ctl = composers.get(ctx.threadId); |
| + const ctl = composerFor(wrap, ctx); |
| if (ctl) ctl.startEdit(m); |
| }); |
| actions.appendChild(editBtn); |
| @@ -1360,15 +1401,18 @@ function buildReactionsRow(m, ctx) { |
| // The "N Replies" tally below a bubble, tappable to open the thread view. The |
| // tally only ever renders under a loaded root (it IS this message), so no |
| // paging is needed on this path. Old jump-to-first-reply kept as a belt for a |
| -// mount without thread controls. |
| +// mount without thread controls. A real <button> (style.css resets the pill |
| +// chrome), like the quote card: the thread is keyboard-reachable, and it's |
| +// the opener the view hands focus back to on close. |
| function buildReplyCount(m, wrap, ctx) { |
| - const rc = document.createElement("div"); |
| + const rc = document.createElement("button"); |
| + rc.type = "button"; |
| rc.className = "reply-count"; |
| rc.textContent = m.replyCount === 1 ? "1 Reply" : m.replyCount + " Replies"; |
| rc.onclick = () => { |
| const t = threads.get(ctx.threadId); |
| if (t) { |
| - t.open(m.sid, { id: m.sid, name: m.name, snippet: replySnippet(m.raw) }); |
| + t.open(m.sid, { id: m.sid, name: m.name, snippet: replySnippet(m.raw) }, rc); |
| return; |
| } |
| const feed = wrap.closest(".chatapp-feed"); |
| @@ -1530,10 +1574,14 @@ function openMessagePicker(m, ctx, anchor) { |
| b.textContent = emoji; |
| b.onclick = () => { |
| toggleReaction(m, emoji, ctx); |
| - pop.remove(); |
| + teardown(); |
| }; |
| pop.appendChild(b); |
| } |
| + // Every button dismisses via teardown (below), never a bare pop.remove(): |
| + // the picker's document listeners must go with it, or the digit/Escape |
| + // handler outlives the menu and a "1" typed into the composer the action |
| + // just focused toggles a reaction instead. |
| const action = (svg, label, run) => { |
| const b = document.createElement("button"); |
| b.className = "picker-action"; |
| @@ -1542,14 +1590,20 @@ function openMessagePicker(m, ctx, anchor) { |
| b.title = label; |
| b.onclick = () => { |
| run(); |
| - pop.remove(); |
| + teardown(); |
| }; |
| pop.appendChild(b); |
| }; |
| action(icon("reply"), "Reply", () => { |
| - const ctl = composers.get(ctx.threadId); |
| + const ctl = composerFor(anchor, ctx); |
| if (ctl) ctl.startReply(m); |
| }); |
| + // Reply in thread — the same glyph as Reply, the label tells them apart: |
| + // opens the message's thread (docked beside the chat at desktop widths, |
| + // lifted over it on narrow ones) rather than staging a reply below. The |
| + // anchor is the opener: the hover strip's button on that path, else the |
| + // bubble itself (unfocusable — openReplyThread drops it). |
| + action(icon("reply"), "Reply in thread", () => openReplyThread(m, ctx, anchor)); |
| // Copy — the original touch sheet's action; native selection is suppressed on |
| // bubbles (long-press opens this menu), so Copy must live here. |
| if (m.text.trim()) { |
| @@ -1572,7 +1626,7 @@ function openMessagePicker(m, ctx, anchor) { |
| const mine = ownMessage({ uid: m.uid, name: m.name }, ctx.me, ctx.meName); |
| if (mine && m.text.trim() && Date.now() - m.ts < EDIT_WINDOW_MS) { |
| action(icon("edit"), "Edit", () => { |
| - const ctl = composers.get(ctx.threadId); |
| + const ctl = composerFor(anchor, ctx); |
| if (ctl) ctl.startEdit(m); |
| }); |
| } |
| @@ -1603,7 +1657,7 @@ function openMessagePicker(m, ctx, anchor) { |
| b.title = act.label; |
| b.onclick = () => { |
| act.run(); |
| - pop.remove(); |
| + teardown(); |
| }; |
| pop.appendChild(b); |
| } |
| @@ -1640,19 +1694,24 @@ function openMessagePicker(m, ctx, anchor) { |
| } |
| |
| // The actions in the touch long-press sheet, in the original feed's order: Reply, |
| -// Copy, Copy link, then app-contributed actions (Save link…), then your-own Edit (in-window) |
| -// and Delete. Each is { label, icon | glyph, danger?, run } — mirrors messageActions |
| -// in the original chat.js. |
| -function sheetActions(m, ctx) { |
| +// Reply in thread, Copy, Copy link, then app-contributed actions (Save link…), |
| +// then your-own Edit (in-window) and Delete. Each is { label, icon | glyph, |
| +// danger?, run } — mirrors messageActions in the original chat.js. `wrap` is |
| +// the pressed bubble: Reply/Edit route to the composer it shares a pane with |
| +// (the docked thread pane's own when it lives there), and it's the thread |
| +// verb's opener. |
| +function sheetActions(m, ctx, wrap) { |
| const acts = []; |
| acts.push({ |
| label: "Reply", |
| icon: icon("reply"), |
| run: () => { |
| - const ctl = composers.get(ctx.threadId); |
| + const ctl = composerFor(wrap, ctx); |
| if (ctl) ctl.startReply(m); |
| }, |
| }); |
| + // Same glyph as Reply; the label tells them apart (see the desktop picker). |
| + acts.push({ label: "Reply in thread", icon: icon("reply"), run: () => openReplyThread(m, ctx, wrap) }); |
| // Native selection is suppressed on bubbles (long-press opens this sheet), so |
| // Copy must live here. |
| if (m.text.trim()) { |
| @@ -1684,7 +1743,7 @@ function sheetActions(m, ctx) { |
| label: "Edit", |
| icon: icon("edit"), |
| run: () => { |
| - const ctl = composers.get(ctx.threadId); |
| + const ctl = composerFor(wrap, ctx); |
| if (ctl) ctl.startEdit(m); |
| }, |
| }); |
| @@ -1759,7 +1818,7 @@ function openMessageSheet(m, ctx, wrap) { |
| |
| const menu = document.createElement("div"); |
| menu.className = "msg-sheet-menu"; |
| - for (const a of sheetActions(m, ctx)) { |
| + for (const a of sheetActions(m, ctx, wrap)) { |
| const item = document.createElement("button"); |
| item.className = "msg-sheet-item" + (a.danger ? " danger" : ""); |
| const label = document.createElement("span"); |
| @@ -2035,7 +2094,7 @@ function attachSwipeReply(wrap, m, ctx) { |
| cleanup(); |
| mode = ""; |
| if (trigger) { |
| - const ctl = composers.get(ctx.threadId); |
| + const ctl = composerFor(wrap, ctx); |
| if (ctl) ctl.startReply(m); |
| } |
| }; |
| @@ -3525,7 +3584,12 @@ function buildSkeleton(el, ctx) { |
| // 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(); |
| + if (spec && threadSid && threadDocked) { |
| + // Never silently drop what was typed: a pane edit in flight keeps its |
| + // pane, and this panel takes the top band instead of the dock. |
| + if (paneBlock && paneBlock.api.editing()) spec = { ...spec, position: "top" }; |
| + else closeThread(); |
| + } |
| const prev = panelSpec; |
| panelSpec = spec && typeof spec.render === "function" ? spec : null; |
| clearPanelEase(); // a mid-animation replace starts clean |
| @@ -4083,7 +4147,20 @@ function buildSkeleton(el, ctx) { |
| // 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")) { |
| + // A pane edit in flight is the innermost layer of all: focus in neither |
| + // field, Escape cancels THAT — never the pane around it, which would |
| + // drop the typed edit (the pane's own field handles it when focused). |
| + const paneEditing = !!(threadDocked && paneBlock && paneBlock.api.editing()); |
| + if (e.key === "Escape" && paneEditing && !e.defaultPrevented && !document.querySelector(".emoji-picker, .msg-sheet, .lightbox")) { |
| + e.preventDefault(); |
| + paneBlock.api.cancelEdit(); |
| + return; |
| + } |
| + // Keyboard Escape is never destructive (the Mac rule too): a docked pane |
| + // holding a typed draft or staged files stays put — the ✕ is the |
| + // deliberate close-with-content gesture. |
| + const paneHasContent = !!(threadDocked && paneBlock && (!paneBlock.composer.isEmpty() || paneBlock.api.staged())); |
| + if (e.key === "Escape" && threadSid && !paneHasContent && !api.editing() && !(threadDocked && api.stagedReply()) && !e.defaultPrevented && !composer.hasFocus() && !document.querySelector(".emoji-picker, .msg-sheet, .lightbox")) { |
| e.preventDefault(); |
| closeThread(); |
| return; |