eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1// Chat, as a mini-app — the existing chat retooled onto the framework, not a
2// parallel one. It speaks the wire protocol we already have (the composer
3// emits an original-shaped untyped payload) AND renders the original feed's exact
4// markup: every element uses the same class names and structure appendMessage
5// builds, so style.css styles both feeds identically — hover-revealed times
6// and action icons, reply quotes, separators, reactions — with no duplicated
7// CSS. If the two ever look different, it's a bug here, not a style fork.
8//
9// The host (chat.js) feeds the app everything the original feed consumes:
10//
11// chat.msg ← each stored message's decrypted payload (text, attachment
12// metadata, replyTo), history / live / scroll-back alike
13// chat.react ← the server's reaction frames / load-time reaction lists
14// chat.edit ← edit frames (server-verified author), decrypted
15// chat.delete ← delete frames (server-verified author, hard-deleted)
16//
17// Those four are hostEvents — host-only fold handlers, never wire types — so a
18// member cannot spoof a reaction/edit/delete by posting a typed message.
19// Everything outbound (send, react, edit, unsend, upload, unfurl, older pages)
20// goes back through the original machinery via the narrow host bridge below.
21// Typing is the one wire type the app owns, as a relay-only ephemeral event.
22//
23// This is the DEFAULT chat experience for group/DM threads; a device can
24// switch back per-device (⋮ menu → "Use original chat" — see chat.js).
25import { defineApp, namespace, keyedList, runCommand, runAppMention, messageActionsFor, commandCatalog, buildLocalNotice, renderApp, deliverAnswer, cancelAnswer, setAnswerCancelledHook, parseMeCommand, defaultCommandFor, appChatReadOnly, setCommandPanelHost, notifyCommandPanel, motionOK } from "../apps.js";
26import { replySnippet, formatBytes, emojiOnlyCount, firstLink, youtubeId, typingSummary } from "../format.js";
27import { openLightbox } from "../lightbox.js";
28import { parseMessage, renderTree, plainText as mdPlainText } from "../markdown.js";
29import { createComposer, adoptComposer, classicComposerPref, formatAsYouTypePref } from "../composer.js";
30
31// The rich (ProseMirror) composer backend loads via a primed dynamic import —
32// fetching starts at module load, mounts long after — and never in the native
33// shells' webviews (they draw their own composer; the hidden one stays the
34// cheap textarea). If the import fails, the factory quietly falls back to the
35// plain backend: chat always works.
36let pmModule = null;
37// (typeof guard: the event-vector tests import this module under Node.)
38if (typeof document !== "undefined" && !document.documentElement.classList.contains("native")) {
39 import("../composer-pm.js").then((m) => (pmModule = m)).catch(() => null);
40}
41import { renderCard, CARD_CSS } from "../cards.js";
42import { emojify } from "../emoji.js";
43import { fileIcon, shouldSend, composerToken, ownMessage, sameSender } from "../ui.js";
44import { decodeBlurhash } from "../blurhash.js";
45
46// The host bridge: chat.js injects the pieces that are server mechanisms,
47// key-holding machinery, or host state — the app never touches crypto/tokens.
48// react(threadId, sid, emoji, on, targetUid, targetText)
49// encrypted react/unreact event
50// del(threadId, sid) original delete frame (server-verified)
51// edit(threadId, sid, payload) re-encrypt + original edit frame
52// loadBlob(threadId, att) → url fetch + decrypt an attachment blob
53// upload(threadId, file) → att | null downscale/allow-list/encrypt/upload
54// unfurl(url) → preview | null the OpenGraph card data
55// loadOlder(threadId, beforeSid) → n fold an older history page: rows
56// folded, 0 = the server says history
57// ends here, -1 = the FETCH FAILED
58// (never latch `exhausted` on -1)
59// mentionNames() / mention(label, el) roster labels + the member menu
60// isGroup(threadId) / agentKind(name) sender-name + AI/BOT-badge rules
61// icons the original in-code SVG icon strings
62let host = null;
63export function setHost(h) {
64 host = h;
65}
66
67const REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🎉"]; // mirrors allowedReactions (hub.go)
68const EDIT_WINDOW_MS = 15 * 60 * 1000; // mirrors editWindow (hub.go); server enforces it
69const TIME_SEPARATOR_GAP_MS = 15 * 60 * 1000; // mirrors the original feed's separator rule
70// Typing beats, mirroring the original composer: while you're typing we re-emit a
71// beat every TYPING_INTERVAL_MS so the receiver's indicator survives a pause, and
72// each beat expires after TYPING_EXPIRY_S — comfortably longer than the interval,
73// so a single late/dropped beat doesn't blink the indicator out.
74const TYPING_INTERVAL_MS = 10000;
75const TYPING_EXPIRY_S = 15;
76const coarse = typeof matchMedia !== "undefined" && matchMedia("(pointer: coarse)").matches;
77
78// Event TYPES only: N.ev("msg") → "chat.msg", etc. (chat reuses the original
79// feed's shared CSS classes — .msg/.bubble/.feed/… — which stay literal.)
80const N = namespace("chat");
81
82// Per-thread presence: name → epoch ms until which they count as typing.
83// Ephemeral events never fold, so this lives outside reducer state — it's
84// transient UI, allowed to differ between devices.
85const typing = new Map(); // threadId → Map(name → until)
86const refreshTyping = new Map(); // threadId → fn redrawing the typing row
87
88// Private command replies ("only visible to you") live in the feed as CLIENT-ONLY
89// rows: never sent, gone on reload, and interleaved with real messages so they
90// stack like chat bubbles and persist (no auto-dismiss). Each carries a `seq` just
91// above the newest message at creation time, so a later real message falls below
92// it. render() folds these into its keyedList (see the notice branch in renderItem).
93const localNotices = new Map(); // threadId → [{ id, seq, sender, text, actions }]
94const refreshFeed = new Map(); // threadId → fn re-rendering this chat pane
95const mountedCtx = new Map(); // threadId → live app ctx (for shell-injected notices)
96const pendingNotices = new Map(); // threadId → [spec] queued until that thread mounts
97let noticeSeq = 0; // monotonic: unique ids + a stable tie-break within a thread
98
99// appendNotice injects a private "only visible to you" notice into a thread's feed
100// from OUTSIDE a command (the shell uses it when an action chip answers in place —
101// the welcome's "Take the tour", and the tour continuing after it navigates you to
102// the Eleven DM). If the target thread isn't mounted yet (we just navigated to it
103// and its feed is still loading), the notice is queued and flushed when it mounts.
104export function appendNotice(threadId, spec) {
105 const ctx = mountedCtx.get(threadId);
106 if (ctx) {
107 addLocalNotice(ctx, spec);
108 return;
109 }
110 const q = pendingNotices.get(threadId) || [];
111 q.push(spec);
112 pendingNotices.set(threadId, q);
113}
114
115// setNoticeTyping shows/clears a typing row for a SCRIPTED speaker (the setup
116// conversation's "Eleven is typing…" beat before each reply). Local-only — it
117// rides the same transient typing state as real members' beats, never the wire.
118// The until-cap self-expires a beat whose clear got lost.
119export function setNoticeTyping(threadId, name, on) {
120 let m = typing.get(threadId);
121 if (!m) typing.set(threadId, (m = new Map()));
122 if (on) m.set(name, Date.now() + 10000);
123 else m.delete(name);
124 const refresh = refreshTyping.get(threadId);
125 if (refresh) refresh();
126}
127
128// addLocalNotice records a command reply for `threadId` and re-renders the pane so
129// it appears immediately. Anchored just above the current newest message so it
130// sits where you typed it and never jumps around as the conversation continues.
131// The composer placeholder follows the conversation: a scripted question can
132// carry a `hint` ("e.g. Finn Wolfhard") that replaces "Message" the moment the
133// question renders, or an `attachHint` ("Optional: describe your photo") that
134// applies only while attachments are staged — a photo question shouldn't
135// relabel the box before there's a photo. A `label` ("Your name:") renders as
136// an inset field label INSIDE the input — the Mail/Messages "To:" pattern —
137// so the ask survives even when the placeholder is hidden by typed text.
138// Sending resets everything to "Message"; the next question sets its own.
139// Keyed like refreshFeed.
140const composerHints = new Map();
141const composerAttachHints = new Map();
142const composerLabels = new Map();
143const refreshComposerHint = new Map();
144function applyComposerHint(threadId) {
145 const fn = refreshComposerHint.get(threadId);
146 if (fn) fn();
147}
148function clearComposerHint(threadId) {
149 composerHints.set(threadId, "");
150 composerAttachHints.set(threadId, "");
151 composerLabels.set(threadId, "");
152 applyComposerHint(threadId);
153}
154// A cancelled ask never strands its pill (#1375) — whoever cancelled it.
155setAnswerCancelledHook((threadId) => clearComposerHint(threadId));
156
157// Feed-follow with intent: feedGlideToBottom eases towards the (still
158// growing) bottom, and while it's in flight feedNearBottom stays true — an
159// instantaneous scrollTop read mid-glide looks "scrolled away" and used to make
160// the typing row skip its pin. A user wheel/touch cancels the chase.
161const feedGlides = new WeakSet();
162const feedGlideCancels = new WeakSet();
163export function feedNearBottom(feed, slack = 80) {
164 return feedGlides.has(feed) || feed.scrollHeight - feed.scrollTop - feed.clientHeight < slack;
165}
166// How far the reader has to scroll up before the "jump to latest" button
167// appears — deliberately much wider than feedNearBottom's 60px auto-pin
168// slack, so the button doesn't show for merely reading the newest message.
169const JUMP_BUTTON_GAP = 400;
170export function feedGlideToBottom(feed, jump) {
171 if (!feedGlideCancels.has(feed)) {
172 feedGlideCancels.add(feed);
173 const cancel = () => feedGlides.delete(feed);
174 // Cancel on SCROLL INTENT only — wheel, or an actual touch DRAG. A bare
175 // touchstart is any tap in the feed (action chips included), and cancelling
176 // on those broke the follow for every chip-tapped step on touch devices.
177 feed.addEventListener("wheel", cancel, { passive: true });
178 feed.addEventListener("touchmove", cancel, { passive: true });
179 }
180 if (jump || matchMedia("(prefers-reduced-motion: reduce)").matches) {
181 feedGlides.delete(feed);
182 feed.scrollTop = feed.scrollHeight;
183 return;
184 }
185 if (feedGlides.has(feed)) return; // the running glide chases new growth itself
186 feedGlides.add(feed);
187 // NB for anyone adding a scroll of their own: while this set holds the feed,
188 // step() writes scrollTop EVERY frame and feedNearBottom reports "at bottom"
189 // regardless of geometry — so a smooth scrollIntoView elsewhere is cancelled
190 // on the next frame and each render re-arms the follow. Call feedCancelGlide
191 // first (see revealMessage).
192 // Also detect intervention we DON'T get events for (scrollbar drag, PageUp,
193 // arrow keys): if scrollTop is meaningfully below where we last placed it, the
194 // user moved it — bail so we never fight them back down.
195 let lastSet = feed.scrollTop;
196 const step = () => {
197 if (!feedGlides.has(feed)) return; // cancelled by the user (wheel/touch)
198 if (feed.scrollTop < lastSet - 12) {
199 feedGlides.delete(feed);
200 return;
201 }
202 const target = feed.scrollHeight - feed.clientHeight;
203 const d = target - feed.scrollTop;
204 if (Math.abs(d) < 2) {
205 feed.scrollTop = target;
206 feedGlides.delete(feed);
207 return;
208 }
209 feed.scrollTop += d * 0.16;
210 lastSet = feed.scrollTop;
211 requestAnimationFrame(step);
212 };
213 requestAnimationFrame(step);
214}
215
216// Stop a running follow-the-bottom glide. The wheel/touchmove listeners above
217// do this when the reader intervenes; a scroll WE start has to do it
218// explicitly, because step() would otherwise overwrite scrollTop on its next
219// frame (an auto-behaviour write cancels an in-flight smooth scroll) and the
220// glide's presence keeps feedNearBottom true, re-arming the follow on every
221// subsequent render.
222export function feedCancelGlide(feed) {
223 feedGlides.delete(feed);
224}
225
226function addLocalNotice(ctx, spec) {
227 composerHints.set(ctx.threadId, spec.hint || "");
228 composerAttachHints.set(ctx.threadId, spec.attachHint || "");
229 composerLabels.set(ctx.threadId, spec.label || "");
230 applyComposerHint(ctx.threadId);
231 // Hint-only (a persisted question with no chips): the placeholder was the
232 // whole point — don't add an empty row to the feed.
233 if (!spec.text && !spec.img && !(spec.actions && spec.actions.length)) return;
234 const msgs = (ctx.getState() && ctx.getState().msgs) || [];
235 const maxSid = msgs.length ? msgs[msgs.length - 1].sid : 0;
236 const list = localNotices.get(ctx.threadId) || [];
237 noticeSeq += 1;
238 // maxSid + a tiny monotonic fraction: strictly between this sid and the next, so
239 // ordering vs. integer message sids is unambiguous and creation order is kept.
240 // `at` (creation time) drives the loud→compact collapse in buildLocalNotice: a
241 // fresh reply shows the full card then calms down; one rebuilt long after it
242 // landed (thread reopened) starts compact. `short` is the terse one-liner text.
243 list.push({ id: "ln" + noticeSeq, seq: maxSid + noticeSeq * 1e-6, sender: spec.sender || null, text: spec.text || "", short: spec.short || "", place: spec.place || "", actions: spec.actions || null, img: spec.img || "", imgAlt: spec.imgAlt || "", at: Date.now() });
244 localNotices.set(ctx.threadId, list);
245 // A notice is always a response to something the reader just did (a command,
246 // an action chip, a scripted step) — snap to it like a local send, even if
247 // they'd scrolled up.
248 // A full notice snaps the feed like a local send (pinNext → instant); a
249 // chips-only row rides the smooth glide of the bubble it belongs to — the
250 // extra instant jump right after a glide is what reads as jank.
251 if (spec.text || spec.img) pinNext.add(ctx.threadId);
252 const refresh = refreshFeed.get(ctx.threadId);
253 if (refresh) refresh();
254}
255
256function typingNames(threadId, exceptName) {
257 const m = typing.get(threadId);
258 if (!m) return [];
259 const now = Date.now();
260 const out = [];
261 for (const [name, until] of m) {
262 if (until <= now) m.delete(name);
263 else if (name && name !== exceptName) out.push(name);
264 }
265 return out;
266}
267
268// Command panels: the transient results surface a slash command can open
269// (ctx.panel — see the command-panels section of apps.js). Each mounted pane
270// registers its controller in buildSkeleton; the one injected host routes an
271// open/close to the pane it belongs to. Returning false when the thread has no
272// mounted chat surface lets the command fall back to a plain reply.
273const cmdPanels = new Map(); // threadId → (spec|null) => void
274setCommandPanelHost((threadId, spec) => {
275 const show = cmdPanels.get(threadId);
276 if (!show) return false;
277 show(spec);
278 return true;
279});
280
281// Which reactions are mine, keyed "sid|emoji" — seeded from the load-time mine
282// flags and kept current by local toggles, exactly like the original feed's
283// myReactions set (live reaction frames carry counts only).
284const myReactions = new Set();
285
286// The composer controller for each mounted thread, so bubble actions (Reply,
287// Edit) can reach the composer they share a pane with.
288const composers = new Map(); // threadId → { refreshSend(), startReply(item), startEdit(item) }
289
290// The reply-thread view controller for each mounted thread, so bubble taps (a
291// quote card, a "N Replies" tally) can lift that thread over the pane they live
292// in. Built alongside the composer in buildSkeleton, overwritten per mount.
293// paneComposer() hands out the docked pane's composer api while one is mounted
294// (null otherwise) — how setConnected and bubble actions reach the second
295// composer without a second registry.
296const threads = new Map(); // threadId → { open(rootSid, ref, opener), close(), isOpen(), paneComposer(), openForMessage(sid), reveal(sid) }
297
298// composerFor: the composer a bubble action (Reply, Edit, swipe-reply) lands
299// in — the docked thread pane's own when the acting bubble lives inside that
300// pane, else the thread's main composer. Decided on the DOM (is the anchor
301// inside `.chatapp-thread-panel--docked`?), not on state: the pane's rows come
302// from the same renderItem as the transcript's, so WHERE the bubble is is the
303// only thing that tells the two apart. The overlay has no pane composer
304// (paneComposer() is null), so it resolves to the main one — as before.
305function composerFor(anchor, ctx) {
306 const t = threads.get(ctx.threadId);
307 const pane = t && t.paneComposer && t.paneComposer();
308 if (pane && anchor && anchor.closest && anchor.closest(".chatapp-thread-panel--docked")) return pane;
309 return composers.get(ctx.threadId);
310}
311
312// openReplyThread: the "Reply in thread" verb — open the thread `m` belongs
313// to (threadTarget: a message already in a thread continues it; anything else
314// roots a new thread on itself), docked or lifted per openThread's
315// width check. From a bubble inside the pane itself this is harmless: same
316// root, so openThread's already-open branch just refocuses. The opener is
317// where focus returns on close, so only a node that can take focus is worth
318// recording: press-and-hold and the cmd/ctrl-T hotkey anchor the picker on
319// the bubble (a plain div), and handing closeThread an unfocusable node would
320// swallow its main-composer fallback.
321function openReplyThread(m, ctx, anchor) {
322 const t = threads.get(ctx.threadId);
323 if (!t) return;
324 const target = threadTarget(((ctx.getState() || {}).msgs) || [], m);
325 if (!target.id) return;
326 t.open(target.id, target, anchor && anchor.tabIndex >= 0 ? anchor : null);
327}
328
329// convertChain / convertToThread: the "Convert to thread" verb — take the
330// inline reply chain hanging under `m` out of the transcript and into a thread
331// rooted on `m`. The chain is derived here, on the converting client, and
332// shipped as an explicit id list (see the chat.convert fold): the list is what
333// everyone folds, so no two clients can disagree about what moved.
334//
335// convertChain is the same question the menus ask to decide whether to offer
336// the verb at all — a message with nothing under it never shows it, and neither
337// does a bubble inside a thread (nothing inline hangs off one).
338//
339// `anchor` is the bubble the menu opened on, and it settles the one case the
340// chain can't: a thread's ROOT is rendered inside the thread view too, and it
341// CAN still have inline replies out in the feed. Tidying those is something
342// you do while looking at the feed, so the verb stays out of the thread's own
343// menus — the same call the native containers make.
344function convertChain(m, ctx, anchor) {
345 if (!m || !m.sid || !host || !host.convert) return [];
346 if (anchor && anchor.closest && anchor.closest(".chatapp-thread-panel")) return [];
347 return inlineChainIds(((ctx.getState() || {}).msgs) || [], m.sid);
348}
349
350// The confirm is deliberate, not ceremony: this reorganises the chat for
351// EVERYONE and, in v1, can't be undone (see convertConfirm).
352//
353// A conversion that can't reach the server has to SAY so. Nothing about a
354// failed one is visible — the chain simply stays where it was, which is
355// exactly what it looks like when nobody converted anything — so after
356// confirming a one-way act the reader would be left believing it happened. The
357// answer is an ordinary private notice in the feed (inline, "only visible to
358// you"), never a floating toast.
359function convertToThread(m, ctx, anchor) {
360 const ids = convertChain(m, ctx, anchor);
361 if (!ids.length) return;
362 if (!confirm(convertConfirm(ids.length))) return;
363 Promise.resolve(host.convert(ctx.threadId, m.sid, ids)).then((sent) => {
364 if (!sent) addLocalNotice(ctx, { text: "Those replies didn't move — the connection dropped. Try again once you're back online.", short: "Couldn't move those replies" });
365 });
366}
367
368// activeThreadPane: the open reply-thread controller for a mounted thread
369// (null when none is open) — how the shell's ▤ Views menu lists an open
370// thread as a view and puts focus back in it. openThread/closeThread dispatch
371// lchat:views-changed, so that menu repaints as the pane comes and goes.
372export function activeThreadPane(threadId) {
373 const t = threads.get(threadId);
374 return t && t.isOpen() ? t : null;
375}
376
377// threadListCount / openThreadList: the ▤ Threads row's two questions — is
378// there anything to list in this chat (0 hides the row, and with it the whole
379// menu when nothing else offers a view), and open the list. Kept as plain
380// exports beside activeThreadPane so the shell asks chat.js rather than
381// re-deriving threads from state it doesn't own.
382export function threadListCount(threadId) {
383 const t = threads.get(threadId);
384 return t && t.listCount ? t.listCount() : 0;
385}
386
387export function openThreadList(threadId) {
388 const t = threads.get(threadId);
389 return !!(t && t.openList && t.openList());
390}
391
392// Threads whose next render should snap to the newest message even if the reader
393// had scrolled up — set when you send from the composer (a reply included), since
394// there's no optimistic echo: your message only lands on the server round-trip's
395// re-render, by which time render()'s scroll-position check has forgotten you just
396// sent. render() consumes (and clears) this so a send always brings your message
397// into view, like every familiar chat client.
398const pinNext = new Set(); // threadId
399
400// Whether the socket is currently open. chat.js flips this on ws open/close so
401// each composer can grey its send button while a message can't reach the server
402// — a send while down is kept in the composer, never dropped (see compose.onsubmit).
403let connected = true;
404export function setConnected(v) {
405 connected = v;
406 for (const c of composers.values()) c.refreshSend();
407 // The docked thread pane's composer isn't in `composers` (one entry per
408 // thread, the main block's) — reach it through the threads registry so its
409 // send button greys with the socket too.
410 for (const t of threads.values()) {
411 const pane = t.paneComposer && t.paneComposer();
412 if (pane) pane.refreshSend();
413 }
414}
415
416// Successful unfurl results cached by URL so a bubble re-render doesn't
417// refetch; a failed or empty unfurl is evicted (see addLinkPreview) so a later
418// render retries rather than staying blank until a full reload.
419const unfurls = new Map(); // url → Promise<preview | null>
420
421// One document-level hotkey listener, dispatching to the currently-active
422// mount's handler. render() re-points this on every open — including the
423// instant-restore path where buildSkeleton is skipped — and only ever to a
424// connected root, so a parked/torn-down pane never hijacks the shortcuts.
425let hotkeys = null;
426if (typeof document !== "undefined") {
427 document.addEventListener("keydown", (e) => {
428 if (hotkeys) hotkeys(e);
429 });
430}
431
432// ---- time helpers (the original feed's exact rules) -------------------------
433
434function formatTime(ms) {
435 return new Date(ms).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
436}
437function sameDay(a, b) {
438 const da = new Date(a);
439 const db = new Date(b);
440 return da.getFullYear() === db.getFullYear() && da.getMonth() === db.getMonth() && da.getDate() === db.getDate();
441}
442// "Today" / "Yesterday" / a weekday (past week) / a date — the separator label.
443function dayLabel(ms) {
444 const now = Date.now();
445 if (sameDay(ms, now)) return "Today";
446 if (sameDay(ms, now - 86400000)) return "Yesterday";
447 const d = new Date(ms);
448 if (now - ms < 7 * 86400000) return d.toLocaleDateString([], { weekday: "long" });
449 const sameYear = new Date(now).getFullYear() === d.getFullYear();
450 return d.toLocaleDateString([], sameYear ? { month: "short", day: "numeric" } : { year: "numeric", month: "short", day: "numeric" });
451}
452
453// viewItems derives the render list from folded messages, with the original
454// feed's exact grouping rules: a .time-sep when a message opens the feed, the
455// calendar day changes (dated), or >15 min passed (time only); `cont` (tighter
456// spacing + hidden repeated name) for a same-sender run with no separator
457// between; and each message's reply tally. Pure and exported for tests.
458//
459// Two revs, so keyedList rebuilds a bubble only when it truly must: `vrev` is the
460// HARD rev — content (text/edit) and neighbour-derived layout (separator, run) —
461// and any change to it re-renders the whole bubble. `srev` is the SOFT rev —
462// reactions and the reply tally — and a change to it patches only those rows in
463// place (see patchBubble), never rebuilding, so a decrypted photo's <img> and a
464// playing YouTube embed survive a reaction or an incoming reply landing on it.
465// threadRootOf: the sid of the thread a message BELONGS to, or 0 for a message
466// that's in no thread. A thread exists only because someone chose "Reply in
467// thread", so membership is exactly the wire flag: a reply carrying
468// `reply.thread` points DIRECTLY at its thread's root (every thread send
469// derives its target from the root — see threadReplyTarget), so this is a
470// straight read, never a walk. Plain quote replies — the ordinary Reply verb,
471// and every reply in the history from before the two verbs split — belong to
472// no thread at all: an inline chain is a conversation in the feed, not a
473// thread nobody opened.
474function threadRootOf(m) {
475 if (m.reply && m.reply.thread && m.reply.id) return m.reply.id;
476 // …or the thread a CONVERSION moved it into after the fact (see
477 // claimConverts). The wire flag is read first on purpose: a reply someone
478 // composed in a thread belongs to that thread, and no conversion can pull it
479 // out of one.
480 return m.movedTo || 0;
481}
482
483// ---- converting an inline chain into a thread (the chat.convert fold) -------
484//
485// Reply and Reply-in-thread are separate verbs, and EVERY reply in the history
486// from before that split is inline — so the way a long chain becomes a thread
487// is always after the fact. The messages themselves can't be rewritten to say
488// so: only their author may edit one, only for fifteen minutes, and rewriting N
489// messages would churn N authorships and timestamps to change a display rule.
490//
491// So a conversion is its own encrypted event — one message-shaped payload
492// saying "these ids now live in the thread rooted at R" — that every client
493// folds client-side, exactly as a reaction does. The server stores one more
494// opaque blob and learns nothing; a client too old to know the payload type
495// drops it and goes on showing the chain inline.
496
497// A server message id as the wire may carry it: a positive whole number, and
498// NOTHING a coercion would rescue. Deliberately not `Number(x) || 0` — Swift's
499// MessagePayload types root/ids as Int64, so `"400"` or `400.5` fails to decode
500// and native drops the whole payload. Coercing here would convert on web and do
501// nothing on native: the same messages, a different transcript per platform,
502// which the cross-platform contract calls a hard error.
503function isSid(n) {
504 return Number.isSafeInteger(n) && n > 0;
505}
506
507// claimConverts: merge one convert event's claims into the map (sid →
508// { root, at }, where `at` is the CONVERT EVENT's own server id).
509//
510// Two people can convert overlapping chains, and the same event folds twice
511// routinely (history page + live echo, or a reconnect catch-up), so the merge
512// must be a pure function of the SET of events, never of their arrival order:
513// the OLDEST conversion (lowest `at`) owns a message, and re-folding one
514// changes nothing. Ties are impossible — `at` is a server message id. Returns
515// null when nothing changed, so the fold can hand back the same state.
516// Exported for tests: "the oldest conversion wins, and a repeat is a no-op" is
517// the rule that makes the fold order-independent, so it's pinned directly.
518//
519// The guards mirror Swift's exactly (see isSid): a payload native would drop is
520// dropped here too, whole or per id.
521export function claimConverts(claims, root, ids, at) {
522 if (!isSid(root) || !isSid(at)) return null;
523 let out = null;
524 for (const sid of ids.slice(0, MAX_CONVERT_IDS)) {
525 if (!isSid(sid) || sid === root) continue; // a root is never inside its own thread
526 const prev = (out || claims)[sid];
527 if (prev && prev.at <= at) continue; // an older conversion already owns it
528 if (!out) out = { ...claims };
529 out[sid] = { root, at };
530 }
531 return out;
532}
533
534// convertedRoots: the claims resolved to the thread each message actually
535// lands in — a pure function of the claim map, so every client and every
536// arrival order derives the same answer. Exported for tests.
537//
538// Claims can nest: one conversion moves [b, c] under root a while another moves
539// [c] under root b. A claimed root is followed through to the thread IT ended
540// up in, so claim-rooted nesting flattens onto the outermost root. (That is
541// what's guaranteed — NOT "threads never nest". A reply someone composed in a
542// thread carries the wire flag, which is authoritative and can't be
543// re-pointed, so if a conversion moves the message that reply's thread is
544// rooted on, its thread ends up inside the converted one. Benign and reachable
545// — it renders from its root's bubble in the outer thread, exactly like any
546// thread — and only a race can produce it: a "Reply in thread" in flight while
547// someone converts the chain it lives in.)
548//
549// A cycle can only come from a malformed or hostile payload (a real reply's sid
550// is always higher than the message it quotes, so real chains can't loop) and
551// resolves to no conversion at all rather than hanging.
552export function convertedRoots(claims) {
553 const out = new Map();
554 for (const key of Object.keys(claims || {})) {
555 const sid = Number(key);
556 let cur = claims[key].root;
557 const seen = new Set([sid]);
558 while (claims[cur] && !seen.has(cur)) {
559 seen.add(cur);
560 cur = claims[cur].root;
561 }
562 if (claims[cur]) continue; // cycled — leave every message in it inline
563 if (cur && cur !== sid) out.set(sid, cur);
564 }
565 return out;
566}
567
568// convertHolds: does a conversion's claim on `m` actually hold? The sender's
569// id list is a CLAIM, not a fact, and the receiver checks it against the
570// message itself: a conversion moves a reply CHAIN, so each message it names
571// must really hang off the thread it's being moved into — its own quote is
572// either the root, or another message the conversions claimed.
573//
574// Without this the shipped primitive would be "move any N messages under any
575// root", which is a great deal worse than it sounds: a conversion renders no
576// bubble (its only trace is a transient chat-list line), the oldest claim wins
577// forever so no later event can undo it, and deleting the convert row isn't an
578// undo either — so one crafted payload could quietly hide a couple of hundred
579// unrelated messages under some old root, unattributably. Now such an id is
580// simply ignored and the message stays exactly where it is.
581//
582// Order-independent, and it needs no loaded window beyond the message itself: a
583// claimed message that hasn't arrived yet has no quote to check, so it stays
584// inline until it does — the same degradation as a thread reply whose root is
585// paged out.
586function convertHolds(m, claims) {
587 const claim = claims[m.sid];
588 if (!claim) return false;
589 const quoted = (m.reply && m.reply.id) || 0;
590 return quoted === claim.root || !!claims[quoted];
591}
592
593// applyConverts: stamp each message with the thread a conversion moved it into
594// (`movedTo` — the one field threadRootOf reads besides the wire flag).
595// Derived, never authoritative: the claim map is the state and this is its
596// projection onto the loaded window, re-run on every fold so a message that
597// arrives AFTER its own conversion (an older page paging in, an out-of-order
598// live frame) lands in the thread just the same. Messages it doesn't move keep
599// their identity, so nothing else re-renders.
600export function applyConverts(msgs, claims) {
601 const roots = convertedRoots(claims);
602 let changed = false;
603 const out = msgs.map((m) => {
604 const to = (m.sid && convertHolds(m, claims) && roots.get(m.sid)) || 0;
605 if ((m.movedTo || 0) === to) return m;
606 changed = true;
607 return { ...m, movedTo: to };
608 });
609 return changed ? out : msgs;
610}
611
612// inlineChainIds: the inline reply chain hanging under `rootSid` — every
613// message that quotes it, everything that quotes those, and so on — which is
614// exactly what "Convert to thread" moves. Empty when there's nothing to move,
615// which is also the whole gate on offering the verb.
616//
617// This is the transitive quote walk the two-verb split deleted, back in the
618// one narrow place that needs it: deciding what a conversion covers. It is NOT
619// a membership rule — nothing here reads it to decide where a message renders
620// (that's threadRootOf, still a straight read) — and it runs on the CONVERTING
621// client only, at menu time, to build the event's id list.
622//
623// One forward pass suffices because `msgs` is in server order and a reply's sid
624// is always higher than the message it quotes: by the time the walk reaches a
625// reply, whatever it quotes has already been seen. Messages already living in a
626// thread are skipped, so the chain is only ever inline conversation — and a
627// bubble inside a thread pane has no chain at all, which is what keeps the verb
628// off the menus in there.
629export function inlineChainIds(msgs, rootSid) {
630 if (!rootSid) return [];
631 const root = msgs.find((m) => m.sid === rootSid);
632 if (!root || threadRootOf(root)) return []; // unloaded, or already in a thread
633 const chain = new Set([rootSid]);
634 const out = [];
635 for (const m of msgs) {
636 if (!m.sid || threadRootOf(m)) continue; // pending copy, or already in a thread
637 const quoted = (m.reply && m.reply.id) || 0;
638 if (!quoted || !chain.has(quoted)) continue;
639 chain.add(m.sid);
640 out.push(m.sid);
641 }
642 return out;
643}
644
645// Does a conversion name this sid? The O(1) question the message fold asks
646// before paying for a re-stamp — cheap enough to ask on every arrival, and
647// false for every message in the overwhelming case of a chat where nobody has
648// converted anything.
649function converted(s, sid) {
650 return !!(sid && s.converts && s.converts[sid]);
651}
652
653// A hostile client could name thousands of ids in one payload (the server caps
654// a blob at 64KB and can't read it), and the claim map is never pruned — so the
655// fold takes a generous slice rather than however many arrived. Far above any
656// real chain: a conversation with more than this many inline replies to one
657// message doesn't exist. Exported so the chat-list line counts what the fold
658// actually took, never what the payload asked for.
659export const MAX_CONVERT_IDS = 2000;
660
661// The one wording for the conversion's confirm, so web, Mac and iOS ask the
662// same question — pluralised and locale-grouped like every displayed count.
663// It says what everyone will see and that it's one-way, because it is: v1 has
664// no undo.
665export function convertConfirm(n) {
666 const what = n === 1 ? "this reply" : `these ${n.toLocaleString()} replies`;
667 return `Move ${what} into a thread? Everyone will see the conversation there instead of in the chat, and this can't be undone.`;
668}
669
670// quoteTarget: the replyTo a PLAIN reply to `m` carries — the exact message
671// clicked, nothing resolved. Replying to a reply quotes that reply; the chain
672// stacks inline in the feed and starts no thread. Pure and exported for tests
673// (mirrored natively by ChatFeedModel.quoteTarget — pinned by the
674// reply_thread_grouping vector's expected_reply_targets.quote).
675export function quoteTarget(m) {
676 return { id: m.sid, name: m.name, snippet: replySnippet(m.raw) };
677}
678
679// threadTarget: the replyTo a "Reply in thread" on `m` carries — the ROOT of
680// the thread the reply belongs in. A message already IN a thread continues
681// that thread (its root, snapshotted fresh when loaded, the quoted snapshot
682// until then); anything else — a plain message, a quote reply, a whole inline
683// chain — roots a NEW thread on ITSELF, because until someone chose this verb
684// there was no thread to join. Pure and exported for tests (mirrored natively
685// by ChatFeedModel.threadTarget). The `thread: true` wire flag is added at the
686// compose seam (threadReplyTarget), never here.
687export function threadTarget(msgs, m) {
688 const rootSid = threadRootOf(m);
689 if (!rootSid) return quoteTarget(m);
690 const root = msgs.find((x) => x.sid === rootSid);
691 if (root) return { id: root.sid, name: root.name, snippet: replySnippet(root.raw) };
692 // Root paged out. The frozen snapshot a FLAGGED reply carries is the root's
693 // own (every thread send derives its ref from the root), so it stands in. A
694 // CONVERTED message's ref is the message it quoted — which is not the root —
695 // so there's nothing truthful to fall back on and the id travels alone: the
696 // reply still lands in the right thread, and the view says the original
697 // isn't available rather than naming the wrong message.
698 const flagged = !!(m.reply && m.reply.thread);
699 return { id: rootSid, name: (flagged && m.reply.name) || "", snippet: (flagged && m.reply.snippet) || "" };
700}
701
702// threadItems: the reply thread rooted at `rootSid` — the root message (when
703// loaded) followed by every message whose thread root is that sid, in server
704// order. Thread membership is the flag and a direct pointer (threadRootOf):
705// an inline quote chain hanging off the same root is NOT part of the thread,
706// it's ordinary feed conversation. When the root itself has scrolled out of
707// the loaded window the replies still fold — the view's list simply starts at
708// the oldest loaded reply, under its note. Pure and exported for tests — the
709// thread view re-derives this from current state on every fold, never a
710// cached list.
711export function threadItems(msgs, rootSid) {
712 if (!rootSid) return []; // 0 is the pending-copy key, never a thread root
713 const out = [];
714 const root = msgs.find((m) => m.sid === rootSid);
715 if (root) out.push(root);
716 for (const m of msgs) {
717 if (threadRootOf(m) === rootSid) out.push(m);
718 }
719 // Same order as the feed: confirmed by sid, a still-pending optimistic reply
720 // (sid 0) LAST. A plain by-sid sort put the pending copy first, ahead of the
721 // root — and for the whole server round-trip the thread view read "root not
722 // loaded": the count jumped, the "original message isn't available" note
723 // flashed, the root sat below the reply answering it, then all snapped back
724 // on the echo — the blink on every reply sent from the thread.
725 return out.sort(bySidPendingLast);
726}
727
728// threadListItems: every thread in the loaded window, newest activity first —
729// what the ▤ Threads list shows. One row per thread ROOT: the root's author +
730// snippet (the line you recognise it by), how many replies it holds, and the
731// message that last landed in it.
732//
733// Membership is threadRootOf — the same read the "N Replies" tally makes, so
734// the count on a row and the count on its root can never disagree. Plain
735// quote replies therefore never appear: an inline chain is a conversation
736// already visible in the feed, not a thread anyone opened, and it has no
737// thread to come back to.
738//
739// Ordering is by LAST REPLY, not root age: the list answers "where is the
740// conversation", so this morning's root that nobody answered sits below a
741// week-old one someone replied to a minute ago. `last` is picked (and the
742// rows sorted) with the feed's own comparator, so a still-pending optimistic
743// reply counts as the newest thing in its thread — as it visibly is.
744//
745// A thread whose root has scrolled out of the loaded window keeps its row,
746// headed by the quoted snapshot its replies carry — exactly the fallback the
747// thread view's own header uses (`rootLoaded` says which one a row is).
748// Pure and exported for tests.
749export function threadListItems(msgs) {
750 const bySid = bySidIndex(msgs);
751 const byRoot = new Map(); // root sid → row
752 for (const m of msgs) {
753 const rootSid = threadRootOf(m);
754 if (!rootSid) continue;
755 let row = byRoot.get(rootSid);
756 if (!row) {
757 const root = bySid.get(rootSid);
758 // Unloaded root: only a FLAGGED reply's ref is the root's own snapshot
759 // (see threadTarget) — a converted message quotes something else, so its
760 // row is headed by nothing rather than by the wrong message.
761 const stand = m.reply && m.reply.thread ? m.reply : {};
762 row = {
763 rootSid,
764 rootLoaded: !!root,
765 name: root ? root.name || "" : stand.name || "",
766 snippet: root ? replySnippet(root.raw) : stand.snippet || "",
767 count: 0,
768 last: m,
769 };
770 byRoot.set(rootSid, row);
771 }
772 row.count++;
773 if (bySidPendingLast(row.last, m) < 0) row.last = m;
774 }
775 return [...byRoot.values()].sort((a, b) => bySidPendingLast(b.last, a.last));
776}
777
778// threadListSignature: everything the rendered list actually shows — order,
779// each row's identity, its tally, and the line it's headed by (which changes
780// when an unloaded root pages in, or when someone edits it). The open panel
781// compares this before repainting, so a fold that leaves the list looking
782// identical never rebuilds its buttons under a keyboard user. Exported for
783// tests: "a repaint happens exactly when the list changed" is a rule worth
784// pinning, not a coincidence of string building.
785export function threadListSignature(rows) {
786 return rows.map((r) => `${r.rootSid}:${r.count}:${r.name}:${r.snippet}`).join("|");
787}
788
789// bySidIndex: the loaded window keyed by sid — what inMainFeed checks a thread
790// reply's root against. Exported so callers outside viewItems (jump fallback,
791// hotkeys, ↑-to-edit, tests) build the same index rather than each re-deriving
792// "is the root loaded".
793export function bySidIndex(msgs) {
794 return new Map(msgs.map((m) => [m.sid, m]));
795}
796
797// inMainFeed: whether a message renders in the main transcript. A reply
798// composed IN a thread (`reply.thread` — the pane's or overlay's composer, the
799// "Reply in thread" verb) lives only in that thread, Slack-style: the
800// transcript shows just its root's "N Replies" tally. Plain quote replies
801// (the flag absent — every message from before the flag, and the ordinary
802// Reply verb) stay inline exactly as they always did.
803//
804// ROOT-AWARE: the hide only holds while the reply's root is in the loaded
805// window (`bySid` — the index viewItems already builds). The tally lives on
806// the root's bubble, so with the root paged out (a fresh reply to an
807// old message, cold-opening a busy chat) hiding the reply would leave it
808// rendered NOWHERE until the reader blind-scrolled back past the root. So it
809// degrades to an ordinary inline bubble — quote card and all — and collapses
810// into the tally the moment the root pages in. Pure and exported: the feed
811// filter, the jump fallbacks and the event vectors all read this one
812// predicate (native mirrors it — pinned by message_in_thread and
813// thread_reply_root_unloaded).
814export function inMainFeed(m, bySid) {
815 // Flagged but pointing nowhere resolvable is a feed bubble, same as
816 // unflagged: id 0 is the pending-copy key, so without threadRootOf's guard
817 // a ref-less thread reply would hide itself behind any in-flight send —
818 // and no root tally would carry it. (Native's threadRoots skips id 0
819 // for the same reason; the two predicates must agree.)
820 const root = threadRootOf(m);
821 if (!root) return true;
822 return !bySid.has(root);
823}
824
825// Message order: confirmed messages by server id (the stable identity), with any
826// still-pending optimistic copies (sid 0) after all of them, ordered by send time
827// — so a just-sent bubble sits at the bottom until its echo slots it into place.
828function bySidPendingLast(a, b) {
829 if (a.sid && b.sid) return a.sid - b.sid;
830 if (!a.sid && !b.sid) return a.ts - b.ts;
831 return a.sid ? -1 : 1;
832}
833
834// `mainFeed: true` is the transcript's call: replies that live only in their
835// thread (inMainFeed false — root loaded) drop out of the list — they still
836// tally on their root, and the separators + sender runs are derived over
837// what's actually shown, so a hidden reply never breaks a run or strands a
838// day separator. The thread view calls it plain (every reply shows there).
839export function viewItems(msgs, { mainFeed = false } = {}) {
840 // The "N Replies" tally counts a root's THREAD — only the replies someone
841 // deliberately made in it (threadRootOf: the `reply.thread` flag). An
842 // ordinary inline reply contributes nothing: it's already visible right
843 // there in the feed, so a tally on top of it would be counting the same
844 // conversation twice and would read as a thread nobody started. Each thread
845 // reply carries its root sid (`replyRoot`) so taps/jumps land on the thread;
846 // tallies run over EVERY message, because a thread reply counts even though
847 // the transcript never lists it (the tally is its only trace there).
848 const bySid = bySidIndex(msgs);
849 const replyCounts = new Map(); // thread root sid → messages in its thread
850 const roots = new Map(); // thread reply sid → its thread's root sid
851 for (const m of msgs) {
852 const root = threadRootOf(m);
853 if (root) {
854 roots.set(m.sid, root);
855 replyCounts.set(root, (replyCounts.get(root) || 0) + 1);
856 }
857 }
858 const out = [];
859 let prev = null;
860 for (const m of msgs) {
861 if (mainFeed && !inMainFeed(m, bySid)) continue; // lives in its (loaded) thread; `prev` stays the last VISIBLE message
862 let timeSep = null;
863 if (!prev) timeSep = { withDay: true }; // the oldest message opens with a dated separator
864 else {
865 const newDay = !sameDay(m.ts, prev.ts);
866 if (newDay || m.ts - prev.ts > TIME_SEPARATOR_GAP_MS) timeSep = { withDay: newDay };
867 }
868 const cont = !timeSep && sameSender(prev, m);
869 const replyCount = replyCounts.get(m.sid) || 0;
870 // A THREAD reply's own view of its thread's tally: the SAME number the
871 // root's "N Replies" shows, so a quote card that doubles as a thread link
872 // can never disagree with the root it points at (threadLinkCount decides
873 // whether it wears one). 0 for a plain inline reply — its card is a quote
874 // and a jump, never a way into a thread.
875 const threadCount = replyCounts.get(roots.get(m.sid)) || 0;
876 // The soft rev is derived purely from reactions + tally (never myReactions —
877 // viewItems stays pure/exported/tested); a chip's count moves on every
878 // react/unreact, so this signature captures mine-flips too.
879 const reactSig = (m.reactions || []).map((r) => `${r.emoji}${r.count}`).join(",");
880 out.push({
881 ...m,
882 timeSep,
883 cont,
884 replyCount,
885 threadCount,
886 replyRoot: roots.get(m.sid) || 0,
887 vrev: `${m.rev}|${timeSep ? (timeSep.withDay ? "D" : "t") : ""}|${cont ? 1 : 0}`,
888 // Delivery state rides the SOFT rev: the pending→slow→confirmed transitions
889 // patch the bubble in place (data-id, the "sending…" chip, action affordances)
890 // without rebuilding it — so an optimistic photo never flashes on confirm.
891 // The quote card's thread link is soft too (patchBubble repaints it in
892 // place), and only a reply ever carries one — so the segment stays absent
893 // on everything else and a root's signature is unchanged.
894 srev: `${reactSig}|${replyCount}|${m.pending ? (m.slow ? "slow" : "send") : "ok"}|${m.sid ? 1 : 0}` +
895 (threadCount ? `|t${threadCount}` : ""),
896 });
897 prev = m;
898 }
899 return out;
900}
901
902// How one history page's result reads — the distinction every backward walk
903// turns on, pulled out pure so it's pinned by a test rather than re-decided at
904// each call site. `n` is host.loadOlder's return: rows folded, 0 = the SERVER
905// says history ends here, -1 = the FETCH FAILED.
906//
907// "end" and "error" must never be conflated. Only "end" may latch `exhausted`
908// (which kills scroll-back for the rest of the mount) or answer a jump with
909// "that message isn't available any more" — an offline blip or a 502 during a
910// deploy is neither the start of history nor a deletion, so it stays
911// retryable.
912export function pageResult(n) {
913 if (n < 0) return "error";
914 if (n === 0) return "end";
915 return "page";
916}
917
918// The inline wording for a jump that couldn't land, by how the backward walk
919// ended (pageBackTo's outcome). "" for "found" — nothing to say. Three
920// genuinely different answers, because "gone" is final and "capped" means
921// keep scrolling; the reader is never left tapping a control that does
922// nothing. Pure + exported so the Node suite pins it against its Swift twin,
923// ChatFeedModel.jumpReachNote — web, Mac and iOS must say the same thing.
924export function jumpReachNote(reach) {
925 if (reach === "capped") return "That message is further back than this jump reaches — keep scrolling up for it.";
926 if (reach === "gone") return "That message isn't available any more.";
927 return "";
928}
929
930// The one wording for a thread's size, wherever it's shown: the root's tally,
931// the quote-card thread link and the thread view's own header read as the same
932// thing because they ARE the same string. Pluralised, and grouped for the
933// locale (1,204 Replies).
934export function replyCountLabel(n) {
935 return n === 1 ? "1 Reply" : `${n.toLocaleString()} Replies`;
936}
937
938// How many REPLIES a threadItems list holds — the list minus its root, when
939// the root is loaded (it's the first item; see threadItems). The thread view
940// renders root and replies as one list now, so "how many rows" and "how many
941// answers" are no longer the same number: a thread nobody has answered yet is
942// one row and must still read "0 Replies", never "1 Reply". Pure and exported
943// so that invariant is pinned by a test rather than re-derived at each render
944// site — native's twin is ChatFeedModel.threadReplyCount.
945export function threadReplyCount(items, rootSid) {
946 if (!items.length) return 0;
947 return items[0].sid === rootSid ? items.length - 1 : items.length;
948}
949
950// Does this reply's quote card double as a link into its thread, and with what
951// count? Only a THREAD reply ever wears one (an inline reply's threadCount is
952// 0 — its card quotes and jumps, nothing more), and only while its thread
953// holds more than just it: rendered inline because its root is paged out, its
954// card is the one way back into the conversation it belongs to. The number is
955// the thread's WHOLE tally (the same one the root wears), never "the others
956// besides me": two people looking at the same thread from different bubbles
957// must read the same size. Returns 0 for "no thread link".
958export function threadLinkCount(m) {
959 const n = (m && m.threadCount) || 0;
960 return n > 1 ? n : 0;
961}
962
963export const app = defineApp({
964 name: "chat",
965 title: "Chat",
966 glyph: "💬",
967 places: [], // host-mounted for chat threads — never in the Apps menu
968 snapshot: false, // state IS the message log; snapshotting would duplicate it
969 incremental: true, // reconcile the pane in place — appends, not rebuilds
970
971 initialState: () => ({ msgs: [] }),
972
973 // The one wire type the app owns: typing, relay-only (handled by the
974 // ephemeral hook below). The no-op fold claims the type; if a hostile client
975 // stores one as a real message, it folds to nothing.
976 events: {
977 [N.ev("typing")]: (s) => s,
978 },
979
980 // Host-only folds: the existing protocol, translated. Messages key on the
981 // server message id (the original identity) and stay sorted by it, so a
982 // scroll-back page folded later still lands before newer messages. `raw`
983 // keeps the original payload fields (attachments with w/h, replyTo, name…)
984 // so an edit can rebuild the full payload exactly like the original composer
985 // ({ ...orig, text }). `rev` is the HARD rev: it bumps only on a content change
986 // (edit) so keyedList rebuilds that bubble. A reaction does NOT bump it —
987 // reactions ride viewItems' soft rev and patch the row in place (see patchBubble),
988 // so reacting never recreates the bubble's photo or its playing YouTube embed.
989 hostEvents: {
990 [N.ev("msg")]: (s, e, meta) => {
991 const text = typeof e.text === "string" ? e.text : "";
992 const atts = Array.isArray(e.attachments) ? e.attachments.filter((a) => a && a.id) : [];
993 const sid = (meta && meta.id) || 0;
994 const cid = typeof e._cid === "string" ? e._cid : "";
995 const pending = !!(meta && meta.pending);
996 // A server message needs its id; an optimistic local echo has none yet
997 // (it's pending, keyed only by cid until its own broadcast confirms it).
998 if ((!text.trim() && !atts.length) || (!sid && !pending)) return s;
999 if (sid && s.msgs.some((m) => m.sid === sid)) return s; // re-fold — already present
1000 // Our own echo confirming an optimistic copy: upgrade that pending record
1001 // IN PLACE — adopt the server id, drop the pending flags — keeping its
1002 // stable keyedList id (its bubble is patched, never rebuilt, so a photo or
1003 // playing embed never flashes). A second optimistic fold of the same cid
1004 // (no sid yet) is a no-op.
1005 if (cid) {
1006 const idx = s.msgs.findIndex((m) => m.cid === cid);
1007 if (idx >= 0) {
1008 if (!sid) return s;
1009 const msgs = s.msgs.slice();
1010 msgs[idx] = { ...msgs[idx], sid, ts: e.ts || msgs[idx].ts, pending: false, slow: false };
1011 msgs.sort(bySidPendingLast);
1012 // It has a real sid now, so a conversion could already name it.
1013 return { ...s, msgs: converted(s, sid) ? applyConverts(msgs, s.converts) : msgs };
1014 }
1015 }
1016 const raw = { name: e.name || "", text, ts: e.ts || ((meta && meta.ts) || 0) };
1017 if (e.uid) raw.uid = e.uid;
1018 if (atts.length) raw.attachments = atts.map((a) => ({ ...a }));
1019 if (e.replyTo) raw.replyTo = { ...e.replyTo };
1020 if (Array.isArray(e.actions) && e.actions.length) raw.actions = e.actions; // helper chips (host-whitelisted at render)
1021 if (e.card && typeof e.card === "object") raw.card = e.card; // bot-authored card — validated at render (cards.js)
1022 if (typeof e.key === "string" && e.key) raw.key = e.key; // scripted-line dedup key (one-Eleven welcome, shell.js)
1023 if (e.action) raw.action = true; // keeps a /me line rendering as an action line if you edit it
1024 if (e.warn) raw.warn = true; // an admin warning keeps its framing through edits too
1025 const msgs = [
1026 ...s.msgs,
1027 {
1028 // A pending optimistic copy keys on its cid for life (stable across the
1029 // upgrade to a real sid), so its bubble is never destroyed/recreated.
1030 id: sid ? "m" + sid : "c" + cid,
1031 sid,
1032 cid,
1033 pending, // shown, but not yet acknowledged by the server
1034 slow: false, // flipped true by chat.pending after 5s unconfirmed
1035 text,
1036 name: raw.name,
1037 uid: e.uid || "",
1038 ts: raw.ts,
1039 attachments: raw.attachments || [],
1040 // `thread` (additive, cross-platform — see inMainFeed): the reply was
1041 // composed IN its thread, so it lives only there. Absent/false is a
1042 // quote reply that renders inline — old clients, existing history.
1043 reply: e.replyTo ? { id: e.replyTo.id || 0, name: e.replyTo.name || "", snippet: e.replyTo.snippet || "", thread: !!e.replyTo.thread } : null,
1044 actions: raw.actions || [],
1045 card: raw.card || null,
1046 action: !!e.action, // an app-thread /me-style action line, not a bubble
1047 // An admin warning (issue #578): same bubble, distinct system framing.
1048 // It rides inside the ciphertext — the server can't tell a warning
1049 // from a "hello"; it only audits that one was issued.
1050 warn: !!e.warn,
1051 reactions: [],
1052 edited: false,
1053 raw,
1054 rev: 0,
1055 },
1056 ];
1057 msgs.sort(bySidPendingLast);
1058 // A message can arrive AFTER the conversion that moves it (an older page
1059 // paging in, an out-of-order live frame, a reconnect catch-up): stamp it
1060 // so it lands in its thread rather than inline. ONLY when a conversion
1061 // actually names this sid — every other message's stamp is unchanged by
1062 // an arrival (a claim resolves against the claim map alone, never against
1063 // the loaded window), so a history page of a hundred rows does one O(1)
1064 // lookup each instead of a hundred full re-resolutions.
1065 return { ...s, msgs: converted(s, sid) ? applyConverts(msgs, s.converts) : msgs };
1066 },
1067 // An inline reply chain converted into a thread: the ids now live in the
1068 // thread rooted at `root`. Never a wire type — shell.js recognises the
1069 // encrypted "threadconvert" payload and synthesises this, exactly as it
1070 // does for reactions, so nobody can spoof one with a typed message. The
1071 // claim map IS the state; where each message renders is derived from it
1072 // (applyConverts → movedTo → threadRootOf), so folds in any order, and
1073 // repeated folds of the same event, settle on the same feed.
1074 [N.ev("convert")]: (s, e, meta) => {
1075 const at = (meta && meta.id) || 0; // the convert event's own server id
1076 const ids = Array.isArray(e.ids) ? e.ids : [];
1077 if (!ids.length) return s;
1078 // No coercion: claimConverts validates root/at/each id the way Swift's
1079 // decoder does, so a payload native drops is dropped here too.
1080 const converts = claimConverts(s.converts || {}, e.root, ids, at);
1081 if (!converts) return s;
1082 return { ...s, converts, msgs: applyConverts(s.msgs, converts) };
1083 },
1084 // A still-unconfirmed optimistic send, 5s on: mark it slow so the bubble
1085 // surfaces a subtle "sending…" state (host-fed by shell.js's markSlow timer).
1086 [N.ev("pending")]: (s, e) => {
1087 const cid = typeof e._cid === "string" ? e._cid : "";
1088 if (!cid) return s;
1089 const idx = s.msgs.findIndex((m) => m.cid === cid && m.pending);
1090 if (idx < 0) return s;
1091 const msgs = s.msgs.slice();
1092 msgs[idx] = { ...msgs[idx], slow: true };
1093 return { ...s, msgs };
1094 },
1095 [N.ev("reaction")]: (s, e, meta, ctx) => {
1096 if (e.targetSid === undefined || e.emoji === undefined || e.uid === undefined || e.on === undefined) return s;
1097 const me = ctx && ctx.me;
1098 const msgs = s.msgs.map((m) => {
1099 if (m.sid !== e.targetSid) return m;
1100 const reactionEvents = m.reactionEvents || [];
1101 let found = false;
1102 const updated = reactionEvents.map((ev) => {
1103 if (ev.emoji === e.emoji && ev.uid === e.uid) {
1104 found = true;
1105 return { ...ev, on: e.on, ts: e.ts };
1106 }
1107 return ev;
1108 });
1109 if (!found && e.on) {
1110 updated.push({ emoji: e.emoji, uid: e.uid, on: true, ts: e.ts });
1111 }
1112 const currentReactions = updated.filter((ev) => ev.on);
1113 const derived = [];
1114 const emojiMap = new Map();
1115 for (const ev of currentReactions) {
1116 if (!emojiMap.has(ev.emoji)) {
1117 emojiMap.set(ev.emoji, []);
1118 }
1119 emojiMap.get(ev.emoji).push(ev.uid);
1120 }
1121 for (const [emoji, uids] of emojiMap) {
1122 derived.push({ emoji, count: uids.length, mine: uids.includes(me) });
1123 }
1124 derived.sort((a, b) => a.emoji.localeCompare(b.emoji));
1125 return { ...m, reactions: derived, reactionEvents: updated, rev: m.rev + 1 };
1126 });
1127 return { ...s, msgs };
1128 },
1129 [N.ev("edit")]: (s, e) => ({
1130 ...s,
1131 msgs: s.msgs.map((m) => {
1132 if (m.sid !== e.msgId) return m;
1133 const text = typeof e.text === "string" ? e.text : m.text;
1134 return { ...m, text, raw: { ...m.raw, text }, edited: true, rev: m.rev + 1 };
1135 }),
1136 }),
1137 // A deleted message leaves its conversion claim behind — harmless, since
1138 // nothing resolves it any more. Its neighbours are untouched: every claim
1139 // names its root directly, so deleting one message mid-chain never spills
1140 // the rest of the thread back into the feed.
1141 [N.ev("delete")]: (s, e) => ({ ...s, msgs: s.msgs.filter((m) => m.sid !== e.msgId) }),
1142 },
1143
1144 // Typing: relay-only, never stored or folded. The server echoes your own beat
1145 // back to you, so drop it here rather than at display time — matching by uid is
1146 // robust even in a fresh thread, where myName() stays null until one of your
1147 // messages folds into view. A uid-less legacy beat falls back to the screen
1148 // name, mirroring the original feed's `payload.name === session.name` guard.
1149 ephemeral(e, ctx) {
1150 if (e.type !== N.ev("typing") || !e.name) return;
1151 if (e.uid ? e.uid === ctx.me : e.name === ctx.meName) return;
1152 let m = typing.get(ctx.threadId);
1153 if (!m) typing.set(ctx.threadId, (m = new Map()));
1154 const secs = Math.min(Math.max(Number(e.expiry) || 0, 0), 60);
1155 if (secs === 0) m.delete(e.name);
1156 else m.set(e.name, Date.now() + secs * 1000);
1157 const refresh = refreshTyping.get(ctx.threadId);
1158 if (refresh) refresh();
1159 },
1160
1161 // Almost no CSS of its own: the feed content is original markup styled by
1162 // style.css. What remains is layout glue — the pane loses its app padding so
1163 // the feed/composer sit exactly like the original ones, the item wrapper is
1164 // display:contents so .msg/.time-sep participate in the feed's flex layout
1165 // as if they were direct children, and .chatapp-feed mirrors the #feed rule
1166 // (the one original selector that's id-based, so unreusable).
1167 styles: `
1168#app-pane:has(> .chatapp-root) { padding: 0; gap: 0; }
1169.chatapp-root { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; width: 100%; position: relative; }
1170.chatapp-item { display: contents; }
1171/* Embedded in the native shell: the shell draws its own composer + banners, so
1172 the app shows only the feed (mirrors the original .native-embed layout). */
1173.chatapp-root.embed > .composer,
1174.chatapp-root.embed .typeahead,
1175.chatapp-root.embed > .composer-reply,
1176.chatapp-root.embed > .composer-edit { display: none; }
1177/* A read-only chat view (the owning app declares chatReadOnly — the Admin app's
1178 notice stream): the app posts here, the user doesn't, so no composer at all. */
1179.chatapp-root.readonly > .composer,
1180.chatapp-root.readonly .typeahead,
1181.chatapp-root.readonly > .composer-reply,
1182.chatapp-root.readonly > .composer-edit { display: none; }
1183/* The docked thread pane's composer is nested (not a root child), so the two
1184 child-combinator sweeps above miss it — same rule, same reasons: embedded,
1185 the shell draws the composer; read-only, nobody replies. */
1186.chatapp-root.embed .chatapp-thread-composer,
1187.chatapp-root.readonly .chatapp-thread-composer { display: none; }
1188/* Embedded: keep the attachment preview visible (a staged photo shows just above
1189 the native composer) and give the feed the native safe-area insets — mirrors
1190 the original feed's .native-embed rules. */
1191.native-embed .chatapp-feed {
1192 padding-top: calc(18px + var(--native-top-inset, 0px));
1193 padding-bottom: calc(18px + var(--native-bottom-inset, 0px));
1194}
1195.native-embed .feed-jump-btn { bottom: calc(18px + var(--native-bottom-inset, 0px)); }
1196.chatapp-feed {
1197 flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden;
1198 padding: 18px; display: flex; flex-direction: column; gap: 4px;
1199}
1200.chatapp-feed::before { content: ""; margin-top: auto; }
1201/* The feed's positioning wrapper: the reply-thread view overlays exactly this
1202 box (inset:0 in style.css), so it dims/covers the transcript but never the
1203 composer stack below — the real composer stays usable under the thread. */
1204.chatapp-feedwrap {
1205 flex: 1 1 auto; min-height: 0; min-width: 0; position: relative;
1206 display: flex; flex-direction: column;
1207}
1208/* The feed's inline status line — the transcript's twin of the thread view's
1209 .chatapp-thread-note, and deliberately NOT a floating toast: it sits in
1210 flow above the feed and takes real layout space (CLAUDE.md's no-toast
1211 rule). Today it answers a jump that has to page history back for its
1212 target, so a tap is never a silent no-op. */
1213.chatapp-feed-note {
1214 flex: 0 0 auto; padding: 8px 18px;
1215 font-size: 12px; color: var(--muted);
1216 border-bottom: 1px solid var(--border);
1217}
1218/* Empty = no layout, but deliberately NOT display:none. It's a role="status"
1219 live region: display:none takes the node out of the accessibility tree, and
1220 a region that is hidden when its text is written then revealed in the same
1221 tick is announced unreliably (often not at all). Collapsing to nothing keeps
1222 it in the tree, so the next update is announced from a node that was already
1223 there. */
1224.chatapp-feed-note:empty { padding: 0; border-bottom: 0; }
1225/* Jump-to-latest: bottom-right of the feed, shown once the reader has
1226 scrolled a moderate distance up (see JUMP_BUTTON_GAP). Sits below the
1227 reply-thread overlay's z-index (30) so that view still covers it. */
1228.feed-jump-btn {
1229 position: absolute; right: 18px; bottom: 18px; z-index: 20;
1230 width: 40px; height: 40px; padding: 0; border-radius: 50%;
1231 display: inline-flex; align-items: center; justify-content: center;
1232 background: var(--bubble-them); color: var(--ink);
1233 box-shadow: 0 6px 20px var(--shadow);
1234 transition: opacity 0.15s ease, transform 0.15s ease;
1235}
1236.feed-jump-btn svg { width: 20px; height: 20px; }
1237.feed-jump-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
1238@media (prefers-reduced-motion: reduce) { .feed-jump-btn { transition: none; } }
1239` + CARD_CSS,
1240
1241 // Incremental render: the skeleton (feed / banners / composer) is built once
1242 // and kept; each state change only reconciles the message list via keyedList
1243 // (the composite vrev re-renders a bubble when its content or its
1244 // neighbour-derived presentation changes). Scroll stays pinned to the bottom
1245 // unless the reader scrolled up.
1246 render(state, el, ctx) {
1247 let root = el.querySelector(":scope > .chatapp-root");
1248 if (!root) {
1249 root = buildSkeleton(el, ctx);
1250 }
1251 // Stamp the mount with its thread so a stray refresh can tell whether the
1252 // pane still belongs to it (see doRefresh). #app-pane is shared across
1253 // threads, so an async welcome/tour step firing after you've navigated
1254 // away would otherwise re-render its thread into whatever's on screen now.
1255 root.dataset.thread = ctx.threadId;
1256 // Point the single document-level hotkey listener at THIS mount. On the
1257 // instant-restore path buildSkeleton is skipped, so this — not the build —
1258 // is what keeps the shortcuts live after switching threads. Guard on
1259 // isConnected so a parked thread re-rendering off-screen can't steal the
1260 // active pane's shortcuts.
1261 if (root.isConnected) hotkeys = root._hotkeys;
1262 // Same for the native shell's composer bridge: registerNative sets one
1263 // dispatcher in the host, so without re-pointing it here a native
1264 // send/typing/attach after revisiting a chat would route to the last-built
1265 // (now detached) thread. Guarded on isConnected — like the hotkeys re-point
1266 // above — so a parked pane re-rendering off-screen can't grab the bridge.
1267 if (root.isConnected && root._native && host && host.registerNative) {
1268 host.registerNative(root._native);
1269 }
1270 // Same again for the native macOS shell's Finder file-drop: it fills the
1271 // page's single static #file-input and fires `change`, so point that input at
1272 // the thread on screen (not the last-built one). Idempotent .onchange = so
1273 // re-rendering doesn't stack listeners. (The classic composer that used to own
1274 // #file-input is gone, so nothing else is listening.)
1275 if (root.isConnected && root._dropFiles) {
1276 const fileDropInput = document.getElementById("file-input");
1277 if (fileDropInput) fileDropInput.onchange = root._dropFiles;
1278 }
1279
1280 // A closed DM (issue #578): the server refuses new traffic, so the
1281 // composer gives way to an honest banner with the one useful action —
1282 // Reopen. Re-evaluated every render, so the dm_closed/dm_reopened frames
1283 // flip the pane live.
1284 paintClosedState(root, ctx);
1285
1286 // Let addLocalNotice re-render this pane when a command reply comes in — the
1287 // reply isn't folded state, so nothing else would. Points at the live mount
1288 // (like the hotkeys/native re-points above), self-removed on teardown.
1289 const doRefresh = () => {
1290 // A parked thread's refresh (a late welcome/tour notice, a scripted
1291 // typing beat) must not paint into the shared #app-pane once another
1292 // thread has taken it over — that's how Eleven's onboarding messages
1293 // leaked into the chat you'd navigated to. The notice is already stored
1294 // in localNotices and paints when this thread is next opened; skip the
1295 // live render when the pane no longer belongs to us.
1296 const cur = el.querySelector(":scope > .chatapp-root");
1297 if (cur && cur.dataset.thread && cur.dataset.thread !== ctx.threadId) return;
1298 renderApp(ctx.threadId, "chat", el);
1299 };
1300 refreshFeed.set(ctx.threadId, doRefresh);
1301 // Keep the live mount ctx so the shell can inject a private notice into this
1302 // thread (e.g. the welcome DM's "Take the tour" chip starting Eleven's tour).
1303 mountedCtx.set(ctx.threadId, ctx);
1304 // Flush any notices that were queued for this thread before it mounted (the
1305 // tour navigating to the Eleven DM appends its next step while the feed loads).
1306 const queued = pendingNotices.get(ctx.threadId);
1307 if (queued) {
1308 pendingNotices.delete(ctx.threadId);
1309 for (const spec of queued) addLocalNotice(ctx, spec);
1310 }
1311 ctx.onCleanup(() => {
1312 if (refreshFeed.get(ctx.threadId) === doRefresh) refreshFeed.delete(ctx.threadId);
1313 if (mountedCtx.get(ctx.threadId) === ctx) mountedCtx.delete(ctx.threadId);
1314 });
1315
1316 const feed = root.querySelector(".chatapp-feed");
1317 // A local send arms pinNext: snap to the newest even if we'd scrolled up.
1318 // Set.delete both reads and clears it, so it only forces this one render.
1319 const forcePin = pinNext.delete(ctx.threadId);
1320 const wasAtBottom = forcePin || feedNearBottom(feed, 60) || !feed.childElementCount;
1321
1322 const msgs = (state && state.msgs) || [];
1323 // A sender's message arriving means they've stopped typing — clear their
1324 // row immediately rather than waiting out the beat expiry (original rule).
1325 const last = msgs[msgs.length - 1];
1326 if (last && last.name && Date.now() - last.ts < 10000) {
1327 const tm = typing.get(ctx.threadId);
1328 if (tm) tm.delete(last.name);
1329 }
1330 const firstBuild = !feed.childElementCount;
1331 // The transcript: thread-only replies fold away into their root's tally
1332 // (an open thread view lists them — root._refreshThread below).
1333 const items = viewItems(msgs, { mainFeed: true });
1334 // Client-only command replies ("only visible to you") ride in the feed as keyed
1335 // rows, interleaved by their creation-time seq (see addLocalNotice) so they
1336 // stack like bubbles and stay put as the conversation continues. Merge + sort
1337 // only when some exist, so a normal feed keeps its plain message order.
1338 const notices = localNotices.get(ctx.threadId);
1339 if (notices && notices.length) {
1340 // In a solo thread (a persona conversation like setup — no other members)
1341 // "Only visible to you" is implicit, so the tag stays off.
1342 const solo = !!(host && host.soloThread && host.soloThread(ctx.threadId));
1343 for (const n of notices) items.push({ id: n.id, notice: n, seq: n.seq, solo, vrev: "notice", srev: "" });
1344 items.sort((a, b) => (a.notice ? a.seq : a.sid) - (b.notice ? b.seq : b.sid));
1345 // Notices stack in sender runs like regular messages: consecutive notices
1346 // from the same speaker keep one header (name + badge), the rest continue.
1347 let prevNotice = null;
1348 for (const it of items) {
1349 if (it.notice) {
1350 it.cont = !!(prevNotice && prevNotice.sender && it.notice.sender && prevNotice.sender.name === it.notice.sender.name);
1351 // Spent chips (their step resolved — a.stale()) fold into the vrev so
1352 // the keyed renderer rebuilds the row when a later render pass finds
1353 // them newly stale; without this a resolved chip stays looking live.
1354 const spent = (it.notice.actions || []).reduce((n, a) => n + (a && a.stale && a.stale() ? 1 : 0), 0);
1355 it.vrev = `notice|${it.cont ? 1 : 0}|${it.solo ? 1 : 0}|${spent}`;
1356 prevNotice = it.notice;
1357 } else {
1358 prevNotice = null;
1359 }
1360 }
1361 }
1362 // The bot's intro ("This is the start of your chat history with To-do…") is a
1363 // PERSISTENT first item — a keyed row that scrolls up as the conversation and
1364 // its action lines grow, marking the beginning, rather than a vanishing
1365 // empty-state. host.chatIntro is null for a normal chat (no intro row).
1366 const introText = host && host.chatIntro && host.chatIntro(ctx.threadId);
1367 // vrev tracks the text so a dynamic intro (an open group's live "N people
1368 // here") re-renders when it changes; static intros keep a stable rev.
1369 if (introText) items.unshift({ id: "chatapp-intro", intro: true, text: introText, vrev: "intro|" + introText });
1370 keyedList(feed, items, {
1371 key: (m) => m.id,
1372 rev: (m) => m.vrev + mutedRev(m, ctx) + mentionRev(m),
1373 srev: (m) => m.srev,
1374 render: (m) => renderItem(m, ctx),
1375 update: (node, m) => patchBubble(node, m, ctx),
1376 });
1377 if (wasAtBottom) {
1378 // History builds — and a local send from scrolled-up (forcePin) — jump
1379 // straight to the newest; a plain live append glides (the original ease),
1380 // respecting reduced-motion. A send jumps rather than gliding the whole
1381 // history so it lands instantly on your message, not after a long scroll.
1382 feedGlideToBottom(feed, firstBuild || forcePin);
1383 }
1384 if (root._updateJumpBtn) root._updateJumpBtn();
1385
1386 // An open reply-thread view folds from this same state — re-derive and
1387 // re-render it every state change so new replies/reactions/edits land live
1388 // (it reads current state via threadItems, never a cached list). No-op when
1389 // closed; stashed on the root like _hotkeys so the instant-restore path
1390 // (which skips buildSkeleton) keeps the live mount's view current.
1391 if (root._refreshThread) root._refreshThread();
1392 // The ▤ Threads list folds from that same state: a new reply re-sorts it
1393 // and moves a count. Repaints only when what it shows actually changed
1394 // (its own signature check) — the rows are buttons, and a reader tabbing
1395 // them must not lose their place to someone else's message.
1396 if (root._refreshThreadList) root._refreshThreadList();
1397
1398 // The in-feed typing row, exactly like the original feed: a .typing element
1399 // kept as the feed's last child (below the newest message). keyedList only
1400 // manages keyed children, so it never touches this row.
1401 const myName = () => {
1402 const mine = msgs.find((m) => m.uid && m.uid === ctx.me);
1403 return mine ? mine.name : null;
1404 };
1405 const drawTyping = () => {
1406 const names = typingNames(ctx.threadId, myName());
1407 let row = feed.querySelector(":scope > .typing");
1408 if (!names.length) {
1409 if (row) row.remove();
1410 return;
1411 }
1412 if (!row) {
1413 row = document.createElement("div");
1414 row.setAttribute("aria-live", "polite");
1415 }
1416 const dm = !(host && host.isGroup && host.isGroup(ctx.threadId));
1417 const label = typingSummary(names);
1418 // Only rebuild the contents on a real change — recreating the dots on
1419 // every ~10s beat would visibly restart their animation (original rule).
1420 if (row.dataset.mode !== (dm ? "bubble" : "text") || row.dataset.label !== label) {
1421 row.dataset.mode = dm ? "bubble" : "text";
1422 row.dataset.label = label;
1423 row.className = dm ? "typing typing-bubble" : "typing";
1424 row.textContent = ""; // textContent: names are untrusted
1425 if (dm) {
1426 for (let i = 0; i < 3; i++) {
1427 const dot = document.createElement("span");
1428 dot.className = "dot";
1429 row.appendChild(dot);
1430 }
1431 const sr = document.createElement("span"); // dots say nothing to a screen reader
1432 sr.className = "sr-only";
1433 sr.textContent = label;
1434 row.appendChild(sr);
1435 } else {
1436 row.textContent = label;
1437 }
1438 }
1439 const atBottom = feedNearBottom(feed);
1440 feed.appendChild(row); // (re-)appending keeps it below the newest message
1441 if (atBottom) feedGlideToBottom(feed);
1442 };
1443 refreshTyping.set(ctx.threadId, drawTyping);
1444 drawTyping();
1445 ctx.interval(drawTyping, 1000);
1446 ctx.onCleanup(() => {
1447 if (refreshTyping.get(ctx.threadId) === drawTyping) refreshTyping.delete(ctx.threadId);
1448 });
1449 // Keep the composer's outbound typing beat alive across a mid-message pause.
1450 // Like drawTyping this has to live on the render path, not in buildSkeleton:
1451 // the framework clears every interval before each render, so a once-per-mount
1452 // ticker would be torn down here and never re-armed (state lives in the
1453 // composer, so re-registering the timer each draw is harmless).
1454 const composer = composers.get(ctx.threadId);
1455 if (composer && composer.tickTyping) ctx.interval(composer.tickTyping, 1000);
1456 // The docked thread pane's composer beats typing too. Resolved lazily on
1457 // each tick, not captured: the pane opens and closes between renders (no
1458 // re-render marks either), so a captured handle would go stale — and with
1459 // no pane this is a 1s no-op, the same cost as the ticker above.
1460 ctx.interval(() => {
1461 const t = threads.get(ctx.threadId);
1462 const pane = t && t.paneComposer && t.paneComposer();
1463 if (pane && pane.tickTyping) pane.tickTyping();
1464 }, 1000);
1465 },
1466});
1467
1468// Muted-message stubs the user tapped open this session (by sid). Deliberately
1469// not persisted: reopening the app re-folds — a mute means muted.
1470const revealedMuted = new Set();
1471
1472// Muted-person folding is render-level state, not folded state — mix it into
1473// the keyed-list rev so muting, unmuting, or revealing rebuilds exactly the
1474// rows it changes and nothing else (m0 = folded stub, m1 = revealed).
1475function mutedRev(m, ctx) {
1476 if (!m.uid || !ctx || m.uid === ctx.me || !host || !host.mutedPerson || !host.mutedPerson(m.uid)) return "";
1477 return revealedMuted.has(m.sid) ? "|m1" : "|m0";
1478}
1479
1480// A message renders its @mentions against the roster AT RENDER TIME
1481// (renderRichText reads host.mentionNames() fresh) — but keyedList only calls
1482// render() once per key; an already-rendered bubble is never revisited unless
1483// its rev changes. A person who joins (or is newly added to a group) after a
1484// message mentioning them already rendered stays plain, unbolded text
1485// forever, until something touches that row (issue #249). repaintMentionsOf
1486// marks exactly the messages that need one (sids only); mentionRev reads that
1487// mark so keyedList rebuilds those rows and leaves everything else alone —
1488// bumping a global rev would force-rebuild the whole visible feed for a
1489// change that's almost always irrelevant to it.
1490const mentionDirty = new Set();
1491function mentionRev(m) {
1492 return mentionDirty.has(m.sid) ? "|mention" : "";
1493}
1494
1495// Pure: which of the last `cap` messages newly reference one of `names` —
1496// "newly" meaning not already in `alreadyDirty`, so a repeat call over the
1497// same feed finds nothing and triggers nothing. (An already-dirty row's rev
1498// is the fixed "|mention" tag, so a second name resolving in the same message
1499// couldn't rebuild it anyway — skipping it loses no repaint.) Exported for
1500// the Node tests.
1501export function newlyDirtyMentionSids(msgs, names, alreadyDirty, cap = 100) {
1502 const out = [];
1503 for (const m of (msgs || []).slice(-cap)) {
1504 if (m.text && !alreadyDirty.has(m.sid) && names.some((n) => m.text.includes("@" + n))) out.push(m.sid);
1505 }
1506 return out;
1507}
1508
1509// repaintMentionsOf(names): one or more roster labels just became known (a
1510// fresh joiner, or a label that finished decrypting) — called from shell.js's
1511// decryptRosterLabels. Scans each mounted thread's last 100 messages for ones
1512// that actually reference a newly-known name and marks only those dirty, then
1513// asks the thread to re-render — a small, targeted repaint instead of a
1514// blanket one (issue #249).
1515export function repaintMentionsOf(names) {
1516 if (!names || !names.length) return;
1517 // Iterate a SNAPSHOT: refresh() re-renders, and a re-render tears down the
1518 // old mount (deleting its mountedCtx entry) before the new one re-adds it.
1519 // A delete + re-add lands at the end of the Map's iteration order, so a
1520 // live iterator here would visit the same thread again — and, with the
1521 // same @name still in its feed, again forever: the 2026-07-17 hard freeze
1522 // on web boot. The newly-dirty gate below is the second lock on that door.
1523 for (const ctx of [...mountedCtx.values()]) {
1524 const msgs = (ctx.getState() && ctx.getState().msgs) || [];
1525 const fresh = newlyDirtyMentionSids(msgs, names, mentionDirty);
1526 if (!fresh.length) continue;
1527 for (const sid of fresh) mentionDirty.add(sid);
1528 const refresh = refreshFeed.get(ctx.threadId);
1529 if (refresh) refresh();
1530 }
1531}
1532
1533// Swap the composer for the closed-conversation banner while the host says
1534// this DM is closed (issue #578). The banner occupies the composer's layout
1535// slot — inline, never floating over the feed.
1536function paintClosedState(root, ctx) {
1537 const closed = !!(host && host.dmClosed && host.dmClosed(ctx.threadId));
1538 // Direct children only: a docked thread pane nests its own ".composer",
1539 // and this paint must never grab that one by first match.
1540 const compose = root.querySelector(":scope > .composer");
1541 const float = root.querySelector(":scope > .composer-float");
1542 const paneCompose = root.querySelector(".chatapp-thread-composer");
1543 let bar = root.querySelector(":scope > .dm-closed-bar");
1544 if (!closed) {
1545 if (bar) bar.remove();
1546 if (compose) compose.classList.remove("hidden");
1547 if (float) float.classList.remove("hidden");
1548 if (paneCompose) paneCompose.classList.remove("hidden");
1549 return;
1550 }
1551 if (compose) compose.classList.add("hidden");
1552 if (float) float.classList.add("hidden");
1553 if (paneCompose) paneCompose.classList.add("hidden");
1554 if (bar) return; // already painted; keep any inline error it shows
1555 bar = document.createElement("div");
1556 bar.className = "dm-closed-bar";
1557 const text = document.createElement("span");
1558 text.textContent = "This conversation is closed.";
1559 bar.appendChild(text);
1560 const reopen = document.createElement("button");
1561 reopen.type = "button";
1562 reopen.textContent = "Reopen";
1563 reopen.onclick = async () => {
1564 reopen.disabled = true;
1565 const r = host && host.reopenDM ? await host.reopenDM(ctx.threadId) : { ok: false };
1566 if (r && r.ok) return; // the host repaints; the banner unmounts itself
1567 reopen.disabled = false;
1568 // "dms closed" = their DM door (issue #578); anything else is transient.
1569 text.textContent =
1570 r && String(r.error).includes("dms closed")
1571 ? "They're not accepting direct messages right now."
1572 : "Couldn't reopen — try again.";
1573 };
1574 bar.appendChild(reopen);
1575 root.appendChild(bar);
1576}
1577
1578// ---- bubbles: the original feed's exact markup ------------------------------
1579
1580// renderItem builds one feed row inside a display:contents wrapper, so the
1581// optional .time-sep and the .msg land in the feed's flex flow exactly like
1582// original siblings. Structure and class names mirror appendMessage verbatim.
1583function renderItem(m, ctx) {
1584 const item = document.createElement("div");
1585 item.className = "chatapp-item";
1586
1587 if (m.timeSep) {
1588 const sep = document.createElement("div");
1589 sep.className = "time-sep";
1590 if (m.timeSep.withDay) {
1591 const day = document.createElement("strong");
1592 day.textContent = dayLabel(m.ts);
1593 sep.appendChild(day);
1594 sep.appendChild(document.createTextNode(" " + formatTime(m.ts)));
1595 } else {
1596 sep.textContent = formatTime(m.ts);
1597 }
1598 item.appendChild(sep);
1599 }
1600
1601 // The persistent "start of your chat history" intro row (keyed first).
1602 if (m.intro) {
1603 const el = document.createElement("div");
1604 el.className = "chatapp-intro";
1605 el.textContent = m.text;
1606 item.appendChild(el);
1607 return item;
1608 }
1609
1610 // A private "only visible to you" command reply — a client-only feed row that
1611 // persists and stacks like a bubble (see addLocalNotice / buildLocalNotice).
1612 if (m.notice) {
1613 item.appendChild(buildLocalNotice(m.notice, { cont: !!m.cont, solo: !!m.solo }));
1614 return item;
1615 }
1616
1617 // An app-thread action ("added 'milk'") — a centered /me-style line, not a
1618 // bubble: a record of your interaction with the bot in the chat history.
1619 if (m.action) {
1620 const line = document.createElement("div");
1621 line.className = "chatapp-action";
1622 line.textContent = m.text;
1623 item.appendChild(line);
1624 return item;
1625 }
1626
1627 // A muted member's message (issue #578) folds to a quiet stub — visible and
1628 // tappable rather than silently gone, because the honest framing of a mute
1629 // is "you stop seeing them", not "they stop existing". Reveal is per-message
1630 // and per-session. Your own messages never fold.
1631 if (m.uid && m.uid !== ctx.me && host && host.mutedPerson && host.mutedPerson(m.uid) && !revealedMuted.has(m.sid)) {
1632 const stub = document.createElement("div");
1633 stub.className = "muted-stub";
1634 const label = document.createElement("span");
1635 label.className = "muted-stub-label";
1636 label.textContent = "Muted message";
1637 stub.appendChild(label);
1638 const show = document.createElement("button");
1639 show.type = "button";
1640 show.className = "muted-stub-show";
1641 show.textContent = "Show";
1642 show.setAttribute("aria-label", "Show muted message");
1643 show.onclick = () => {
1644 revealedMuted.add(m.sid);
1645 item.replaceWith(renderItem(m, ctx)); // re-render this one row in place
1646 };
1647 stub.appendChild(show);
1648 item.appendChild(stub);
1649 return item;
1650 }
1651
1652 // Mirror the original feed's attribution (ownMessage): prefer the inline uid,
1653 // but fall back to a screen-name match so your own legacy (uid-less) messages
1654 // still render as yours — right-aligned, name hidden in group runs, editable.
1655 const mine = ownMessage({ uid: m.uid, name: m.name }, ctx.me, ctx.meName);
1656 const wrap = document.createElement("div");
1657 wrap.className = "msg " + (mine ? "me" : "them") + (m.cont ? " cont" : "");
1658 if (Date.now() - m.ts < 3000) wrap.classList.add("appear"); // live arrivals ease in, history doesn't
1659 wrap.dataset.id = m.sid || ""; // its server id (empty while pending) — lets a reply quote scroll back here
1660 wrap.dataset.t = m.ts;
1661 wrap.dataset.sender = m.name;
1662 // Tag with the thread root for a thread reply (replyRoot), the quoted id for
1663 // a plain one — so the "N Replies" jump belt on a root finds its thread's
1664 // replies, and a plain quote still points at what it quotes.
1665 if (m.reply && m.reply.id) wrap.dataset.replyTo = m.replyRoot || m.reply.id;
1666
1667 // The header shows the sender's CURRENT roster name (looked up by the uid
1668 // inside the ciphertext), falling back to the name snapshot the sender
1669 // stamped into the payload. The lookup heals messages sent before a member
1670 // picked a name — permanently nameless snapshots — and retitles history
1671 // after a rename; the snapshot still covers uid-less legacy messages and
1672 // members who've since left the roster.
1673 const senderName = (m.uid && host && host.memberName && host.memberName(m.uid)) || m.name;
1674 if (!mine && senderName && host && host.isGroup && host.isGroup(ctx.threadId) && !m.cont) {
1675 const nm = document.createElement("div");
1676 nm.className = "name";
1677 nm.style.display = "flex"; // center the name text with its AI/BOT badge
1678 nm.style.alignItems = "center";
1679 nm.textContent = senderName;
1680 const kind = host.agentKind && host.agentKind(senderName);
1681 if (kind) nm.appendChild(agentTag(kind));
1682 wrap.appendChild(nm);
1683 }
1684
1685 // The bubble + its quote/image/preview + hover icons live in .content (the
1686 // positioning context for the hover affordances); reactions sit below it.
1687 const content = document.createElement("div");
1688 content.className = "content";
1689
1690 // An admin warning (issue #578) keeps the ordinary bubble but announces
1691 // itself — a formal notice should never read like small talk.
1692 if (m.warn) {
1693 wrap.classList.add("warn");
1694 const w = document.createElement("div");
1695 w.className = "warn-tag";
1696 w.textContent = "⚠️ A notice from an admin";
1697 content.appendChild(w);
1698 }
1699
1700 if (m.reply && (m.reply.name || m.reply.snippet)) {
1701 const q = document.createElement("button");
1702 q.type = "button";
1703 q.className = "reply-quote";
1704 const who = document.createElement("div");
1705 who.className = "reply-quote-name";
1706 who.textContent = m.reply.name;
1707 const txt = document.createElement("div");
1708 txt.className = "reply-quote-text";
1709 txt.textContent = m.reply.snippet;
1710 q.append(who, txt);
1711 paintQuoteThreadLink(q, m);
1712 q.onclick = () => {
1713 // Two verbs, two cards. A THREAD reply's card is the way into its thread
1714 // — it's only rendered inline because its root is paged out, so opening
1715 // the thread (threadTarget also hands us the root's name/snippet as the
1716 // header fallback) is the whole point; the card is the opener, so focus
1717 // returns to it when the view closes. A PLAIN reply's card is a quote,
1718 // and tapping a quote jumps to the message it quotes — the behaviour
1719 // that predates threads, and the one a reader expects when nothing here
1720 // said "thread". Scroll-jump also covers a mount with no thread controls.
1721 //
1722 // "In a thread" is threadRootOf, not the wire flag: a CONVERTED reply
1723 // rendered inline (its root paged out) wears the same thread tally on
1724 // its card — paintQuoteThreadLink reads the same rollup — so the card
1725 // must open the thread it advertises. Native asks the same question
1726 // (MessageBubble's replyQuote → threadRootID).
1727 const t = threads.get(ctx.threadId);
1728 const ref = threadRootOf(m) ? threadTarget(((ctx.getState() || {}).msgs) || [], m) : null;
1729 if (t && ref && ref.id) t.open(ref.id, ref, q);
1730 else scrollToMessage(wrap, m.reply.id);
1731 };
1732 content.appendChild(q);
1733 }
1734
1735 for (const a of m.attachments) {
1736 if (a.type && a.type.startsWith("image/")) {
1737 const img = document.createElement("img");
1738 // A GIF's click belongs to its play/pause toggle, not the lightbox —
1739 // .msg-gif swaps the zoom-in cursor for a plain pointer.
1740 img.className = a.type === "image/gif" ? "msg-image msg-gif" : "msg-image";
1741 img.alt = a.name || "photo";
1742 // Reserve the image's final box before the blob decrypts (see the original
1743 // appendMessage note) so the feed doesn't jump as photos pop in.
1744 if (a.w && a.h) {
1745 img.style.aspectRatio = `${a.w} / ${a.h}`;
1746 const scale = Math.min(260 / a.w, 320 / a.h, 1);
1747 img.style.width = Math.round(a.w * scale) + "px";
1748 }
1749 // The BlurHash ghost of the photo while its blob downloads + decrypts
1750 // (attachments[].bh, inside the encrypted payload): decoded into a tiny
1751 // canvas and painted as the element's background — the decoded pixels
1752 // simply cover it, and matching colours make the swap read gently.
1753 if (a.bh) {
1754 try {
1755 const pw = 32, ph = a.w && a.h ? Math.max(1, Math.round((32 * a.h) / a.w)) : 24;
1756 const px = decodeBlurhash(a.bh, pw, ph);
1757 if (px) {
1758 const c = document.createElement("canvas");
1759 c.width = pw;
1760 c.height = ph;
1761 c.getContext("2d").putImageData(new ImageData(px, pw, ph), 0, 0);
1762 img.style.backgroundImage = `url(${c.toDataURL()})`;
1763 img.style.backgroundSize = "cover";
1764 }
1765 } catch { /* a bad hash keeps the flat placeholder */ }
1766 }
1767 if (host) {
1768 host.loadBlob(ctx.threadId, a).then((url) => {
1769 if (!url) return;
1770 img.src = url;
1771 if (a.type === "image/gif") wireGifToggle(img, url);
1772 else img.onclick = () => openLightbox(url, img);
1773 });
1774 }
1775 content.appendChild(img);
1776 } else if (a.type && a.type.startsWith("video/")) {
1777 content.appendChild(buildVideoCard(a, ctx));
1778 } else {
1779 content.appendChild(buildFileCard(a, ctx));
1780 }
1781 }
1782
1783 // A message carrying a valid card renders the card as its body; the text is
1784 // that card's plain fallback and would be pure duplication next to it. A
1785 // card that fails to normalize (unknown schema/block — fail closed,
1786 // cards.js) renders null and the text shows as usual.
1787 const cardEl = m.card ? renderCard(m.card) : null;
1788 if (m.text.trim() && !cardEl) {
1789 const bubble = document.createElement("div");
1790 const jumbo = !m.attachments.length && emojiOnlyCount(m.text) > 0;
1791 bubble.className = jumbo ? "bubble jumbo-emoji" : "bubble";
1792 renderRichText(bubble, m.text);
1793 content.appendChild(bubble);
1794 const link = firstLink(m.text);
1795 const yt = link && youtubeId(link);
1796 if (yt) content.appendChild(buildYouTubeCard(yt));
1797 else if (link) addLinkPreview(content, link);
1798 }
1799 if (cardEl) content.appendChild(cardEl);
1800
1801 // Helper-proposed one-tap action chips ("Switch to dark" …): the host
1802 // whitelists, labels, and runs them (they're host machinery — theme,
1803 // profile); the app mounts the returned row in the original position.
1804 if (m.actions && m.actions.length && host && host.actionChips) {
1805 const chips = host.actionChips(m.actions);
1806 if (chips) content.appendChild(chips);
1807 }
1808
1809 wrap.appendChild(content);
1810
1811 // Reactions below the bubble (buildReactionsRow seeds the load-time "mine"
1812 // flags). It's the SAME builder patchBubble reuses, so a later reaction
1813 // reconciles this row in place rather than rebuilding the whole bubble.
1814 const reactions = buildReactionsRow(m, ctx);
1815 if (reactions) wrap.appendChild(reactions);
1816
1817 if (m.replyCount) wrap.appendChild(buildReplyCount(m, wrap, ctx));
1818
1819 if (m.edited) {
1820 const e = document.createElement("div");
1821 e.className = "edited-tag";
1822 e.textContent = "Edited";
1823 wrap.appendChild(e);
1824 }
1825
1826 // A still-pending optimistic send withholds its interactive actions — you can't
1827 // react to, reply to, link, or edit a message that hasn't been sent yet — and
1828 // wears a subtle status the CSS only reveals once it's `.slow` (5s unconfirmed).
1829 // On confirmation patchBubble promotes it: attaches these actions with the real
1830 // server id, no rebuild. A confirmed message gets them straight away.
1831 if (m.pending) {
1832 wrap.dataset.pending = "1";
1833 wrap.classList.add("pending");
1834 if (m.slow) wrap.classList.add("slow");
1835 wrap.appendChild(buildSendStatus());
1836 } else {
1837 attachActions(wrap, content, m, ctx);
1838 }
1839
1840 item.appendChild(wrap);
1841 return item;
1842}
1843
1844// The interactive message actions — desktop's hover strip (react / reply /
1845// reply-in-thread / edit / copy-link) plus its press-and-hold, or touch's
1846// swipe-reply + long-press picker.
1847// Split out of renderItem so patchBubble can attach them to a bubble that first
1848// rendered while pending (actions withheld) the moment its echo confirms it — with
1849// the now-real server id, and without rebuilding the bubble (no photo flash).
1850function attachActions(wrap, content, m, ctx) {
1851 if (coarse) {
1852 attachSwipeReply(wrap, m, ctx);
1853 attachTouchPicker(wrap, m, ctx);
1854 return;
1855 }
1856 // Desktop: the hover strip beside the bubble — action icons + exact time,
1857 // revealed on hover (see .msg-actions in style.css).
1858 const mine = ownMessage({ uid: m.uid, name: m.name }, ctx.me, ctx.meName);
1859 const actions = document.createElement("div");
1860 actions.className = "msg-actions";
1861 actions.appendChild(hoverBtn("react-hover", "React", icon("react"), (e) => openMessagePicker(m, ctx, e.currentTarget)));
1862 actions.appendChild(
1863 hoverBtn("reply-hover", "Reply", icon("reply"), () => {
1864 const ctl = composerFor(wrap, ctx);
1865 if (ctl) ctl.startReply(m);
1866 }),
1867 );
1868 // Reply in thread, right beside Reply — the two are siblings, so they belong
1869 // side by side. It was menu-only (the picker + the touch sheet, both of which
1870 // keep it), and on desktop that menu opens from the *React* smiley: nobody
1871 // hunting for threads had a reason to click a smiley, so threads read as a
1872 // feature we hadn't built. The distinct glyph is what makes two neighbouring
1873 // reply verbs legible — see ICON_THREAD. The button is its own opener, so
1874 // closing the thread puts focus back on the icon that opened it. Inside the
1875 // thread view this button is hidden (style.css), the same self-pointing-row
1876 // rule as the quote card and the reply tally: the verb would resolve to the
1877 // thread you're already standing in.
1878 actions.appendChild(
1879 hoverBtn("thread-hover", "Reply in thread", icon("thread"), (e) => openReplyThread(m, ctx, e.currentTarget)),
1880 );
1881 const remaining = m.ts + EDIT_WINDOW_MS - Date.now();
1882 if (mine && m.text.trim() && remaining > 0) {
1883 const editBtn = hoverBtn("edit-hover", "Edit", icon("edit"), () => {
1884 const ctl = composerFor(wrap, ctx);
1885 if (ctl) ctl.startEdit(m);
1886 });
1887 actions.appendChild(editBtn);
1888 setTimeout(() => editBtn.remove(), remaining); // drop it when the window lapses
1889 }
1890 // The exact-time hover affordance doubles as a "Copy link" trigger (issue
1891 // #893) — the Slack/Discord convention. A <button> (not a bare span) so it's
1892 // keyboard-reachable and gets a real accessible name, not just visible text
1893 // that reads as a plain timestamp.
1894 const timeBtn = document.createElement("button");
1895 timeBtn.type = "button";
1896 timeBtn.className = "msg-time";
1897 timeBtn.textContent = formatTime(m.ts);
1898 timeBtn.title = "Copy link to this message";
1899 timeBtn.setAttribute("aria-label", `Copy link to this message, sent ${formatTime(m.ts)}`);
1900 timeBtn.onclick = (e) => {
1901 e.stopPropagation();
1902 copyMessageLink(ctx, m);
1903 };
1904 actions.appendChild(timeBtn);
1905 content.appendChild(actions);
1906 attachPressHold(wrap, m, ctx);
1907}
1908
1909// The pending-send status footer: a spinner + "Sending…", kept out of the tab
1910// order and hidden by CSS until the bubble goes `.slow`. `role="status"` +
1911// aria-live so a screen reader announces the delay when it finally surfaces
1912// rather than the instant every message is sent.
1913function buildSendStatus() {
1914 const s = document.createElement("div");
1915 s.className = "send-status";
1916 s.setAttribute("role", "status");
1917 s.textContent = "Sending…";
1918 return s;
1919}
1920
1921// The reactions row below a bubble (or null when there are none). Seeds the
1922// load-time "mine" flags first — add-only and idempotent (live frames carry
1923// counts only, never a mine flag), so it's safe to run from BOTH the initial
1924// render and the in-place reaction patch (patchBubble). Mirrors the original
1925// feed's renderReactions, minus the direct wrap mutation.
1926function buildReactionsRow(m, ctx) {
1927 for (const r of m.reactions || []) {
1928 if (r.mine) myReactions.add(`${m.sid}|${r.emoji}`);
1929 }
1930 const row = document.createElement("div");
1931 row.className = "reactions";
1932 for (const r of m.reactions || []) {
1933 if (!r.count) continue;
1934 const chip = document.createElement("button");
1935 chip.className = "reaction-chip" + (myReactions.has(`${m.sid}|${r.emoji}`) ? " mine" : "");
1936 chip.textContent = r.count > 1 ? `${r.emoji} ${r.count}` : r.emoji;
1937 chip.onclick = (e) => {
1938 e.stopPropagation();
1939 toggleReaction(m, r.emoji, ctx);
1940 };
1941 row.appendChild(chip);
1942 }
1943 return row.childElementCount ? row : null;
1944}
1945
1946// The "N Replies" line inside a quote card, marking it as a way INTO a thread
1947// rather than just a quote. Only a thread reply rendered inline (its root
1948// paged out) ever wears one — see threadLinkCount, which is the whole gate —
1949// and then only while its thread holds more than it: the card carries the
1950// SAME wording the root wears, the vocabulary members already read as
1951// "there's a conversation through here", inside the card so the whole thing
1952// reads as one control. A plain quote reply's card never gets one; tapping it
1953// jumps to the message it quotes. Create / update / remove in one place —
1954// buildMessageEl calls it as the card is built, patchBubble when a sibling
1955// reply lands or is deleted.
1956function paintQuoteThreadLink(q, m) {
1957 const n = threadLinkCount(m);
1958 let line = q.querySelector(":scope > .reply-quote-thread");
1959 if (!n) {
1960 if (line) line.remove();
1961 q.removeAttribute("aria-label");
1962 return;
1963 }
1964 const label = replyCountLabel(n);
1965 if (!line) {
1966 line = document.createElement("div");
1967 line.className = "reply-quote-thread";
1968 q.appendChild(line);
1969 }
1970 line.textContent = label;
1971 // The card's own name says what tapping it does — "4 Replies" alone is a
1972 // number read aloud. It keeps the visible label inside the spoken one
1973 // (WCAG 2.5.3) and carries the quote it replaced.
1974 const quoted = [m.reply && m.reply.name, m.reply && m.reply.snippet].filter(Boolean).join(": ");
1975 q.setAttribute("aria-label", quoted ? `Open thread, ${label} — quoting ${quoted}` : `Open thread, ${label}`);
1976}
1977
1978// The "N Replies" tally below a bubble, tappable to open the thread view. The
1979// tally only ever renders under a loaded root (it IS this message), so no
1980// paging is needed on this path. Old jump-to-first-reply kept as a belt for a
1981// mount without thread controls. A real <button> (style.css resets the pill
1982// chrome), like the quote card: the thread is keyboard-reachable, and it's
1983// the opener the view hands focus back to on close.
1984function buildReplyCount(m, wrap, ctx) {
1985 const rc = document.createElement("button");
1986 rc.type = "button";
1987 rc.className = "reply-count";
1988 rc.textContent = replyCountLabel(m.replyCount);
1989 rc.onclick = () => {
1990 const t = threads.get(ctx.threadId);
1991 if (t) {
1992 t.open(m.sid, { id: m.sid, name: m.name, snippet: replySnippet(m.raw) }, rc);
1993 return;
1994 }
1995 const feed = wrap.closest(".chatapp-feed");
1996 const first = feed && feed.querySelector(`.msg[data-reply-to="${m.sid}"]`);
1997 if (first) scrollToMessage(wrap, first.dataset.id);
1998 };
1999 return rc;
2000}
2001
2002// Patch a retained bubble's SOFT parts in place — the reactions row and the reply
2003// tally, both siblings AFTER .content. keyedList calls this (never a rebuild) when
2004// only the soft rev changed, so it MUST NOT descend into .content: that subtree
2005// holds the decrypted <img> and the YouTube embed, and leaving it untouched is
2006// precisely what stops a photo reloading and a playing video resetting when a
2007// reaction lands or a reply tallies. Mirrors the original feed's renderReactions /
2008// paintReplyCount, which likewise only ever touched these two rows.
2009function patchBubble(node, m, ctx) {
2010 const wrap = node.querySelector(":scope > .msg");
2011 if (!wrap) return;
2012 const content = wrap.querySelector(":scope > .content");
2013 // Delivery state (soft rev). A bubble that rendered while pending either goes
2014 // `.slow` (5s unconfirmed) or gets PROMOTED here the moment its echo lands: drop
2015 // the pending chrome, adopt the real server id, and attach the interactive
2016 // actions it was withheld — all outside .content, so the bubble is never rebuilt.
2017 if (wrap.dataset.pending === "1") {
2018 if (m.pending) {
2019 wrap.classList.toggle("slow", !!m.slow);
2020 } else {
2021 wrap.dataset.pending = "0";
2022 wrap.classList.remove("pending", "slow");
2023 wrap.dataset.id = m.sid || "";
2024 const status = wrap.querySelector(":scope > .send-status");
2025 if (status) status.remove();
2026 attachActions(wrap, content, m, ctx);
2027 }
2028 }
2029 // The quote card's thread link: a sibling reply landing (or being deleted)
2030 // moves this reply's thread size, so repaint it. Surgical by design — the
2031 // card's own text node, nothing else in .content is read or replaced, so the
2032 // photo/embed rule above still holds.
2033 const quote = content && content.querySelector(":scope > .reply-quote");
2034 if (quote) paintQuoteThreadLink(quote, m);
2035 // Reactions: rebuild the row and reinsert it right after .content, so it stays
2036 // above the reply/edited tags regardless of which of them are present.
2037 const oldRow = wrap.querySelector(":scope > .reactions");
2038 if (oldRow) oldRow.remove();
2039 const newRow = buildReactionsRow(m, ctx);
2040 if (newRow && content) content.after(newRow);
2041 // Reply tally: create (0→N), update (N→M), or remove (N→0 — a reply can be
2042 // deleted, dropping the parent's tally on the next viewItems).
2043 let rc = wrap.querySelector(":scope > .reply-count");
2044 if (m.replyCount) {
2045 if (rc) rc.textContent = replyCountLabel(m.replyCount);
2046 else {
2047 rc = buildReplyCount(m, wrap, ctx);
2048 const edited = wrap.querySelector(":scope > .edited-tag");
2049 if (edited) edited.before(rc);
2050 else wrap.appendChild(rc);
2051 }
2052 } else if (rc) {
2053 rc.remove();
2054 }
2055}
2056
2057function icon(name) {
2058 return (host && host.icons && host.icons[name]) || "";
2059}
2060
2061function hoverBtn(className, label, svg, onclick) {
2062 const b = document.createElement("button");
2063 b.type = "button";
2064 b.className = className;
2065 b.innerHTML = svg; // static in-code SVG from the host (safe)
2066 b.setAttribute("aria-label", label);
2067 // Same label as a native tooltip (the picker's actions already do this): an
2068 // icon-only strip is only self-explanatory once you know the glyphs, and
2069 // "Reply" vs "Reply in thread" is exactly the distinction hovering settles.
2070 b.title = label;
2071 b.onclick = (e) => {
2072 e.stopPropagation();
2073 onclick(e);
2074 };
2075 return b;
2076}
2077
2078function agentTag(kind) {
2079 const t = document.createElement("span");
2080 const ai = kind === "ai";
2081 t.textContent = ai ? "AI" : "BOT";
2082 t.title = ai
2083 ? "An AI helper — what you send it is sent to an AI provider to answer"
2084 : "An automated helper (bot), not a person — it runs on this server";
2085 // flex:none keeps it from shrinking, line-height:1 keeps the box tight — the
2086 // name line is a flex row with align-items:center (see the call site).
2087 t.style.cssText =
2088 "flex:none;margin-left:6px;font-size:0.7em;line-height:1;font-weight:600;letter-spacing:0.03em;padding:2px 5px;border-radius:6px;vertical-align:middle;" +
2089 "background:var(--border,#ddd);color:var(--muted,#666);";
2090 return t;
2091}
2092
2093// The landing itself: bring a rendered bubble into view and wave it. Shared
2094// so every route to a message (the transcript, the thread view, the paged-in
2095// one) ends the same way.
2096function flashMessage(target) {
2097 target.scrollIntoView({ behavior: "smooth", block: "center" });
2098 target.classList.remove("flash");
2099 void target.offsetWidth; // restart the animation if it's still mid-flash
2100 target.classList.add("flash");
2101}
2102
2103// Exported for the shell's search-result jump (#586): `near` is any node
2104// inside the target feed (the feed itself works).
2105export function scrollToMessage(near, sid) {
2106 const feed = near.closest(".chatapp-feed");
2107 const target = feed && feed.querySelector(`.msg[data-id="${sid}"]`);
2108 if (target) return flashMessage(target);
2109 const root = near.closest(".chatapp-root");
2110 const t = root && threads.get(root.dataset.thread);
2111 if (!t) return;
2112 // Not in the transcript, for one of two reasons.
2113 //
2114 // It's loaded but lives only in a thread (a reply made there — inMainFeed
2115 // false): open the thread on its root and land on the bubble inside the
2116 // view. The view renders synchronously on open, so its body already holds
2117 // the bubble.
2118 if (t.openForMessage(Number(sid))) {
2119 const inThread = root.querySelector(`.chatapp-thread-msgs .msg[data-id="${sid}"]`);
2120 if (inThread) flashMessage(inThread);
2121 return;
2122 }
2123 // Or it simply isn't loaded — older than the page we hold. Page history
2124 // back for it (revealMessage), which lands on it or says why it can't.
2125 // Doing nothing here is what made a plain reply's quote card a dead control
2126 // on a cold-opened chat.
2127 t.reveal(Number(sid));
2128}
2129
2130// "Copy link" (issue #893): mint the permalink through the host (it holds
2131// session.token and the fetch to /api/messages/{id}/link — see shell.js
2132// mintMessageLink) and write it to the clipboard. Same await-and-toast
2133// pattern as the existing plain-text Copy action, just via the host for the
2134// network round-trip a link mint needs that a local clipboard copy doesn't.
2135async function copyMessageLink(ctx, m) {
2136 if (!host || !host.mintMessageLink || !host.toast) return;
2137 const url = await host.mintMessageLink(ctx.threadId, m.sid);
2138 if (!url) {
2139 host.toast("Couldn't copy link");
2140 return;
2141 }
2142 try {
2143 await navigator.clipboard.writeText(url);
2144 host.toast("Link copied");
2145 } catch {
2146 host.toast("Couldn't copy link");
2147 }
2148}
2149
2150function toggleReaction(m, emoji, ctx) {
2151 if (!host) return;
2152 const sid = m.sid;
2153 const key = `${sid}|${emoji}`;
2154 const mine = myReactions.has(key);
2155 // Reactions are now encrypted events. Optimistically update local state, but
2156 // if the send fails, revert on the next render. host.react is now async.
2157 // m.uid (the reacted-to message's author) rides inside the encrypted payload
2158 // so push handlers can show the reaction only to the author; its snippet
2159 // rides along too so the chat list can say what was reacted to.
2160 // Through the markdown plain projection, like every other preview line —
2161 // quoting raw text would show the marks the bubble renders as styling, and
2162 // the composer's opt-out escapes as visible backslashes. Projected here, at
2163 // the stamp, so no payload ever carries them.
2164 const onVal = !mine;
2165 const quote = replySnippet({ ...m.raw, text: mdPlainText(m.raw.text || "") });
2166 host.react(ctx.threadId, sid, emoji, onVal, m.uid || "", quote).then((sent) => {
2167 if (!sent) {
2168 if (onVal) myReactions.delete(key);
2169 else myReactions.add(key);
2170 }
2171 });
2172 if (mine) myReactions.delete(key);
2173 else myReactions.add(key);
2174}
2175
2176// The emoji picker over a message — the original .emoji-picker markup: the six
2177// reaction options, then icon actions (reply / edit / delete) and any actions
2178// other mini-apps contribute (e.g. "Save link"), glyphs as textContent.
2179function openMessagePicker(m, ctx, anchor) {
2180 document.querySelector(".emoji-picker")?.remove();
2181 const pop = document.createElement("div");
2182 pop.className = "emoji-picker";
2183 for (const emoji of REACTIONS) {
2184 const b = document.createElement("button");
2185 b.className = "emoji-opt";
2186 b.textContent = emoji;
2187 b.onclick = () => {
2188 toggleReaction(m, emoji, ctx);
2189 teardown();
2190 };
2191 pop.appendChild(b);
2192 }
2193 // Every button dismisses via teardown (below), never a bare pop.remove():
2194 // the picker's document listeners must go with it, or the digit/Escape
2195 // handler outlives the menu and a "1" typed into the composer the action
2196 // just focused toggles a reaction instead.
2197 const action = (svg, label, run) => {
2198 const b = document.createElement("button");
2199 b.className = "picker-action";
2200 b.innerHTML = svg; // static in-code SVG from the host (safe)
2201 b.setAttribute("aria-label", label);
2202 b.title = label;
2203 b.onclick = () => {
2204 run();
2205 teardown();
2206 };
2207 pop.appendChild(b);
2208 };
2209 action(icon("reply"), "Reply", () => {
2210 const ctl = composerFor(anchor, ctx);
2211 if (ctl) ctl.startReply(m);
2212 });
2213 // Reply in thread — opens the message's thread (docked beside the chat at
2214 // desktop widths, lifted over it on narrow ones) rather than staging a reply
2215 // below. Its own glyph, not a second reply arrow: this menu's rows are
2216 // icon-only, so twin arrows left the two verbs told apart by tooltip alone.
2217 // The anchor is the opener: the hover strip's button on that path, else the
2218 // bubble itself (unfocusable — openReplyThread drops it).
2219 action(icon("thread"), "Reply in thread", () => openReplyThread(m, ctx, anchor));
2220 // Convert to thread — offered only where there IS an inline chain to move
2221 // (convertChain), so it never shows on a message nobody answered, and never
2222 // inside the thread pane. Right after the thread verb: the two are about the
2223 // same place, one for what comes next and one for what already happened.
2224 if (convertChain(m, ctx, anchor).length) {
2225 action(icon("foldIn"), "Convert to thread", () => convertToThread(m, ctx, anchor));
2226 }
2227 // Copy — the original touch sheet's action; native selection is suppressed on
2228 // bubbles (long-press opens this menu), so Copy must live here.
2229 if (m.text.trim()) {
2230 action(icon("copy"), "Copy", async () => {
2231 // Mirror the classic feed's copyMessageText: await the write and confirm
2232 // via the host's status pill so success and failure both show up.
2233 try {
2234 await navigator.clipboard.writeText(m.text);
2235 if (host && host.toast) host.toast("Copied");
2236 } catch {
2237 if (host && host.toast) host.toast("Couldn't copy");
2238 }
2239 });
2240 }
2241 // Copy link (issue #893) — a shareable permalink that jumps a member
2242 // straight to this message; lives right after Copy.
2243 action(icon("link"), "Copy link", () => copyMessageLink(ctx, m));
2244 // Same uid-or-name attribution as the feed, so the Edit/Delete actions show up
2245 // on your own legacy (uid-less) messages too.
2246 const mine = ownMessage({ uid: m.uid, name: m.name }, ctx.me, ctx.meName);
2247 if (mine && m.text.trim() && Date.now() - m.ts < EDIT_WINDOW_MS) {
2248 action(icon("edit"), "Edit", () => {
2249 const ctl = composerFor(anchor, ctx);
2250 if (ctl) ctl.startEdit(m);
2251 });
2252 }
2253 if (mine) {
2254 action(icon("trash"), "Delete", () => {
2255 if (confirm("Delete this message for everyone? This can't be undone.") && host) host.del(ctx.threadId, m.sid, attachmentBlobIds(m));
2256 });
2257 }
2258 // Mute the sender (issue #578) — their messages fold to a quiet stub. Needs
2259 // a uid to key on (legacy uid-less messages can't be muted per-person).
2260 if (!mine && m.uid && host && host.mutedPerson && host.setPersonMuted) {
2261 const who = (host.memberName && host.memberName(m.uid)) || m.name || "them";
2262 const isMuted = host.mutedPerson(m.uid);
2263 action(icon("mute"), isMuted ? `Unmute ${who}` : `Mute ${who}`, () => {
2264 host.setPersonMuted(m.uid, !isMuted);
2265 });
2266 }
2267 // Report to the space's admins (issue #578) — the host owns the sheet (it
2268 // holds the roster + keys to seal the report to admin keys).
2269 if (!mine && m.uid && host && host.reportMessage) {
2270 action(icon("report"), "Report", () => host.reportMessage(ctx.threadId, m));
2271 }
2272 for (const act of messageActionsFor({ text: m.text, from: m.name })) {
2273 const b = document.createElement("button");
2274 b.className = "picker-action";
2275 b.textContent = act.glyph; // app-supplied — textContent, never innerHTML
2276 b.setAttribute("aria-label", act.label);
2277 b.title = act.label;
2278 b.onclick = () => {
2279 act.run();
2280 teardown();
2281 };
2282 pop.appendChild(b);
2283 }
2284 document.body.appendChild(pop);
2285 const r = anchor.getBoundingClientRect();
2286 pop.style.top = `${Math.max(8, r.top - pop.offsetHeight - 8)}px`;
2287 pop.style.left = `${Math.min(r.left, window.innerWidth - pop.offsetWidth - 8)}px`;
2288 const teardown = () => {
2289 pop.remove();
2290 document.removeEventListener("pointerdown", close, true);
2291 document.removeEventListener("keydown", onKey, true);
2292 };
2293 const close = (e) => {
2294 if (!pop.contains(e.target)) teardown();
2295 };
2296 // Keyboard: Escape dismisses; 1–6 toggles that reaction (mirrors the original
2297 // feed's cmd-T flow, where the menu opens from the keyboard).
2298 const onKey = (e) => {
2299 if (e.key === "Escape") {
2300 e.preventDefault();
2301 teardown();
2302 return;
2303 }
2304 const num = parseInt(e.key, 10);
2305 if (num >= 1 && num <= REACTIONS.length) {
2306 e.preventDefault();
2307 e.stopImmediatePropagation();
2308 toggleReaction(m, REACTIONS[num - 1], ctx);
2309 teardown();
2310 }
2311 };
2312 document.addEventListener("pointerdown", close, true);
2313 document.addEventListener("keydown", onKey, true);
2314}
2315
2316// The actions in the touch long-press sheet, in the original feed's order: Reply,
2317// Reply in thread, Copy, Copy link, then app-contributed actions (Save link…),
2318// then your-own Edit (in-window) and Delete. Each is { label, icon | glyph,
2319// danger?, run } — mirrors messageActions in the original chat.js. `wrap` is
2320// the pressed bubble: Reply/Edit route to the composer it shares a pane with
2321// (the docked thread pane's own when it lives there), and it's the thread
2322// verb's opener.
2323function sheetActions(m, ctx, wrap) {
2324 const acts = [];
2325 acts.push({
2326 label: "Reply",
2327 icon: icon("reply"),
2328 run: () => {
2329 const ctl = composerFor(wrap, ctx);
2330 if (ctl) ctl.startReply(m);
2331 },
2332 });
2333 // Its own glyph (see the desktop picker) — the sheet's rows are labelled, so
2334 // this is about the two verbs looking as different as they behave.
2335 acts.push({ label: "Reply in thread", icon: icon("thread"), run: () => openReplyThread(m, ctx, wrap) });
2336 // Convert to thread — same gate as the desktop picker: only on a message
2337 // that actually has an inline chain under it.
2338 if (convertChain(m, ctx, wrap).length) {
2339 acts.push({ label: "Convert to thread", icon: icon("foldIn"), run: () => convertToThread(m, ctx, wrap) });
2340 }
2341 // Native selection is suppressed on bubbles (long-press opens this sheet), so
2342 // Copy must live here.
2343 if (m.text.trim()) {
2344 acts.push({
2345 label: "Copy",
2346 icon: icon("copy"),
2347 run: async () => {
2348 // Mirror the classic feed's copyMessageText: await the write and confirm
2349 // via the host's status pill so success and failure both show up.
2350 try {
2351 await navigator.clipboard.writeText(m.text);
2352 if (host && host.toast) host.toast("Copied");
2353 } catch {
2354 if (host && host.toast) host.toast("Couldn't copy");
2355 }
2356 },
2357 });
2358 }
2359 // Copy link (issue #893) — same permalink as the desktop picker's action.
2360 acts.push({ label: "Copy link", icon: icon("link"), run: () => copyMessageLink(ctx, m) });
2361 // Actions other mini-apps contribute (e.g. Bookmarks' "Save link") — each an
2362 // emoji glyph rendered as textContent, never innerHTML.
2363 for (const act of messageActionsFor({ text: m.text, from: m.name })) {
2364 acts.push({ label: act.label, glyph: act.glyph, run: act.run });
2365 }
2366 const mine = !!m.uid && m.uid === ctx.me;
2367 if (mine && m.text.trim() && Date.now() - m.ts < EDIT_WINDOW_MS) {
2368 acts.push({
2369 label: "Edit",
2370 icon: icon("edit"),
2371 run: () => {
2372 const ctl = composerFor(wrap, ctx);
2373 if (ctl) ctl.startEdit(m);
2374 },
2375 });
2376 }
2377 if (mine) {
2378 acts.push({
2379 label: "Delete",
2380 icon: icon("trash"),
2381 danger: true,
2382 run: () => {
2383 if (confirm("Delete this message for everyone? This can't be undone.") && host) host.del(ctx.threadId, m.sid, attachmentBlobIds(m));
2384 },
2385 });
2386 }
2387 // Mute the sender (issue #578) — same gate as the desktop picker.
2388 if (!mine && m.uid && host && host.mutedPerson && host.setPersonMuted) {
2389 const who = (host.memberName && host.memberName(m.uid)) || m.name || "them";
2390 const isMuted = host.mutedPerson(m.uid);
2391 acts.push({
2392 label: isMuted ? `Unmute ${who}` : `Mute ${who}`,
2393 icon: icon("mute"),
2394 run: () => host.setPersonMuted(m.uid, !isMuted),
2395 });
2396 }
2397 // Report to the space's admins (issue #578) — same gate as the picker.
2398 if (!mine && m.uid && host && host.reportMessage) {
2399 acts.push({
2400 label: "Report",
2401 icon: icon("report"),
2402 run: () => host.reportMessage(ctx.threadId, m),
2403 });
2404 }
2405 return acts;
2406}
2407
2408// Touch long-press → the labelled message sheet (WhatsApp-style): a dim backdrop
2409// with the emoji reactions floating above the lifted message and a labelled
2410// Reply/Copy/Edit/Delete menu below it. Mirrors openMessageSheet in the original
2411// feed and reuses its .msg-sheet CSS.
2412function openMessageSheet(m, ctx, wrap) {
2413 document.querySelector(".msg-sheet")?.remove();
2414 const bubble = wrap.querySelector(".bubble") || wrap;
2415 const brect = bubble.getBoundingClientRect(); // for horizontal alignment
2416 const rect = wrap.getBoundingClientRect(); // the whole message (what we lift)
2417 const mine = !!m.uid && m.uid === ctx.me;
2418
2419 const backdrop = document.createElement("div");
2420 backdrop.className = "msg-sheet";
2421
2422 // A non-interactive copy of the message, lifted above the dim so it stays legible
2423 // while the reactions float above it and the menu below — the whole cluster is
2424 // repositioned vertically so both fit even when the message is at the screen edge.
2425 const clone = wrap.cloneNode(true);
2426 clone.classList.add("msg-sheet-clone");
2427 clone.classList.remove("appear");
2428 clone.style.transform = ""; // drop any residual swipe-to-reply translate
2429 clone.style.left = rect.left + "px";
2430 clone.style.width = rect.width + "px";
2431
2432 const reactions = document.createElement("div");
2433 reactions.className = "msg-sheet-reactions";
2434 for (const emoji of REACTIONS) {
2435 const b = document.createElement("button");
2436 b.className = "sheet-emoji";
2437 b.textContent = emoji;
2438 b.onclick = () => {
2439 toggleReaction(m, emoji, ctx);
2440 close();
2441 };
2442 reactions.appendChild(b);
2443 }
2444
2445 const menu = document.createElement("div");
2446 menu.className = "msg-sheet-menu";
2447 for (const a of sheetActions(m, ctx, wrap)) {
2448 const item = document.createElement("button");
2449 item.className = "msg-sheet-item" + (a.danger ? " danger" : "");
2450 const label = document.createElement("span");
2451 label.textContent = a.label;
2452 const ic = document.createElement("span");
2453 ic.className = "msg-sheet-icon";
2454 if (a.glyph) ic.textContent = a.glyph; // app action: emoji glyph (textContent)
2455 else ic.innerHTML = a.icon; // static in-code SVG from the host (safe)
2456 item.appendChild(label);
2457 item.appendChild(ic);
2458 item.onclick = () => {
2459 a.run();
2460 close();
2461 };
2462 menu.appendChild(item);
2463 }
2464
2465 backdrop.appendChild(clone);
2466 backdrop.appendChild(reactions);
2467 backdrop.appendChild(menu);
2468 document.body.appendChild(backdrop);
2469
2470 const M = 8;
2471 const GAP = 10;
2472 const vw = window.innerWidth;
2473 const vh = window.innerHeight;
2474 const rH = reactions.offsetHeight;
2475 const mH = menu.offsetHeight;
2476 // Cap the lifted message so the reactions + menu always fit around it.
2477 const maxMsgH = Math.max(60, vh - 2 * M - rH - mH - 2 * GAP);
2478 const bH = Math.min(rect.height, maxMsgH);
2479 if (rect.height > bH) {
2480 clone.style.maxHeight = bH + "px";
2481 clone.style.overflow = "hidden";
2482 }
2483 // Keep the message near where it was, but slide it just enough that the reactions
2484 // fit above and the menu below.
2485 const minTop = M + rH + GAP;
2486 const maxTop = Math.max(minTop, vh - M - mH - GAP - bH);
2487 const msgTop = Math.max(minTop, Math.min(rect.top, maxTop));
2488 clone.style.top = msgTop + "px";
2489
2490 const placeX = (el) => {
2491 const w = el.offsetWidth;
2492 const left = mine ? brect.right - w : brect.left;
2493 return Math.max(M, Math.min(left, vw - w - M));
2494 };
2495 reactions.style.top = msgTop - GAP - rH + "px";
2496 reactions.style.left = placeX(reactions) + "px";
2497 menu.style.top = msgTop + bH + GAP + "px";
2498 menu.style.left = placeX(menu) + "px";
2499
2500 // Hide the original while its lifted copy is shown; restore on close.
2501 wrap.style.visibility = "hidden";
2502
2503 function close() {
2504 wrap.style.visibility = "";
2505 backdrop.remove();
2506 document.removeEventListener("pointerdown", onDoc, true);
2507 document.removeEventListener("keydown", onEsc);
2508 }
2509 const onDoc = (e) => {
2510 if (!reactions.contains(e.target) && !menu.contains(e.target)) close();
2511 };
2512 const onEsc = (e) => {
2513 if (e.key === "Escape") {
2514 e.preventDefault();
2515 close();
2516 }
2517 };
2518 // Deferred so the press that opened the sheet doesn't immediately close it.
2519 setTimeout(() => {
2520 document.addEventListener("pointerdown", onDoc, true);
2521 document.addEventListener("keydown", onEsc);
2522 }, 0);
2523}
2524
2525// Touch double-tap → a quick emoji-only reactions bar. Just the emojis: the actions
2526// (reply/copy/edit/delete) live in the long-press sheet. Reuses the compact
2527// .emoji-picker, mirrors openReactionBar in the original feed.
2528function openReactionBar(m, ctx, anchor) {
2529 document.querySelector(".emoji-picker")?.remove();
2530 const pop = document.createElement("div");
2531 pop.className = "emoji-picker";
2532 for (const emoji of REACTIONS) {
2533 const b = document.createElement("button");
2534 b.className = "emoji-opt";
2535 b.textContent = emoji;
2536 b.onclick = () => {
2537 toggleReaction(m, emoji, ctx);
2538 pop.remove();
2539 };
2540 pop.appendChild(b);
2541 }
2542 document.body.appendChild(pop);
2543 const r = anchor.getBoundingClientRect();
2544 pop.style.top = `${Math.max(8, r.top - pop.offsetHeight - 8)}px`;
2545 pop.style.left = `${Math.min(r.left, window.innerWidth - pop.offsetWidth - 8)}px`;
2546 const close = (e) => {
2547 if (!pop.contains(e.target)) {
2548 pop.remove();
2549 document.removeEventListener("pointerdown", close, true);
2550 }
2551 };
2552 setTimeout(() => document.addEventListener("pointerdown", close, true), 0);
2553}
2554
2555// Desktop press-and-hold on the bubble opens the picker (the hover strip is
2556// the discoverable path; this mirrors the original gesture).
2557function attachPressHold(wrap, m, ctx) {
2558 let timer = null;
2559 wrap.addEventListener("pointerdown", (e) => {
2560 if (e.button !== 0) return;
2561 timer = setTimeout(() => openMessagePicker(m, ctx, wrap), 500);
2562 });
2563 const cancel = () => clearTimeout(timer);
2564 wrap.addEventListener("pointerup", cancel);
2565 wrap.addEventListener("pointerleave", cancel);
2566 wrap.addEventListener("pointermove", cancel);
2567}
2568
2569// Touch: long-press (≈500ms) → the labelled message sheet; a quick double-tap →
2570// the emoji-only reactions bar. Mirrors attachReactionGesture's coarse branch in
2571// the original feed (a drag past 10px is a swipe/scroll, not a long-press).
2572function attachTouchPicker(wrap, m, ctx) {
2573 let timer = null;
2574 let sx = 0;
2575 let sy = 0;
2576 let lastTap = 0;
2577 let longFired = false;
2578 const cancel = () => {
2579 if (timer) {
2580 clearTimeout(timer);
2581 timer = null;
2582 }
2583 };
2584 wrap.addEventListener(
2585 "touchstart",
2586 (e) => {
2587 cancel();
2588 longFired = false;
2589 if (e.touches.length !== 1) return;
2590 sx = e.touches[0].clientX;
2591 sy = e.touches[0].clientY;
2592 timer = setTimeout(() => {
2593 timer = null;
2594 longFired = true;
2595 openMessageSheet(m, ctx, wrap);
2596 }, 500);
2597 },
2598 { passive: true },
2599 );
2600 wrap.addEventListener(
2601 "touchmove",
2602 (e) => {
2603 if (!timer) return;
2604 const t = e.touches[0];
2605 // A drag (swipe-to-reply or a scroll) isn't a long-press.
2606 if (Math.abs(t.clientX - sx) > 10 || Math.abs(t.clientY - sy) > 10) cancel();
2607 },
2608 { passive: true },
2609 );
2610 wrap.addEventListener("touchend", () => {
2611 const wasLong = longFired;
2612 cancel();
2613 if (wasLong) {
2614 lastTap = 0; // the long-press already opened the sheet — not a tap
2615 return;
2616 }
2617 const now = Date.now();
2618 if (now - lastTap < 300) {
2619 lastTap = 0;
2620 openReactionBar(m, ctx, wrap); // second quick tap
2621 } else {
2622 lastTap = now;
2623 }
2624 });
2625 wrap.addEventListener("touchcancel", () => {
2626 cancel();
2627 lastTap = 0;
2628 });
2629}
2630
2631// Swipe a message right (touch) to reply — the original gesture: the message
2632// follows the finger, a reply arrow fades in, release past the threshold replies.
2633function attachSwipeReply(wrap, m, ctx) {
2634 const TRIGGER = 56;
2635 const MAX = 80;
2636 let startX = 0;
2637 let startY = 0;
2638 let dx = 0;
2639 let mode = ""; // "" undecided, "swipe", or "no"
2640 let iconEl = null;
2641 let raf = 0;
2642
2643 // Write the transform once per frame (rAF-batched) using translate3d, which
2644 // keeps the bubble on a GPU compositor layer — no per-move repaint of the
2645 // subtree, so the drag stays smooth even in the iOS webview.
2646 const render = () => {
2647 raf = 0;
2648 const shift = Math.min(Math.max(dx, 0), MAX);
2649 wrap.style.transform = `translate3d(${shift}px,0,0)`;
2650 if (iconEl) iconEl.style.opacity = Math.min(shift / TRIGGER, 1);
2651 };
2652 const schedule = () => {
2653 if (!raf) raf = requestAnimationFrame(render);
2654 };
2655
2656 const cleanup = () => {
2657 if (raf) {
2658 cancelAnimationFrame(raf);
2659 raf = 0;
2660 }
2661 if (iconEl) {
2662 iconEl.remove();
2663 iconEl = null;
2664 }
2665 wrap.style.transition = "transform 0.18s ease";
2666 wrap.style.transform = "";
2667 setTimeout(() => {
2668 wrap.style.transition = "";
2669 wrap.style.willChange = "";
2670 }, 200);
2671 };
2672 wrap.addEventListener(
2673 "touchstart",
2674 (e) => {
2675 if (e.touches.length !== 1) return;
2676 startX = e.touches[0].clientX;
2677 startY = e.touches[0].clientY;
2678 dx = 0;
2679 mode = "";
2680 },
2681 { passive: true },
2682 );
2683 wrap.addEventListener(
2684 "touchmove",
2685 (e) => {
2686 const t = e.touches[0];
2687 dx = t.clientX - startX;
2688 const dy = t.clientY - startY;
2689 if (!mode) {
2690 // Mirror the original feed's axis-lock: ignore tiny jitter, then only
2691 // enter swipe when the horizontal drag clearly dominates the vertical
2692 // (by ~1.3×) — a diagonal drag the original reads as a scroll must not
2693 // trip the reply here either.
2694 if (Math.abs(dx) < 10 && Math.abs(dy) < 10) return;
2695 if (dx > 0 && Math.abs(dx) > Math.abs(dy) * 1.3) {
2696 mode = "swipe";
2697 wrap.style.willChange = "transform"; // promote the bubble to its own layer for the drag
2698 } else {
2699 mode = "no";
2700 }
2701 }
2702 if (mode !== "swipe") return;
2703 if (!iconEl) {
2704 iconEl = document.createElement("span");
2705 iconEl.className = "swipe-reply-icon";
2706 iconEl.innerHTML = icon("reply");
2707 wrap.appendChild(iconEl);
2708 }
2709 schedule();
2710 },
2711 { passive: true },
2712 );
2713 // A pointer swipe ends on touchend, but a system interruption (incoming
2714 // call, notification) fires touchcancel with no touchend — route both
2715 // through one path so an interrupted swipe still resets, instead of leaving
2716 // the bubble stuck mid-transform until the next touch. Only a real release
2717 // can trigger the reply; a cancel just resets.
2718 const onEnd = (interrupted) => {
2719 const trigger = !interrupted && mode === "swipe" && dx > TRIGGER;
2720 cleanup();
2721 mode = "";
2722 if (trigger) {
2723 const ctl = composerFor(wrap, ctx);
2724 if (ctl) ctl.startReply(m);
2725 }
2726 };
2727 wrap.addEventListener("touchend", () => onEnd(false));
2728 wrap.addEventListener("touchcancel", () => onEnd(true));
2729}
2730
2731// Rich text through the allowlisted CommonMark subset (markdown.js, #531):
2732// parseMessage builds the vector-pinned semantic tree, renderTree walks it into
2733// DOM nodes, and @mentions are scanned inside the plain runs via the hook —
2734// same class names (.mention / .mention-link), textContent everywhere.
2735function escapeRegExp(s) {
2736 return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2737}
2738function renderRichText(el, text) {
2739 const labels = host && host.mentionNames ? host.mentionNames() : [];
2740 const mentionRe = labels.length
2741 ? new RegExp("@(?:" + labels.sort((a, b) => b.length - a.length).map(escapeRegExp).join("|") + ")(?![\\w])", "g")
2742 : null;
2743 const plain = (target, t) => {
2744 if (!mentionRe) {
2745 target.appendChild(document.createTextNode(t));
2746 return;
2747 }
2748 mentionRe.lastIndex = 0;
2749 let last = 0;
2750 let mt;
2751 while ((mt = mentionRe.exec(t))) {
2752 const prevCh = t[mt.index - 1];
2753 if (prevCh !== undefined && /[\w@.]/.test(prevCh)) continue; // mid-word / email → not a mention
2754 if (mt.index > last) target.appendChild(document.createTextNode(t.slice(last, mt.index)));
2755 const span = document.createElement("span");
2756 span.className = "mention";
2757 const label = mt[0].slice(1);
2758 // The chip shows the viewer's own name for the person when the host has
2759 // one (per-viewer names); the match above stays on the published name.
2760 // When the two differ, the published name waits under a hover title.
2761 const shown = (host && host.mentionDisplay && host.mentionDisplay(label)) || label;
2762 span.textContent = "@" + shown;
2763 if (shown !== label) span.title = `Goes by ${label}`;
2764 // Only OTHER members get the tappable link/menu; a self-mention (or an
2765 // unresolved name) stays a plain bold chip, matching the classic feed.
2766 if (host && host.mention && (!host.mentionable || host.mentionable(label))) {
2767 span.classList.add("mention-link");
2768 span.setAttribute("role", "button");
2769 span.tabIndex = 0;
2770 const open = (e) => {
2771 e.preventDefault();
2772 e.stopPropagation();
2773 host.mention(label, span);
2774 };
2775 span.addEventListener("click", open);
2776 span.addEventListener("keydown", (e) => {
2777 if (e.key === "Enter" || e.key === " ") open(e);
2778 });
2779 }
2780 target.appendChild(span);
2781 last = mt.index + mt[0].length;
2782 }
2783 if (last < t.length) target.appendChild(document.createTextNode(t.slice(last)));
2784 };
2785 renderTree(el, parseMessage(text), { plain });
2786}
2787
2788// A download chip for a non-image attachment — the original .file-chip markup.
2789function buildFileCard(a, ctx) {
2790 const card = document.createElement("a");
2791 card.className = "file-chip";
2792 card.href = "#";
2793 card.download = a.name || "file";
2794 const ic = document.createElement("span");
2795 ic.className = "file-icon";
2796 ic.textContent = fileIcon(a.name);
2797 const meta = document.createElement("span");
2798 meta.className = "file-meta";
2799 const nm = document.createElement("span");
2800 nm.className = "file-name";
2801 nm.textContent = a.name || "file";
2802 meta.appendChild(nm);
2803 if (a.size) {
2804 const sz = document.createElement("span");
2805 sz.className = "file-sub";
2806 sz.textContent = formatBytes(a.size);
2807 meta.appendChild(sz);
2808 }
2809 card.append(ic, meta);
2810 card.addEventListener("click", (e) => {
2811 if (card.getAttribute("href") === "#") e.preventDefault();
2812 });
2813 if (host) {
2814 host.loadBlob(ctx.threadId, a).then((url) => {
2815 if (url) card.href = url;
2816 });
2817 }
2818 return card;
2819}
2820
2821// A download glyph (static, in-code SVG — safe for innerHTML) for the unplayable
2822// video fallback. host.icons has no download icon, so it lives here.
2823const ICON_DOWNLOAD =
2824 '<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12m0 0l-4-4m4 4l4-4M4 21h16"/></svg>';
2825
2826// Whole seconds as m:ss (or h:mm:ss) for the video duration badge.
2827function formatDuration(secs) {
2828 secs = Math.max(0, Math.round(secs));
2829 const h = Math.floor(secs / 3600), m = Math.floor((secs % 3600) / 60), s = secs % 60;
2830 const mm = h ? String(m).padStart(2, "0") : String(m);
2831 return (h ? h + ":" : "") + mm + ":" + String(s).padStart(2, "0");
2832}
2833
2834// Download an object-URL'd blob under a filename — the video fallback when the
2835// browser can't play the format inline.
2836function triggerDownload(url, name) {
2837 const a = document.createElement("a");
2838 a.href = url;
2839 a.download = name;
2840 document.body.appendChild(a);
2841 a.click();
2842 a.remove();
2843}
2844
2845// A lazy, send-and-play video card: poster + play badge + duration, with NO bytes
2846// fetched until it's tapped (a thread full of clips must not auto-download). On
2847// tap we download+decrypt the clip (a radial ring shows progress) then play it
2848// full-screen in the lightbox. If the browser can't decode the format (e.g. an
2849// iPhone HEVC clip) we download it instead — the bytes are always retrievable.
2850function buildVideoCard(a, ctx) {
2851 const card = document.createElement("div");
2852 card.className = "msg-video";
2853 // Reserve the box before the poster loads so the feed doesn't jump; fall back to
2854 // 16/9 when we couldn't read the clip's dimensions (so it's never a collapsed or
2855 // oddly-shaped box).
2856 if (a.w && a.h) {
2857 // Reserve the box before the poster loads so the feed doesn't jump.
2858 card.style.aspectRatio = `${a.w} / ${a.h}`;
2859 const scale = Math.min(260 / a.w, 320 / a.h, 1);
2860 card.style.width = Math.round(a.w * scale) + "px";
2861 } else {
2862 // No stored dimensions (older upload, or a clip whose metadata didn't decode):
2863 // a sensible default box — NOT a 16px sliver from treating the 16:9 fallback
2864 // ratio as pixel dimensions.
2865 card.style.aspectRatio = "16 / 9";
2866 card.style.width = "260px";
2867 }
2868
2869 // canPlayType is only a hint. "" means definitely-can't-play → the card is a
2870 // download from the start.
2871 const probe = document.createElement("video");
2872 const playable = !!(a.type && probe.canPlayType && probe.canPlayType(a.type) !== "");
2873
2874 // The resting (poster) contents, reused if we have to revert after a failed play.
2875 const renderPoster = (downloadOnly) => {
2876 const poster = document.createElement("img");
2877 poster.className = "msg-video-poster";
2878 poster.alt = a.name || "video";
2879 if (host && a.poster) {
2880 host.loadBlob(ctx.threadId, { id: a.poster, type: "image/jpeg" }).then((url) => url && (poster.src = url));
2881 }
2882 const badge = document.createElement("span");
2883 badge.className = "msg-video-play";
2884 badge.innerHTML = downloadOnly ? ICON_DOWNLOAD : icon("play");
2885 const kids = [poster, badge];
2886 if (a.dur) {
2887 const dur = document.createElement("span");
2888 dur.className = "msg-video-dur";
2889 dur.textContent = formatDuration(a.dur);
2890 kids.push(dur);
2891 }
2892 card.replaceChildren(...kids);
2893 card.classList.toggle("unplayable", !!downloadOnly);
2894 };
2895 renderPoster(!playable);
2896
2897 card.onclick = () => {
2898 if (!host || card.dataset.loading === "1") return;
2899 card.dataset.loading = "1";
2900 // Show the radial progress ring only if the download takes a beat — a cached
2901 // or fast load skips it, so there's no flash of an empty ring.
2902 let ring = null;
2903 const ringTimer = setTimeout(() => { ring = showVideoRing(card); }, 150);
2904 host.loadBlob(ctx.threadId, a, (p) => ring && ring.set(p)).then((url) => {
2905 clearTimeout(ringTimer);
2906 card.dataset.loading = "";
2907 if (ring) ring.remove();
2908 if (!url) return;
2909 if (!playable) { triggerDownload(url, a.name || "video"); return; }
2910 openVideoLightbox(url, () => triggerDownload(url, a.name || "video"));
2911 });
2912 };
2913 return card;
2914}
2915
2916// A circular download-progress ring around the play button, shown while a clip
2917// downloads on tap. Returns { set(0..1), remove() }.
2918function showVideoRing(card) {
2919 const R = 20, C = 2 * Math.PI * R;
2920 const wrap = document.createElement("div");
2921 wrap.className = "msg-video-ring";
2922 wrap.innerHTML =
2923 '<svg viewBox="0 0 48 48"><circle class="ring-track" cx="24" cy="24" r="' + R + '"></circle>' +
2924 '<circle class="ring-fill" cx="24" cy="24" r="' + R + '"></circle></svg>';
2925 const fill = wrap.querySelector(".ring-fill");
2926 fill.style.strokeDasharray = String(C);
2927 fill.style.strokeDashoffset = String(C);
2928 card.appendChild(wrap);
2929 card.classList.add("downloading");
2930 return {
2931 set: (p) => { fill.style.strokeDashoffset = String(C * (1 - Math.max(0, Math.min(1, p)))); },
2932 remove: () => { wrap.remove(); card.classList.remove("downloading"); },
2933 };
2934}
2935
2936// Play a decrypted clip full-screen in the lightbox (a small inline player isn't
2937// worth much). Backdrop tap / Escape closes; a decode error falls back to download.
2938function openVideoLightbox(url, onDownloadFallback) {
2939 const back = document.createElement("div");
2940 back.className = "lightbox video-lightbox";
2941 const backdrop = document.createElement("div");
2942 backdrop.className = "lightbox-backdrop";
2943 const vid = document.createElement("video");
2944 vid.className = "lightbox-video";
2945 vid.src = url;
2946 vid.controls = true;
2947 vid.autoplay = true;
2948 vid.playsInline = true;
2949 back.append(backdrop, vid);
2950 const close = () => {
2951 vid.pause();
2952 back.remove();
2953 document.removeEventListener("keydown", onKey);
2954 };
2955 const onKey = (e) => { if (e.key === "Escape") close(); };
2956 back.addEventListener("click", (e) => { if (e.target !== vid) close(); });
2957 vid.onerror = () => { close(); if (onDownloadFallback) onDownloadFallback(); };
2958 document.addEventListener("keydown", onKey);
2959 document.body.appendChild(back);
2960 vid.play().catch(() => {});
2961}
2962
2963// The attachment blob ids a message references (clip + poster + images/files),
2964// reported to the host on delete so the server can GC them.
2965function attachmentBlobIds(m) {
2966 const ids = [];
2967 for (const a of (m && m.attachments) || []) {
2968 if (a && a.id) ids.push(a.id);
2969 if (a && a.poster) ids.push(a.poster);
2970 }
2971 return ids;
2972}
2973
2974// An animated GIF plays in the feed, and a click pauses/resumes it in place —
2975// the click is the toggle, not a lightbox opener (the message menu still holds
2976// the other actions). Pausing swaps the img's src for a canvas-rendered still
2977// of the GIF's first frame (the "default image" drawImage yields for animated
2978// sources — no re-decode of the whole animation); resuming restores the blob
2979// URL. prefers-reduced-motion starts the GIF frozen.
2980function wireGifToggle(img, url) {
2981 let still = null; // the frozen frame's data URL, rendered once on first pause
2982 const freeze = () => {
2983 if (!still) {
2984 if (!img.naturalWidth) return; // not decoded yet — nothing to freeze
2985 const c = document.createElement("canvas");
2986 c.width = img.naturalWidth;
2987 c.height = img.naturalHeight;
2988 c.getContext("2d").drawImage(img, 0, 0);
2989 still = c.toDataURL();
2990 }
2991 img.src = still;
2992 img.classList.add("gif-paused");
2993 };
2994 img.onclick = () => {
2995 if (img.classList.contains("gif-paused")) {
2996 img.src = url;
2997 img.classList.remove("gif-paused");
2998 } else {
2999 freeze();
3000 }
3001 };
3002 if (matchMedia("(prefers-reduced-motion: reduce)").matches) {
3003 if (img.complete && img.naturalWidth) freeze();
3004 else img.addEventListener("load", freeze, { once: true });
3005 }
3006}
3007
3008
3009// The original click-to-play YouTube card (.yt-card): thumbnail from the video
3010// id, swapped for the privacy-friendly nocookie embed on tap.
3011function buildYouTubeCard(yt) {
3012 const card = document.createElement("div");
3013 card.className = "yt-card";
3014 const btn = document.createElement("button");
3015 btn.type = "button";
3016 btn.className = "yt-poster";
3017 btn.setAttribute("aria-label", "Play video");
3018 const img = document.createElement("img");
3019 img.className = "yt-thumb";
3020 img.alt = "";
3021 img.loading = "lazy";
3022 img.src = `https://i.ytimg.com/vi/${yt.id}/hqdefault.jpg`;
3023 img.onload = () => {
3024 if (img.naturalWidth <= 120) card.classList.add("yt-bare"); // dead/private video placeholder
3025 };
3026 img.onerror = () => card.classList.add("yt-bare");
3027 const play = document.createElement("span");
3028 play.className = "yt-play";
3029 play.innerHTML = icon("play"); // static in-code SVG from the host (safe)
3030 btn.append(img, play);
3031 btn.onclick = () => {
3032 const frame = document.createElement("iframe");
3033 frame.className = "yt-frame";
3034 frame.src = `https://www.youtube-nocookie.com/embed/${yt.id}?autoplay=1&rel=0` + (yt.start ? `&start=${yt.start}` : "");
3035 frame.allow = "autoplay; encrypted-media; fullscreen; picture-in-picture";
3036 frame.allowFullscreen = true;
3037 // The app-wide Referrer-Policy is no-referrer, but YouTube rejects
3038 // referrer-less embeds (error 153, "Video player configuration error").
3039 // Send just the origin for this iframe -- never the page path.
3040 frame.referrerPolicy = "origin";
3041 frame.title = "YouTube video";
3042 btn.replaceWith(frame);
3043 };
3044 card.appendChild(btn);
3045 return card;
3046}
3047
3048// The OpenGraph card (.link-preview), fetched through the host. A successful
3049// preview is cached per URL so a bubble re-render never refetches; a failed or
3050// empty unfurl is evicted below so a later render retries (the original never
3051// cached and refetched every time).
3052function addLinkPreview(content, url) {
3053 if (!host || !host.unfurl) return;
3054 if (!unfurls.has(url)) unfurls.set(url, host.unfurl(url).catch(() => null));
3055 unfurls.get(url).then((pv) => {
3056 // A rejection resolves to null here, and the host itself may return null or
3057 // {ok:false} for a transient failure. Don't cache that miss — drop it so a
3058 // later render (e.g. reopening the thread) refetches and may show the card.
3059 if (!pv || !pv.ok || (!pv.title && !pv.image)) {
3060 unfurls.delete(url);
3061 return;
3062 }
3063 // Usable preview but the bubble was torn down before it arrived: keep the
3064 // cache entry so the next render of this URL reuses it.
3065 if (!content.isConnected) return;
3066 const card = document.createElement("a");
3067 card.className = "link-preview";
3068 card.href = url;
3069 card.target = "_blank";
3070 card.rel = "noopener noreferrer";
3071 if (pv.image) {
3072 const img = document.createElement("img");
3073 img.className = "lp-image";
3074 img.alt = "";
3075 img.src = pv.image;
3076 img.onerror = () => img.remove();
3077 card.appendChild(img);
3078 }
3079 const body = document.createElement("div");
3080 body.className = "lp-body";
3081 if (pv.title) {
3082 const t = document.createElement("div");
3083 t.className = "lp-title";
3084 t.textContent = pv.title;
3085 body.appendChild(t);
3086 }
3087 if (pv.description) {
3088 const d = document.createElement("div");
3089 d.className = "lp-desc";
3090 d.textContent = pv.description;
3091 body.appendChild(d);
3092 }
3093 try {
3094 const h = document.createElement("div");
3095 h.className = "lp-host";
3096 // .host (not .hostname) so an explicit non-default port shows, matching
3097 // the original feed's link-preview label.
3098 h.textContent = new URL(url).host;
3099 body.appendChild(h);
3100 } catch {}
3101 card.appendChild(body);
3102 content.appendChild(card);
3103 });
3104}
3105
3106// ---- composer block: banners, staging, typeahead, input, send ---------------
3107
3108// buildComposerBlock builds one complete composer — the reply/edit banners,
3109// the floating preview/typeahead anchor, attach + upload staging, the
3110// plain/rich input facade, and send — as an instantiable unit. It exists so
3111// the docked reply-thread pane (next PR) can mount a SECOND composer beside
3112// the main one; today buildSkeleton is its only caller, and the extraction
3113// changes no behaviour. The block owns its own reply/edit/staging state;
3114// everything per-PANE — the command panel, the thread view, the feed and its
3115// follow behaviour, the composers registry, hotkeys, and the native bridge —
3116// stays with the caller, wired through the cfg hooks and the returned api.
3117//
3118// cfg:
3119// fixedReply null = the main composer: replies stage freely and banners
3120// show. Or () => {id,name,snippet}: the block is pinned to a
3121// thread root — the target derives fresh per send, no reply
3122// banner or cancel ever shows, and the block stands down from
3123// the primary-surface machinery (labelled asks, composer
3124// hints, deliverAnswer), which targets the main composer only.
3125// inEmbed the native shell draws the visible composer (host.native
3126// mirrors banners/staging; this block is hidden by CSS).
3127// commands false = no command routing at all (slash, /me, @app
3128// mentions, default commands) — text sends verbatim.
3129// onSent(payload) a send reached the server (the caller's scroll pin +
3130// thread re-stage — see compose.onsubmit).
3131// onEscape(hasEdit) pane-owned Escape layers; return true when consumed.
3132// onReplyCancel() the reply banner's ✕; return true when the pane
3133// consumed it (closing an open thread view) — otherwise
3134// the block just drops its staged reply.
3135// onEditCancelled() an edit ended — the pane may re-stage a thread reply.
3136// onFieldGrow() the input grew this keystroke (caller re-pins its feed).
3137// onStripChange() the staging strip appeared/changed (caller keeps the
3138// feed's newest content in view).
3139//
3140// Returns { el, float, composer, api }. `el` is a fragment of the block's
3141// four top-level nodes — kept as SIBLINGS under the caller's root, never
3142// wrapped, because the .embed/.readonly rules hide composer surfaces with
3143// `.chatapp-root > .composer` child combinators. `float` is the zero-height
3144// anchor command panels insert before; `composer` is the input facade; `api`
3145// carries the verbs the caller wires outward (the composers registry, the
3146// thread view, hotkeys, the native bridge).
3147function buildComposerBlock(ctx, host, cfg) {
3148 const inEmbed = !!cfg.inEmbed;
3149 // The primary (main) composer is the one surface the labelled-ask/hint
3150 // machinery and scripted answers (deliverAnswer) target; a root-pinned
3151 // block never is.
3152 const primary = !cfg.fixedReply;
3153
3154 const float = document.createElement("div");
3155 float.className = "composer-float";
3156 const preview = document.createElement("div");
3157 preview.className = "composer-preview hidden";
3158 float.appendChild(preview);
3159
3160 // The typeahead is a drop-UP that OVERLAYS the feed (not a flex band that shoves
3161 // the messages up as you type), so it lives inside .composer-float — the
3162 // zero-height anchor at the top of the composer stack, exactly like the
3163 // attachment preview above. Positioned absolutely in CSS.
3164 const taBox = document.createElement("div");
3165 taBox.className = "typeahead hidden";
3166 taBox.setAttribute("role", "listbox");
3167 float.appendChild(taBox);
3168
3169 const replyBanner = document.createElement("div");
3170 replyBanner.className = "composer-reply hidden";
3171 const editBanner = document.createElement("div");
3172 editBanner.className = "composer-edit hidden";
3173
3174 const compose = document.createElement("form");
3175 compose.className = "composer";
3176 compose.autocomplete = "off";
3177 const attach = document.createElement("button");
3178 attach.type = "button";
3179 attach.className = "attach-btn";
3180 attach.setAttribute("aria-label", "Add a photo or file");
3181 attach.textContent = "+";
3182 const fileInput = document.createElement("input");
3183 fileInput.type = "file";
3184 fileInput.multiple = true;
3185 fileInput.accept = "image/*,video/mp4,video/quicktime,video/webm,.pdf,.txt,.md,.markdown,.csv,.json,.doc,.docx,.xls,.xlsx,.ppt,.pptx";
3186 fileInput.hidden = true;
3187 const field = document.createElement("div");
3188 field.className = "composer-field";
3189 // The inset field label ("Your name:") — sits inside the input's box, pinned
3190 // to the first line, with the text indented past it. Decorative to a screen
3191 // reader (the textarea's aria-label carries the same words).
3192 const fieldLabel = document.createElement("span");
3193 fieldLabel.className = "composer-label";
3194 fieldLabel.setAttribute("aria-hidden", "true");
3195 fieldLabel.hidden = true;
3196 // The labelled ask's dismiss X (#1375): back to a regular input without
3197 // answering. It borrows the send button's slot while that's free (typing
3198 // swaps it for Send; Escape still dismisses). Dismissing disarms the
3199 // pending answer too, so a later plain message is an ordinary message, not
3200 // a swallowed reply to a question the user waved off — the parked step
3201 // re-arms on the next resume, exactly like a reload.
3202 const labelDismiss = document.createElement("button");
3203 labelDismiss.type = "button";
3204 labelDismiss.className = "composer-label-x";
3205 labelDismiss.setAttribute("aria-label", "Dismiss question");
3206 labelDismiss.hidden = true;
3207 labelDismiss.innerHTML =
3208 '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><line x1="6" y1="6" x2="18" y2="18"></line><line x1="18" y1="6" x2="6" y2="18"></line></svg>';
3209 const dismissComposerAsk = () => {
3210 cancelAnswer(ctx.threadId); // the hook clears the label + hint with it
3211 composer.focus();
3212 };
3213 labelDismiss.addEventListener("click", dismissComposerAsk);
3214 const refreshLabelDismiss = () => {
3215 labelDismiss.hidden = !primary || !(composerLabels.get(ctx.threadId) || "") || !send.hidden;
3216 };
3217 // When plain text runs the app's default command (typing into To-do adds a
3218 // to-do — see defaultCommandFor), the placeholder must SAY so: the implicit
3219 // routing is only honest if the composer advertises it before the first send.
3220 const defaultCmd = () => defaultCommandFor(ctx.threadId);
3221 const basePlaceholder = () => {
3222 const dc = defaultCmd();
3223 return (dc && dc.app.composerPlaceholder) || "Message";
3224 };
3225 // The message input itself lives behind the composer facade (composer.js) —
3226 // chat.js never touches an element's .value directly, so the rich backend
3227 // can slot in behind the same handle. The handler bodies are function
3228 // declarations further down (hoisted; they run long after the skeleton is
3229 // fully built, so the consts they close over are all live by then).
3230 const composerOpts = {
3231 placeholder: primary ? composerHints.get(ctx.threadId) || basePlaceholder() : "Reply…",
3232 formatAsYouType: formatAsYouTypePref(),
3233 onInput: () => onComposerInput(),
3234 onKeydown: (e) => {
3235 // Escape dismisses an active labelled ask (#1375) — the keyboard twin
3236 // of the X. Only while the pill shows; otherwise Escape keeps its
3237 // usual meanings (close typeahead, cancel edit…) downstream.
3238 if (e.key === "Escape" && primary && (composerLabels.get(ctx.threadId) || "")) {
3239 e.preventDefault();
3240 dismissComposerAsk();
3241 return;
3242 }
3243 onComposerKeydown(e);
3244 },
3245 // Caret moves (click / arrows) can enter or leave a token too — re-check.
3246 onKeyup: (e) => {
3247 if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) updateTypeahead();
3248 },
3249 onClick: () => updateTypeahead(),
3250 // The original keyboard-inset rule (.composer.editing on coarse pointers)
3251 // keys off focus — mirror it so the composer sits on the keyboard.
3252 onFocus: () => compose.classList.add("editing"),
3253 onBlur: () => {
3254 compose.classList.remove("editing");
3255 setTimeout(closeTypeahead, 100);
3256 },
3257 onPaste: (e) => onComposerPaste(e),
3258 // Rich-backend file routes (ProseMirror owns paste/drop on its element).
3259 onPasteFiles: (files) => stageFiles(files),
3260 onDropFiles: (files) => stageFiles(files),
3261 onOversizePaste: () => stageError("That's too long for a message — attach it as a file instead."),
3262 };
3263 const composer =
3264 pmModule && !inEmbed && !appChatReadOnly(ctx.threadId) && !classicComposerPref()
3265 ? adoptComposer(pmModule.createRichComposer(composerOpts))
3266 : createComposer(composerOpts);
3267
3268 const applyFieldLabel = () => {
3269 const label = composerLabels.get(ctx.threadId) || "";
3270 fieldLabel.textContent = label;
3271 fieldLabel.hidden = !label;
3272 composer.setAriaLabel(label ? label.replace(/[::]\s*$/, "") : "Message");
3273 refreshLabelDismiss();
3274 if (!label) {
3275 composer.setIndent(0);
3276 return;
3277 }
3278 // Indent the text past the pill — measured after layout so the width is real.
3279 requestAnimationFrame(() => {
3280 if (!fieldLabel.hidden) composer.setIndent(fieldLabel.offsetWidth + 12 + 8);
3281 });
3282 };
3283 // The labelled-ask / composer-hint machinery targets the PRIMARY surface
3284 // only — a second (root-pinned) block never registers, so a scripted
3285 // question can't relabel a thread pane's box.
3286 if (primary) {
3287 refreshComposerHint.set(ctx.threadId, () => {
3288 const attach = pending.length ? composerAttachHints.get(ctx.threadId) : "";
3289 composer.setPlaceholder(attach || composerHints.get(ctx.threadId) || basePlaceholder());
3290 applyFieldLabel();
3291 });
3292 }
3293
3294 const send = document.createElement("button");
3295 send.type = "submit";
3296 send.className = "send-btn";
3297 send.setAttribute("aria-label", "Send");
3298 send.hidden = true;
3299 send.innerHTML =
3300 '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="19" x2="12" y2="5"></line><polyline points="6 11 12 5 18 11"></polyline></svg>';
3301 field.append(fieldLabel, composer.el, labelDismiss, send);
3302 compose.append(attach, fileInput, field);
3303 // A default-command thread takes text only — with no one to read a file, an
3304 // attachment is a dead end, so the affordance goes (stageFiles gates paste and
3305 // drop the same way).
3306 attach.hidden = !!defaultCmd();
3307
3308 // ---- composer state (per block; a fresh open rebuilds it clean) ----
3309 let replyTo = null; // { id, name, snippet } — the original payload shape
3310 let editing = null; // the item being edited
3311 let editBaseline = ""; // getMarkdown() right after edit-load (the ↓-undo comparator)
3312 let pending = []; // staged attachments: { att | null (uploading), name, isImage }
3313 let sendQueued = false; // Enter landed mid-upload — auto-send once the uploads finish
3314 let staging = 0; // stageFiles batches in flight — a queued send waits for the whole batch
3315 // A remount mid-conversation must restore the inset label. The caller
3316 // appends the block in the same tick, and the one part that needs layout
3317 // (the width measure in applyFieldLabel) is rAF-deferred past that insert.
3318 if (primary) applyComposerHint(ctx.threadId);
3319
3320 const refreshBanners = () => {
3321 // A root-pinned block derives its reply per send — no reply banner. The
3322 // EDIT banner still renders: entering edit mode without saying so would
3323 // let Enter silently overwrite an earlier message.
3324 replyBanner.textContent = "";
3325 replyBanner.classList.add("hidden");
3326 editBanner.textContent = "";
3327 editBanner.classList.add("hidden");
3328 if (editing) {
3329 editBanner.append("Editing message");
3330 const x = document.createElement("button");
3331 x.className = "banner-x";
3332 x.type = "button";
3333 x.textContent = "×";
3334 x.onclick = cancelEdit;
3335 editBanner.appendChild(x);
3336 editBanner.classList.remove("hidden");
3337 } else if (replyTo && !cfg.fixedReply) {
3338 const bar = document.createElement("div");
3339 bar.className = "reply-bar";
3340 const ic = document.createElement("span");
3341 ic.className = "reply-bar-icon";
3342 ic.innerHTML = icon("reply");
3343 const body = document.createElement("div");
3344 body.className = "reply-bar-body";
3345 const who = document.createElement("div");
3346 who.className = "reply-bar-name";
3347 who.textContent = "Replying to " + (replyTo.name || "");
3348 const txt = document.createElement("div");
3349 txt.className = "reply-bar-text";
3350 txt.textContent = replyTo.snippet || "";
3351 body.append(who, txt);
3352 const x = document.createElement("button");
3353 x.type = "button";
3354 x.className = "reply-bar-cancel";
3355 x.innerHTML = icon("x");
3356 x.setAttribute("aria-label", "Cancel reply");
3357 // The pane may own this gesture (an open thread view treats its staged
3358 // reply as the view's own state — see buildSkeleton's onReplyCancel);
3359 // otherwise it just drops the staged reply.
3360 x.onclick = () => {
3361 if (!(cfg.onReplyCancel && cfg.onReplyCancel())) cancelReply();
3362 };
3363 bar.append(ic, body, x);
3364 replyBanner.appendChild(bar);
3365 replyBanner.classList.remove("hidden");
3366 }
3367 };
3368
3369 const cancelReply = () => {
3370 if (cfg.fixedReply) return; // nothing staged to cancel — the reply derives per send
3371 replyTo = null;
3372 refreshBanners();
3373 if (inEmbed) host.native({ replying: null });
3374 };
3375 const cancelEdit = () => {
3376 editing = null;
3377 refreshBanners();
3378 if (inEmbed) host.native({ editing: null });
3379 // The pane may need to re-arm state the edit displaced (an open thread
3380 // view's staged reply — see buildSkeleton's onEditCancelled).
3381 if (cfg.onEditCancelled) cfg.onEditCancelled();
3382 };
3383
3384 // Staged attachments as original .preview-chip tiles: a spinner while
3385 // uploading, then a thumbnail (image) or a file chip, each with its remove.
3386 const refreshStaged = () => {
3387 if (inEmbed) host.native({ staged: pending.length > 0 }); // enables the native send button
3388 if (primary) applyComposerHint(ctx.threadId); // an attach-gated hint appears/disappears with the staged files
3389 // The strip takes real layout space — the caller keeps its feed pinned
3390 // (see buildSkeleton's onStripChange).
3391 if (cfg.onStripChange) cfg.onStripChange();
3392 preview.textContent = "";
3393 preview.classList.toggle("hidden", pending.length === 0);
3394 for (const p of pending) {
3395 const chip = document.createElement("div");
3396 chip.className = "preview-chip";
3397 if (!p.att) {
3398 const label = document.createElement("span");
3399 label.className = "preview-name";
3400 if (p.isVideo && typeof p.progress === "number") {
3401 // Determinate upload progress for the (large) clip: percentage + a bar,
3402 // updated in place by the onProgress callback (see stageFiles).
3403 chip.classList.add("preview-uploading");
3404 label.textContent = "Uploading " + Math.round(p.progress * 100) + "%";
3405 const bar = document.createElement("div");
3406 bar.className = "preview-progress";
3407 const fill = document.createElement("div");
3408 fill.className = "preview-progress-fill";
3409 fill.style.width = Math.round(p.progress * 100) + "%";
3410 bar.appendChild(fill);
3411 chip.append(label, bar);
3412 p._fill = fill;
3413 p._label = label;
3414 } else {
3415 const spin = document.createElement("span");
3416 spin.className = "preview-spinner";
3417 spin.setAttribute("aria-hidden", "true");
3418 label.textContent = p.isImage ? "Adding photo…" : p.isVideo ? "Adding video…" : "Adding file…";
3419 chip.append(spin, label);
3420 }
3421 } else if (p.isImage) {
3422 const img = document.createElement("img");
3423 img.alt = p.att.name || "photo";
3424 if (host) host.loadBlob(ctx.threadId, p.att).then((url) => url && (img.src = url));
3425 chip.appendChild(img);
3426 } else if (p.isVideo) {
3427 // Show the poster thumbnail with a small play glyph, so a staged clip
3428 // reads as a video rather than a generic file.
3429 chip.classList.add("preview-video");
3430 const img = document.createElement("img");
3431 img.alt = p.att.name || "video";
3432 if (host && p.att.poster) {
3433 host.loadBlob(ctx.threadId, { id: p.att.poster, type: "image/jpeg" }).then((url) => url && (img.src = url));
3434 }
3435 const play = document.createElement("span");
3436 play.className = "preview-play";
3437 play.innerHTML = icon("play");
3438 chip.append(img, play);
3439 } else {
3440 const ic = document.createElement("span");
3441 ic.className = "preview-file";
3442 ic.textContent = fileIcon(p.att.name);
3443 const nm = document.createElement("span");
3444 nm.className = "preview-name";
3445 nm.textContent = p.att.name || "file";
3446 chip.append(ic, nm);
3447 }
3448 const x = document.createElement("button");
3449 x.className = "preview-remove";
3450 x.type = "button";
3451 x.setAttribute("aria-label", "Remove attachment");
3452 x.innerHTML = icon("x");
3453 x.onclick = () => {
3454 pending = pending.filter((q) => q !== p);
3455 refreshStaged();
3456 refreshSend();
3457 // Removing a chip can resolve a queued send either way: nothing left to
3458 // send cancels it; removing the last still-uploading chip releases it.
3459 if (sendQueued && composer.isEmpty() && !pending.length) setSendQueued(false);
3460 else fireQueuedSend();
3461 };
3462 chip.appendChild(x);
3463 preview.appendChild(chip);
3464 }
3465 };
3466 // The send button shows with text or staged files, and disables while an
3467 // upload is in flight — the original rules exactly. It also greys while the
3468 // socket is down (a send would be dropped), mirroring the original composer.
3469 const refreshSend = () => {
3470 send.hidden = composer.isEmpty() && pending.length === 0;
3471 send.disabled = pending.some((p) => !p.att) || !connected;
3472 refreshLabelDismiss(); // the X borrows Send's slot — they never overlap
3473 };
3474 // Enter during an upload queues the send instead of dropping it: the button
3475 // swaps its arrow for a spinner (.send-waiting) and the message goes out the
3476 // moment the last upload lands. Typing again, a failed upload, or emptying
3477 // the composer cancels the queue — the auto-send must never fire on content
3478 // that no longer matches what was queued.
3479 const setSendQueued = (on) => {
3480 sendQueued = on;
3481 send.classList.toggle("send-waiting", on);
3482 refreshSend();
3483 };
3484 const fireQueuedSend = () => {
3485 if (!sendQueued || staging > 0 || pending.some((p) => !p.att)) return;
3486 setSendQueued(false);
3487 compose.requestSubmit();
3488 };
3489
3490 // Matches maxUploadBytes (main.go) / the original composer's MAX_UPLOAD — used
3491 // only to word the "too big" message; the host bridge does the real limiting.
3492 const MAX_UPLOAD = 25 * 1024 * 1024;
3493 const MAX_VIDEO_UPLOAD = 50 * 1024 * 1024; // clips get a larger cap (see shell.js)
3494 // alert() would freeze an embedded webview (modal blocks the native message
3495 // channel) — route the error to the shell instead.
3496 const stageError = (msg) => {
3497 if (inEmbed) host.native({ error: msg });
3498 else alert(msg);
3499 };
3500 // One staging path — attach button, paste, and drag-drop all land here. The
3501 // wrapper counts in-flight batches so a queued send only fires once the whole
3502 // drop/paste has staged — mid-batch, later files aren't in `pending` yet, and
3503 // firing early would send the message without them.
3504 const stageFiles = async (files) => {
3505 if (!host || !host.upload) return;
3506 if (defaultCmd()) return; // text-only surface — see the hidden attach button
3507 staging++;
3508 try {
3509 await stageFilesBatch(files);
3510 } finally {
3511 staging--;
3512 fireQueuedSend();
3513 }
3514 };
3515 const stageFilesBatch = async (files) => {
3516 for (const file of files) {
3517 const isImage = !!(file.type && file.type.startsWith("image/"));
3518 const isVideo = !!(file.type && file.type.startsWith("video/"));
3519 // Any file type stages — size is the only gate (the bridge enforces it).
3520 const entry = { att: null, isImage, isVideo };
3521 pending.push(entry);
3522 refreshStaged();
3523 refreshSend();
3524 const att = await host.upload(ctx.threadId, file, (p) => {
3525 // First measurable byte → build the bar once (a refresh); after that update
3526 // it in place so we don't rebuild the whole strip on every progress tick.
3527 const first = typeof entry.progress !== "number";
3528 entry.progress = p;
3529 if (first || !entry._fill || !entry._fill.isConnected) refreshStaged();
3530 else {
3531 entry._fill.style.width = Math.round(p * 100) + "%";
3532 if (entry._label) entry._label.textContent = "Uploading " + Math.round(p * 100) + "%";
3533 }
3534 });
3535 if (!pending.includes(entry)) continue; // removed while uploading
3536 if (att && typeof att === "object") entry.att = att;
3537 else {
3538 pending = pending.filter((q) => q !== entry);
3539 // A rejected upload cancels any queued send — the message would go out
3540 // missing an attachment the sender believed was included.
3541 if (sendQueued) setSendQueued(false);
3542 // The bridge returns a reason string (not a bare null) so each rejection
3543 // gets the original composer's distinct wording.
3544 const msg = att === "photo" ? "Couldn't add that photo. Try a smaller one."
3545 : att === "size" ? `Couldn't add that file (max ${formatBytes(MAX_UPLOAD)}).`
3546 : att === "video" ? `Couldn't add that video (max ${formatBytes(MAX_VIDEO_UPLOAD)}).`
3547 : "Couldn't add that file.";
3548 stageError(msg);
3549 }
3550 refreshStaged();
3551 refreshSend();
3552 }
3553 };
3554 attach.onclick = () => fileInput.click();
3555 fileInput.onchange = () => {
3556 const files = [...(fileInput.files || [])];
3557 fileInput.value = "";
3558 stageFiles(files);
3559 };
3560
3561 function onComposerPaste(e) {
3562 const files = [...((e.clipboardData && e.clipboardData.files) || [])];
3563 if (files.length) {
3564 e.preventDefault();
3565 stageFiles(files);
3566 }
3567 // Blockquote continuation: pasting multi-line text onto a bare "> " line
3568 // prefixes every line after the first with "> " so the whole quote stays a
3569 // quote — matches the original composer's paste behaviour.
3570 if (!files.length && composer.kind === "plain") {
3571 const textItem = [...(e.clipboardData?.items || [])].find((i) => i.type === "text/plain");
3572 if (textItem) {
3573 const start = composer.getCaret();
3574 const beforeCursor = composer.getText().substring(0, start);
3575 const currentLine = beforeCursor.split("\n").pop();
3576 if (/^>\s*$/.test(currentLine)) {
3577 e.preventDefault();
3578 textItem.getAsString((pastedText) => {
3579 const blockquoted = pastedText.split("\n").map((line, i) => i === 0 ? line : "> " + line).join("\n");
3580 composer.replaceRange(start, composer.getSelectionEnd(), blockquoted);
3581 });
3582 }
3583 }
3584 }
3585 }
3586
3587 // ---- :emoji / @mention typeahead — the original menu, verbatim ----
3588 // composerToken (pure) detects the in-progress token at the caret; the host
3589 // ranks the candidates (host.typeaheadItems — the same function that feeds
3590 // the original menu and the native strip, so all three rank identically).
3591 let typeahead = null; // { kind, start, items, index } or null
3592 const closeTypeahead = () => {
3593 typeahead = null;
3594 taBox.classList.add("hidden");
3595 taBox.innerHTML = "";
3596 };
3597 const acceptTypeahead = () => {
3598 if (!typeahead) return;
3599 const it = typeahead.items[typeahead.index];
3600 const caret = composer.getCaret();
3601 const start = typeahead.start;
3602 closeTypeahead();
3603 // replaceRange notifies (resize + send-visibility + token re-check) and
3604 // restores focus — the original splice, behind the facade.
3605 composer.replaceRange(start, caret, it.insert);
3606 };
3607 const renderTa = () => {
3608 if (!typeahead) return;
3609 taBox.innerHTML = "";
3610 typeahead.items.forEach((it, i) => {
3611 const row = document.createElement("button");
3612 row.type = "button";
3613 row.className = "ta-item" + (i === typeahead.index ? " active" : "");
3614 row.setAttribute("role", "option");
3615 row.setAttribute("aria-selected", String(i === typeahead.index));
3616 if (typeahead.kind === "command") {
3617 const n = document.createElement("span");
3618 n.className = "ta-name";
3619 n.textContent = it.command + (it.args ? " " + it.args : "");
3620 row.appendChild(n);
3621 if (it.desc) {
3622 const d = document.createElement("span");
3623 d.className = "ta-desc";
3624 d.textContent = it.desc;
3625 row.appendChild(d);
3626 }
3627 } else if (typeahead.kind === "emoji") {
3628 const g = document.createElement("span");
3629 g.className = "ta-emoji";
3630 g.textContent = it.glyph;
3631 const c = document.createElement("span");
3632 c.className = "ta-code";
3633 c.textContent = ":" + it.code + ":";
3634 row.append(g, c);
3635 } else {
3636 // A group-added app in the @ typeahead wears its glyph avatar (there's
3637 // no roster photo to load); people keep the original avatar element.
3638 if (it.glyph) {
3639 const g = document.createElement("span");
3640 g.className = "avatar app-avatar";
3641 g.textContent = it.glyph;
3642 row.appendChild(g);
3643 } else if (host && host.taAvatar) row.appendChild(host.taAvatar(it.name, it.userId));
3644 const n = document.createElement("span");
3645 n.className = "ta-name";
3646 n.textContent = it.name;
3647 row.appendChild(n);
3648 }
3649 // pointerdown fires before the textarea blurs, so the tap isn't lost.
3650 row.addEventListener("pointerdown", (e) => {
3651 e.preventDefault();
3652 typeahead.index = i;
3653 acceptTypeahead();
3654 });
3655 taBox.appendChild(row);
3656 });
3657 taBox.classList.remove("hidden");
3658 };
3659 const moveTypeahead = (dir) => {
3660 if (!typeahead) return;
3661 const n = typeahead.items.length;
3662 typeahead.index = (typeahead.index + dir + n) % n;
3663 renderTa();
3664 taBox.children[typeahead.index]?.scrollIntoView({ block: "nearest" });
3665 };
3666 const updateTypeahead = () => {
3667 const tok = composerToken(composer.getText(), composer.getCaret());
3668 if (!tok || !host || !host.typeaheadItems) return closeTypeahead();
3669 // No command routing (cfg.commands off) means no command typeahead either:
3670 // a leading "/" in this block is ordinary text that sends verbatim (the
3671 // thread pane's composer), so offering completions would be a lie.
3672 if (tok.kind === "command" && !cfg.commands) return closeTypeahead();
3673 const items = host.typeaheadItems(tok);
3674 if (!items.length) return closeTypeahead();
3675 typeahead = { kind: tok.kind, start: tok.start, items, index: 0 };
3676 renderTa();
3677 };
3678
3679 // Auto-grow + send-button reveal + typing beats. Emitting only on keystrokes
3680 // let the receiver's indicator lapse during a mid-message pause, so we mirror
3681 // the original composer: `lastBeat` tracks the last keystroke (while there's
3682 // text), and tickTyping — driven by a render-scoped interval (see render) —
3683 // re-emits a beat every TYPING_INTERVAL_MS through a pause and issues the idle
3684 // stop once the keystrokes dry up. Each beat carries TYPING_EXPIRY_S, so a
3685 // single one outlives the interval. An explicit expiry-0 beat clears the
3686 // indicator now on empty / send / idle.
3687 let lastBeat = 0; // ms timestamp of the last keystroke while text was present (0 = not typing)
3688 let lastEmit = 0; // ms timestamp of the last beat we actually sent
3689 let beating = false; // a beat receivers are currently showing
3690 const sendBeat = (expiry) => {
3691 lastEmit = Date.now();
3692 ctx.emitEphemeral({ type: N.ev("typing"), expiry });
3693 };
3694 const beatTyping = () => {
3695 lastBeat = Date.now();
3696 // First keystroke of a burst beats now (once per burst, not per keystroke —
3697 // the old 4s throttle's intent); the ticker sustains it through the pause.
3698 if (!beating) {
3699 beating = true;
3700 sendBeat(TYPING_EXPIRY_S);
3701 }
3702 };
3703 const stopTypingBeat = () => {
3704 lastBeat = 0;
3705 if (!beating) return; // nothing in flight — don't emit a spurious expiry-0
3706 beating = false;
3707 sendBeat(0);
3708 };
3709 // Keyed off absolute timestamps (not fire-count), so render() re-registering
3710 // this interval every draw can't break it — it just has to fire once past the
3711 // threshold to re-beat, or to stop once the keystrokes have gone idle.
3712 const tickTyping = () => {
3713 if (!beating) return;
3714 const now = Date.now();
3715 if (now - lastBeat >= TYPING_INTERVAL_MS) stopTypingBeat(); // idle since the last keystroke
3716 else if (now - lastEmit >= TYPING_INTERVAL_MS) sendBeat(TYPING_EXPIRY_S); // sustain the indicator
3717 };
3718
3719 function onComposerInput() {
3720 // (Auto-grow already ran inside the facade.) The composer just grew,
3721 // shrinking the caller's flex feed — let it re-pin synchronously in this
3722 // same layout (see buildSkeleton's onFieldGrow for the WebKit why).
3723 if (cfg.onFieldGrow) cfg.onFieldGrow();
3724 if (sendQueued) setSendQueued(false); // still composing — don't auto-send under them
3725 refreshSend();
3726 updateTypeahead();
3727 // A "/command" in progress is private — never announce typing for it (and
3728 // clear any beat already sent if the text just became a command).
3729 const v = composer.getText();
3730 if (v && v[0] !== "/") beatTyping();
3731 else if (lastBeat) stopTypingBeat();
3732 }
3733 function onComposerKeydown(e) {
3734 // The typeahead grabs the nav keys first when it's open (original order).
3735 if (typeahead) {
3736 if (e.key === "ArrowDown") return e.preventDefault(), moveTypeahead(1);
3737 if (e.key === "ArrowUp") return e.preventDefault(), moveTypeahead(-1);
3738 if (e.key === "Enter" || e.key === "Tab") return e.preventDefault(), acceptTypeahead();
3739 if (e.key === "Escape") return e.preventDefault(), closeTypeahead();
3740 }
3741 // Desktop Enter sends (Shift+Enter newlines); touch Enter newlines. A
3742 // code fence is the one context that explicitly means "multi-line
3743 // content", so Enter newlines there too. The isComposing guard stops a
3744 // Japanese/Korean composition-confirm from sending early (a pre-existing
3745 // textarea quirk, fixed while we're here).
3746 if (shouldSend(e, coarse) && !composer.inCodeBlock() && !e.isComposing) {
3747 e.preventDefault();
3748 compose.requestSubmit();
3749 return;
3750 }
3751 if (e.key === "Escape" && sendQueued) {
3752 setSendQueued(false); // un-queue first; a second Escape cancels reply/edit
3753 e.preventDefault(); // handled — the document dispatcher must not also peel a layer
3754 return;
3755 }
3756 // Pane-owned layers (the command panel, the thread view) peel before the
3757 // block's own reply/edit below — the caller supplies the order and the
3758 // edit-in-flight exception (see buildSkeleton's onEscape). A consumed
3759 // Escape is marked handled: with two composer blocks live (the docked
3760 // thread pane), the document dispatcher can't tell "this block dealt with
3761 // it" from focus alone, and one Escape must peel exactly one layer.
3762 if (e.key === "Escape" && cfg.onEscape && cfg.onEscape(!!editing)) {
3763 e.preventDefault();
3764 return;
3765 }
3766 if (e.key === "Escape" && (editing || replyTo)) {
3767 e.preventDefault(); // same one-layer rule as above
3768 const wasEditing = !!editing;
3769 cancelReply();
3770 cancelEdit();
3771 if (wasEditing) {
3772 composer.setText("");
3773 refreshSend();
3774 }
3775 }
3776 // ↑ on an empty composer edits your latest still-editable message.
3777 if (e.key === "ArrowUp" && !composer.getText() && !editing && !cfg.fixedReply) {
3778 const state = ctx.getState();
3779 const msgs = (state && state.msgs) || [];
3780 const bySid = bySidIndex(msgs);
3781 const mine = msgs
3782 .filter((m) => inMainFeed(m, bySid) && ownMessage({ uid: m.uid, name: m.name }, ctx.me, ctx.meName) && m.text.trim() && Date.now() - m.ts < EDIT_WINDOW_MS) // main feed only — never an invisible thread reply
3783 .pop();
3784 if (mine) {
3785 e.preventDefault();
3786 // This block's own verb — the registry entry is the same function for
3787 // the main composer, and a second block must edit in ITS box, not the
3788 // registered one's.
3789 api.startEdit(mine);
3790 }
3791 }
3792 // ↓ undoes an accidental ↑-to-edit: only while unchanged and with no line
3793 // below the cursor to navigate to (the original rule).
3794 if (e.key === "ArrowDown" && editing) {
3795 const afterCursor = composer.getText().slice(composer.getSelectionEnd());
3796 if (composer.getMarkdown() === editBaseline && !afterCursor.includes("\n")) {
3797 e.preventDefault();
3798 composer.setText("");
3799 cancelEdit();
3800 refreshSend();
3801 }
3802 }
3803 }
3804
3805 compose.onsubmit = (ev) => {
3806 ev.preventDefault();
3807 if (pending.some((p) => !p.att)) {
3808 setSendQueued(true); // still uploading — send as soon as it lands
3809 return;
3810 }
3811 // Two readings of the composer: commands parse the PLAIN text (a command
3812 // is not prose), the wire carries the serialized markdown. On the plain
3813 // backend the two are identical bytes, exactly as before.
3814 const plain = emojify(composer.getText().trim());
3815 const text = emojify(composer.getMarkdown().trim()); // :tada: → 🎉, like the original composer
3816 const resetInput = () => {
3817 composer.clear();
3818 refreshSend();
3819 stopTypingBeat(); // command / edit / send all clear the field — end the beat now
3820 if (primary) clearComposerHint(ctx.threadId); // the question was answered — back to "Message" now, not at the reply
3821 };
3822 // Command routing below is gated on cfg.commands — a block with commands
3823 // off sends every line verbatim, "/" text included (the pane composer).
3824 // /me is the one command that BROADCASTS a real message — an action-style
3825 // line ("Alice waves"), not a bubble — instead of answering privately, so
3826 // it's handled here rather than through runCommand (see parseMeCommand).
3827 if (cfg.commands && !editing) {
3828 const action = parseMeCommand(plain);
3829 if (action !== null) {
3830 if (!action) return resetInput(); // bare "/me" — nothing to say
3831 ctx.emit({ text: `${ctx.meName || "Someone"} ${action}`, action: true }).then((sent) => {
3832 if (!sent) return;
3833 pinNext.add(ctx.threadId);
3834 resetInput();
3835 });
3836 return;
3837 }
3838 }
3839 // A slash command (mini-app command, /help, or an unknown "/foo") is handled
3840 // instead of sending — it answers in place with a private "only visible to you"
3841 // reply that stacks in the feed and persists (addLocalNotice). Skipped while
3842 // editing, like the original composer.
3843 if (cfg.commands && !editing && runCommand(plain, ctx.threadId, (r) => addLocalNotice(ctx, r))) {
3844 resetInput();
3845 return;
3846 }
3847 // "@To-do buy milk" in a chat with apps added: the mention addresses the
3848 // app, the text runs its default command — an EVENT into this thread (shown
3849 // as an action line), not a message. Leading-@ only; a mention mid-text is
3850 // ordinary conversation. See runAppMention.
3851 if (cfg.commands && !editing && !replyTo && !pending.length && plain && plain[0] === "@" && runAppMention(plain, ctx.threadId, (r) => addLocalNotice(ctx, r))) {
3852 resetInput();
3853 return;
3854 }
3855 // Plain text in a default-command thread runs the app's default command
3856 // instead of sending a message nobody reads — typing "buy milk" into To-do
3857 // just adds it (defaultCommandFor gates this to app-threads with no bot;
3858 // slash text never falls through here, a "/" always means command).
3859 if (cfg.commands && !editing && !replyTo && plain && plain[0] !== "/" && !pending.length) {
3860 const dc = defaultCmd();
3861 if (dc) {
3862 const typed = plain;
3863 runCommand(dc.name + " " + typed, ctx.threadId, (r) => {
3864 addLocalNotice(ctx, r);
3865 // A reply with no `short` is the command PUSHING BACK ("when should
3866 // I remind you?"), not a confirmation — put the typed text back so
3867 // it can be fixed, not retyped (matches the native .rejected path).
3868 if (r && !r.short && !composer.getText()) {
3869 composer.setText(typed);
3870 refreshSend();
3871 }
3872 });
3873 resetInput();
3874 return;
3875 }
3876 }
3877 if (editing) {
3878 // An empty edit just cancels; otherwise only clear once the edit actually
3879 // reaches the server — host.edit resolves false on a down socket, so the
3880 // typed changes survive to be resent after reconnecting.
3881 if (!text || !host || !host.edit) {
3882 resetInput();
3883 cancelEdit();
3884 return;
3885 }
3886 Promise.resolve(host.edit(ctx.threadId, editing.sid, { ...editing.raw, text })).then((sent) => {
3887 if (!sent) return;
3888 resetInput();
3889 cancelEdit();
3890 });
3891 return;
3892 }
3893 if (!text && !pending.length) return;
3894 const payload = { text };
3895 if (pending.length) payload.attachments = pending.map((p) => p.att);
3896 // A root-pinned block derives its reply target fresh at send time (the
3897 // root may have loaded since); the main composer sends what's staged.
3898 if (cfg.fixedReply) {
3899 const target = cfg.fixedReply();
3900 if (target) payload.replyTo = target;
3901 } else if (replyTo) payload.replyTo = replyTo;
3902 // Only clear the composer once the send actually reaches the server: ctx.emit
3903 // resolves false when the socket is down/reconnecting, so keep the text, staged
3904 // files, and reply target intact to resend — never silently drop what was typed.
3905 ctx.emit(payload).then((sent) => {
3906 if (!sent) return;
3907 resetInput();
3908 pending = [];
3909 // The caller's post-send policy runs here — arming the next render's
3910 // scroll pin, re-staging an open thread view's reply — exactly where
3911 // the staged-reply/thread handoff always sat (before the strip repaint
3912 // and the refocus below).
3913 if (cfg.onSent) cfg.onSent(payload);
3914 refreshStaged();
3915 composer.focus();
3916 // A scripted conversation awaiting an answer in this thread is told what
3917 // was sent — text AND attachments (the setup photo step takes a picture as
3918 // its answer). The message itself already sent normally; it's in the
3919 // transcript either way. Primary-surface machinery, like the asks it
3920 // answers.
3921 if (primary) deliverAnswer(ctx.threadId, payload, (r) => addLocalNotice(ctx, r));
3922 });
3923 };
3924
3925 // The block's outward face. startReply/startEdit/refreshSend/tickTyping are
3926 // the registry verbs (buildSkeleton publishes them in `composers`); the
3927 // rest are the narrow seams the pane chrome — thread view, hotkeys, native
3928 // bridge, command panel, drop targets — needs to drive this composer
3929 // without owning its state.
3930 const api = {
3931 refreshSend,
3932 startReply(m) {
3933 editing = null;
3934 // Plain Reply quotes the EXACT message tapped — never a root walk.
3935 // Replying to a reply stacks another quote in the feed; joining a thread
3936 // is the other verb's job ("Reply in thread"), and only that verb's.
3937 replyTo = quoteTarget(m);
3938 refreshBanners();
3939 // Embedded: the native shell draws the reply banner and focuses its own
3940 // composer (this one is hidden).
3941 if (inEmbed) host.native({ replying: { name: replyTo.name || "", snippet: replyTo.snippet || "" } });
3942 else composer.focus();
3943 },
3944 startEdit(m) {
3945 replyTo = null;
3946 editing = m;
3947 composer.setMarkdown(m.text, { notify: true }); // resize + reveal send
3948 editBaseline = composer.getMarkdown(); // ↓-undo compares against this, not raw bytes
3949 refreshBanners();
3950 if (inEmbed) host.native({ editing: { text: m.text } });
3951 if (!inEmbed) composer.focus();
3952 },
3953 // stageReply arms a reply target the caller derived itself (the thread
3954 // view's root, quoted-snapshot fallback included) — startReply's
3955 // resolve-and-focus stays for bubble actions; focus is the caller's call.
3956 stageReply(target) {
3957 editing = null;
3958 replyTo = target;
3959 refreshBanners();
3960 if (inEmbed) host.native({ replying: { name: replyTo.name || "", snippet: replyTo.snippet || "" } });
3961 },
3962 cancelReply,
3963 cancelEdit,
3964 stagedReply: () => replyTo,
3965 editing: () => !!editing,
3966 focus: () => composer.focus(),
3967 submit: () => compose.requestSubmit(),
3968 stageFiles,
3969 uploading: () => pending.some((p) => !p.att), // an upload still in flight
3970 staged: () => pending.length > 0,
3971 typing: (hasText) => (hasText ? beatTyping() : stopTypingBeat()),
3972 tickTyping, // render() re-arms the outbound-beat ticker each draw
3973 };
3974 // Four sibling nodes, not a wrapper: the .embed/.readonly rules hide the
3975 // composer surfaces with `.chatapp-root > .composer` child combinators, so
3976 // the block must land as direct children of the caller's root.
3977 const el = document.createDocumentFragment();
3978 el.append(float, replyBanner, editBanner, compose);
3979 return { el, float, composer, api };
3980}
3981
3982// ---- skeleton: feed, panels, thread view, composer (original markup) --------
3983
3984// buildSkeleton constructs the once-per-mount frame — the feed, the command
3985// panel, the reply-thread view, and (via buildComposerBlock above) the main
3986// composer with the original structure and class names: .composer-reply /
3987// .composer-edit banners, the floating .composer-preview staging strip, the
3988// .composer form — so style.css styles them identically to the original ones
3989// (which are hidden in app-mode). Send emits the original untyped payload;
3990// editing re-sends the full original payload with new text through the host
3991// bridge.
3992function buildSkeleton(el, ctx) {
3993 const root = document.createElement("div");
3994 root.className = "chatapp-root";
3995
3996
3997 const feed = document.createElement("div");
3998 feed.className = "feed chatapp-feed"; // .feed for original descendant rules; .chatapp-feed mirrors #feed's layout
3999 // The wrapper bounds the reply-thread overlay to exactly the feed's box (it
4000 // dims the transcript, never the composer). Feed selectors everywhere are
4001 // depth-independent (.chatapp-feed querySelectors, no child combinators), so
4002 // the extra level is invisible to the rest of the shell + native probes.
4003 const feedWrap = document.createElement("div");
4004 feedWrap.className = "chatapp-feedwrap";
4005 // The feed's inline status line, above the transcript and in flow (never a
4006 // floating toast). role="status" so a screen reader hears it politely when
4007 // it changes — a jump that has to page history back announces itself, and
4008 // one that can't reach its target says so instead of doing nothing.
4009 const feedNote = document.createElement("div");
4010 feedNote.className = "chatapp-feed-note";
4011 feedNote.setAttribute("role", "status");
4012 // The note answers something the reader just did, so it clears itself
4013 // rather than going stale across thread switches — the same transient-status
4014 // convention the native busy captions use (FEED_NOTE_MS). A note that's
4015 // still true while work is in flight ("Looking for that message…") passes
4016 // `sticky`: it's replaced by the outcome, never timed out from under it.
4017 const FEED_NOTE_MS = 6000;
4018 let feedNoteTimer = 0;
4019 const setFeedNote = (text, sticky) => {
4020 // Text only — the node stays put and stays in the accessibility tree; CSS
4021 // collapses it when empty (.chatapp-feed-note:empty).
4022 feedNote.textContent = text || "";
4023 clearTimeout(feedNoteTimer);
4024 if (text && !sticky) feedNoteTimer = setTimeout(() => setFeedNote(""), FEED_NOTE_MS);
4025 };
4026 feedWrap.append(feedNote, feed);
4027
4028 // Jump-to-latest: the classic chat affordance for getting back to the
4029 // newest message without hunting for it — shown/hidden by the feed's
4030 // "scroll" listener below (and re-checked after each render, since new
4031 // messages can widen the gap without a scroll event).
4032 const jumpBtn = document.createElement("button");
4033 jumpBtn.type = "button";
4034 jumpBtn.className = "feed-jump-btn hidden";
4035 // aria-label carries the accessible name; title mirrors it as a mouse-hover
4036 // tooltip (icon-only button — see docs/accessibility.md's icon-label rule).
4037 jumpBtn.setAttribute("aria-label", "Jump to latest");
4038 jumpBtn.title = "Jump to latest";
4039 jumpBtn.innerHTML = icon("chevronDown");
4040 jumpBtn.onclick = () => feedGlideToBottom(feed, false);
4041 feedWrap.appendChild(jumpBtn);
4042
4043 // The composer is built by buildComposerBlock so the docked thread pane
4044 // (next PR) can instantiate a second, root-pinned composer beside this one.
4045 // The cfg hooks below wire the block back into pane-owned chrome — the
4046 // command panel, the reply-thread view, the feed's follow behaviour — which
4047 // stays out here (per-pane, not per-composer). Hook closures reference
4048 // consts declared further down (panelOpen, closeThread, …): they only run
4049 // on user input, long after buildSkeleton completes, so every binding is
4050 // live by then (the same hoisted-handlers pattern the block itself uses).
4051 const inEmbed = !!(host && host.embed);
4052 const block = buildComposerBlock(ctx, host, {
4053 fixedReply: null, // the main composer stages replies freely (and owns the labelled-ask surface)
4054 inEmbed,
4055 commands: true,
4056 // Escape peels pane layers before the composer cancels its own reply/edit:
4057 // an open command panel is the most recently summoned layer, then the
4058 // thread view — unless an edit is in flight, which Escape must cancel
4059 // first (cancelEdit re-stages the thread reply, so a second Escape then
4060 // closes the view).
4061 onEscape: (hasEdit) => {
4062 if (panelOpen()) {
4063 hidePanel();
4064 return true;
4065 }
4066 // Docked, the main composer's staged reply is its OWN state (never the
4067 // thread's) — let the block cancel it below before an Escape reaches
4068 // for the pane; the next Escape then closes the pane. Overlay, the
4069 // staged reply IS the thread's, so closing the view clears it in one.
4070 if (threadSid && !hasEdit && !(threadDocked && block.api.stagedReply())) {
4071 closeThread();
4072 return true;
4073 }
4074 return false;
4075 },
4076 // While the thread view is open its staged reply IS the view's composer
4077 // state — cancelling it means leaving the thread, so close the whole view
4078 // (which clears the reply) rather than stranding an open thread whose
4079 // sends would land outside it. Docked, the pane carries its own composer
4080 // and the main composer's staged reply is unrelated — just drop it.
4081 onReplyCancel: () => {
4082 if (!threadSid || threadDocked) return false;
4083 closeThread();
4084 return true;
4085 },
4086 // An edit inside an open thread view borrowed the composer (startEdit
4087 // clears the staged reply); hand it back so the next send still continues
4088 // the thread. Docked, the main composer was never staged to the thread.
4089 onEditCancelled: () => {
4090 if (threadSid && !threadDocked) stageThreadReply();
4091 },
4092 // The composer just grew, shrinking the flex feed. Re-pin to the newest
4093 // message synchronously in the same layout — the ResizeObserver below also
4094 // re-pins, but only on a later frame, which leaves one painted frame
4095 // stranded a line short of the bottom (the visible "jump" on WebKit / the
4096 // Mac app). Guard on feedAtBottom so typing never yanks a reader who
4097 // scrolled up.
4098 onFieldGrow: () => {
4099 if (feedAtBottom) feed.scrollTop = feed.scrollHeight;
4100 },
4101 // The staging strip takes real layout space, so the feed shrinks when it
4102 // appears — keep the newest messages (and any action chips) in view, not
4103 // under the fold.
4104 onStripChange: () => {
4105 if (feedNearBottom(feed, 160)) requestAnimationFrame(() => feedGlideToBottom(feed, true));
4106 },
4107 // Post-send: snap the echo's re-render to the newest message (set now,
4108 // well before the server round-trip; render() clears it), and keep an open
4109 // OVERLAY thread view's reply staged — sending from it continues the same
4110 // thread, like iMessage — else the armed reply is spent. Docked, the pane
4111 // owns the thread's composer, so the main one always just spends its reply.
4112 // A thread reply (the overlay's staged root) never pins: it lands in the
4113 // thread view, not the transcript, and snapping the transcript would only
4114 // yank a reader who'd scrolled it for nothing they can see.
4115 onSent: (payload) => {
4116 if (!(payload.replyTo && payload.replyTo.thread)) pinNext.add(ctx.threadId);
4117 if (threadSid && !threadDocked) stageThreadReply();
4118 else block.api.cancelReply();
4119 },
4120 });
4121 const { composer, api } = block;
4122
4123 root.append(feedWrap, block.el);
4124 el.appendChild(root);
4125
4126 // Keep the feed pinned to the newest message when the composer, its staged
4127 // reply/edit banner, or the preview strip grows and steals vertical space —
4128 // they're in-flow siblings, so the flex feed shrinks and this observer fires.
4129 // (The original re-pins the same way; render() alone can't, since a resize
4130 // isn't a state change.) Track "at bottom" from real scrolls so the decision
4131 // reflects the state *before* the feed shrank — reading the gap inside the
4132 // callback would already show it past the threshold and wrongly bail. The
4133 // `< 60` matches render()'s pin check.
4134 let feedAtBottom = true;
4135 const updateFeedScrollState = () => {
4136 const gap = feed.scrollHeight - feed.scrollTop - feed.clientHeight;
4137 feedAtBottom = gap < 60;
4138 jumpBtn.classList.toggle("hidden", gap < JUMP_BUTTON_GAP);
4139 };
4140 feed.addEventListener("scroll", updateFeedScrollState, { passive: true });
4141 // render() re-checks after every message-list update too — new messages
4142 // widen the gap (or a glide-to-bottom closes it) without firing "scroll".
4143 root._updateJumpBtn = updateFeedScrollState;
4144 new ResizeObserver(() => {
4145 if (!feedAtBottom) return;
4146 // WebKit restores the pre-resize scrollTop at *layout* time — which lands
4147 // after this callback AND after a single rAF — so one re-assert loses the
4148 // race and strands the feed the growth-delta (~one composer line) above the
4149 // bottom (Chrome doesn't do this). Re-pin across a few frames so a
4150 // correction always lands after the restoration, whenever it happens. The
4151 // scroll listener keeps feedAtBottom true through the restoration (its gap
4152 // stays under the 60px threshold), so this bails only if the reader
4153 // genuinely scrolls up mid-window.
4154 let frame = 0;
4155 const pin = () => {
4156 if (!feedAtBottom) return; // reader scrolled away — stop fighting them
4157 feed.scrollTop = feed.scrollHeight;
4158 if (++frame < 4) requestAnimationFrame(pin);
4159 };
4160 pin();
4161 }).observe(feed);
4162
4163 if (inEmbed) root.classList.add("embed"); // the native shell draws the composer
4164 // The owning app declared its chat a read-only stream — no composer to draw.
4165 if (appChatReadOnly(ctx.threadId)) root.classList.add("readonly");
4166
4167 // Bubble actions (Reply, Edit) resolve their composer through this registry
4168 // — still exactly one entry per thread: the main composer's verbs, straight
4169 // off the block's api (tickTyping rides along for render()'s beat ticker).
4170 composers.set(ctx.threadId, {
4171 refreshSend: api.refreshSend,
4172 // Under the OVERLAY the main composer IS the open thread's reply box, so a
4173 // Reply tapped on a bubble inside the view (the root, or one of its thread
4174 // replies — threadTarget resolves both to this thread) stages the root
4175 // WITH the thread flag, the same staging the view's open uses. Without
4176 // this the plain verb would quote that bubble and land the reply in the
4177 // transcript, out of the thread the reader is standing in. Docked, pane
4178 // bubbles route to the pane's own composer (composerFor), so this only
4179 // ever sees the transcript's replies. Closures over consts declared
4180 // further down, run on user input only — buildSkeleton's hoisted pattern.
4181 startReply: (m) => {
4182 if (threadSid && !threadDocked && threadTarget(((ctx.getState() || {}).msgs) || [], m).id === threadSid) {
4183 stageThreadReply();
4184 if (!inEmbed) composer.focus(); // a deliberate Reply tap wants the caret, touch included (startReply's rule)
4185 return;
4186 }
4187 api.startReply(m);
4188 },
4189 startEdit: api.startEdit,
4190 tickTyping: api.tickTyping,
4191 });
4192
4193 // ---- command panel: the transient results surface a command opens ----
4194 // (ctx.panel — see the command-panels section of apps.js.) In-flow, never
4195 // floating: it sits above the composer stack by default, or pinned above the
4196 // feed with position: "top", taking layout space either way. One per thread —
4197 // opening another replaces it; its ✕, Escape, or the app's panel.close()
4198 // dismisses it. Client-only chrome: nothing here is sent or folded.
4199 const panelBox = document.createElement("section");
4200 panelBox.className = "cmd-panel hidden";
4201 panelBox.setAttribute("role", "region");
4202 let panelSpec = null;
4203 const panelOpen = () => !!panelSpec;
4204 const renderPanelBody = (spec, body) => {
4205 body.replaceChildren();
4206 // The handle a panel renders against: dismiss it, redraw it, and act on
4207 // the composer it sits above — insert text at the caret, or stage
4208 // attachments through the same path the attach button / paste / drop use.
4209 const panel = {
4210 threadId: ctx.threadId,
4211 close: () => hidePanel(),
4212 refresh: () => {
4213 if (panelSpec === spec && body.isConnected) renderPanelBody(spec, body);
4214 },
4215 insertText: (t) => {
4216 const text = String(t == null ? "" : t);
4217 if (!text) return;
4218 // Embedded: the native shell owns the visible composer — forward the
4219 // insertion (a shell that doesn't handle composerInsert ignores it).
4220 if (inEmbed) return host.native({ composerInsert: text });
4221 composer.insertAtCaret(text);
4222 },
4223 attachFiles: (files) => api.stageFiles([...(files || [])]),
4224 };
4225 try {
4226 spec.render(body, panel);
4227 } catch (e) {
4228 // A panel must never take down the pane — contain the error in place.
4229 console.error(`command panel (${(spec.sender && spec.sender.name) || "app"}) render failed:`, e);
4230 body.replaceChildren();
4231 const p = document.createElement("p");
4232 p.textContent = "This panel hit an error and couldn't load.";
4233 body.appendChild(p);
4234 }
4235 };
4236 // Whether the panel eases open/shut: the app's call (animate: false opts
4237 // out), gated on reduced-motion — and skipped in the command-host embed,
4238 // where the panel is the whole page and the NATIVE shell animates the
4239 // frame instead (an in-page ease inside a just-revealed frame reads as a
4240 // flash, not a slide).
4241 // (The side column skips the ease outright — its reveal is horizontal, and
4242 // a height roll inside a full-height column reads as a glitch.)
4243 const panelEases = (spec) =>
4244 spec && spec.animate !== false && spec.position !== "side" && motionOK() && !document.body.classList.contains("command-host");
4245 const clearPanelEase = () => {
4246 panelBox.style.removeProperty("height");
4247 panelBox.style.removeProperty("overflow");
4248 panelBox.style.removeProperty("transition");
4249 };
4250 const showPanel = (spec) => {
4251 // A docked thread and a command panel can't share the right dock (and a
4252 // bottom band under an open thread reads as the thread's) — opening a
4253 // panel closes the thread, the mirror of openThread's hidePanel(). Gated
4254 // on `spec`: hidePanel's eased teardown lands here as showPanel(null),
4255 // possibly AFTER openThread already re-set threadSid (the 200ms roll-up),
4256 // and that must never close the thread it was cleared for.
4257 if (spec && threadSid && threadDocked) {
4258 // Never silently drop what was typed: a pane edit in flight keeps its
4259 // pane, and this panel takes the top band instead of the dock.
4260 if (paneBlock && paneBlock.api.editing()) spec = { ...spec, position: "top" };
4261 else closeThread();
4262 }
4263 const prev = panelSpec;
4264 panelSpec = spec && typeof spec.render === "function" ? spec : null;
4265 clearPanelEase(); // a mid-animation replace starts clean
4266 // Replacing (or closing) a panel tells the outgoing one — best-effort.
4267 if (prev && prev.onClose) {
4268 try {
4269 prev.onClose();
4270 } catch (e) {
4271 console.error(e);
4272 }
4273 }
4274 panelBox.replaceChildren();
4275 panelBox.classList.toggle("hidden", !panelSpec);
4276 panelBox.classList.toggle("cmd-panel--top", !!(panelSpec && panelSpec.position === "top"));
4277 panelBox.classList.toggle("cmd-panel--side", !!(panelSpec && panelSpec.position === "side"));
4278 // The side placement reserves a right-hand gutter on the root (padding,
4279 // so the column occupies real layout space and nothing renders under it).
4280 root.classList.toggle("has-side-panel", !!(panelSpec && panelSpec.position === "side"));
4281 if (!panelSpec) {
4282 notifyCommandPanel(ctx.threadId, null);
4283 return;
4284 }
4285 // Every placement is in-flow: "top" pins it above the feed (under the
4286 // shared-app surface); "side" is a full-height right column (a vertical
4287 // sidebar — list-shaped content wants a tall, narrow box, not a wide,
4288 // short band; on narrow screens its CSS falls back to the bottom band);
4289 // the default sits at the bottom of the pane, above the composer stack
4290 // (before the zero-height .composer-float anchor).
4291 root.insertBefore(panelBox, panelSpec.position === "top" ? feedWrap : block.float);
4292 const head = document.createElement("div");
4293 head.className = "cmd-panel-head";
4294 const s = panelSpec.sender || {};
4295 if (s.glyph) {
4296 const g = document.createElement("span");
4297 g.textContent = s.glyph;
4298 head.appendChild(g);
4299 }
4300 const title = document.createElement("span");
4301 title.className = "cmd-panel-title";
4302 title.textContent = panelSpec.title || s.name || "";
4303 const x = document.createElement("button");
4304 x.type = "button";
4305 x.className = "cmd-panel-x";
4306 x.setAttribute("aria-label", "Dismiss");
4307 x.innerHTML = icon("x");
4308 x.onclick = () => hidePanel();
4309 // headExtra: a spec-provided slot in the title row, between the title and
4310 // the dismiss — for a panel's one standing control (Memory's "Forget…"),
4311 // so it sits inline with the name instead of eating body space.
4312 if (typeof panelSpec.headExtra === "function") {
4313 const extra = document.createElement("span");
4314 extra.className = "cmd-panel-extra";
4315 try {
4316 panelSpec.headExtra(extra);
4317 } catch (e) {
4318 console.error(e);
4319 }
4320 head.append(title, extra, x);
4321 } else {
4322 head.append(title, x);
4323 }
4324 const body = document.createElement("div");
4325 body.className = "cmd-panel-body";
4326 panelBox.append(head, body);
4327 renderPanelBody(panelSpec, body);
4328 notifyCommandPanel(ctx.threadId, panelSpec);
4329 // Ease up from nothing to the panel's natural height (see panelEases).
4330 if (panelEases(panelSpec)) {
4331 const h = panelBox.offsetHeight;
4332 if (h) {
4333 panelBox.style.overflow = "hidden";
4334 panelBox.style.height = "0px";
4335 void panelBox.offsetHeight; // commit the start height
4336 panelBox.style.transition = "height 0.22s ease";
4337 panelBox.style.height = h + "px";
4338 panelBox.addEventListener("transitionend", clearPanelEase, { once: true });
4339 setTimeout(clearPanelEase, 350); // backstop if transitionend never fires
4340 }
4341 }
4342 // A bottom panel takes its space from the feed — keep the newest in view.
4343 if (feedNearBottom(feed, 160)) requestAnimationFrame(() => feedGlideToBottom(feed, true));
4344 };
4345 // Dismissal rolls the panel down before tearing it out, unless the app (or
4346 // the OS) asked for instant. panelClosing keeps a second Escape (or the ✕)
4347 // from re-arming the roll-up mid-flight; a NEW panel opening mid-animation
4348 // wins — showPanel resets the inline styles, and the captured spec check
4349 // keeps the stale finish() from tearing the newcomer down.
4350 let panelClosing = false;
4351 const hidePanel = () => {
4352 if (!panelSpec || panelClosing) return;
4353 if (!panelEases(panelSpec) || !panelBox.isConnected) return showPanel(null);
4354 panelClosing = true;
4355 const closing = panelSpec;
4356 let finished = false;
4357 const finish = () => {
4358 if (finished) return;
4359 finished = true;
4360 panelClosing = false;
4361 clearPanelEase();
4362 if (panelSpec === closing) showPanel(null);
4363 };
4364 const startH = panelBox.offsetHeight;
4365 if (!startH) return finish();
4366 panelBox.style.overflow = "hidden";
4367 panelBox.style.height = startH + "px";
4368 void panelBox.offsetHeight;
4369 panelBox.style.transition = "height 0.2s ease";
4370 panelBox.style.height = "0px";
4371 panelBox.addEventListener("transitionend", finish, { once: true });
4372 setTimeout(finish, 320);
4373 };
4374 // Programmatic closes (ctx.closePanel) take the same eased path as ✕/Escape.
4375 cmdPanels.set(ctx.threadId, (spec) => (spec ? showPanel(spec) : hidePanel()));
4376
4377 // Drag-drop overlay affordance (.drop-overlay — the same class the original
4378 // pane uses, so it looks identical). The overlay element exists in *both* modes;
4379 // only the web drop *listeners* are web-only (see below).
4380 const dropOverlay = document.createElement("div");
4381 dropOverlay.className = "drop-overlay hidden";
4382 dropOverlay.textContent = "Drop a photo or file to send";
4383 root.appendChild(dropOverlay);
4384 // The native macOS shell (Eleven) drives the overlay during a Finder drag:
4385 // WebKit swallows the drag events natively (see the listener note below), so the
4386 // page's own dragenter/leave never fire in the app. Selector-based so it always
4387 // targets the connected root's overlay — survives thread switches / the
4388 // instant-restore path. (DropWebView in ChatFeedWebView.swift calls this.)
4389 window.__lchatDropOverlay = (show) => {
4390 const o = document.querySelector(".chatapp-root .drop-overlay");
4391 if (o) o.classList.toggle("hidden", !show);
4392 };
4393 // The native wrapper intercepts Finder file-drops itself — a WKWebView doesn't
4394 // hand the page the dropped files on macOS, and merely *registering* a web drop
4395 // handler makes WebKit claim the drop, bypassing the native path, so nothing
4396 // uploads. So skip the web drop listeners in the native app (html.native); there
4397 // the shell delivers files via __lchatAddFiles and shows the overlay via
4398 // __lchatDropOverlay above.
4399 if (!document.documentElement.classList.contains("native")) {
4400 let dragDepth = 0;
4401 root.addEventListener("dragenter", (e) => {
4402 if (!e.dataTransfer || ![...e.dataTransfer.types].includes("Files")) return;
4403 dragDepth++;
4404 dropOverlay.classList.remove("hidden");
4405 });
4406 root.addEventListener("dragleave", () => {
4407 if (--dragDepth <= 0) {
4408 dragDepth = 0;
4409 dropOverlay.classList.add("hidden");
4410 }
4411 });
4412 root.addEventListener("dragover", (e) => {
4413 if (e.dataTransfer && [...e.dataTransfer.types].includes("Files")) e.preventDefault();
4414 });
4415 root.addEventListener("drop", (e) => {
4416 dragDepth = 0;
4417 dropOverlay.classList.add("hidden");
4418 const files = [...((e.dataTransfer && e.dataTransfer.files) || [])];
4419 if (files.length) {
4420 e.preventDefault();
4421 api.stageFiles(files);
4422 }
4423 });
4424 }
4425 // The native macOS shell (LChatMac) can't hand Finder file-drops to the page —
4426 // a WKWebView doesn't deliver them and the web drop listeners above are skipped
4427 // in native mode — so it fills the page's static #file-input and dispatches
4428 // `change` (see ChatFeedWebView's __lchatAddFiles). The live composer uses its
4429 // own input, so expose a handler that pulls the files off that static input
4430 // into this thread's staging path; render() wires it to #file-input for the
4431 // thread on screen. (A plain browser never reaches this — its drops land on the
4432 // web listeners above.)
4433 root._dropFiles = () => {
4434 const el = document.getElementById("file-input");
4435 if (!el) return;
4436 const files = [...(el.files || [])];
4437 el.value = "";
4438 if (files.length) api.stageFiles(files);
4439 };
4440
4441 // Scroll-back pagination: nearing the top folds the previous history page
4442 // (through the host, which owns tokens + decryption), then restores the
4443 // reader's place by anchoring the scroll offset across the prepend.
4444 let loadingOlder = false;
4445 let exhausted = false;
4446 feed.addEventListener("scroll", async () => {
4447 if (loadingOlder || exhausted || feed.scrollTop > 60) return;
4448 const state = ctx.getState();
4449 const oldest = state && state.msgs.length ? state.msgs[0].sid : 0;
4450 if (!oldest || !host || !host.loadOlder) return;
4451 loadingOlder = true;
4452 const prevHeight = feed.scrollHeight;
4453 try {
4454 // ONLY an empty page is the start of history. A failed fetch must not
4455 // latch `exhausted`, or one offline moment leaves scroll-back dead for
4456 // the rest of this mount (see pageResult).
4457 if (pageResult(await host.loadOlder(ctx.threadId, oldest)) === "end") exhausted = true;
4458 } catch { /* errored page — retryable, like -1 */ } finally {
4459 feed.scrollTop += feed.scrollHeight - prevHeight;
4460 loadingOlder = false;
4461 }
4462 });
4463
4464 // ---- reply-thread view: tap a quote or a "N Replies" tally to lift that
4465 // thread over the transcript (narrow), or dock it as a right-hand column
4466 // beside it (desktop widths), Slack-style ----
4467 // Overlay: occupies the feed's box only (see .chatapp-feedwrap), so the
4468 // real composer below stays live with its reply state staged to the thread's
4469 // root — sending from it continues the thread. Docked: the panel parks in
4470 // the root's right gutter (the command panel's side-dock technique) and
4471 // carries its OWN root-pinned composer, leaving the main one alone. Either
4472 // way the new reply folds into the open view (render() → root._refreshThread
4473 // re-derives from current state each fold; the view never caches its list).
4474 //
4475 // ONE scrolling list, root first: the message a thread is about is simply the
4476 // thread's first message, not a pinned box above a second scroll region. A
4477 // thread reads as a small chat window — which is what it is — and an
4478 // unanswered one is a single bubble instead of a pinned message, a rule and
4479 // an empty pane.
4480 let threadSid = 0; // the open thread's root sid (0 = closed)
4481 let threadFallback = null; // { id, name, snippet } — the composer's reply target while the root isn't loaded
4482 let threadEl = null; // { ov, panel, count, note, body } while open (ov null when docked)
4483 let threadPaging = false; // paging history backward looking for the root
4484 let threadRootShown = false; // did the last fold render the root? (the one prepend the list ever sees)
4485 let threadDocked = false; // this open used the docked presentation (decided at open time, like every width check)
4486 let threadOpener = null; // the element that opened the view — focus returns there on close
4487 let paneBlock = null; // the docked pane's composer block (buildComposerBlock), docked only
4488
4489 // The reply target for a message composed IN this thread: the root (a
4490 // fresh snapshot when loaded, the quoted fallback until then) plus the
4491 // thread flag — the wire marker that makes this a thread reply at all, and
4492 // keeps it out of the transcript (inMainFeed). The pure targets stay
4493 // flag-free; the flag is added here, at the one seam every thread-view send
4494 // passes through. quoteTarget of the loaded root IS the root's own ref (a
4495 // root is never itself in a thread), so no resolution is needed.
4496 const threadReplyTarget = () => {
4497 const msgs = ((ctx.getState() || {}).msgs) || [];
4498 const rootMsg = msgs.find((m) => m.sid === threadSid);
4499 const target = rootMsg
4500 ? quoteTarget(rootMsg)
4501 : { id: threadSid, name: (threadFallback && threadFallback.name) || "", snippet: (threadFallback && threadFallback.snippet) || "" };
4502 return { ...target, thread: true };
4503 };
4504
4505 // Stage the composer's reply on the thread's root — the whole point of the
4506 // view's composer contract ("Replying to <root author>"). api.stageReply
4507 // mirrors startReply (a reply displaces an in-flight edit; native shells
4508 // mirror the banner); the focus is skipped on touch, where popping the
4509 // keyboard would cover the thread the reader just opened.
4510 const stageThreadReply = () => {
4511 api.stageReply(threadReplyTarget());
4512 if (!inEmbed && !coarse) composer.focus();
4513 };
4514
4515 const closeThread = () => {
4516 const sid = threadSid;
4517 if (!sid && !threadEl) return;
4518 const docked = threadDocked;
4519 const opener = threadOpener;
4520 threadSid = 0;
4521 threadFallback = null;
4522 threadRootShown = false;
4523 threadDocked = false;
4524 threadOpener = null;
4525 if (threadEl) {
4526 if (docked) {
4527 threadEl.panel.remove();
4528 root.classList.remove("has-thread-pane");
4529 } else threadEl.ov.remove();
4530 threadEl = null;
4531 }
4532 paneBlock = null; // the pane composer dies with its panel — a re-open builds fresh
4533 // Overlay only: leaving the thread drops its staged reply — never an
4534 // unrelated one staged since (an edit clears the staged reply itself, so
4535 // that survives untouched). Docked, the main composer was never staged to
4536 // the thread, so a same-id reply the user armed themselves is theirs to keep.
4537 if (!docked) {
4538 const staged = api.stagedReply();
4539 if (sid && staged && staged.id === sid) api.cancelReply();
4540 }
4541 // Hand focus back where the view came from (the tally/quote that opened
4542 // it), the standard dialog return — but never to a node a re-render has
4543 // since replaced. Docked with no usable opener, fall back to the main
4544 // composer: closing removed the focused pane, and keyboard/AT users must
4545 // not be dropped onto <body>.
4546 if (opener && opener.isConnected && typeof opener.focus === "function") opener.focus();
4547 else if (docked && !inEmbed && !coarse) composer.focus();
4548 // The ▤ Views menu lists an open thread as a view (PR 3) — same repaint
4549 // signal the bot views use.
4550 document.dispatchEvent(new CustomEvent("lchat:views-changed"));
4551 };
4552
4553 const buildThread = (docked) => {
4554 const panel = document.createElement("div");
4555 panel.className = "chatapp-thread-panel" + (docked ? " chatapp-thread-panel--docked" : "");
4556 // Docked it's an ordinary non-modal region beside the chat (a landmark,
4557 // no focus trap); the overlay presents as a dialog over the transcript.
4558 // No aria-modal on the overlay: its reply surface is the MAIN composer
4559 // below the dialog, and aria-modal would tell AT to ignore it.
4560 panel.setAttribute("role", docked ? "complementary" : "dialog");
4561 panel.setAttribute("aria-label", "Reply thread");
4562 const head = document.createElement("div");
4563 head.className = "chatapp-thread-head";
4564 const title = document.createElement("div");
4565 title.className = "chatapp-thread-title";
4566 title.textContent = "Thread";
4567 const count = document.createElement("div");
4568 count.className = "chatapp-thread-count";
4569 const x = document.createElement("button");
4570 x.type = "button";
4571 x.className = "chatapp-thread-x";
4572 x.innerHTML = icon("x"); // static in-code SVG from the host (safe)
4573 x.setAttribute("aria-label", "Close thread");
4574 x.onclick = () => closeThread();
4575 head.append(title, count, x);
4576 const note = document.createElement("div");
4577 note.className = "chatapp-thread-note hidden";
4578 const body = document.createElement("div");
4579 body.className = "chatapp-thread-msgs";
4580 panel.append(head, note, body);
4581 if (docked) {
4582 // The pane's own composer, pinned to the thread root: a SECOND
4583 // buildComposerBlock instance. fixedReply derives the target fresh per
4584 // send (the root may load mid-thread — the same threadReplyTarget the
4585 // overlay stages, thread flag included), so no banner ever shows and
4586 // nothing is staged on the main composer. Every pane send passes
4587 // through it — a Reply/swipe on a pane bubble only stages the block's
4588 // (ignored) replyTo, the send still derives from here — so no pane
4589 // reply can land in the transcript. commands off: "/" text sends verbatim, Slack-style (v1 —
4590 // a pane-summoned command panel has nowhere to dock; see the spec's
4591 // follow-ups). The whole 4-node fragment mounts INSIDE the wrapper —
4592 // including the block's float, which carries the attachment staging
4593 // strip and the emoji/@ typeahead drop-up (without it the typeahead
4594 // would arm invisibly and swallow Enter). That float is harmless here:
4595 // command panels anchor on the MAIN block's float captured in showPanel,
4596 // never this one — and nesting keeps the `.chatapp-root >` child
4597 // combinators (embed/readonly sweeps) deliberately blind to the pane.
4598 const composerWrap = document.createElement("div");
4599 composerWrap.className = "chatapp-thread-composer";
4600 paneBlock = buildComposerBlock(ctx, host, {
4601 fixedReply: threadReplyTarget,
4602 commands: false,
4603 // Escape in the pane's box: an in-flight edit cancels first (return
4604 // false — the block's own cancel path runs, then a second Escape
4605 // lands back here); otherwise the pane is the layer, so close it.
4606 onEscape: (hasEdit) => {
4607 if (hasEdit) return false;
4608 closeThread();
4609 return true;
4610 },
4611 // Refocus the pane's box so the next reply types straight away (the
4612 // factory's own post-send focus covers the common path; this keeps
4613 // the contract explicit). Deliberately NO pinNext: the reply shows in
4614 // the pane (whose body follows), and snapping the main transcript
4615 // would yank a reader who scrolled it while working the thread —
4616 // the overlay never had that problem because the dim covers the feed.
4617 onSent: () => {
4618 if (paneBlock) paneBlock.api.focus();
4619 },
4620 });
4621 composerWrap.appendChild(paneBlock.el);
4622 panel.appendChild(composerWrap);
4623 return { ov: null, panel, count, note, body };
4624 }
4625 const ov = document.createElement("div");
4626 ov.className = "chatapp-thread";
4627 const dim = document.createElement("div");
4628 dim.className = "chatapp-thread-dim";
4629 dim.onclick = () => closeThread(); // tapping the dimmed transcript leaves the thread
4630 ov.append(dim, panel);
4631 return { ov, panel, count, note, body };
4632 };
4633
4634 // Thread rows reuse the feed's exact bubble machinery — renderItem +
4635 // patchBubble under keyedList — so reactions, attachments, long-press
4636 // actions, hover strips and edits all keep working inside the view (and a
4637 // reaction patches in place instead of reloading a photo).
4638 const threadKeyed = (container, items) =>
4639 keyedList(container, items, {
4640 key: (m) => m.id,
4641 rev: (m) => m.vrev + mutedRev(m, ctx) + mentionRev(m),
4642 srev: (m) => m.srev,
4643 render: (m) => renderItem(m, ctx),
4644 update: (node, m) => patchBubble(node, m, ctx),
4645 });
4646
4647 const refreshThread = () => {
4648 if (!threadSid || !threadEl) return;
4649 const msgs = ((ctx.getState() || {}).msgs) || [];
4650 const items = threadItems(msgs, threadSid);
4651 // The whole thread got deleted under the open view — nothing left to show.
4652 // A loaded root with no replies is NOT that: items is [root], and a thread
4653 // someone just started must stay open to be replied to.
4654 if (!items.length && !threadPaging) return closeThread();
4655 // Length-guarded: the close above only fires on a SETTLED view, so an
4656 // empty list still reaches here while paging hunts for the root.
4657 const rootLoaded = !!(items.length && items[0].sid === threadSid);
4658 // The header counts the REPLIES, never the root — the one place the tally
4659 // still appears in this view, in the same words as the tally that opened it.
4660 threadEl.count.textContent = replyCountLabel(threadReplyCount(items, threadSid));
4661 // With the root in the list there's no snapshot header to stand in for it:
4662 // while it's missing the note carries that job alone and the list simply
4663 // starts at the oldest loaded reply. (The frozen quote lives on in
4664 // threadFallback, which is what the composer replies TO until the root
4665 // pages in.)
4666 const noteText = threadPaging
4667 ? "Loading the start of this thread…"
4668 : rootLoaded ? "" : "The original message isn't available — showing its replies.";
4669 threadEl.note.textContent = noteText;
4670 threadEl.note.classList.toggle("hidden", !noteText);
4671 // Follow the newest reply like the feed does, but only when already there —
4672 // a reader scrolled up a long thread isn't yanked down by a live arrival.
4673 const body = threadEl.body;
4674 const follow = body.scrollHeight - body.scrollTop - body.clientHeight < 80 || !body.childElementCount;
4675 const prevHeight = body.scrollHeight;
4676 // One keyed list, root first, straight through viewItems — so the root
4677 // gets the same separators, run-grouping and in-place reaction/edit
4678 // patching every other row has. Its own "N Replies" tally comes back
4679 // non-zero here and is hidden by CSS, like every other row's: inside the
4680 // view they'd all point at where the reader is already standing.
4681 threadKeyed(body, viewItems(items));
4682 if (follow) body.scrollTop = body.scrollHeight;
4683 else if (rootLoaded && !threadRootShown) {
4684 // The root just paged in ABOVE what the reader is looking at — the only
4685 // prepend this list ever sees. Re-anchor the way the feed's scroll-back
4686 // does, so the rows they're reading stay put instead of being shunted
4687 // down by the height the root (and its day separator) just added.
4688 body.scrollTop += body.scrollHeight - prevHeight;
4689 }
4690 threadRootShown = rootLoaded;
4691 };
4692 root._refreshThread = refreshThread;
4693
4694 // Page history backward until message `sid` is in the loaded window. Two
4695 // callers want exactly this walk: the thread view fetching its root, and a
4696 // jump whose target is older than the loaded page (revealMessage). Bounded
4697 // by PAGE_BACK_CAP so a target deep in a huge history can't spin the loader
4698 // forever, and it shares the scroll-back path's loadingOlder/exhausted
4699 // guards while keeping the feed's scroll anchored across each prepend, like
4700 // scroll-back does. `stillWanted()` lets a caller abandon the walk (the
4701 // thread closed, another jump started).
4702 //
4703 // Terminates, always: every iteration either returns or performs exactly one
4704 // loadOlder, and `i` climbs unconditionally to the cap. Four more exits sit
4705 // inside it — the target turning up, paging PAST the target (pages are
4706 // contiguous, so a loaded window that starts below `sid` without holding it
4707 // means the message was deleted), a page coming back EMPTY (the start of
4708 // history, latched in `exhausted` for the whole mount), and a page that
4709 // FAILED to fetch.
4710 //
4711 // Empty and failed are deliberately different (see pageResult): an offline
4712 // blip or a 502 mid-deploy is not the start of history, so it must neither
4713 // latch `exhausted` — which would kill scroll-back for the rest of the mount
4714 // — nor answer "gone", which tells the reader a message was deleted when it
4715 // is merely unreachable this second. It answers "capped", the retryable one.
4716 //
4717 // Returns "found" | "gone" (deleted / history exhausted) | "capped" (still
4718 // more history — further back than one jump walks, or a fetch that failed).
4719 //
4720 // PAGE_BACK_CAP × the server's 100-row page is the REACH of one jump, and
4721 // it's pinned to the native walk's (ChatFeedModel.jumpPageCap) so the same
4722 // message gets the same answer everywhere: native passes no `limit` and
4723 // takes the same server default, so both sides walk ~800 messages.
4724 const PAGE_BACK_CAP = 8;
4725 const loaded = (sid) => (((ctx.getState() || {}).msgs) || []).some((m) => m.sid === sid);
4726 const pageBackTo = async (sid, stillWanted) => {
4727 for (let i = 0; i < PAGE_BACK_CAP; i++) {
4728 if (!stillWanted()) return "capped"; // abandoned — the caller decides what that means
4729 const msgs = ((ctx.getState() || {}).msgs) || [];
4730 if (msgs.some((m) => m.sid === sid)) return "found";
4731 const oldest = msgs.length ? msgs[0].sid : 0;
4732 if (exhausted || !oldest || oldest < sid) return "gone";
4733 if (!host || !host.loadOlder) return "capped";
4734 loadingOlder = true; // keep the feed's own scroll-back handler out
4735 const prevHeight = feed.scrollHeight;
4736 let n = -1;
4737 try {
4738 n = await host.loadOlder(ctx.threadId, oldest);
4739 } catch {
4740 n = -1; // a host that rejects is an errored page, not the end of history
4741 } finally {
4742 // Always re-anchor and release the flag: a throw here used to latch
4743 // loadingOlder forever, killing scroll-back AND every later walk.
4744 feed.scrollTop += feed.scrollHeight - prevHeight;
4745 loadingOlder = false;
4746 }
4747 const page = pageResult(n);
4748 if (page === "error") return loaded(sid) ? "found" : "capped"; // retryable — exhausted stays untouched
4749 if (page === "end") {
4750 exhausted = true;
4751 return loaded(sid) ? "found" : "gone";
4752 }
4753 }
4754 return loaded(sid) ? "found" : "capped";
4755 };
4756
4757 const pageInRoot = async () => {
4758 if (threadPaging || loadingOlder) return;
4759 const sid = threadSid;
4760 threadPaging = true;
4761 refreshThread(); // show the inline loading note straight away
4762 await pageBackTo(sid, () => threadSid === sid);
4763 threadPaging = false;
4764 if (threadSid !== sid) return; // closed / re-opened elsewhere while paging
4765 refreshThread();
4766 // The root may have just loaded — refresh the banner to its live snapshot,
4767 // but only while the staged reply is still this thread's (not an edit).
4768 const staged = api.stagedReply();
4769 if (!api.editing() && staged && staged.id === sid) stageThreadReply();
4770 };
4771
4772 // revealMessage(sid): a jump whose target isn't rendered anywhere — the
4773 // quote card above a plain reply whose parent is older than the loaded page
4774 // (the DEFAULT for every reply in existing history, since none of it
4775 // carries the thread flag), a search hit, a permalink. It used to be a
4776 // silent no-op: tap the card on a cold-opened chat and nothing happened at
4777 // all. Now the walk pages back for it and lands on it — and when it can't,
4778 // the reader is told, inline in the feed, rather than left tapping a dead
4779 // control.
4780 let revealSid = 0; // the target of the walk in flight (0 = none)
4781 const revealMessage = async (sid) => {
4782 if (!sid || revealSid === sid) return; // already walking for this one
4783 // A DIFFERENT target supersedes: the walk in flight sees stillWanted() go
4784 // false, drops out, and leaves the note to us (its own `revealSid !== sid`
4785 // check keeps it from writing one). Tapping a second card must not be
4786 // answered by flashing the first one's target.
4787 revealSid = sid;
4788 setFeedNote("Looking for that message…", true); // sticky: the outcome replaces it
4789 let outcome = "capped";
4790 try {
4791 // The reader may already have been scroll-loading history when they
4792 // tapped (or the superseded walk may still be mid-fetch). Both paths
4793 // drive host.loadOlder and each re-anchors scrollTop against its OWN
4794 // pre-prepend height, so overlapping them would jerk the feed — wait the
4795 // in-flight page out instead, bounded so a wedged fetch can't hold the
4796 // walk open. Still busy after that reads as "couldn't get there from
4797 // here", which is what the capped note already says.
4798 for (let i = 0; i < 20 && loadingOlder && revealSid === sid; i++) {
4799 await new Promise((r) => setTimeout(r, 100));
4800 }
4801 if (revealSid !== sid) return;
4802 if (!loadingOlder) outcome = await pageBackTo(sid, () => revealSid === sid);
4803 if (revealSid !== sid) return; // superseded; whoever took over owns the note
4804 // Found and rendered: land on it. The page folded through
4805 // host.loadOlder, which repaints before it resolves, so the bubble
4806 // exists by now. A hit that folded but still isn't in the transcript (it
4807 // turned out to live in a thread) hands off to the thread view, the same
4808 // as a loaded one.
4809 const target = outcome === "found" && feed.querySelector(`.msg[data-id="${sid}"]`);
4810 const t = threads.get(ctx.threadId);
4811 const inThread = !target && outcome === "found" && t && t.openForMessage(sid)
4812 ? root.querySelector(`.chatapp-thread-msgs .msg[data-id="${sid}"]`)
4813 : null;
4814 if (target || inThread) {
4815 setFeedNote("");
4816 // STOP THE FEED FOLLOWING BEFORE SCROLLING. Every page we just folded
4817 // re-rendered the feed, and render() decides "was at bottom" BEFORE
4818 // the prepend — so from the bottom of a busy chat each page started a
4819 // glide, and a live glide both overwrites scrollTop every frame
4820 // (cancelling the smooth scrollIntoView below on its very next frame)
4821 // and reports "at bottom" to the next render, re-arming itself. The
4822 // symptom was the note flashing and the feed never moving — and only
4823 // with Reduce Motion OFF, since the reduced-motion path jumps instead
4824 // of gliding. feedAtBottom goes with it so the ResizeObserver's
4825 // re-pin bails too (the note's row vanishing is a resize).
4826 feedCancelGlide(feed);
4827 feedAtBottom = false;
4828 flashMessage(target || inThread);
4829 return;
4830 }
4831 // Never silent. A "found" that still has nothing to land on reads the
4832 // same as gone from where the reader sits; "capped" is the one answer
4833 // that isn't final (further back than one jump walks, or a fetch that
4834 // failed), so it says so.
4835 setFeedNote(jumpReachNote(outcome === "capped" ? "capped" : "gone"));
4836 } finally {
4837 // Never leave the walk (or its sticky note) latched: without this a
4838 // throw anywhere above would block every future reveal for the mount.
4839 if (revealSid === sid) {
4840 revealSid = 0;
4841 if (feedNote.textContent === "Looking for that message…") setFeedNote(jumpReachNote("capped"));
4842 }
4843 }
4844 };
4845
4846 const openThread = (rootSid, ref, opener) => {
4847 if (!rootSid) return;
4848 if (threadSid === rootSid && threadEl) {
4849 // Already open on this root — just put the caret back in the acting
4850 // composer (the pane's when docked, the main one under the overlay).
4851 if (threadDocked && paneBlock) paneBlock.api.focus();
4852 else if (!inEmbed && !coarse) composer.focus();
4853 return;
4854 }
4855 closeThread();
4856 // Presentation is decided per open, at open time — the codebase's width
4857 // idiom (point-in-time matchMedia, no live resize listeners). 721px is
4858 // the command panel side-dock's breakpoint; below it the overlay is
4859 // byte-for-byte today's.
4860 threadDocked = matchMedia("(min-width: 721px)").matches;
4861 // One right dock: an open command panel yields to a DOCKED thread
4862 // (showPanel does the mirror). The overlay never shared a dock with the
4863 // panel, so narrow widths keep both — exactly as before.
4864 if (threadDocked) hidePanel();
4865 threadSid = rootSid;
4866 threadFallback = ref || null;
4867 threadOpener = opener || null;
4868 threadEl = buildThread(threadDocked);
4869 if (threadDocked) {
4870 // The side-dock technique: the root reserves the gutter with padding
4871 // (real layout space), the panel parks absolutely in it — same slot the
4872 // command panel's side column uses. Appended LAST deliberately: the
4873 // pane carries its own ".composer", and every first-match query in the
4874 // tree (closed-DM paint, shell/search/onboarding focus hops) must keep
4875 // finding the MAIN composer first; last also puts the pane after the
4876 // transcript and composer in Tab order.
4877 root.classList.add("has-thread-pane");
4878 root.appendChild(threadEl.panel);
4879 } else {
4880 feedWrap.appendChild(threadEl.ov);
4881 // Overlay only: the main composer below IS the thread's reply box, so
4882 // stage it to the root. The docked pane's composer needs no staging —
4883 // it derives its fixedReply target per send.
4884 stageThreadReply();
4885 }
4886 // Render before any paging: the view must show what's loaded even when
4887 // pageInRoot bails (e.g. a scroll-back already in flight). When paging does
4888 // run, its own refresh follows synchronously, so no wrong-note flash.
4889 refreshThread();
4890 const msgs = ((ctx.getState() || {}).msgs) || [];
4891 if (!msgs.some((m) => m.sid === rootSid)) pageInRoot();
4892 // Move focus into the pane once it's laid out (the rAF lets the insert
4893 // paint first). Same touch guard as stageThreadReply's focus: popping the
4894 // keyboard over a just-opened pane helps nobody on coarse pointers.
4895 if (threadDocked && !inEmbed && !coarse) requestAnimationFrame(() => {
4896 if (paneBlock) paneBlock.api.focus();
4897 });
4898 document.dispatchEvent(new CustomEvent("lchat:views-changed"));
4899 };
4900
4901 // ---- the ▤ Threads list: this chat's threads, newest activity first ----
4902 // The gap the pane left: "there's no way to see a list of threads — it's
4903 // hard to find a thread if I miss clicking the notification." A thread only
4904 // exists where someone chose it, so a chat has a handful, and they're
4905 // findable — if there's somewhere to look. This is that somewhere.
4906 //
4907 // It rides the SIDE COMMAND PANEL (the docked column Memory and bot views
4908 // already use) rather than inventing a surface. That reuse carries one
4909 // deliberate consequence: there is a single right dock, so a docked thread
4910 // and this list can't both be up. Tapping a row therefore closes the list
4911 // as the thread arrives — which is the behaviour we'd want anyway: you came
4912 // to find a thread, you found it. We close it here explicitly rather than
4913 // leaning on openThread's own hidePanel(), because that one is gated on the
4914 // DOCKED presentation — on a narrow screen the thread lifts as an overlay
4915 // and would otherwise leave the list's bottom band stranded beneath it.
4916 //
4917 // NO UNREAD DOT, deliberately. Read state in this product is per CHAT and
4918 // nothing else: one `read_marks(user_id, thread_id, last_read_at)` watermark
4919 // server-side, `readMarks` in shell.js, `ReadState.lastSeen` natively. Opening
4920 // the chat advances it past every thread reply in it — thread replies are
4921 // ordinary messages, so they move the chat's `last_at` even though the
4922 // transcript never shows them. A dot derived from that would read "all
4923 // caught up" the instant you glanced at the chat, which is exactly the lie
4924 // Paul is complaining about. Per-thread read state is a real feature (a
4925 // watermark per root, synced like the chat one); half-inventing it here
4926 // would put a wrong dot in front of members. Rows carry activity order
4927 // instead — the freshest thread is simply top.
4928 let threadListBody = null; // the open panel's body element (null = closed)
4929 let threadListSig = ""; // what that body last painted — see the fold hook
4930
4931 const paintThreadList = (el) => {
4932 const rows = threadListItems(((ctx.getState() || {}).msgs) || []);
4933 threadListSig = threadListSignature(rows);
4934 el.replaceChildren();
4935 if (!rows.length) {
4936 // Only reachable with the list already open (the ▤ row hides at zero) —
4937 // every thread's replies were deleted while you were looking at them.
4938 const p = document.createElement("p");
4939 p.className = "thread-list-empty";
4940 p.textContent = "No threads in this chat. Choose “Reply in thread” on a message to start one.";
4941 el.appendChild(p);
4942 return;
4943 }
4944 const list = document.createElement("div");
4945 list.className = "thread-list";
4946 for (const r of rows) {
4947 const label = replyCountLabel(r.count); // the same string the root's tally wears
4948 const who = r.name ? r.name + ": " : "";
4949 // A CONVERTED chain whose root has scrolled out of the loaded window has
4950 // no truthful stand-in (threadListItems: only a flagged reply's ref is
4951 // its root's own snapshot), so the row would be a blank line above its
4952 // tally — and would read "Open thread, 3 Replies," to a screen reader.
4953 // Say what the thread view says in exactly the same case.
4954 const line0 = r.snippet || (r.rootLoaded ? "" : "The original message isn't available");
4955 const b = document.createElement("button");
4956 b.type = "button";
4957 b.className = "thread-list-row";
4958 b.dataset.root = String(r.rootSid); // how a repaint puts focus back on your row
4959 const line = document.createElement("span");
4960 line.className = "thread-list-line";
4961 const nameEl = document.createElement("span");
4962 nameEl.className = "thread-list-who";
4963 nameEl.textContent = who;
4964 const snip = document.createElement("span");
4965 snip.className = "thread-list-snippet";
4966 snip.textContent = line0;
4967 line.append(nameEl, snip);
4968 const count = document.createElement("span");
4969 count.className = "thread-list-count";
4970 count.textContent = label;
4971 b.append(line, count);
4972 // The accessible name says what the row DOES ("Open thread") before the
4973 // size and the line, so a screen reader announces a destination rather
4974 // than a fragment of someone's sentence. Both visible strings are
4975 // carried through verbatim, so voice control still matches on the words
4976 // a sighted user can see (WCAG 2.5.3).
4977 b.setAttribute("aria-label", `Open thread, ${label}, ${who}${line0}`);
4978 b.onclick = () => {
4979 hidePanel(); // one right dock — see the note above
4980 // No opener worth recording: the row dies with the list, so focus
4981 // return falls to closeThread's main-composer fallback.
4982 openThread(r.rootSid, { id: r.rootSid, name: r.name, snippet: r.snippet }, null);
4983 };
4984 list.appendChild(b);
4985 }
4986 el.appendChild(list);
4987 };
4988
4989 // Two things follow the fold. (1) The ▤ menu's Threads row appears the
4990 // moment this chat gets its first thread and goes when its last one does —
4991 // so a thread someone else starts while you're reading surfaces the row
4992 // without a reload. That's the same repaint signal the pane's own row uses,
4993 // fired only on a real count change (a menu repaint per message would be
4994 // silly). (2) The open list itself repaints — a new reply re-sorts the rows
4995 // and moves a count — but only when what it SHOWS changed, because the rows
4996 // are BUTTONS: rebuilding them on every fold would yank focus out from under
4997 // someone tabbing the list. When a real change does land, focus goes back on
4998 // the row it was on (same root), so a live thread can't steal the keyboard.
4999 // Starts at 0 — the state the menu is already painted in when a chat opens,
5000 // so a chat with no threads never fires a pointless repaint on first render.
5001 let threadListSeen = 0; // last count the ▤ menu was told about
5002 root._refreshThreadList = () => {
5003 const rows = threadListItems(((ctx.getState() || {}).msgs) || []);
5004 if (rows.length !== threadListSeen) {
5005 threadListSeen = rows.length;
5006 document.dispatchEvent(new CustomEvent("lchat:views-changed"));
5007 }
5008 if (!threadListBody || !threadListBody.isConnected) return;
5009 if (threadListSignature(rows) === threadListSig) return;
5010 const active = document.activeElement;
5011 const keep = active && threadListBody.contains(active) ? active.dataset.root : "";
5012 paintThreadList(threadListBody);
5013 if (keep) {
5014 const back = threadListBody.querySelector(`.thread-list-row[data-root="${CSS.escape(keep)}"]`);
5015 if (back) back.focus();
5016 }
5017 };
5018
5019 threads.set(ctx.threadId, {
5020 open: openThread,
5021 close: closeThread,
5022 isOpen: () => !!threadSid,
5023 // How many threads this chat has right now — the ▤ row's gate.
5024 listCount: () => threadListItems(((ctx.getState() || {}).msgs) || []).length,
5025 openList: () => {
5026 showPanel({
5027 title: "Threads",
5028 position: "side",
5029 render: (el) => {
5030 threadListBody = el;
5031 paintThreadList(el);
5032 },
5033 onClose: () => {
5034 threadListBody = null;
5035 threadListSig = "";
5036 },
5037 });
5038 return true;
5039 },
5040 // The docked pane's composer api, while one is mounted — how setConnected
5041 // (and PR 3's composerFor routing) reach the second composer.
5042 paneComposer: () => (threadDocked && paneBlock ? paneBlock.api : null),
5043 // openForMessage(sid): a message that lives only in its thread (a reply
5044 // made there — inMainFeed false) has no transcript bubble to scroll to,
5045 // so a jump to it opens the thread on its root instead
5046 // (scrollToMessage's fallback). False when the message isn't loaded or
5047 // belongs in the transcript — then it's a plain scroll miss, not ours.
5048 // (A thread reply whose root is paged out renders inline, so the
5049 // scroll finds it directly and never gets here.)
5050 openForMessage: (sid) => {
5051 const msgs = ((ctx.getState() || {}).msgs) || [];
5052 const m = msgs.find((x) => x.sid === sid);
5053 if (!m || inMainFeed(m, bySidIndex(msgs))) return false;
5054 const ref = threadTarget(msgs, m);
5055 if (!ref.id) return false;
5056 openThread(ref.id, ref, null);
5057 return true;
5058 },
5059 // reveal(sid): the other half of the same jump — a target that isn't
5060 // loaded at all. Pages history back for it and lands on it, or says
5061 // inline why it can't (revealMessage). Mount-scoped like openForMessage,
5062 // which is why it rides this registry rather than being a feed concern of
5063 // its own.
5064 reveal: revealMessage,
5065 });
5066
5067 // Keyboard shortcuts, mirroring the original feed: with an empty composer,
5068 // cmd/ctrl-T opens the message menu on the latest message from someone else
5069 // (number keys then toggle reactions, Escape dismisses — openMessagePicker)
5070 // and cmd/ctrl-R starts a reply to the latest message. Escape also cancels
5071 // an armed reply/edit even when the composer isn't focused. "Latest" means
5072 // the latest in the TRANSCRIPT: a reply that lives only in its thread has
5073 // no bubble here to anchor a picker on, and the shortcuts act on the main
5074 // feed by definition.
5075 const latest = (othersOnly) => {
5076 const msgs = (ctx.getState() || {}).msgs || [];
5077 const bySid = bySidIndex(msgs);
5078 for (let i = msgs.length - 1; i >= 0; i--) {
5079 if (!inMainFeed(msgs[i], bySid)) continue;
5080 if (othersOnly && msgs[i].uid && msgs[i].uid === ctx.me) continue;
5081 return msgs[i];
5082 }
5083 return null;
5084 };
5085 // Stashed on the root so render() can re-point the shared dispatcher at
5086 // whichever mount is live now — including the instant-restore path, which
5087 // re-attaches this parked root without rebuilding the skeleton.
5088 root._hotkeys = (e) => {
5089 if (!root.isConnected) return;
5090 if ((e.metaKey || e.ctrlKey) && e.key === "t" && !composer.getText()) {
5091 e.preventDefault();
5092 const m = latest(true);
5093 if (m) openMessagePicker(m, ctx, feed.querySelector(`.msg[data-id="${m.sid}"]`) || feed);
5094 }
5095 if ((e.metaKey || e.ctrlKey) && e.key === "r" && !composer.getText()) {
5096 e.preventDefault();
5097 const m = latest(false);
5098 if (m) composers.get(ctx.threadId).startReply(m);
5099 }
5100 // Escape dismisses an open command panel when the composer isn't focused
5101 // (the focused case is in onComposerKeydown), with the same layer checks as
5102 // the thread-view close below — one Escape peels one layer, never two.
5103 if (e.key === "Escape" && panelOpen() && !e.defaultPrevented && !composer.hasFocus() && !document.querySelector(".emoji-picker, .msg-sheet, .lightbox")) {
5104 e.preventDefault();
5105 hidePanel();
5106 return;
5107 }
5108 // Escape closes an open thread view when the composer isn't focused (the
5109 // focused cases — main box and the docked pane's — are handled in each
5110 // block's onComposerKeydown, which marks a consumed Escape handled).
5111 // Skipped when a layer that owns Escape is up — the picker preventDefaults
5112 // before us (capture phase), the sheet/lightbox are still in the DOM when
5113 // we run — so one Escape peels one layer, never two. Docked, a staged
5114 // reply the user armed on the main composer is the newer layer: cancel it
5115 // (the branch below) before an Escape reaches the pane.
5116 // A pane edit in flight is the innermost layer of all: focus in neither
5117 // field, Escape cancels THAT — never the pane around it, which would
5118 // drop the typed edit (the pane's own field handles it when focused).
5119 const paneEditing = !!(threadDocked && paneBlock && paneBlock.api.editing());
5120 if (e.key === "Escape" && paneEditing && !e.defaultPrevented && !document.querySelector(".emoji-picker, .msg-sheet, .lightbox")) {
5121 e.preventDefault();
5122 paneBlock.api.cancelEdit();
5123 return;
5124 }
5125 // Keyboard Escape is never destructive (the Mac rule too): a docked pane
5126 // holding a typed draft or staged files stays put — the ✕ is the
5127 // deliberate close-with-content gesture.
5128 const paneHasContent = !!(threadDocked && paneBlock && (!paneBlock.composer.isEmpty() || paneBlock.api.staged()));
5129 if (e.key === "Escape" && threadSid && !paneHasContent && !api.editing() && !(threadDocked && api.stagedReply()) && !e.defaultPrevented && !composer.hasFocus() && !document.querySelector(".emoji-picker, .msg-sheet, .lightbox")) {
5130 e.preventDefault();
5131 closeThread();
5132 return;
5133 }
5134 if (e.key === "Escape" && !e.defaultPrevented && !composer.hasFocus() && (api.stagedReply() || api.editing()) && !document.querySelector(".emoji-picker")) {
5135 e.preventDefault();
5136 const wasEditing = api.editing();
5137 api.cancelReply();
5138 api.cancelEdit();
5139 if (wasEditing) {
5140 composer.setText("");
5141 api.refreshSend();
5142 }
5143 }
5144 };
5145
5146 // Desktop opens ready to type, like the original feed.
5147 if (!coarse && !inEmbed) composer.focus();
5148
5149 // Embedded: the native composer drives THIS composer through the shell's
5150 // window.lchatNative bridge (see installNativeBridge in chat.js) — text,
5151 // typing beats, staged photos, and reply cancels all land in the same code
5152 // paths the hidden web composer uses, so behaviour can't fork.
5153 //
5154 // Stashed on the root (like _hotkeys) rather than registered here: the host
5155 // keeps a SINGLE native dispatcher, so render() re-points it at whichever
5156 // mount is live now — including the instant-restore path that re-attaches a
5157 // parked root without rebuilding — so a stale thread never gets the events.
5158 if (inEmbed && host.registerNative) {
5159 root._native = {
5160 sendText(text) {
5161 if (api.uploading()) return false; // still uploading
5162 composer.setText(text || "");
5163 if (composer.isEmpty() && !api.staged()) return false;
5164 api.submit();
5165 api.typing(false);
5166 return true;
5167 },
5168 typing(hasText) {
5169 api.typing(hasText);
5170 },
5171 stage(file) {
5172 api.stageFiles([file]);
5173 },
5174 cancelReply() {
5175 // The native banner's ✕ means the same as the web one: leaving an open
5176 // thread view, not just dropping its staged reply.
5177 if (threadSid) closeThread();
5178 else api.cancelReply();
5179 },
5180 cancelEdit() {
5181 api.cancelEdit();
5182 },
5183 didAttach(force) {
5184 const atBottom = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 60;
5185 if (force || atBottom) feed.scrollTop = feed.scrollHeight;
5186 },
5187 };
5188 }
5189
5190 return root;
5191}
5192