| index 9897a50..4ad2858 100644 |
| --- a/web/static/apps/chat.js |
| +++ b/web/static/apps/chat.js |
| @@ -224,6 +224,11 @@ const myReactions = new Set(); |
| // Edit) can reach the composer they share a pane with. |
| const composers = new Map(); // threadId → { refreshSend(), startReply(item), startEdit(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() } |
| + |
| // 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 |
| @@ -322,6 +327,25 @@ export function replyTarget(msgs, m) { |
| : { id: ref.id || 0, name: ref.name || "", snippet: ref.snippet || "" }; |
| } |
| |
| +// threadItems: the reply thread rooted at `rootSid` — the root message (when |
| +// loaded) followed by every message whose RESOLVED thread root is that sid, in |
| +// server order. Legacy chains (a reply quoting another reply) resolve |
| +// transitively via rootRef, so they land in the same thread as their ancestors; |
| +// when the root itself has scrolled out of the loaded window the replies still |
| +// fold (the view renders the quoted snapshot as its header instead). Pure and |
| +// exported for tests — the thread view re-derives this from current state on |
| +// every fold, never a cached list. |
| +export function threadItems(msgs, rootSid) { |
| + const bySid = new Map(msgs.map((m) => [m.sid, m])); |
| + const out = []; |
| + const root = bySid.get(rootSid); |
| + if (root) out.push(root); |
| + for (const m of msgs) { |
| + if (m.reply && m.reply.id && rootRef(bySid, m.reply).id === rootSid) out.push(m); |
| + } |
| + return out.sort((a, b) => a.sid - b.sid); |
| +} |
| + |
| export function viewItems(msgs) { |
| // Reply threads group on the resolved ROOT message: every reply tallies on |
| // its thread's top message (legacy chains resolve transitively), and each |
| @@ -484,6 +508,13 @@ export const app = defineApp({ |
| padding: 18px; display: flex; flex-direction: column; gap: 4px; |
| } |
| .chatapp-feed::before { content: ""; margin-top: auto; } |
| +/* The feed's positioning wrapper: the reply-thread view overlays exactly this |
| + box (inset:0 in style.css), so it dims/covers the transcript but never the |
| + composer stack below — the real composer stays usable under the thread. */ |
| +.chatapp-feedwrap { |
| + flex: 1 1 auto; min-height: 0; min-width: 0; position: relative; |
| + display: flex; flex-direction: column; |
| +} |
| `, |
| |
| // Incremental render: the skeleton (feed / banners / composer) is built once |
| @@ -609,6 +640,13 @@ export const app = defineApp({ |
| feedGlideToBottom(feed, firstBuild || forcePin); |
| } |
| |
| + // An open reply-thread view folds from this same state — re-derive and |
| + // re-render it every state change so new replies/reactions/edits land live |
| + // (it reads current state via threadItems, never a cached list). No-op when |
| + // closed; stashed on the root like _hotkeys so the instant-restore path |
| + // (which skips buildSkeleton) keeps the live mount's view current. |
| + if (root._refreshThread) root._refreshThread(); |
| + |
| // The in-feed typing row, exactly like the original feed: a .typing element |
| // kept as the feed's last child (below the newest message). keyedList only |
| // manages keyed children, so it never touches this row. |
| @@ -760,7 +798,17 @@ function renderItem(m, ctx) { |
| txt.className = "reply-quote-text"; |
| txt.textContent = m.reply.snippet; |
| q.append(who, txt); |
| - q.onclick = () => scrollToMessage(wrap, m.reply.id); |
| + q.onclick = () => { |
| + // 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 |
| + // 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); |
| + else scrollToMessage(wrap, m.reply.id); |
| + }; |
| content.appendChild(q); |
| } |
| |
| @@ -847,7 +895,7 @@ function renderItem(m, ctx) { |
| const reactions = buildReactionsRow(m, ctx); |
| if (reactions) wrap.appendChild(reactions); |
| |
| - if (m.replyCount) wrap.appendChild(buildReplyCount(m, wrap)); |
| + if (m.replyCount) wrap.appendChild(buildReplyCount(m, wrap, ctx)); |
| |
| if (m.edited) { |
| const e = document.createElement("div"); |
| @@ -895,12 +943,20 @@ function buildReactionsRow(m, ctx) { |
| return row.childElementCount ? row : null; |
| } |
| |
| -// The "N Replies" tally below a bubble, tappable to jump to the first reply. |
| -function buildReplyCount(m, wrap) { |
| +// 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. |
| +function buildReplyCount(m, wrap, ctx) { |
| const rc = document.createElement("div"); |
| 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) }); |
| + return; |
| + } |
| const feed = wrap.closest(".chatapp-feed"); |
| const first = feed && feed.querySelector(`.msg[data-reply-to="${m.sid}"]`); |
| if (first) scrollToMessage(wrap, first.dataset.id); |
| @@ -931,7 +987,7 @@ function patchBubble(node, m, ctx) { |
| if (m.replyCount) { |
| if (rc) rc.textContent = m.replyCount === 1 ? "1 Reply" : m.replyCount + " Replies"; |
| else { |
| - rc = buildReplyCount(m, wrap); |
| + rc = buildReplyCount(m, wrap, ctx); |
| const edited = wrap.querySelector(":scope > .edited-tag"); |
| if (edited) edited.before(rc); |
| else wrap.appendChild(rc); |
| @@ -2280,6 +2336,13 @@ function buildSkeleton(el, ctx) { |
| |
| 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); |
| |
| const float = document.createElement("div"); |
| float.className = "composer-float"; |
| @@ -2347,7 +2410,7 @@ function buildSkeleton(el, ctx) { |
| attach.hidden = !!defaultCmd(); |
| |
| |
| - root.append(surface, feed, float, replyBanner, editBanner, compose); |
| + root.append(surface, feedWrap, float, replyBanner, editBanner, compose); |
| el.appendChild(root); |
| |
| // Keep the feed pinned to the newest message when the composer, its staged |
| @@ -2426,7 +2489,11 @@ function buildSkeleton(el, ctx) { |
| x.className = "reply-bar-cancel"; |
| x.innerHTML = icon("x"); |
| x.setAttribute("aria-label", "Cancel reply"); |
| - x.onclick = cancelReply; |
| + // 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()); |
| bar.append(ic, body, x); |
| replyBanner.appendChild(bar); |
| replyBanner.classList.remove("hidden"); |
| @@ -2444,6 +2511,9 @@ 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(); |
| }; |
| |
| // Staged attachments as original .preview-chip tiles: a spinner while |
| @@ -2921,6 +2991,13 @@ function buildSkeleton(el, ctx) { |
| setSendQueued(false); // un-queue first; a second Escape cancels reply/edit |
| 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(); |
| + return; |
| + } |
| if (e.key === "Escape" && (editing || replyTo)) { |
| const wasEditing = !!editing; |
| cancelReply(); |
| @@ -3052,7 +3129,11 @@ function buildSkeleton(el, ctx) { |
| pinNext.add(ctx.threadId); |
| resetInput(); |
| pending = []; |
| - cancelReply(); |
| + // 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(); |
| input.focus(); |
| // A scripted conversation awaiting an answer in this thread is told what |
| @@ -3081,6 +3162,198 @@ function buildSkeleton(el, ctx) { |
| loadingOlder = false; |
| }); |
| |
| + // ---- 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 |
| + // 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). |
| + 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 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. |
| + 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) input.focus(); |
| + }; |
| + |
| + const closeThread = () => { |
| + const sid = threadSid; |
| + if (!sid && !threadEl) return; |
| + threadSid = 0; |
| + threadFallback = null; |
| + if (threadEl) { |
| + threadEl.ov.remove(); |
| + 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(); |
| + }; |
| + |
| + 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 panel = document.createElement("div"); |
| + panel.className = "chatapp-thread-panel"; |
| + panel.setAttribute("role", "dialog"); |
| + panel.setAttribute("aria-label", "Reply thread"); |
| + const head = document.createElement("div"); |
| + head.className = "chatapp-thread-head"; |
| + const title = document.createElement("div"); |
| + title.className = "chatapp-thread-title"; |
| + title.textContent = "Thread"; |
| + const count = document.createElement("div"); |
| + count.className = "chatapp-thread-count"; |
| + const x = document.createElement("button"); |
| + x.type = "button"; |
| + x.className = "chatapp-thread-x"; |
| + x.innerHTML = icon("x"); // static in-code SVG from the host (safe) |
| + x.setAttribute("aria-label", "Close thread"); |
| + x.onclick = () => closeThread(); |
| + head.append(title, count, x); |
| + const rootBox = document.createElement("div"); |
| + rootBox.className = "chatapp-thread-rootmsg"; |
| + const note = document.createElement("div"); |
| + note.className = "chatapp-thread-note hidden"; |
| + const body = document.createElement("div"); |
| + body.className = "chatapp-thread-msgs"; |
| + panel.append(head, rootBox, note, body); |
| + ov.append(dim, panel); |
| + return { ov, count, rootBox, note, body }; |
| + }; |
| + |
| + // Thread rows reuse the feed's exact bubble machinery — renderItem + |
| + // patchBubble under keyedList — so reactions, attachments, long-press |
| + // actions, hover strips and edits all keep working inside the view (and a |
| + // reaction patches in place instead of reloading a photo). |
| + const threadKeyed = (container, items) => |
| + keyedList(container, items, { |
| + key: (m) => m.id, |
| + rev: (m) => m.vrev, |
| + srev: (m) => m.srev, |
| + render: (m) => renderItem(m, ctx), |
| + update: (node, m) => patchBubble(node, m, ctx), |
| + }); |
| + |
| + const refreshThread = () => { |
| + if (!threadSid || !threadEl) return; |
| + const msgs = ((ctx.getState() || {}).msgs) || []; |
| + const items = threadItems(msgs, threadSid); |
| + // The whole thread got deleted under the open view — nothing left to show. |
| + if (!items.length && !threadPaging) return closeThread(); |
| + const rootLoaded = !!(items.length && items[0].sid === threadSid); |
| + const replies = rootLoaded ? items.slice(1) : items; |
| + threadEl.count.textContent = replies.length === 1 ? "1 reply" : replies.length + " replies"; |
| + if (rootLoaded) { |
| + const m = items[0]; |
| + if (threadEl.rootBox.dataset.mode !== "msg") { |
| + threadEl.rootBox.textContent = ""; // swap out the snapshot fallback |
| + threadEl.rootBox.dataset.mode = "msg"; |
| + } |
| + // The pinned root is a real keyed bubble, sans separator and sans its own |
| + // tally (the replies ARE the panel). Same soft-rev split as viewItems so |
| + // a reaction patches the row in place, never rebuilding the bubble. |
| + const reactSig = (m.reactions || []).map((r) => `${r.emoji}${r.count}`).join(","); |
| + threadKeyed(threadEl.rootBox, [{ ...m, timeSep: null, cont: false, replyCount: 0, replyRoot: 0, vrev: `${m.rev}|root`, srev: reactSig }]); |
| + } else if (threadEl.rootBox.dataset.mode !== "fall") { |
| + threadEl.rootBox.textContent = ""; |
| + threadEl.rootBox.dataset.mode = "fall"; |
| + // Root not loaded (yet): the quoted snapshot stands in as the header. |
| + const fall = document.createElement("div"); |
| + fall.className = "chatapp-thread-rootfall"; |
| + const who = document.createElement("div"); |
| + who.className = "reply-quote-name"; |
| + who.textContent = (threadFallback && threadFallback.name) || ""; |
| + const txt = document.createElement("div"); |
| + txt.className = "chatapp-thread-fallsnippet"; |
| + txt.textContent = (threadFallback && threadFallback.snippet) || ""; |
| + fall.append(who, txt); |
| + threadEl.rootBox.appendChild(fall); |
| + } |
| + const noteText = threadPaging |
| + ? "Loading the start of this thread…" |
| + : rootLoaded ? "" : "The original message isn't available — showing its replies."; |
| + threadEl.note.textContent = noteText; |
| + threadEl.note.classList.toggle("hidden", !noteText); |
| + // Follow the newest reply like the feed does, but only when already there — |
| + // a reader scrolled up a long thread isn't yanked down by a live arrival. |
| + const follow = threadEl.body.scrollHeight - threadEl.body.scrollTop - threadEl.body.clientHeight < 80 || !threadEl.body.childElementCount; |
| + threadKeyed(threadEl.body, viewItems(replies)); |
| + if (follow) threadEl.body.scrollTop = threadEl.body.scrollHeight; |
| + }; |
| + root._refreshThread = refreshThread; |
| + |
| + // Page history backward until the thread's root is loaded — bounded, so a |
| + // reply deep in a huge history can't spin the loader forever. Shares the |
| + // scroll-back path's loadingOlder/exhausted guards and keeps the dimmed |
| + // transcript's scroll anchored across each prepend, like scroll-back does. |
| + const THREAD_PAGE_CAP = 5; |
| + const pageInRoot = async () => { |
| + if (threadPaging || loadingOlder) return; |
| + const sid = threadSid; |
| + threadPaging = true; |
| + refreshThread(); // show the inline loading note straight away |
| + for (let i = 0; i < THREAD_PAGE_CAP && threadSid === sid && !exhausted; i++) { |
| + const msgs = ((ctx.getState() || {}).msgs) || []; |
| + if (msgs.some((m) => m.sid === sid)) break; |
| + const oldest = msgs.length ? msgs[0].sid : 0; |
| + // Pages are contiguous: once we've loaded past the root's sid without |
| + // finding it, the root was deleted — more paging can't surface it. |
| + if (!oldest || oldest < sid || !host || !host.loadOlder) break; |
| + loadingOlder = true; // keep the feed's own scroll-back handler out |
| + const prevHeight = feed.scrollHeight; |
| + const n = await host.loadOlder(ctx.threadId, oldest); |
| + feed.scrollTop += feed.scrollHeight - prevHeight; |
| + loadingOlder = false; |
| + if (n === 0) { |
| + exhausted = true; |
| + break; |
| + } |
| + } |
| + threadPaging = false; |
| + if (threadSid !== sid) return; // closed / re-opened elsewhere while paging |
| + 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 openThread = (rootSid, ref) => { |
| + if (!rootSid) return; |
| + if (threadSid === rootSid && threadEl) return; // already open on this root |
| + closeThread(); |
| + threadSid = rootSid; |
| + threadFallback = ref || null; |
| + threadEl = buildThread(); |
| + feedWrap.appendChild(threadEl.ov); |
| + 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(); |
| + }; |
| + threads.set(ctx.threadId, { open: openThread, close: closeThread, isOpen: () => !!threadSid }); |
| + |
| // Keyboard shortcuts, mirroring the original feed: with an empty composer, |
| // cmd/ctrl-T opens the message menu on the latest message from someone else |
| // (number keys then toggle reactions, Escape dismisses — openMessagePicker) |
| @@ -3109,6 +3382,16 @@ function buildSkeleton(el, ctx) { |
| const m = latest(false); |
| if (m) composers.get(ctx.threadId).startReply(m); |
| } |
| + // Escape closes an open thread view when the composer isn't focused (the |
| + // focused case is handled in input.onkeydown). Skipped when a layer that |
| + // owns Escape is up — the picker preventDefaults before us (capture phase), |
| + // the sheet/lightbox are still in the DOM when we run — so one Escape peels |
| + // one layer, never two. |
| + if (e.key === "Escape" && threadSid && !editing && !e.defaultPrevented && document.activeElement !== input && !document.querySelector(".emoji-picker, .msg-sheet, .lightbox")) { |
| + e.preventDefault(); |
| + closeThread(); |
| + return; |
| + } |
| if (e.key === "Escape" && document.activeElement !== input && (replyTo || editing) && !document.querySelector(".emoji-picker")) { |
| e.preventDefault(); |
| const wasEditing = !!editing; |
| @@ -3150,7 +3433,12 @@ function buildSkeleton(el, ctx) { |
| stage(file) { |
| stageFiles([file]); |
| }, |
| - cancelReply, |
| + 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(); |
| + }, |
| cancelEdit() { |
| cancelEdit(); |
| }, |