eleven / messenger Public

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

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
56// mentionNames() / mention(label, el) roster labels + the member menu
57// isGroup(threadId) / agentKind(name) sender-name + AI/BOT-badge rules
58// icons the original in-code SVG icon strings
59let host = null;
60export function setHost(h) {
61 host = h;
62}
63
64const REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🎉"]; // mirrors allowedReactions (hub.go)
65const EDIT_WINDOW_MS = 15 * 60 * 1000; // mirrors editWindow (hub.go); server enforces it
66const TIME_SEPARATOR_GAP_MS = 15 * 60 * 1000; // mirrors the original feed's separator rule
67// Typing beats, mirroring the original composer: while you're typing we re-emit a
68// beat every TYPING_INTERVAL_MS so the receiver's indicator survives a pause, and
69// each beat expires after TYPING_EXPIRY_S — comfortably longer than the interval,
70// so a single late/dropped beat doesn't blink the indicator out.
71const TYPING_INTERVAL_MS = 10000;
72const TYPING_EXPIRY_S = 15;
73const coarse = typeof matchMedia !== "undefined" && matchMedia("(pointer: coarse)").matches;
74
75// Event TYPES only: N.ev("msg") → "chat.msg", etc. (chat reuses the original
76// feed's shared CSS classes — .msg/.bubble/.feed/… — which stay literal.)
77const N = namespace("chat");
78
79// Per-thread presence: name → epoch ms until which they count as typing.
80// Ephemeral events never fold, so this lives outside reducer state — it's
81// transient UI, allowed to differ between devices.
82const typing = new Map(); // threadId → Map(name → until)
83const refreshTyping = new Map(); // threadId → fn redrawing the typing row
84
85// Private command replies ("only visible to you") live in the feed as CLIENT-ONLY
86// rows: never sent, gone on reload, and interleaved with real messages so they
87// stack like chat bubbles and persist (no auto-dismiss). Each carries a `seq` just
88// above the newest message at creation time, so a later real message falls below
89// it. render() folds these into its keyedList (see the notice branch in renderItem).
90const localNotices = new Map(); // threadId → [{ id, seq, sender, text, actions }]
91const refreshFeed = new Map(); // threadId → fn re-rendering this chat pane
92const mountedCtx = new Map(); // threadId → live app ctx (for shell-injected notices)
93const pendingNotices = new Map(); // threadId → [spec] queued until that thread mounts
94let noticeSeq = 0; // monotonic: unique ids + a stable tie-break within a thread
95
96// appendNotice injects a private "only visible to you" notice into a thread's feed
97// from OUTSIDE a command (the shell uses it when an action chip answers in place —
98// the welcome's "Take the tour", and the tour continuing after it navigates you to
99// the Eleven DM). If the target thread isn't mounted yet (we just navigated to it
100// and its feed is still loading), the notice is queued and flushed when it mounts.
101export function appendNotice(threadId, spec) {
102 const ctx = mountedCtx.get(threadId);
103 if (ctx) {
104 addLocalNotice(ctx, spec);
105 return;
106 }
107 const q = pendingNotices.get(threadId) || [];
108 q.push(spec);
109 pendingNotices.set(threadId, q);
110}
111
112// setNoticeTyping shows/clears a typing row for a SCRIPTED speaker (the setup
113// conversation's "Eleven is typing…" beat before each reply). Local-only — it
114// rides the same transient typing state as real members' beats, never the wire.
115// The until-cap self-expires a beat whose clear got lost.
116export function setNoticeTyping(threadId, name, on) {
117 let m = typing.get(threadId);
118 if (!m) typing.set(threadId, (m = new Map()));
119 if (on) m.set(name, Date.now() + 10000);
120 else m.delete(name);
121 const refresh = refreshTyping.get(threadId);
122 if (refresh) refresh();
123}
124
125// addLocalNotice records a command reply for `threadId` and re-renders the pane so
126// it appears immediately. Anchored just above the current newest message so it
127// sits where you typed it and never jumps around as the conversation continues.
128// The composer placeholder follows the conversation: a scripted question can
129// carry a `hint` ("e.g. Finn Wolfhard") that replaces "Message" the moment the
130// question renders, or an `attachHint` ("Optional: describe your photo") that
131// applies only while attachments are staged — a photo question shouldn't
132// relabel the box before there's a photo. A `label` ("Your name:") renders as
133// an inset field label INSIDE the input — the Mail/Messages "To:" pattern —
134// so the ask survives even when the placeholder is hidden by typed text.
135// Sending resets everything to "Message"; the next question sets its own.
136// Keyed like refreshFeed.
137const composerHints = new Map();
138const composerAttachHints = new Map();
139const composerLabels = new Map();
140const refreshComposerHint = new Map();
141function applyComposerHint(threadId) {
142 const fn = refreshComposerHint.get(threadId);
143 if (fn) fn();
144}
145function clearComposerHint(threadId) {
146 composerHints.set(threadId, "");
147 composerAttachHints.set(threadId, "");
148 composerLabels.set(threadId, "");
149 applyComposerHint(threadId);
150}
151// A cancelled ask never strands its pill (#1375) — whoever cancelled it.
152setAnswerCancelledHook((threadId) => clearComposerHint(threadId));
153
154// Feed-follow with intent: feedGlideToBottom eases towards the (still
155// growing) bottom, and while it's in flight feedNearBottom stays true — an
156// instantaneous scrollTop read mid-glide looks "scrolled away" and used to make
157// the typing row skip its pin. A user wheel/touch cancels the chase.
158const feedGlides = new WeakSet();
159const feedGlideCancels = new WeakSet();
160export function feedNearBottom(feed, slack = 80) {
161 return feedGlides.has(feed) || feed.scrollHeight - feed.scrollTop - feed.clientHeight < slack;
162}
163// How far the reader has to scroll up before the "jump to latest" button
164// appears — deliberately much wider than feedNearBottom's 60px auto-pin
165// slack, so the button doesn't show for merely reading the newest message.
166const JUMP_BUTTON_GAP = 400;
167export function feedGlideToBottom(feed, jump) {
168 if (!feedGlideCancels.has(feed)) {
169 feedGlideCancels.add(feed);
170 const cancel = () => feedGlides.delete(feed);
171 // Cancel on SCROLL INTENT only — wheel, or an actual touch DRAG. A bare
172 // touchstart is any tap in the feed (action chips included), and cancelling
173 // on those broke the follow for every chip-tapped step on touch devices.
174 feed.addEventListener("wheel", cancel, { passive: true });
175 feed.addEventListener("touchmove", cancel, { passive: true });
176 }
177 if (jump || matchMedia("(prefers-reduced-motion: reduce)").matches) {
178 feedGlides.delete(feed);
179 feed.scrollTop = feed.scrollHeight;
180 return;
181 }
182 if (feedGlides.has(feed)) return; // the running glide chases new growth itself
183 feedGlides.add(feed);
184 // Also detect intervention we DON'T get events for (scrollbar drag, PageUp,
185 // arrow keys): if scrollTop is meaningfully below where we last placed it, the
186 // user moved it — bail so we never fight them back down.
187 let lastSet = feed.scrollTop;
188 const step = () => {
189 if (!feedGlides.has(feed)) return; // cancelled by the user (wheel/touch)
190 if (feed.scrollTop < lastSet - 12) {
191 feedGlides.delete(feed);
192 return;
193 }
194 const target = feed.scrollHeight - feed.clientHeight;
195 const d = target - feed.scrollTop;
196 if (Math.abs(d) < 2) {
197 feed.scrollTop = target;
198 feedGlides.delete(feed);
199 return;
200 }
201 feed.scrollTop += d * 0.16;
202 lastSet = feed.scrollTop;
203 requestAnimationFrame(step);
204 };
205 requestAnimationFrame(step);
206}
207
208function addLocalNotice(ctx, spec) {
209 composerHints.set(ctx.threadId, spec.hint || "");
210 composerAttachHints.set(ctx.threadId, spec.attachHint || "");
211 composerLabels.set(ctx.threadId, spec.label || "");
212 applyComposerHint(ctx.threadId);
213 // Hint-only (a persisted question with no chips): the placeholder was the
214 // whole point — don't add an empty row to the feed.
215 if (!spec.text && !spec.img && !(spec.actions && spec.actions.length)) return;
216 const msgs = (ctx.getState() && ctx.getState().msgs) || [];
217 const maxSid = msgs.length ? msgs[msgs.length - 1].sid : 0;
218 const list = localNotices.get(ctx.threadId) || [];
219 noticeSeq += 1;
220 // maxSid + a tiny monotonic fraction: strictly between this sid and the next, so
221 // ordering vs. integer message sids is unambiguous and creation order is kept.
222 // `at` (creation time) drives the loud→compact collapse in buildLocalNotice: a
223 // fresh reply shows the full card then calms down; one rebuilt long after it
224 // landed (thread reopened) starts compact. `short` is the terse one-liner text.
225 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() });
226 localNotices.set(ctx.threadId, list);
227 // A notice is always a response to something the reader just did (a command,
228 // an action chip, a scripted step) — snap to it like a local send, even if
229 // they'd scrolled up.
230 // A full notice snaps the feed like a local send (pinNext → instant); a
231 // chips-only row rides the smooth glide of the bubble it belongs to — the
232 // extra instant jump right after a glide is what reads as jank.
233 if (spec.text || spec.img) pinNext.add(ctx.threadId);
234 const refresh = refreshFeed.get(ctx.threadId);
235 if (refresh) refresh();
236}
237
238function typingNames(threadId, exceptName) {
239 const m = typing.get(threadId);
240 if (!m) return [];
241 const now = Date.now();
242 const out = [];
243 for (const [name, until] of m) {
244 if (until <= now) m.delete(name);
245 else if (name && name !== exceptName) out.push(name);
246 }
247 return out;
248}
249
250// Command panels: the transient results surface a slash command can open
251// (ctx.panel — see the command-panels section of apps.js). Each mounted pane
252// registers its controller in buildSkeleton; the one injected host routes an
253// open/close to the pane it belongs to. Returning false when the thread has no
254// mounted chat surface lets the command fall back to a plain reply.
255const cmdPanels = new Map(); // threadId → (spec|null) => void
256setCommandPanelHost((threadId, spec) => {
257 const show = cmdPanels.get(threadId);
258 if (!show) return false;
259 show(spec);
260 return true;
261});
262
263// Which reactions are mine, keyed "sid|emoji" — seeded from the load-time mine
264// flags and kept current by local toggles, exactly like the original feed's
265// myReactions set (live reaction frames carry counts only).
266const myReactions = new Set();
267
268// The composer controller for each mounted thread, so bubble actions (Reply,
269// Edit) can reach the composer they share a pane with.
270const composers = new Map(); // threadId → { refreshSend(), startReply(item), startEdit(item) }
271
272// The reply-thread view controller for each mounted thread, so bubble taps (a
273// quote card, a "N Replies" tally) can lift that thread over the pane they live
274// in. Built alongside the composer in buildSkeleton, overwritten per mount.
275const threads = new Map(); // threadId → { open(rootSid, ref), close(), isOpen() }
276
277// Threads whose next render should snap to the newest message even if the reader
278// had scrolled up — set when you send from the composer (a reply included), since
279// there's no optimistic echo: your message only lands on the server round-trip's
280// re-render, by which time render()'s scroll-position check has forgotten you just
281// sent. render() consumes (and clears) this so a send always brings your message
282// into view, like every familiar chat client.
283const pinNext = new Set(); // threadId
284
285// Whether the socket is currently open. chat.js flips this on ws open/close so
286// each composer can grey its send button while a message can't reach the server
287// — a send while down is kept in the composer, never dropped (see compose.onsubmit).
288let connected = true;
289export function setConnected(v) {
290 connected = v;
291 for (const c of composers.values()) c.refreshSend();
292}
293
294// Successful unfurl results cached by URL so a bubble re-render doesn't
295// refetch; a failed or empty unfurl is evicted (see addLinkPreview) so a later
296// render retries rather than staying blank until a full reload.
297const unfurls = new Map(); // url → Promise<preview | null>
298
299// One document-level hotkey listener, dispatching to the currently-active
300// mount's handler. render() re-points this on every open — including the
301// instant-restore path where buildSkeleton is skipped — and only ever to a
302// connected root, so a parked/torn-down pane never hijacks the shortcuts.
303let hotkeys = null;
304if (typeof document !== "undefined") {
305 document.addEventListener("keydown", (e) => {
306 if (hotkeys) hotkeys(e);
307 });
308}
309
310// ---- time helpers (the original feed's exact rules) -------------------------
311
312function formatTime(ms) {
313 return new Date(ms).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
314}
315function sameDay(a, b) {
316 const da = new Date(a);
317 const db = new Date(b);
318 return da.getFullYear() === db.getFullYear() && da.getMonth() === db.getMonth() && da.getDate() === db.getDate();
319}
320// "Today" / "Yesterday" / a weekday (past week) / a date — the separator label.
321function dayLabel(ms) {
322 const now = Date.now();
323 if (sameDay(ms, now)) return "Today";
324 if (sameDay(ms, now - 86400000)) return "Yesterday";
325 const d = new Date(ms);
326 if (now - ms < 7 * 86400000) return d.toLocaleDateString([], { weekday: "long" });
327 const sameYear = new Date(now).getFullYear() === d.getFullYear();
328 return d.toLocaleDateString([], sameYear ? { month: "short", day: "numeric" } : { year: "numeric", month: "short", day: "numeric" });
329}
330
331// viewItems derives the render list from folded messages, with the original
332// feed's exact grouping rules: a .time-sep when a message opens the feed, the
333// calendar day changes (dated), or >15 min passed (time only); `cont` (tighter
334// spacing + hidden repeated name) for a same-sender run with no separator
335// between; and each message's reply tally. Pure and exported for tests.
336//
337// Two revs, so keyedList rebuilds a bubble only when it truly must: `vrev` is the
338// HARD rev — content (text/edit) and neighbour-derived layout (separator, run) —
339// and any change to it re-renders the whole bubble. `srev` is the SOFT rev —
340// reactions and the reply tally — and a change to it patches only those rows in
341// place (see patchBubble), never rebuilding, so a decrypted photo's <img> and a
342// playing YouTube embed survive a reaction or an incoming reply landing on it.
343// rootRef walks a reply pointer to its thread's TOP message: new replies point
344// straight at the root, so this is one hop; legacy chains (a reply quoting
345// another reply) are chased through the loaded window. Returns the deepest ref
346// reachable — its .id is the root's sid when the chain resolves, or the last
347// known pointer when the parent scrolled out of the loaded window. Hop-capped
348// so a hostile payload can't cycle forever.
349function rootRef(bySid, reply) {
350 let ref = reply;
351 for (let hops = 0; hops < 100; hops++) {
352 const parent = bySid.get(ref.id);
353 if (!parent || !parent.reply || !parent.reply.id) return ref;
354 ref = parent.reply;
355 }
356 return ref;
357}
358
359// replyTarget: the replyTo a reply to `m` should carry. Replying to any message
360// in a thread continues that thread, so the ref always names the thread's TOP
361// message — never the tapped reply. When the root is loaded, its name/snippet
362// are snapshotted fresh; an unloaded root falls back to the deepest quoted ref
363// we can see. Pure and exported for tests (mirrored natively by
364// ChatFeedModel.replyTarget — pinned by the reply_thread_grouping vector).
365export function replyTarget(msgs, m) {
366 if (!m.reply || !m.reply.id) return { id: m.sid, name: m.name, snippet: replySnippet(m.raw) };
367 const bySid = new Map(msgs.map((x) => [x.sid, x]));
368 const ref = rootRef(bySid, m.reply);
369 const root = bySid.get(ref.id);
370 return root
371 ? { id: root.sid, name: root.name, snippet: replySnippet(root.raw) }
372 : { id: ref.id || 0, name: ref.name || "", snippet: ref.snippet || "" };
373}
374
375// threadItems: the reply thread rooted at `rootSid` — the root message (when
376// loaded) followed by every message whose RESOLVED thread root is that sid, in
377// server order. Legacy chains (a reply quoting another reply) resolve
378// transitively via rootRef, so they land in the same thread as their ancestors;
379// when the root itself has scrolled out of the loaded window the replies still
380// fold (the view renders the quoted snapshot as its header instead). Pure and
381// exported for tests — the thread view re-derives this from current state on
382// every fold, never a cached list.
383export function threadItems(msgs, rootSid) {
384 const bySid = new Map(msgs.map((m) => [m.sid, m]));
385 const out = [];
386 const root = bySid.get(rootSid);
387 if (root) out.push(root);
388 for (const m of msgs) {
389 if (m.reply && m.reply.id && rootRef(bySid, m.reply).id === rootSid) out.push(m);
390 }
391 return out.sort((a, b) => a.sid - b.sid);
392}
393
394// Message order: confirmed messages by server id (the stable identity), with any
395// still-pending optimistic copies (sid 0) after all of them, ordered by send time
396// — so a just-sent bubble sits at the bottom until its echo slots it into place.
397function bySidPendingLast(a, b) {
398 if (a.sid && b.sid) return a.sid - b.sid;
399 if (!a.sid && !b.sid) return a.ts - b.ts;
400 return a.sid ? -1 : 1;
401}
402
403export function viewItems(msgs) {
404 // Reply threads group on the resolved ROOT message: every reply tallies on
405 // its thread's top message (legacy chains resolve transitively), and each
406 // reply carries its root sid (`replyRoot`) so taps/jumps land on the thread.
407 const bySid = new Map(msgs.map((m) => [m.sid, m]));
408 const replyCounts = new Map(); // thread root sid → messages in its thread
409 const roots = new Map(); // reply sid → its thread's root sid
410 for (const m of msgs) {
411 if (m.reply && m.reply.id) {
412 const root = rootRef(bySid, m.reply).id;
413 roots.set(m.sid, root);
414 replyCounts.set(root, (replyCounts.get(root) || 0) + 1);
415 }
416 }
417 const out = [];
418 let prev = null;
419 for (const m of msgs) {
420 let timeSep = null;
421 if (!prev) timeSep = { withDay: true }; // the oldest message opens with a dated separator
422 else {
423 const newDay = !sameDay(m.ts, prev.ts);
424 if (newDay || m.ts - prev.ts > TIME_SEPARATOR_GAP_MS) timeSep = { withDay: newDay };
425 }
426 const cont = !timeSep && sameSender(prev, m);
427 const replyCount = replyCounts.get(m.sid) || 0;
428 // The soft rev is derived purely from reactions + tally (never myReactions —
429 // viewItems stays pure/exported/tested); a chip's count moves on every
430 // react/unreact, so this signature captures mine-flips too.
431 const reactSig = (m.reactions || []).map((r) => `${r.emoji}${r.count}`).join(",");
432 out.push({
433 ...m,
434 timeSep,
435 cont,
436 replyCount,
437 replyRoot: roots.get(m.sid) || 0,
438 vrev: `${m.rev}|${timeSep ? (timeSep.withDay ? "D" : "t") : ""}|${cont ? 1 : 0}`,
439 // Delivery state rides the SOFT rev: the pending→slow→confirmed transitions
440 // patch the bubble in place (data-id, the "sending…" chip, action affordances)
441 // without rebuilding it — so an optimistic photo never flashes on confirm.
442 srev: `${reactSig}|${replyCount}|${m.pending ? (m.slow ? "slow" : "send") : "ok"}|${m.sid ? 1 : 0}`,
443 });
444 prev = m;
445 }
446 return out;
447}
448
449export const app = defineApp({
450 name: "chat",
451 title: "Chat",
452 glyph: "💬",
453 places: [], // host-mounted for chat threads — never in the Apps menu
454 snapshot: false, // state IS the message log; snapshotting would duplicate it
455 incremental: true, // reconcile the pane in place — appends, not rebuilds
456
457 initialState: () => ({ msgs: [] }),
458
459 // The one wire type the app owns: typing, relay-only (handled by the
460 // ephemeral hook below). The no-op fold claims the type; if a hostile client
461 // stores one as a real message, it folds to nothing.
462 events: {
463 [N.ev("typing")]: (s) => s,
464 },
465
466 // Host-only folds: the existing protocol, translated. Messages key on the
467 // server message id (the original identity) and stay sorted by it, so a
468 // scroll-back page folded later still lands before newer messages. `raw`
469 // keeps the original payload fields (attachments with w/h, replyTo, name…)
470 // so an edit can rebuild the full payload exactly like the original composer
471 // ({ ...orig, text }). `rev` is the HARD rev: it bumps only on a content change
472 // (edit) so keyedList rebuilds that bubble. A reaction does NOT bump it —
473 // reactions ride viewItems' soft rev and patch the row in place (see patchBubble),
474 // so reacting never recreates the bubble's photo or its playing YouTube embed.
475 hostEvents: {
476 [N.ev("msg")]: (s, e, meta) => {
477 const text = typeof e.text === "string" ? e.text : "";
478 const atts = Array.isArray(e.attachments) ? e.attachments.filter((a) => a && a.id) : [];
479 const sid = (meta && meta.id) || 0;
480 const cid = typeof e._cid === "string" ? e._cid : "";
481 const pending = !!(meta && meta.pending);
482 // A server message needs its id; an optimistic local echo has none yet
483 // (it's pending, keyed only by cid until its own broadcast confirms it).
484 if ((!text.trim() && !atts.length) || (!sid && !pending)) return s;
485 if (sid && s.msgs.some((m) => m.sid === sid)) return s; // re-fold — already present
486 // Our own echo confirming an optimistic copy: upgrade that pending record
487 // IN PLACE — adopt the server id, drop the pending flags — keeping its
488 // stable keyedList id (its bubble is patched, never rebuilt, so a photo or
489 // playing embed never flashes). A second optimistic fold of the same cid
490 // (no sid yet) is a no-op.
491 if (cid) {
492 const idx = s.msgs.findIndex((m) => m.cid === cid);
493 if (idx >= 0) {
494 if (!sid) return s;
495 const msgs = s.msgs.slice();
496 msgs[idx] = { ...msgs[idx], sid, ts: e.ts || msgs[idx].ts, pending: false, slow: false };
497 msgs.sort(bySidPendingLast);
498 return { msgs };
499 }
500 }
501 const raw = { name: e.name || "", text, ts: e.ts || ((meta && meta.ts) || 0) };
502 if (e.uid) raw.uid = e.uid;
503 if (atts.length) raw.attachments = atts.map((a) => ({ ...a }));
504 if (e.replyTo) raw.replyTo = { ...e.replyTo };
505 if (Array.isArray(e.actions) && e.actions.length) raw.actions = e.actions; // helper chips (host-whitelisted at render)
506 if (e.card && typeof e.card === "object") raw.card = e.card; // bot-authored card — validated at render (cards.js)
507 if (typeof e.key === "string" && e.key) raw.key = e.key; // scripted-line dedup key (one-Eleven welcome, shell.js)
508 if (e.action) raw.action = true; // keeps a /me line rendering as an action line if you edit it
509 if (e.warn) raw.warn = true; // an admin warning keeps its framing through edits too
510 const msgs = [
511 ...s.msgs,
512 {
513 // A pending optimistic copy keys on its cid for life (stable across the
514 // upgrade to a real sid), so its bubble is never destroyed/recreated.
515 id: sid ? "m" + sid : "c" + cid,
516 sid,
517 cid,
518 pending, // shown, but not yet acknowledged by the server
519 slow: false, // flipped true by chat.pending after 5s unconfirmed
520 text,
521 name: raw.name,
522 uid: e.uid || "",
523 ts: raw.ts,
524 attachments: raw.attachments || [],
525 reply: e.replyTo ? { id: e.replyTo.id || 0, name: e.replyTo.name || "", snippet: e.replyTo.snippet || "" } : null,
526 actions: raw.actions || [],
527 card: raw.card || null,
528 action: !!e.action, // an app-thread /me-style action line, not a bubble
529 // An admin warning (issue #578): same bubble, distinct system framing.
530 // It rides inside the ciphertext — the server can't tell a warning
531 // from a "hello"; it only audits that one was issued.
532 warn: !!e.warn,
533 reactions: [],
534 edited: false,
535 raw,
536 rev: 0,
537 },
538 ];
539 msgs.sort(bySidPendingLast);
540 return { msgs };
541 },
542 // A still-unconfirmed optimistic send, 5s on: mark it slow so the bubble
543 // surfaces a subtle "sending…" state (host-fed by shell.js's markSlow timer).
544 [N.ev("pending")]: (s, e) => {
545 const cid = typeof e._cid === "string" ? e._cid : "";
546 if (!cid) return s;
547 const idx = s.msgs.findIndex((m) => m.cid === cid && m.pending);
548 if (idx < 0) return s;
549 const msgs = s.msgs.slice();
550 msgs[idx] = { ...msgs[idx], slow: true };
551 return { msgs };
552 },
553 [N.ev("reaction")]: (s, e, meta, ctx) => {
554 if (e.targetSid === undefined || e.emoji === undefined || e.uid === undefined || e.on === undefined) return s;
555 const me = ctx && ctx.me;
556 const msgs = s.msgs.map((m) => {
557 if (m.sid !== e.targetSid) return m;
558 const reactionEvents = m.reactionEvents || [];
559 let found = false;
560 const updated = reactionEvents.map((ev) => {
561 if (ev.emoji === e.emoji && ev.uid === e.uid) {
562 found = true;
563 return { ...ev, on: e.on, ts: e.ts };
564 }
565 return ev;
566 });
567 if (!found && e.on) {
568 updated.push({ emoji: e.emoji, uid: e.uid, on: true, ts: e.ts });
569 }
570 const currentReactions = updated.filter((ev) => ev.on);
571 const derived = [];
572 const emojiMap = new Map();
573 for (const ev of currentReactions) {
574 if (!emojiMap.has(ev.emoji)) {
575 emojiMap.set(ev.emoji, []);
576 }
577 emojiMap.get(ev.emoji).push(ev.uid);
578 }
579 for (const [emoji, uids] of emojiMap) {
580 derived.push({ emoji, count: uids.length, mine: uids.includes(me) });
581 }
582 derived.sort((a, b) => a.emoji.localeCompare(b.emoji));
583 return { ...m, reactions: derived, reactionEvents: updated, rev: m.rev + 1 };
584 });
585 return { msgs };
586 },
587 [N.ev("edit")]: (s, e) => ({
588 msgs: s.msgs.map((m) => {
589 if (m.sid !== e.msgId) return m;
590 const text = typeof e.text === "string" ? e.text : m.text;
591 return { ...m, text, raw: { ...m.raw, text }, edited: true, rev: m.rev + 1 };
592 }),
593 }),
594 [N.ev("delete")]: (s, e) => ({ msgs: s.msgs.filter((m) => m.sid !== e.msgId) }),
595 },
596
597 // Typing: relay-only, never stored or folded. The server echoes your own beat
598 // back to you, so drop it here rather than at display time — matching by uid is
599 // robust even in a fresh thread, where myName() stays null until one of your
600 // messages folds into view. A uid-less legacy beat falls back to the screen
601 // name, mirroring the original feed's `payload.name === session.name` guard.
602 ephemeral(e, ctx) {
603 if (e.type !== N.ev("typing") || !e.name) return;
604 if (e.uid ? e.uid === ctx.me : e.name === ctx.meName) return;
605 let m = typing.get(ctx.threadId);
606 if (!m) typing.set(ctx.threadId, (m = new Map()));
607 const secs = Math.min(Math.max(Number(e.expiry) || 0, 0), 60);
608 if (secs === 0) m.delete(e.name);
609 else m.set(e.name, Date.now() + secs * 1000);
610 const refresh = refreshTyping.get(ctx.threadId);
611 if (refresh) refresh();
612 },
613
614 // Almost no CSS of its own: the feed content is original markup styled by
615 // style.css. What remains is layout glue — the pane loses its app padding so
616 // the feed/composer sit exactly like the original ones, the item wrapper is
617 // display:contents so .msg/.time-sep participate in the feed's flex layout
618 // as if they were direct children, and .chatapp-feed mirrors the #feed rule
619 // (the one original selector that's id-based, so unreusable).
620 styles: `
621#app-pane:has(> .chatapp-root) { padding: 0; gap: 0; }
622.chatapp-root { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; width: 100%; position: relative; }
623.chatapp-item { display: contents; }
624/* Embedded in the native shell: the shell draws its own composer + banners, so
625 the app shows only the feed (mirrors the original .native-embed layout). */
626.chatapp-root.embed > .composer,
627.chatapp-root.embed .typeahead,
628.chatapp-root.embed > .composer-reply,
629.chatapp-root.embed > .composer-edit { display: none; }
630/* A read-only chat view (the owning app declares chatReadOnly — the Admin app's
631 notice stream): the app posts here, the user doesn't, so no composer at all. */
632.chatapp-root.readonly > .composer,
633.chatapp-root.readonly .typeahead,
634.chatapp-root.readonly > .composer-reply,
635.chatapp-root.readonly > .composer-edit { display: none; }
636/* Embedded: keep the attachment preview visible (a staged photo shows just above
637 the native composer) and give the feed the native safe-area insets — mirrors
638 the original feed's .native-embed rules. */
639.native-embed .chatapp-feed {
640 padding-top: calc(18px + var(--native-top-inset, 0px));
641 padding-bottom: calc(18px + var(--native-bottom-inset, 0px));
642}
643.native-embed .feed-jump-btn { bottom: calc(18px + var(--native-bottom-inset, 0px)); }
644.chatapp-feed {
645 flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden;
646 padding: 18px; display: flex; flex-direction: column; gap: 4px;
647}
648.chatapp-feed::before { content: ""; margin-top: auto; }
649/* The feed's positioning wrapper: the reply-thread view overlays exactly this
650 box (inset:0 in style.css), so it dims/covers the transcript but never the
651 composer stack below — the real composer stays usable under the thread. */
652.chatapp-feedwrap {
653 flex: 1 1 auto; min-height: 0; min-width: 0; position: relative;
654 display: flex; flex-direction: column;
655}
656/* Jump-to-latest: bottom-right of the feed, shown once the reader has
657 scrolled a moderate distance up (see JUMP_BUTTON_GAP). Sits below the
658 reply-thread overlay's z-index (30) so that view still covers it. */
659.feed-jump-btn {
660 position: absolute; right: 18px; bottom: 18px; z-index: 20;
661 width: 40px; height: 40px; padding: 0; border-radius: 50%;
662 display: inline-flex; align-items: center; justify-content: center;
663 background: var(--bubble-them); color: var(--ink);
664 box-shadow: 0 6px 20px var(--shadow);
665 transition: opacity 0.15s ease, transform 0.15s ease;
666}
667.feed-jump-btn svg { width: 20px; height: 20px; }
668.feed-jump-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
669@media (prefers-reduced-motion: reduce) { .feed-jump-btn { transition: none; } }
670` + CARD_CSS,
671
672 // Incremental render: the skeleton (feed / banners / composer) is built once
673 // and kept; each state change only reconciles the message list via keyedList
674 // (the composite vrev re-renders a bubble when its content or its
675 // neighbour-derived presentation changes). Scroll stays pinned to the bottom
676 // unless the reader scrolled up.
677 render(state, el, ctx) {
678 let root = el.querySelector(":scope > .chatapp-root");
679 if (!root) {
680 root = buildSkeleton(el, ctx);
681 }
682 // Stamp the mount with its thread so a stray refresh can tell whether the
683 // pane still belongs to it (see doRefresh). #app-pane is shared across
684 // threads, so an async welcome/tour step firing after you've navigated
685 // away would otherwise re-render its thread into whatever's on screen now.
686 root.dataset.thread = ctx.threadId;
687 // Point the single document-level hotkey listener at THIS mount. On the
688 // instant-restore path buildSkeleton is skipped, so this — not the build —
689 // is what keeps the shortcuts live after switching threads. Guard on
690 // isConnected so a parked thread re-rendering off-screen can't steal the
691 // active pane's shortcuts.
692 if (root.isConnected) hotkeys = root._hotkeys;
693 // Same for the native shell's composer bridge: registerNative sets one
694 // dispatcher in the host, so without re-pointing it here a native
695 // send/typing/attach after revisiting a chat would route to the last-built
696 // (now detached) thread. Guarded on isConnected — like the hotkeys re-point
697 // above — so a parked pane re-rendering off-screen can't grab the bridge.
698 if (root.isConnected && root._native && host && host.registerNative) {
699 host.registerNative(root._native);
700 }
701 // Same again for the native macOS shell's Finder file-drop: it fills the
702 // page's single static #file-input and fires `change`, so point that input at
703 // the thread on screen (not the last-built one). Idempotent .onchange = so
704 // re-rendering doesn't stack listeners. (The classic composer that used to own
705 // #file-input is gone, so nothing else is listening.)
706 if (root.isConnected && root._dropFiles) {
707 const fileDropInput = document.getElementById("file-input");
708 if (fileDropInput) fileDropInput.onchange = root._dropFiles;
709 }
710
711 // A closed DM (issue #578): the server refuses new traffic, so the
712 // composer gives way to an honest banner with the one useful action —
713 // Reopen. Re-evaluated every render, so the dm_closed/dm_reopened frames
714 // flip the pane live.
715 paintClosedState(root, ctx);
716
717 // Let addLocalNotice re-render this pane when a command reply comes in — the
718 // reply isn't folded state, so nothing else would. Points at the live mount
719 // (like the hotkeys/native re-points above), self-removed on teardown.
720 const doRefresh = () => {
721 // A parked thread's refresh (a late welcome/tour notice, a scripted
722 // typing beat) must not paint into the shared #app-pane once another
723 // thread has taken it over — that's how Eleven's onboarding messages
724 // leaked into the chat you'd navigated to. The notice is already stored
725 // in localNotices and paints when this thread is next opened; skip the
726 // live render when the pane no longer belongs to us.
727 const cur = el.querySelector(":scope > .chatapp-root");
728 if (cur && cur.dataset.thread && cur.dataset.thread !== ctx.threadId) return;
729 renderApp(ctx.threadId, "chat", el);
730 };
731 refreshFeed.set(ctx.threadId, doRefresh);
732 // Keep the live mount ctx so the shell can inject a private notice into this
733 // thread (e.g. the welcome DM's "Take the tour" chip starting Eleven's tour).
734 mountedCtx.set(ctx.threadId, ctx);
735 // Flush any notices that were queued for this thread before it mounted (the
736 // tour navigating to the Eleven DM appends its next step while the feed loads).
737 const queued = pendingNotices.get(ctx.threadId);
738 if (queued) {
739 pendingNotices.delete(ctx.threadId);
740 for (const spec of queued) addLocalNotice(ctx, spec);
741 }
742 ctx.onCleanup(() => {
743 if (refreshFeed.get(ctx.threadId) === doRefresh) refreshFeed.delete(ctx.threadId);
744 if (mountedCtx.get(ctx.threadId) === ctx) mountedCtx.delete(ctx.threadId);
745 });
746
747 const feed = root.querySelector(".chatapp-feed");
748 // A local send arms pinNext: snap to the newest even if we'd scrolled up.
749 // Set.delete both reads and clears it, so it only forces this one render.
750 const forcePin = pinNext.delete(ctx.threadId);
751 const wasAtBottom = forcePin || feedNearBottom(feed, 60) || !feed.childElementCount;
752
753 const msgs = (state && state.msgs) || [];
754 // A sender's message arriving means they've stopped typing — clear their
755 // row immediately rather than waiting out the beat expiry (original rule).
756 const last = msgs[msgs.length - 1];
757 if (last && last.name && Date.now() - last.ts < 10000) {
758 const tm = typing.get(ctx.threadId);
759 if (tm) tm.delete(last.name);
760 }
761 const firstBuild = !feed.childElementCount;
762 const items = viewItems(msgs);
763 // Client-only command replies ("only visible to you") ride in the feed as keyed
764 // rows, interleaved by their creation-time seq (see addLocalNotice) so they
765 // stack like bubbles and stay put as the conversation continues. Merge + sort
766 // only when some exist, so a normal feed keeps its plain message order.
767 const notices = localNotices.get(ctx.threadId);
768 if (notices && notices.length) {
769 // In a solo thread (a persona conversation like setup — no other members)
770 // "Only visible to you" is implicit, so the tag stays off.
771 const solo = !!(host && host.soloThread && host.soloThread(ctx.threadId));
772 for (const n of notices) items.push({ id: n.id, notice: n, seq: n.seq, solo, vrev: "notice", srev: "" });
773 items.sort((a, b) => (a.notice ? a.seq : a.sid) - (b.notice ? b.seq : b.sid));
774 // Notices stack in sender runs like regular messages: consecutive notices
775 // from the same speaker keep one header (name + badge), the rest continue.
776 let prevNotice = null;
777 for (const it of items) {
778 if (it.notice) {
779 it.cont = !!(prevNotice && prevNotice.sender && it.notice.sender && prevNotice.sender.name === it.notice.sender.name);
780 // Spent chips (their step resolved — a.stale()) fold into the vrev so
781 // the keyed renderer rebuilds the row when a later render pass finds
782 // them newly stale; without this a resolved chip stays looking live.
783 const spent = (it.notice.actions || []).reduce((n, a) => n + (a && a.stale && a.stale() ? 1 : 0), 0);
784 it.vrev = `notice|${it.cont ? 1 : 0}|${it.solo ? 1 : 0}|${spent}`;
785 prevNotice = it.notice;
786 } else {
787 prevNotice = null;
788 }
789 }
790 }
791 // The bot's intro ("This is the start of your chat history with To-do…") is a
792 // PERSISTENT first item — a keyed row that scrolls up as the conversation and
793 // its action lines grow, marking the beginning, rather than a vanishing
794 // empty-state. host.chatIntro is null for a normal chat (no intro row).
795 const introText = host && host.chatIntro && host.chatIntro(ctx.threadId);
796 // vrev tracks the text so a dynamic intro (an open group's live "N people
797 // here") re-renders when it changes; static intros keep a stable rev.
798 if (introText) items.unshift({ id: "chatapp-intro", intro: true, text: introText, vrev: "intro|" + introText });
799 keyedList(feed, items, {
800 key: (m) => m.id,
801 rev: (m) => m.vrev + mutedRev(m, ctx) + mentionRev(m),
802 srev: (m) => m.srev,
803 render: (m) => renderItem(m, ctx),
804 update: (node, m) => patchBubble(node, m, ctx),
805 });
806 if (wasAtBottom) {
807 // History builds — and a local send from scrolled-up (forcePin) — jump
808 // straight to the newest; a plain live append glides (the original ease),
809 // respecting reduced-motion. A send jumps rather than gliding the whole
810 // history so it lands instantly on your message, not after a long scroll.
811 feedGlideToBottom(feed, firstBuild || forcePin);
812 }
813 if (root._updateJumpBtn) root._updateJumpBtn();
814
815 // An open reply-thread view folds from this same state — re-derive and
816 // re-render it every state change so new replies/reactions/edits land live
817 // (it reads current state via threadItems, never a cached list). No-op when
818 // closed; stashed on the root like _hotkeys so the instant-restore path
819 // (which skips buildSkeleton) keeps the live mount's view current.
820 if (root._refreshThread) root._refreshThread();
821
822 // The in-feed typing row, exactly like the original feed: a .typing element
823 // kept as the feed's last child (below the newest message). keyedList only
824 // manages keyed children, so it never touches this row.
825 const myName = () => {
826 const mine = msgs.find((m) => m.uid && m.uid === ctx.me);
827 return mine ? mine.name : null;
828 };
829 const drawTyping = () => {
830 const names = typingNames(ctx.threadId, myName());
831 let row = feed.querySelector(":scope > .typing");
832 if (!names.length) {
833 if (row) row.remove();
834 return;
835 }
836 if (!row) {
837 row = document.createElement("div");
838 row.setAttribute("aria-live", "polite");
839 }
840 const dm = !(host && host.isGroup && host.isGroup(ctx.threadId));
841 const label = typingSummary(names);
842 // Only rebuild the contents on a real change — recreating the dots on
843 // every ~10s beat would visibly restart their animation (original rule).
844 if (row.dataset.mode !== (dm ? "bubble" : "text") || row.dataset.label !== label) {
845 row.dataset.mode = dm ? "bubble" : "text";
846 row.dataset.label = label;
847 row.className = dm ? "typing typing-bubble" : "typing";
848 row.textContent = ""; // textContent: names are untrusted
849 if (dm) {
850 for (let i = 0; i < 3; i++) {
851 const dot = document.createElement("span");
852 dot.className = "dot";
853 row.appendChild(dot);
854 }
855 const sr = document.createElement("span"); // dots say nothing to a screen reader
856 sr.className = "sr-only";
857 sr.textContent = label;
858 row.appendChild(sr);
859 } else {
860 row.textContent = label;
861 }
862 }
863 const atBottom = feedNearBottom(feed);
864 feed.appendChild(row); // (re-)appending keeps it below the newest message
865 if (atBottom) feedGlideToBottom(feed);
866 };
867 refreshTyping.set(ctx.threadId, drawTyping);
868 drawTyping();
869 ctx.interval(drawTyping, 1000);
870 ctx.onCleanup(() => {
871 if (refreshTyping.get(ctx.threadId) === drawTyping) refreshTyping.delete(ctx.threadId);
872 });
873 // Keep the composer's outbound typing beat alive across a mid-message pause.
874 // Like drawTyping this has to live on the render path, not in buildSkeleton:
875 // the framework clears every interval before each render, so a once-per-mount
876 // ticker would be torn down here and never re-armed (state lives in the
877 // composer, so re-registering the timer each draw is harmless).
878 const composer = composers.get(ctx.threadId);
879 if (composer && composer.tickTyping) ctx.interval(composer.tickTyping, 1000);
880 },
881});
882
883// Muted-message stubs the user tapped open this session (by sid). Deliberately
884// not persisted: reopening the app re-folds — a mute means muted.
885const revealedMuted = new Set();
886
887// Muted-person folding is render-level state, not folded state — mix it into
888// the keyed-list rev so muting, unmuting, or revealing rebuilds exactly the
889// rows it changes and nothing else (m0 = folded stub, m1 = revealed).
890function mutedRev(m, ctx) {
891 if (!m.uid || !ctx || m.uid === ctx.me || !host || !host.mutedPerson || !host.mutedPerson(m.uid)) return "";
892 return revealedMuted.has(m.sid) ? "|m1" : "|m0";
893}
894
895// A message renders its @mentions against the roster AT RENDER TIME
896// (renderRichText reads host.mentionNames() fresh) — but keyedList only calls
897// render() once per key; an already-rendered bubble is never revisited unless
898// its rev changes. A person who joins (or is newly added to a group) after a
899// message mentioning them already rendered stays plain, unbolded text
900// forever, until something touches that row (issue #249). repaintMentionsOf
901// marks exactly the messages that need one (sids only); mentionRev reads that
902// mark so keyedList rebuilds those rows and leaves everything else alone —
903// bumping a global rev would force-rebuild the whole visible feed for a
904// change that's almost always irrelevant to it.
905const mentionDirty = new Set();
906function mentionRev(m) {
907 return mentionDirty.has(m.sid) ? "|mention" : "";
908}
909
910// Pure: which of the last `cap` messages newly reference one of `names` —
911// "newly" meaning not already in `alreadyDirty`, so a repeat call over the
912// same feed finds nothing and triggers nothing. (An already-dirty row's rev
913// is the fixed "|mention" tag, so a second name resolving in the same message
914// couldn't rebuild it anyway — skipping it loses no repaint.) Exported for
915// the Node tests.
916export function newlyDirtyMentionSids(msgs, names, alreadyDirty, cap = 100) {
917 const out = [];
918 for (const m of (msgs || []).slice(-cap)) {
919 if (m.text && !alreadyDirty.has(m.sid) && names.some((n) => m.text.includes("@" + n))) out.push(m.sid);
920 }
921 return out;
922}
923
924// repaintMentionsOf(names): one or more roster labels just became known (a
925// fresh joiner, or a label that finished decrypting) — called from shell.js's
926// decryptRosterLabels. Scans each mounted thread's last 100 messages for ones
927// that actually reference a newly-known name and marks only those dirty, then
928// asks the thread to re-render — a small, targeted repaint instead of a
929// blanket one (issue #249).
930export function repaintMentionsOf(names) {
931 if (!names || !names.length) return;
932 // Iterate a SNAPSHOT: refresh() re-renders, and a re-render tears down the
933 // old mount (deleting its mountedCtx entry) before the new one re-adds it.
934 // A delete + re-add lands at the end of the Map's iteration order, so a
935 // live iterator here would visit the same thread again — and, with the
936 // same @name still in its feed, again forever: the 2026-07-17 hard freeze
937 // on web boot. The newly-dirty gate below is the second lock on that door.
938 for (const ctx of [...mountedCtx.values()]) {
939 const msgs = (ctx.getState() && ctx.getState().msgs) || [];
940 const fresh = newlyDirtyMentionSids(msgs, names, mentionDirty);
941 if (!fresh.length) continue;
942 for (const sid of fresh) mentionDirty.add(sid);
943 const refresh = refreshFeed.get(ctx.threadId);
944 if (refresh) refresh();
945 }
946}
947
948// Swap the composer for the closed-conversation banner while the host says
949// this DM is closed (issue #578). The banner occupies the composer's layout
950// slot — inline, never floating over the feed.
951function paintClosedState(root, ctx) {
952 const closed = !!(host && host.dmClosed && host.dmClosed(ctx.threadId));
953 const compose = root.querySelector(".composer");
954 const float = root.querySelector(".composer-float");
955 let bar = root.querySelector(":scope > .dm-closed-bar");
956 if (!closed) {
957 if (bar) bar.remove();
958 if (compose) compose.classList.remove("hidden");
959 if (float) float.classList.remove("hidden");
960 return;
961 }
962 if (compose) compose.classList.add("hidden");
963 if (float) float.classList.add("hidden");
964 if (bar) return; // already painted; keep any inline error it shows
965 bar = document.createElement("div");
966 bar.className = "dm-closed-bar";
967 const text = document.createElement("span");
968 text.textContent = "This conversation is closed.";
969 bar.appendChild(text);
970 const reopen = document.createElement("button");
971 reopen.type = "button";
972 reopen.textContent = "Reopen";
973 reopen.onclick = async () => {
974 reopen.disabled = true;
975 const r = host && host.reopenDM ? await host.reopenDM(ctx.threadId) : { ok: false };
976 if (r && r.ok) return; // the host repaints; the banner unmounts itself
977 reopen.disabled = false;
978 // "dms closed" = their DM door (issue #578); anything else is transient.
979 text.textContent =
980 r && String(r.error).includes("dms closed")
981 ? "They're not accepting direct messages right now."
982 : "Couldn't reopen — try again.";
983 };
984 bar.appendChild(reopen);
985 root.appendChild(bar);
986}
987
988// ---- bubbles: the original feed's exact markup ------------------------------
989
990// renderItem builds one feed row inside a display:contents wrapper, so the
991// optional .time-sep and the .msg land in the feed's flex flow exactly like
992// original siblings. Structure and class names mirror appendMessage verbatim.
993function renderItem(m, ctx) {
994 const item = document.createElement("div");
995 item.className = "chatapp-item";
996
997 if (m.timeSep) {
998 const sep = document.createElement("div");
999 sep.className = "time-sep";
1000 if (m.timeSep.withDay) {
1001 const day = document.createElement("strong");
1002 day.textContent = dayLabel(m.ts);
1003 sep.appendChild(day);
1004 sep.appendChild(document.createTextNode(" " + formatTime(m.ts)));
1005 } else {
1006 sep.textContent = formatTime(m.ts);
1007 }
1008 item.appendChild(sep);
1009 }
1010
1011 // The persistent "start of your chat history" intro row (keyed first).
1012 if (m.intro) {
1013 const el = document.createElement("div");
1014 el.className = "chatapp-intro";
1015 el.textContent = m.text;
1016 item.appendChild(el);
1017 return item;
1018 }
1019
1020 // A private "only visible to you" command reply — a client-only feed row that
1021 // persists and stacks like a bubble (see addLocalNotice / buildLocalNotice).
1022 if (m.notice) {
1023 item.appendChild(buildLocalNotice(m.notice, { cont: !!m.cont, solo: !!m.solo }));
1024 return item;
1025 }
1026
1027 // An app-thread action ("added 'milk'") — a centered /me-style line, not a
1028 // bubble: a record of your interaction with the bot in the chat history.
1029 if (m.action) {
1030 const line = document.createElement("div");
1031 line.className = "chatapp-action";
1032 line.textContent = m.text;
1033 item.appendChild(line);
1034 return item;
1035 }
1036
1037 // A muted member's message (issue #578) folds to a quiet stub — visible and
1038 // tappable rather than silently gone, because the honest framing of a mute
1039 // is "you stop seeing them", not "they stop existing". Reveal is per-message
1040 // and per-session. Your own messages never fold.
1041 if (m.uid && m.uid !== ctx.me && host && host.mutedPerson && host.mutedPerson(m.uid) && !revealedMuted.has(m.sid)) {
1042 const stub = document.createElement("div");
1043 stub.className = "muted-stub";
1044 const label = document.createElement("span");
1045 label.className = "muted-stub-label";
1046 label.textContent = "Muted message";
1047 stub.appendChild(label);
1048 const show = document.createElement("button");
1049 show.type = "button";
1050 show.className = "muted-stub-show";
1051 show.textContent = "Show";
1052 show.setAttribute("aria-label", "Show muted message");
1053 show.onclick = () => {
1054 revealedMuted.add(m.sid);
1055 item.replaceWith(renderItem(m, ctx)); // re-render this one row in place
1056 };
1057 stub.appendChild(show);
1058 item.appendChild(stub);
1059 return item;
1060 }
1061
1062 // Mirror the original feed's attribution (ownMessage): prefer the inline uid,
1063 // but fall back to a screen-name match so your own legacy (uid-less) messages
1064 // still render as yours — right-aligned, name hidden in group runs, editable.
1065 const mine = ownMessage({ uid: m.uid, name: m.name }, ctx.me, ctx.meName);
1066 const wrap = document.createElement("div");
1067 wrap.className = "msg " + (mine ? "me" : "them") + (m.cont ? " cont" : "");
1068 if (Date.now() - m.ts < 3000) wrap.classList.add("appear"); // live arrivals ease in, history doesn't
1069 wrap.dataset.id = m.sid || ""; // its server id (empty while pending) — lets a reply quote scroll back here
1070 wrap.dataset.t = m.ts;
1071 wrap.dataset.sender = m.name;
1072 // Tag with the resolved thread root (not the quoted id) so the "N Replies"
1073 // jump on the root finds legacy chain replies too.
1074 if (m.reply && m.reply.id) wrap.dataset.replyTo = m.replyRoot || m.reply.id;
1075
1076 // The header shows the sender's CURRENT roster name (looked up by the uid
1077 // inside the ciphertext), falling back to the name snapshot the sender
1078 // stamped into the payload. The lookup heals messages sent before a member
1079 // picked a name — permanently nameless snapshots — and retitles history
1080 // after a rename; the snapshot still covers uid-less legacy messages and
1081 // members who've since left the roster.
1082 const senderName = (m.uid && host && host.memberName && host.memberName(m.uid)) || m.name;
1083 if (!mine && senderName && host && host.isGroup && host.isGroup(ctx.threadId) && !m.cont) {
1084 const nm = document.createElement("div");
1085 nm.className = "name";
1086 nm.style.display = "flex"; // center the name text with its AI/BOT badge
1087 nm.style.alignItems = "center";
1088 nm.textContent = senderName;
1089 const kind = host.agentKind && host.agentKind(senderName);
1090 if (kind) nm.appendChild(agentTag(kind));
1091 wrap.appendChild(nm);
1092 }
1093
1094 // The bubble + its quote/image/preview + hover icons live in .content (the
1095 // positioning context for the hover affordances); reactions sit below it.
1096 const content = document.createElement("div");
1097 content.className = "content";
1098
1099 // An admin warning (issue #578) keeps the ordinary bubble but announces
1100 // itself — a formal notice should never read like small talk.
1101 if (m.warn) {
1102 wrap.classList.add("warn");
1103 const w = document.createElement("div");
1104 w.className = "warn-tag";
1105 w.textContent = "⚠️ A notice from an admin";
1106 content.appendChild(w);
1107 }
1108
1109 if (m.reply && (m.reply.name || m.reply.snippet)) {
1110 const q = document.createElement("button");
1111 q.type = "button";
1112 q.className = "reply-quote";
1113 const who = document.createElement("div");
1114 who.className = "reply-quote-name";
1115 who.textContent = m.reply.name;
1116 const txt = document.createElement("div");
1117 txt.className = "reply-quote-text";
1118 txt.textContent = m.reply.snippet;
1119 q.append(who, txt);
1120 q.onclick = () => {
1121 // A quote tap opens the whole thread, anchored on its resolved ROOT (the
1122 // quoted ref may name a mid-chain parent on a legacy reply). replyTarget
1123 // also hands us the root's name/snippet — the header fallback if the root
1124 // has scrolled out of the loaded window. Old scroll-jump kept as a belt
1125 // for a mount without thread controls.
1126 const t = threads.get(ctx.threadId);
1127 const ref = replyTarget(((ctx.getState() || {}).msgs) || [], m);
1128 if (t && ref.id) t.open(ref.id, ref);
1129 else scrollToMessage(wrap, m.reply.id);
1130 };
1131 content.appendChild(q);
1132 }
1133
1134 for (const a of m.attachments) {
1135 if (a.type && a.type.startsWith("image/")) {
1136 const img = document.createElement("img");
1137 // A GIF's click belongs to its play/pause toggle, not the lightbox —
1138 // .msg-gif swaps the zoom-in cursor for a plain pointer.
1139 img.className = a.type === "image/gif" ? "msg-image msg-gif" : "msg-image";
1140 img.alt = a.name || "photo";
1141 // Reserve the image's final box before the blob decrypts (see the original
1142 // appendMessage note) so the feed doesn't jump as photos pop in.
1143 if (a.w && a.h) {
1144 img.style.aspectRatio = `${a.w} / ${a.h}`;
1145 const scale = Math.min(260 / a.w, 320 / a.h, 1);
1146 img.style.width = Math.round(a.w * scale) + "px";
1147 }
1148 // The BlurHash ghost of the photo while its blob downloads + decrypts
1149 // (attachments[].bh, inside the encrypted payload): decoded into a tiny
1150 // canvas and painted as the element's background — the decoded pixels
1151 // simply cover it, and matching colours make the swap read gently.
1152 if (a.bh) {
1153 try {
1154 const pw = 32, ph = a.w && a.h ? Math.max(1, Math.round((32 * a.h) / a.w)) : 24;
1155 const px = decodeBlurhash(a.bh, pw, ph);
1156 if (px) {
1157 const c = document.createElement("canvas");
1158 c.width = pw;
1159 c.height = ph;
1160 c.getContext("2d").putImageData(new ImageData(px, pw, ph), 0, 0);
1161 img.style.backgroundImage = `url(${c.toDataURL()})`;
1162 img.style.backgroundSize = "cover";
1163 }
1164 } catch { /* a bad hash keeps the flat placeholder */ }
1165 }
1166 if (host) {
1167 host.loadBlob(ctx.threadId, a).then((url) => {
1168 if (!url) return;
1169 img.src = url;
1170 if (a.type === "image/gif") wireGifToggle(img, url);
1171 else img.onclick = () => openLightbox(url, img);
1172 });
1173 }
1174 content.appendChild(img);
1175 } else if (a.type && a.type.startsWith("video/")) {
1176 content.appendChild(buildVideoCard(a, ctx));
1177 } else {
1178 content.appendChild(buildFileCard(a, ctx));
1179 }
1180 }
1181
1182 // A message carrying a valid card renders the card as its body; the text is
1183 // that card's plain fallback and would be pure duplication next to it. A
1184 // card that fails to normalize (unknown schema/block — fail closed,
1185 // cards.js) renders null and the text shows as usual.
1186 const cardEl = m.card ? renderCard(m.card) : null;
1187 if (m.text.trim() && !cardEl) {
1188 const bubble = document.createElement("div");
1189 const jumbo = !m.attachments.length && emojiOnlyCount(m.text) > 0;
1190 bubble.className = jumbo ? "bubble jumbo-emoji" : "bubble";
1191 renderRichText(bubble, m.text);
1192 content.appendChild(bubble);
1193 const link = firstLink(m.text);
1194 const yt = link && youtubeId(link);
1195 if (yt) content.appendChild(buildYouTubeCard(yt));
1196 else if (link) addLinkPreview(content, link);
1197 }
1198 if (cardEl) content.appendChild(cardEl);
1199
1200 // Helper-proposed one-tap action chips ("Switch to dark" …): the host
1201 // whitelists, labels, and runs them (they're host machinery — theme,
1202 // profile); the app mounts the returned row in the original position.
1203 if (m.actions && m.actions.length && host && host.actionChips) {
1204 const chips = host.actionChips(m.actions);
1205 if (chips) content.appendChild(chips);
1206 }
1207
1208 wrap.appendChild(content);
1209
1210 // Reactions below the bubble (buildReactionsRow seeds the load-time "mine"
1211 // flags). It's the SAME builder patchBubble reuses, so a later reaction
1212 // reconciles this row in place rather than rebuilding the whole bubble.
1213 const reactions = buildReactionsRow(m, ctx);
1214 if (reactions) wrap.appendChild(reactions);
1215
1216 if (m.replyCount) wrap.appendChild(buildReplyCount(m, wrap, ctx));
1217
1218 if (m.edited) {
1219 const e = document.createElement("div");
1220 e.className = "edited-tag";
1221 e.textContent = "Edited";
1222 wrap.appendChild(e);
1223 }
1224
1225 // A still-pending optimistic send withholds its interactive actions — you can't
1226 // react to, reply to, link, or edit a message that hasn't been sent yet — and
1227 // wears a subtle status the CSS only reveals once it's `.slow` (5s unconfirmed).
1228 // On confirmation patchBubble promotes it: attaches these actions with the real
1229 // server id, no rebuild. A confirmed message gets them straight away.
1230 if (m.pending) {
1231 wrap.dataset.pending = "1";
1232 wrap.classList.add("pending");
1233 if (m.slow) wrap.classList.add("slow");
1234 wrap.appendChild(buildSendStatus());
1235 } else {
1236 attachActions(wrap, content, m, ctx);
1237 }
1238
1239 item.appendChild(wrap);
1240 return item;
1241}
1242
1243// The interactive message actions — desktop's hover strip (react / reply / edit /
1244// copy-link) plus its press-and-hold, or touch's swipe-reply + long-press picker.
1245// Split out of renderItem so patchBubble can attach them to a bubble that first
1246// rendered while pending (actions withheld) the moment its echo confirms it — with
1247// the now-real server id, and without rebuilding the bubble (no photo flash).
1248function attachActions(wrap, content, m, ctx) {
1249 if (coarse) {
1250 attachSwipeReply(wrap, m, ctx);
1251 attachTouchPicker(wrap, m, ctx);
1252 return;
1253 }
1254 // Desktop: the hover strip beside the bubble — action icons + exact time,
1255 // revealed on hover (see .msg-actions in style.css).
1256 const mine = ownMessage({ uid: m.uid, name: m.name }, ctx.me, ctx.meName);
1257 const actions = document.createElement("div");
1258 actions.className = "msg-actions";
1259 actions.appendChild(hoverBtn("react-hover", "React", icon("react"), (e) => openMessagePicker(m, ctx, e.currentTarget)));
1260 actions.appendChild(
1261 hoverBtn("reply-hover", "Reply", icon("reply"), () => {
1262 const ctl = composers.get(ctx.threadId);
1263 if (ctl) ctl.startReply(m);
1264 }),
1265 );
1266 const remaining = m.ts + EDIT_WINDOW_MS - Date.now();
1267 if (mine && m.text.trim() && remaining > 0) {
1268 const editBtn = hoverBtn("edit-hover", "Edit", icon("edit"), () => {
1269 const ctl = composers.get(ctx.threadId);
1270 if (ctl) ctl.startEdit(m);
1271 });
1272 actions.appendChild(editBtn);
1273 setTimeout(() => editBtn.remove(), remaining); // drop it when the window lapses
1274 }
1275 // The exact-time hover affordance doubles as a "Copy link" trigger (issue
1276 // #893) — the Slack/Discord convention. A <button> (not a bare span) so it's
1277 // keyboard-reachable and gets a real accessible name, not just visible text
1278 // that reads as a plain timestamp.
1279 const timeBtn = document.createElement("button");
1280 timeBtn.type = "button";
1281 timeBtn.className = "msg-time";
1282 timeBtn.textContent = formatTime(m.ts);
1283 timeBtn.title = "Copy link to this message";
1284 timeBtn.setAttribute("aria-label", `Copy link to this message, sent ${formatTime(m.ts)}`);
1285 timeBtn.onclick = (e) => {
1286 e.stopPropagation();
1287 copyMessageLink(ctx, m);
1288 };
1289 actions.appendChild(timeBtn);
1290 content.appendChild(actions);
1291 attachPressHold(wrap, m, ctx);
1292}
1293
1294// The pending-send status footer: a spinner + "Sending…", kept out of the tab
1295// order and hidden by CSS until the bubble goes `.slow`. `role="status"` +
1296// aria-live so a screen reader announces the delay when it finally surfaces
1297// rather than the instant every message is sent.
1298function buildSendStatus() {
1299 const s = document.createElement("div");
1300 s.className = "send-status";
1301 s.setAttribute("role", "status");
1302 s.textContent = "Sending…";
1303 return s;
1304}
1305
1306// The reactions row below a bubble (or null when there are none). Seeds the
1307// load-time "mine" flags first — add-only and idempotent (live frames carry
1308// counts only, never a mine flag), so it's safe to run from BOTH the initial
1309// render and the in-place reaction patch (patchBubble). Mirrors the original
1310// feed's renderReactions, minus the direct wrap mutation.
1311function buildReactionsRow(m, ctx) {
1312 for (const r of m.reactions || []) {
1313 if (r.mine) myReactions.add(`${m.sid}|${r.emoji}`);
1314 }
1315 const row = document.createElement("div");
1316 row.className = "reactions";
1317 for (const r of m.reactions || []) {
1318 if (!r.count) continue;
1319 const chip = document.createElement("button");
1320 chip.className = "reaction-chip" + (myReactions.has(`${m.sid}|${r.emoji}`) ? " mine" : "");
1321 chip.textContent = r.count > 1 ? `${r.emoji} ${r.count}` : r.emoji;
1322 chip.onclick = (e) => {
1323 e.stopPropagation();
1324 toggleReaction(m, r.emoji, ctx);
1325 };
1326 row.appendChild(chip);
1327 }
1328 return row.childElementCount ? row : null;
1329}
1330
1331// The "N Replies" tally below a bubble, tappable to open the thread view. The
1332// tally only ever renders under a loaded root (it IS this message), so no
1333// paging is needed on this path. Old jump-to-first-reply kept as a belt for a
1334// mount without thread controls.
1335function buildReplyCount(m, wrap, ctx) {
1336 const rc = document.createElement("div");
1337 rc.className = "reply-count";
1338 rc.textContent = m.replyCount === 1 ? "1 Reply" : m.replyCount + " Replies";
1339 rc.onclick = () => {
1340 const t = threads.get(ctx.threadId);
1341 if (t) {
1342 t.open(m.sid, { id: m.sid, name: m.name, snippet: replySnippet(m.raw) });
1343 return;
1344 }
1345 const feed = wrap.closest(".chatapp-feed");
1346 const first = feed && feed.querySelector(`.msg[data-reply-to="${m.sid}"]`);
1347 if (first) scrollToMessage(wrap, first.dataset.id);
1348 };
1349 return rc;
1350}
1351
1352// Patch a retained bubble's SOFT parts in place — the reactions row and the reply
1353// tally, both siblings AFTER .content. keyedList calls this (never a rebuild) when
1354// only the soft rev changed, so it MUST NOT descend into .content: that subtree
1355// holds the decrypted <img> and the YouTube embed, and leaving it untouched is
1356// precisely what stops a photo reloading and a playing video resetting when a
1357// reaction lands or a reply tallies. Mirrors the original feed's renderReactions /
1358// paintReplyCount, which likewise only ever touched these two rows.
1359function patchBubble(node, m, ctx) {
1360 const wrap = node.querySelector(":scope > .msg");
1361 if (!wrap) return;
1362 const content = wrap.querySelector(":scope > .content");
1363 // Delivery state (soft rev). A bubble that rendered while pending either goes
1364 // `.slow` (5s unconfirmed) or gets PROMOTED here the moment its echo lands: drop
1365 // the pending chrome, adopt the real server id, and attach the interactive
1366 // actions it was withheld — all outside .content, so the bubble is never rebuilt.
1367 if (wrap.dataset.pending === "1") {
1368 if (m.pending) {
1369 wrap.classList.toggle("slow", !!m.slow);
1370 } else {
1371 wrap.dataset.pending = "0";
1372 wrap.classList.remove("pending", "slow");
1373 wrap.dataset.id = m.sid || "";
1374 const status = wrap.querySelector(":scope > .send-status");
1375 if (status) status.remove();
1376 attachActions(wrap, content, m, ctx);
1377 }
1378 }
1379 // Reactions: rebuild the row and reinsert it right after .content, so it stays
1380 // above the reply/edited tags regardless of which of them are present.
1381 const oldRow = wrap.querySelector(":scope > .reactions");
1382 if (oldRow) oldRow.remove();
1383 const newRow = buildReactionsRow(m, ctx);
1384 if (newRow && content) content.after(newRow);
1385 // Reply tally: create (0→N), update (N→M), or remove (N→0 — a reply can be
1386 // deleted, dropping the parent's tally on the next viewItems).
1387 let rc = wrap.querySelector(":scope > .reply-count");
1388 if (m.replyCount) {
1389 if (rc) rc.textContent = m.replyCount === 1 ? "1 Reply" : m.replyCount + " Replies";
1390 else {
1391 rc = buildReplyCount(m, wrap, ctx);
1392 const edited = wrap.querySelector(":scope > .edited-tag");
1393 if (edited) edited.before(rc);
1394 else wrap.appendChild(rc);
1395 }
1396 } else if (rc) {
1397 rc.remove();
1398 }
1399}
1400
1401function icon(name) {
1402 return (host && host.icons && host.icons[name]) || "";
1403}
1404
1405function hoverBtn(className, label, svg, onclick) {
1406 const b = document.createElement("button");
1407 b.type = "button";
1408 b.className = className;
1409 b.innerHTML = svg; // static in-code SVG from the host (safe)
1410 b.setAttribute("aria-label", label);
1411 b.onclick = (e) => {
1412 e.stopPropagation();
1413 onclick(e);
1414 };
1415 return b;
1416}
1417
1418function agentTag(kind) {
1419 const t = document.createElement("span");
1420 const ai = kind === "ai";
1421 t.textContent = ai ? "AI" : "BOT";
1422 t.title = ai
1423 ? "An AI helper — what you send it is sent to an AI provider to answer"
1424 : "An automated helper (bot), not a person — it runs on this server";
1425 // flex:none keeps it from shrinking, line-height:1 keeps the box tight — the
1426 // name line is a flex row with align-items:center (see the call site).
1427 t.style.cssText =
1428 "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;" +
1429 "background:var(--border,#ddd);color:var(--muted,#666);";
1430 return t;
1431}
1432
1433// Exported for the shell's search-result jump (#586): `near` is any node
1434// inside the target feed (the feed itself works).
1435export function scrollToMessage(near, sid) {
1436 const feed = near.closest(".chatapp-feed");
1437 const target = feed && feed.querySelector(`.msg[data-id="${sid}"]`);
1438 if (!target) return;
1439 target.scrollIntoView({ behavior: "smooth", block: "center" });
1440 target.classList.remove("flash");
1441 void target.offsetWidth; // restart the animation if it's still mid-flash
1442 target.classList.add("flash");
1443}
1444
1445// "Copy link" (issue #893): mint the permalink through the host (it holds
1446// session.token and the fetch to /api/messages/{id}/link — see shell.js
1447// mintMessageLink) and write it to the clipboard. Same await-and-toast
1448// pattern as the existing plain-text Copy action, just via the host for the
1449// network round-trip a link mint needs that a local clipboard copy doesn't.
1450async function copyMessageLink(ctx, m) {
1451 if (!host || !host.mintMessageLink || !host.toast) return;
1452 const url = await host.mintMessageLink(ctx.threadId, m.sid);
1453 if (!url) {
1454 host.toast("Couldn't copy link");
1455 return;
1456 }
1457 try {
1458 await navigator.clipboard.writeText(url);
1459 host.toast("Link copied");
1460 } catch {
1461 host.toast("Couldn't copy link");
1462 }
1463}
1464
1465function toggleReaction(m, emoji, ctx) {
1466 if (!host) return;
1467 const sid = m.sid;
1468 const key = `${sid}|${emoji}`;
1469 const mine = myReactions.has(key);
1470 // Reactions are now encrypted events. Optimistically update local state, but
1471 // if the send fails, revert on the next render. host.react is now async.
1472 // m.uid (the reacted-to message's author) rides inside the encrypted payload
1473 // so push handlers can show the reaction only to the author; its snippet
1474 // rides along too so the chat list can say what was reacted to.
1475 // Through the markdown plain projection, like every other preview line —
1476 // quoting raw text would show the marks the bubble renders as styling, and
1477 // the composer's opt-out escapes as visible backslashes. Projected here, at
1478 // the stamp, so no payload ever carries them.
1479 const onVal = !mine;
1480 const quote = replySnippet({ ...m.raw, text: mdPlainText(m.raw.text || "") });
1481 host.react(ctx.threadId, sid, emoji, onVal, m.uid || "", quote).then((sent) => {
1482 if (!sent) {
1483 if (onVal) myReactions.delete(key);
1484 else myReactions.add(key);
1485 }
1486 });
1487 if (mine) myReactions.delete(key);
1488 else myReactions.add(key);
1489}
1490
1491// The emoji picker over a message — the original .emoji-picker markup: the six
1492// reaction options, then icon actions (reply / edit / delete) and any actions
1493// other mini-apps contribute (e.g. "Save link"), glyphs as textContent.
1494function openMessagePicker(m, ctx, anchor) {
1495 document.querySelector(".emoji-picker")?.remove();
1496 const pop = document.createElement("div");
1497 pop.className = "emoji-picker";
1498 for (const emoji of REACTIONS) {
1499 const b = document.createElement("button");
1500 b.className = "emoji-opt";
1501 b.textContent = emoji;
1502 b.onclick = () => {
1503 toggleReaction(m, emoji, ctx);
1504 pop.remove();
1505 };
1506 pop.appendChild(b);
1507 }
1508 const action = (svg, label, run) => {
1509 const b = document.createElement("button");
1510 b.className = "picker-action";
1511 b.innerHTML = svg; // static in-code SVG from the host (safe)
1512 b.setAttribute("aria-label", label);
1513 b.title = label;
1514 b.onclick = () => {
1515 run();
1516 pop.remove();
1517 };
1518 pop.appendChild(b);
1519 };
1520 action(icon("reply"), "Reply", () => {
1521 const ctl = composers.get(ctx.threadId);
1522 if (ctl) ctl.startReply(m);
1523 });
1524 // Copy — the original touch sheet's action; native selection is suppressed on
1525 // bubbles (long-press opens this menu), so Copy must live here.
1526 if (m.text.trim()) {
1527 action(icon("copy"), "Copy", async () => {
1528 // Mirror the classic feed's copyMessageText: await the write and confirm
1529 // via the host's status pill so success and failure both show up.
1530 try {
1531 await navigator.clipboard.writeText(m.text);
1532 if (host && host.toast) host.toast("Copied");
1533 } catch {
1534 if (host && host.toast) host.toast("Couldn't copy");
1535 }
1536 });
1537 }
1538 // Copy link (issue #893) — a shareable permalink that jumps a member
1539 // straight to this message; lives right after Copy.
1540 action(icon("link"), "Copy link", () => copyMessageLink(ctx, m));
1541 // Same uid-or-name attribution as the feed, so the Edit/Delete actions show up
1542 // on your own legacy (uid-less) messages too.
1543 const mine = ownMessage({ uid: m.uid, name: m.name }, ctx.me, ctx.meName);
1544 if (mine && m.text.trim() && Date.now() - m.ts < EDIT_WINDOW_MS) {
1545 action(icon("edit"), "Edit", () => {
1546 const ctl = composers.get(ctx.threadId);
1547 if (ctl) ctl.startEdit(m);
1548 });
1549 }
1550 if (mine) {
1551 action(icon("trash"), "Delete", () => {
1552 if (confirm("Delete this message for everyone? This can't be undone.") && host) host.del(ctx.threadId, m.sid, attachmentBlobIds(m));
1553 });
1554 }
1555 // Mute the sender (issue #578) — their messages fold to a quiet stub. Needs
1556 // a uid to key on (legacy uid-less messages can't be muted per-person).
1557 if (!mine && m.uid && host && host.mutedPerson && host.setPersonMuted) {
1558 const who = (host.memberName && host.memberName(m.uid)) || m.name || "them";
1559 const isMuted = host.mutedPerson(m.uid);
1560 action(icon("mute"), isMuted ? `Unmute ${who}` : `Mute ${who}`, () => {
1561 host.setPersonMuted(m.uid, !isMuted);
1562 });
1563 }
1564 // Report to the space's admins (issue #578) — the host owns the sheet (it
1565 // holds the roster + keys to seal the report to admin keys).
1566 if (!mine && m.uid && host && host.reportMessage) {
1567 action(icon("report"), "Report", () => host.reportMessage(ctx.threadId, m));
1568 }
1569 for (const act of messageActionsFor({ text: m.text, from: m.name })) {
1570 const b = document.createElement("button");
1571 b.className = "picker-action";
1572 b.textContent = act.glyph; // app-supplied — textContent, never innerHTML
1573 b.setAttribute("aria-label", act.label);
1574 b.title = act.label;
1575 b.onclick = () => {
1576 act.run();
1577 pop.remove();
1578 };
1579 pop.appendChild(b);
1580 }
1581 document.body.appendChild(pop);
1582 const r = anchor.getBoundingClientRect();
1583 pop.style.top = `${Math.max(8, r.top - pop.offsetHeight - 8)}px`;
1584 pop.style.left = `${Math.min(r.left, window.innerWidth - pop.offsetWidth - 8)}px`;
1585 const teardown = () => {
1586 pop.remove();
1587 document.removeEventListener("pointerdown", close, true);
1588 document.removeEventListener("keydown", onKey, true);
1589 };
1590 const close = (e) => {
1591 if (!pop.contains(e.target)) teardown();
1592 };
1593 // Keyboard: Escape dismisses; 1–6 toggles that reaction (mirrors the original
1594 // feed's cmd-T flow, where the menu opens from the keyboard).
1595 const onKey = (e) => {
1596 if (e.key === "Escape") {
1597 e.preventDefault();
1598 teardown();
1599 return;
1600 }
1601 const num = parseInt(e.key, 10);
1602 if (num >= 1 && num <= REACTIONS.length) {
1603 e.preventDefault();
1604 e.stopImmediatePropagation();
1605 toggleReaction(m, REACTIONS[num - 1], ctx);
1606 teardown();
1607 }
1608 };
1609 document.addEventListener("pointerdown", close, true);
1610 document.addEventListener("keydown", onKey, true);
1611}
1612
1613// The actions in the touch long-press sheet, in the original feed's order: Reply,
1614// Copy, Copy link, then app-contributed actions (Save link…), then your-own Edit (in-window)
1615// and Delete. Each is { label, icon | glyph, danger?, run } — mirrors messageActions
1616// in the original chat.js.
1617function sheetActions(m, ctx) {
1618 const acts = [];
1619 acts.push({
1620 label: "Reply",
1621 icon: icon("reply"),
1622 run: () => {
1623 const ctl = composers.get(ctx.threadId);
1624 if (ctl) ctl.startReply(m);
1625 },
1626 });
1627 // Native selection is suppressed on bubbles (long-press opens this sheet), so
1628 // Copy must live here.
1629 if (m.text.trim()) {
1630 acts.push({
1631 label: "Copy",
1632 icon: icon("copy"),
1633 run: async () => {
1634 // Mirror the classic feed's copyMessageText: await the write and confirm
1635 // via the host's status pill so success and failure both show up.
1636 try {
1637 await navigator.clipboard.writeText(m.text);
1638 if (host && host.toast) host.toast("Copied");
1639 } catch {
1640 if (host && host.toast) host.toast("Couldn't copy");
1641 }
1642 },
1643 });
1644 }
1645 // Copy link (issue #893) — same permalink as the desktop picker's action.
1646 acts.push({ label: "Copy link", icon: icon("link"), run: () => copyMessageLink(ctx, m) });
1647 // Actions other mini-apps contribute (e.g. Bookmarks' "Save link") — each an
1648 // emoji glyph rendered as textContent, never innerHTML.
1649 for (const act of messageActionsFor({ text: m.text, from: m.name })) {
1650 acts.push({ label: act.label, glyph: act.glyph, run: act.run });
1651 }
1652 const mine = !!m.uid && m.uid === ctx.me;
1653 if (mine && m.text.trim() && Date.now() - m.ts < EDIT_WINDOW_MS) {
1654 acts.push({
1655 label: "Edit",
1656 icon: icon("edit"),
1657 run: () => {
1658 const ctl = composers.get(ctx.threadId);
1659 if (ctl) ctl.startEdit(m);
1660 },
1661 });
1662 }
1663 if (mine) {
1664 acts.push({
1665 label: "Delete",
1666 icon: icon("trash"),
1667 danger: true,
1668 run: () => {
1669 if (confirm("Delete this message for everyone? This can't be undone.") && host) host.del(ctx.threadId, m.sid, attachmentBlobIds(m));
1670 },
1671 });
1672 }
1673 // Mute the sender (issue #578) — same gate as the desktop picker.
1674 if (!mine && m.uid && host && host.mutedPerson && host.setPersonMuted) {
1675 const who = (host.memberName && host.memberName(m.uid)) || m.name || "them";
1676 const isMuted = host.mutedPerson(m.uid);
1677 acts.push({
1678 label: isMuted ? `Unmute ${who}` : `Mute ${who}`,
1679 icon: icon("mute"),
1680 run: () => host.setPersonMuted(m.uid, !isMuted),
1681 });
1682 }
1683 // Report to the space's admins (issue #578) — same gate as the picker.
1684 if (!mine && m.uid && host && host.reportMessage) {
1685 acts.push({
1686 label: "Report",
1687 icon: icon("report"),
1688 run: () => host.reportMessage(ctx.threadId, m),
1689 });
1690 }
1691 return acts;
1692}
1693
1694// Touch long-press → the labelled message sheet (WhatsApp-style): a dim backdrop
1695// with the emoji reactions floating above the lifted message and a labelled
1696// Reply/Copy/Edit/Delete menu below it. Mirrors openMessageSheet in the original
1697// feed and reuses its .msg-sheet CSS.
1698function openMessageSheet(m, ctx, wrap) {
1699 document.querySelector(".msg-sheet")?.remove();
1700 const bubble = wrap.querySelector(".bubble") || wrap;
1701 const brect = bubble.getBoundingClientRect(); // for horizontal alignment
1702 const rect = wrap.getBoundingClientRect(); // the whole message (what we lift)
1703 const mine = !!m.uid && m.uid === ctx.me;
1704
1705 const backdrop = document.createElement("div");
1706 backdrop.className = "msg-sheet";
1707
1708 // A non-interactive copy of the message, lifted above the dim so it stays legible
1709 // while the reactions float above it and the menu below — the whole cluster is
1710 // repositioned vertically so both fit even when the message is at the screen edge.
1711 const clone = wrap.cloneNode(true);
1712 clone.classList.add("msg-sheet-clone");
1713 clone.classList.remove("appear");
1714 clone.style.transform = ""; // drop any residual swipe-to-reply translate
1715 clone.style.left = rect.left + "px";
1716 clone.style.width = rect.width + "px";
1717
1718 const reactions = document.createElement("div");
1719 reactions.className = "msg-sheet-reactions";
1720 for (const emoji of REACTIONS) {
1721 const b = document.createElement("button");
1722 b.className = "sheet-emoji";
1723 b.textContent = emoji;
1724 b.onclick = () => {
1725 toggleReaction(m, emoji, ctx);
1726 close();
1727 };
1728 reactions.appendChild(b);
1729 }
1730
1731 const menu = document.createElement("div");
1732 menu.className = "msg-sheet-menu";
1733 for (const a of sheetActions(m, ctx)) {
1734 const item = document.createElement("button");
1735 item.className = "msg-sheet-item" + (a.danger ? " danger" : "");
1736 const label = document.createElement("span");
1737 label.textContent = a.label;
1738 const ic = document.createElement("span");
1739 ic.className = "msg-sheet-icon";
1740 if (a.glyph) ic.textContent = a.glyph; // app action: emoji glyph (textContent)
1741 else ic.innerHTML = a.icon; // static in-code SVG from the host (safe)
1742 item.appendChild(label);
1743 item.appendChild(ic);
1744 item.onclick = () => {
1745 a.run();
1746 close();
1747 };
1748 menu.appendChild(item);
1749 }
1750
1751 backdrop.appendChild(clone);
1752 backdrop.appendChild(reactions);
1753 backdrop.appendChild(menu);
1754 document.body.appendChild(backdrop);
1755
1756 const M = 8;
1757 const GAP = 10;
1758 const vw = window.innerWidth;
1759 const vh = window.innerHeight;
1760 const rH = reactions.offsetHeight;
1761 const mH = menu.offsetHeight;
1762 // Cap the lifted message so the reactions + menu always fit around it.
1763 const maxMsgH = Math.max(60, vh - 2 * M - rH - mH - 2 * GAP);
1764 const bH = Math.min(rect.height, maxMsgH);
1765 if (rect.height > bH) {
1766 clone.style.maxHeight = bH + "px";
1767 clone.style.overflow = "hidden";
1768 }
1769 // Keep the message near where it was, but slide it just enough that the reactions
1770 // fit above and the menu below.
1771 const minTop = M + rH + GAP;
1772 const maxTop = Math.max(minTop, vh - M - mH - GAP - bH);
1773 const msgTop = Math.max(minTop, Math.min(rect.top, maxTop));
1774 clone.style.top = msgTop + "px";
1775
1776 const placeX = (el) => {
1777 const w = el.offsetWidth;
1778 const left = mine ? brect.right - w : brect.left;
1779 return Math.max(M, Math.min(left, vw - w - M));
1780 };
1781 reactions.style.top = msgTop - GAP - rH + "px";
1782 reactions.style.left = placeX(reactions) + "px";
1783 menu.style.top = msgTop + bH + GAP + "px";
1784 menu.style.left = placeX(menu) + "px";
1785
1786 // Hide the original while its lifted copy is shown; restore on close.
1787 wrap.style.visibility = "hidden";
1788
1789 function close() {
1790 wrap.style.visibility = "";
1791 backdrop.remove();
1792 document.removeEventListener("pointerdown", onDoc, true);
1793 document.removeEventListener("keydown", onEsc);
1794 }
1795 const onDoc = (e) => {
1796 if (!reactions.contains(e.target) && !menu.contains(e.target)) close();
1797 };
1798 const onEsc = (e) => {
1799 if (e.key === "Escape") {
1800 e.preventDefault();
1801 close();
1802 }
1803 };
1804 // Deferred so the press that opened the sheet doesn't immediately close it.
1805 setTimeout(() => {
1806 document.addEventListener("pointerdown", onDoc, true);
1807 document.addEventListener("keydown", onEsc);
1808 }, 0);
1809}
1810
1811// Touch double-tap → a quick emoji-only reactions bar. Just the emojis: the actions
1812// (reply/copy/edit/delete) live in the long-press sheet. Reuses the compact
1813// .emoji-picker, mirrors openReactionBar in the original feed.
1814function openReactionBar(m, ctx, anchor) {
1815 document.querySelector(".emoji-picker")?.remove();
1816 const pop = document.createElement("div");
1817 pop.className = "emoji-picker";
1818 for (const emoji of REACTIONS) {
1819 const b = document.createElement("button");
1820 b.className = "emoji-opt";
1821 b.textContent = emoji;
1822 b.onclick = () => {
1823 toggleReaction(m, emoji, ctx);
1824 pop.remove();
1825 };
1826 pop.appendChild(b);
1827 }
1828 document.body.appendChild(pop);
1829 const r = anchor.getBoundingClientRect();
1830 pop.style.top = `${Math.max(8, r.top - pop.offsetHeight - 8)}px`;
1831 pop.style.left = `${Math.min(r.left, window.innerWidth - pop.offsetWidth - 8)}px`;
1832 const close = (e) => {
1833 if (!pop.contains(e.target)) {
1834 pop.remove();
1835 document.removeEventListener("pointerdown", close, true);
1836 }
1837 };
1838 setTimeout(() => document.addEventListener("pointerdown", close, true), 0);
1839}
1840
1841// Desktop press-and-hold on the bubble opens the picker (the hover strip is
1842// the discoverable path; this mirrors the original gesture).
1843function attachPressHold(wrap, m, ctx) {
1844 let timer = null;
1845 wrap.addEventListener("pointerdown", (e) => {
1846 if (e.button !== 0) return;
1847 timer = setTimeout(() => openMessagePicker(m, ctx, wrap), 500);
1848 });
1849 const cancel = () => clearTimeout(timer);
1850 wrap.addEventListener("pointerup", cancel);
1851 wrap.addEventListener("pointerleave", cancel);
1852 wrap.addEventListener("pointermove", cancel);
1853}
1854
1855// Touch: long-press (≈500ms) → the labelled message sheet; a quick double-tap →
1856// the emoji-only reactions bar. Mirrors attachReactionGesture's coarse branch in
1857// the original feed (a drag past 10px is a swipe/scroll, not a long-press).
1858function attachTouchPicker(wrap, m, ctx) {
1859 let timer = null;
1860 let sx = 0;
1861 let sy = 0;
1862 let lastTap = 0;
1863 let longFired = false;
1864 const cancel = () => {
1865 if (timer) {
1866 clearTimeout(timer);
1867 timer = null;
1868 }
1869 };
1870 wrap.addEventListener(
1871 "touchstart",
1872 (e) => {
1873 cancel();
1874 longFired = false;
1875 if (e.touches.length !== 1) return;
1876 sx = e.touches[0].clientX;
1877 sy = e.touches[0].clientY;
1878 timer = setTimeout(() => {
1879 timer = null;
1880 longFired = true;
1881 openMessageSheet(m, ctx, wrap);
1882 }, 500);
1883 },
1884 { passive: true },
1885 );
1886 wrap.addEventListener(
1887 "touchmove",
1888 (e) => {
1889 if (!timer) return;
1890 const t = e.touches[0];
1891 // A drag (swipe-to-reply or a scroll) isn't a long-press.
1892 if (Math.abs(t.clientX - sx) > 10 || Math.abs(t.clientY - sy) > 10) cancel();
1893 },
1894 { passive: true },
1895 );
1896 wrap.addEventListener("touchend", () => {
1897 const wasLong = longFired;
1898 cancel();
1899 if (wasLong) {
1900 lastTap = 0; // the long-press already opened the sheet — not a tap
1901 return;
1902 }
1903 const now = Date.now();
1904 if (now - lastTap < 300) {
1905 lastTap = 0;
1906 openReactionBar(m, ctx, wrap); // second quick tap
1907 } else {
1908 lastTap = now;
1909 }
1910 });
1911 wrap.addEventListener("touchcancel", () => {
1912 cancel();
1913 lastTap = 0;
1914 });
1915}
1916
1917// Swipe a message right (touch) to reply — the original gesture: the message
1918// follows the finger, a reply arrow fades in, release past the threshold replies.
1919function attachSwipeReply(wrap, m, ctx) {
1920 const TRIGGER = 56;
1921 const MAX = 80;
1922 let startX = 0;
1923 let startY = 0;
1924 let dx = 0;
1925 let mode = ""; // "" undecided, "swipe", or "no"
1926 let iconEl = null;
1927 let raf = 0;
1928
1929 // Write the transform once per frame (rAF-batched) using translate3d, which
1930 // keeps the bubble on a GPU compositor layer — no per-move repaint of the
1931 // subtree, so the drag stays smooth even in the iOS webview.
1932 const render = () => {
1933 raf = 0;
1934 const shift = Math.min(Math.max(dx, 0), MAX);
1935 wrap.style.transform = `translate3d(${shift}px,0,0)`;
1936 if (iconEl) iconEl.style.opacity = Math.min(shift / TRIGGER, 1);
1937 };
1938 const schedule = () => {
1939 if (!raf) raf = requestAnimationFrame(render);
1940 };
1941
1942 const cleanup = () => {
1943 if (raf) {
1944 cancelAnimationFrame(raf);
1945 raf = 0;
1946 }
1947 if (iconEl) {
1948 iconEl.remove();
1949 iconEl = null;
1950 }
1951 wrap.style.transition = "transform 0.18s ease";
1952 wrap.style.transform = "";
1953 setTimeout(() => {
1954 wrap.style.transition = "";
1955 wrap.style.willChange = "";
1956 }, 200);
1957 };
1958 wrap.addEventListener(
1959 "touchstart",
1960 (e) => {
1961 if (e.touches.length !== 1) return;
1962 startX = e.touches[0].clientX;
1963 startY = e.touches[0].clientY;
1964 dx = 0;
1965 mode = "";
1966 },
1967 { passive: true },
1968 );
1969 wrap.addEventListener(
1970 "touchmove",
1971 (e) => {
1972 const t = e.touches[0];
1973 dx = t.clientX - startX;
1974 const dy = t.clientY - startY;
1975 if (!mode) {
1976 // Mirror the original feed's axis-lock: ignore tiny jitter, then only
1977 // enter swipe when the horizontal drag clearly dominates the vertical
1978 // (by ~1.3×) — a diagonal drag the original reads as a scroll must not
1979 // trip the reply here either.
1980 if (Math.abs(dx) < 10 && Math.abs(dy) < 10) return;
1981 if (dx > 0 && Math.abs(dx) > Math.abs(dy) * 1.3) {
1982 mode = "swipe";
1983 wrap.style.willChange = "transform"; // promote the bubble to its own layer for the drag
1984 } else {
1985 mode = "no";
1986 }
1987 }
1988 if (mode !== "swipe") return;
1989 if (!iconEl) {
1990 iconEl = document.createElement("span");
1991 iconEl.className = "swipe-reply-icon";
1992 iconEl.innerHTML = icon("reply");
1993 wrap.appendChild(iconEl);
1994 }
1995 schedule();
1996 },
1997 { passive: true },
1998 );
1999 // A pointer swipe ends on touchend, but a system interruption (incoming
2000 // call, notification) fires touchcancel with no touchend — route both
2001 // through one path so an interrupted swipe still resets, instead of leaving
2002 // the bubble stuck mid-transform until the next touch. Only a real release
2003 // can trigger the reply; a cancel just resets.
2004 const onEnd = (interrupted) => {
2005 const trigger = !interrupted && mode === "swipe" && dx > TRIGGER;
2006 cleanup();
2007 mode = "";
2008 if (trigger) {
2009 const ctl = composers.get(ctx.threadId);
2010 if (ctl) ctl.startReply(m);
2011 }
2012 };
2013 wrap.addEventListener("touchend", () => onEnd(false));
2014 wrap.addEventListener("touchcancel", () => onEnd(true));
2015}
2016
2017// Rich text through the allowlisted CommonMark subset (markdown.js, #531):
2018// parseMessage builds the vector-pinned semantic tree, renderTree walks it into
2019// DOM nodes, and @mentions are scanned inside the plain runs via the hook —
2020// same class names (.mention / .mention-link), textContent everywhere.
2021function escapeRegExp(s) {
2022 return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2023}
2024function renderRichText(el, text) {
2025 const labels = host && host.mentionNames ? host.mentionNames() : [];
2026 const mentionRe = labels.length
2027 ? new RegExp("@(?:" + labels.sort((a, b) => b.length - a.length).map(escapeRegExp).join("|") + ")(?![\\w])", "g")
2028 : null;
2029 const plain = (target, t) => {
2030 if (!mentionRe) {
2031 target.appendChild(document.createTextNode(t));
2032 return;
2033 }
2034 mentionRe.lastIndex = 0;
2035 let last = 0;
2036 let mt;
2037 while ((mt = mentionRe.exec(t))) {
2038 const prevCh = t[mt.index - 1];
2039 if (prevCh !== undefined && /[\w@.]/.test(prevCh)) continue; // mid-word / email → not a mention
2040 if (mt.index > last) target.appendChild(document.createTextNode(t.slice(last, mt.index)));
2041 const span = document.createElement("span");
2042 span.className = "mention";
2043 const label = mt[0].slice(1);
2044 // The chip shows the viewer's own name for the person when the host has
2045 // one (per-viewer names); the match above stays on the published name.
2046 // When the two differ, the published name waits under a hover title.
2047 const shown = (host && host.mentionDisplay && host.mentionDisplay(label)) || label;
2048 span.textContent = "@" + shown;
2049 if (shown !== label) span.title = `Goes by ${label}`;
2050 // Only OTHER members get the tappable link/menu; a self-mention (or an
2051 // unresolved name) stays a plain bold chip, matching the classic feed.
2052 if (host && host.mention && (!host.mentionable || host.mentionable(label))) {
2053 span.classList.add("mention-link");
2054 span.setAttribute("role", "button");
2055 span.tabIndex = 0;
2056 const open = (e) => {
2057 e.preventDefault();
2058 e.stopPropagation();
2059 host.mention(label, span);
2060 };
2061 span.addEventListener("click", open);
2062 span.addEventListener("keydown", (e) => {
2063 if (e.key === "Enter" || e.key === " ") open(e);
2064 });
2065 }
2066 target.appendChild(span);
2067 last = mt.index + mt[0].length;
2068 }
2069 if (last < t.length) target.appendChild(document.createTextNode(t.slice(last)));
2070 };
2071 renderTree(el, parseMessage(text), { plain });
2072}
2073
2074// A download chip for a non-image attachment — the original .file-chip markup.
2075function buildFileCard(a, ctx) {
2076 const card = document.createElement("a");
2077 card.className = "file-chip";
2078 card.href = "#";
2079 card.download = a.name || "file";
2080 const ic = document.createElement("span");
2081 ic.className = "file-icon";
2082 ic.textContent = fileIcon(a.name);
2083 const meta = document.createElement("span");
2084 meta.className = "file-meta";
2085 const nm = document.createElement("span");
2086 nm.className = "file-name";
2087 nm.textContent = a.name || "file";
2088 meta.appendChild(nm);
2089 if (a.size) {
2090 const sz = document.createElement("span");
2091 sz.className = "file-sub";
2092 sz.textContent = formatBytes(a.size);
2093 meta.appendChild(sz);
2094 }
2095 card.append(ic, meta);
2096 card.addEventListener("click", (e) => {
2097 if (card.getAttribute("href") === "#") e.preventDefault();
2098 });
2099 if (host) {
2100 host.loadBlob(ctx.threadId, a).then((url) => {
2101 if (url) card.href = url;
2102 });
2103 }
2104 return card;
2105}
2106
2107// A download glyph (static, in-code SVG — safe for innerHTML) for the unplayable
2108// video fallback. host.icons has no download icon, so it lives here.
2109const ICON_DOWNLOAD =
2110 '<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>';
2111
2112// Whole seconds as m:ss (or h:mm:ss) for the video duration badge.
2113function formatDuration(secs) {
2114 secs = Math.max(0, Math.round(secs));
2115 const h = Math.floor(secs / 3600), m = Math.floor((secs % 3600) / 60), s = secs % 60;
2116 const mm = h ? String(m).padStart(2, "0") : String(m);
2117 return (h ? h + ":" : "") + mm + ":" + String(s).padStart(2, "0");
2118}
2119
2120// Download an object-URL'd blob under a filename — the video fallback when the
2121// browser can't play the format inline.
2122function triggerDownload(url, name) {
2123 const a = document.createElement("a");
2124 a.href = url;
2125 a.download = name;
2126 document.body.appendChild(a);
2127 a.click();
2128 a.remove();
2129}
2130
2131// A lazy, send-and-play video card: poster + play badge + duration, with NO bytes
2132// fetched until it's tapped (a thread full of clips must not auto-download). On
2133// tap we download+decrypt the clip (a radial ring shows progress) then play it
2134// full-screen in the lightbox. If the browser can't decode the format (e.g. an
2135// iPhone HEVC clip) we download it instead — the bytes are always retrievable.
2136function buildVideoCard(a, ctx) {
2137 const card = document.createElement("div");
2138 card.className = "msg-video";
2139 // Reserve the box before the poster loads so the feed doesn't jump; fall back to
2140 // 16/9 when we couldn't read the clip's dimensions (so it's never a collapsed or
2141 // oddly-shaped box).
2142 if (a.w && a.h) {
2143 // Reserve the box before the poster loads so the feed doesn't jump.
2144 card.style.aspectRatio = `${a.w} / ${a.h}`;
2145 const scale = Math.min(260 / a.w, 320 / a.h, 1);
2146 card.style.width = Math.round(a.w * scale) + "px";
2147 } else {
2148 // No stored dimensions (older upload, or a clip whose metadata didn't decode):
2149 // a sensible default box — NOT a 16px sliver from treating the 16:9 fallback
2150 // ratio as pixel dimensions.
2151 card.style.aspectRatio = "16 / 9";
2152 card.style.width = "260px";
2153 }
2154
2155 // canPlayType is only a hint. "" means definitely-can't-play → the card is a
2156 // download from the start.
2157 const probe = document.createElement("video");
2158 const playable = !!(a.type && probe.canPlayType && probe.canPlayType(a.type) !== "");
2159
2160 // The resting (poster) contents, reused if we have to revert after a failed play.
2161 const renderPoster = (downloadOnly) => {
2162 const poster = document.createElement("img");
2163 poster.className = "msg-video-poster";
2164 poster.alt = a.name || "video";
2165 if (host && a.poster) {
2166 host.loadBlob(ctx.threadId, { id: a.poster, type: "image/jpeg" }).then((url) => url && (poster.src = url));
2167 }
2168 const badge = document.createElement("span");
2169 badge.className = "msg-video-play";
2170 badge.innerHTML = downloadOnly ? ICON_DOWNLOAD : icon("play");
2171 const kids = [poster, badge];
2172 if (a.dur) {
2173 const dur = document.createElement("span");
2174 dur.className = "msg-video-dur";
2175 dur.textContent = formatDuration(a.dur);
2176 kids.push(dur);
2177 }
2178 card.replaceChildren(...kids);
2179 card.classList.toggle("unplayable", !!downloadOnly);
2180 };
2181 renderPoster(!playable);
2182
2183 card.onclick = () => {
2184 if (!host || card.dataset.loading === "1") return;
2185 card.dataset.loading = "1";
2186 // Show the radial progress ring only if the download takes a beat — a cached
2187 // or fast load skips it, so there's no flash of an empty ring.
2188 let ring = null;
2189 const ringTimer = setTimeout(() => { ring = showVideoRing(card); }, 150);
2190 host.loadBlob(ctx.threadId, a, (p) => ring && ring.set(p)).then((url) => {
2191 clearTimeout(ringTimer);
2192 card.dataset.loading = "";
2193 if (ring) ring.remove();
2194 if (!url) return;
2195 if (!playable) { triggerDownload(url, a.name || "video"); return; }
2196 openVideoLightbox(url, () => triggerDownload(url, a.name || "video"));
2197 });
2198 };
2199 return card;
2200}
2201
2202// A circular download-progress ring around the play button, shown while a clip
2203// downloads on tap. Returns { set(0..1), remove() }.
2204function showVideoRing(card) {
2205 const R = 20, C = 2 * Math.PI * R;
2206 const wrap = document.createElement("div");
2207 wrap.className = "msg-video-ring";
2208 wrap.innerHTML =
2209 '<svg viewBox="0 0 48 48"><circle class="ring-track" cx="24" cy="24" r="' + R + '"></circle>' +
2210 '<circle class="ring-fill" cx="24" cy="24" r="' + R + '"></circle></svg>';
2211 const fill = wrap.querySelector(".ring-fill");
2212 fill.style.strokeDasharray = String(C);
2213 fill.style.strokeDashoffset = String(C);
2214 card.appendChild(wrap);
2215 card.classList.add("downloading");
2216 return {
2217 set: (p) => { fill.style.strokeDashoffset = String(C * (1 - Math.max(0, Math.min(1, p)))); },
2218 remove: () => { wrap.remove(); card.classList.remove("downloading"); },
2219 };
2220}
2221
2222// Play a decrypted clip full-screen in the lightbox (a small inline player isn't
2223// worth much). Backdrop tap / Escape closes; a decode error falls back to download.
2224function openVideoLightbox(url, onDownloadFallback) {
2225 const back = document.createElement("div");
2226 back.className = "lightbox video-lightbox";
2227 const backdrop = document.createElement("div");
2228 backdrop.className = "lightbox-backdrop";
2229 const vid = document.createElement("video");
2230 vid.className = "lightbox-video";
2231 vid.src = url;
2232 vid.controls = true;
2233 vid.autoplay = true;
2234 vid.playsInline = true;
2235 back.append(backdrop, vid);
2236 const close = () => {
2237 vid.pause();
2238 back.remove();
2239 document.removeEventListener("keydown", onKey);
2240 };
2241 const onKey = (e) => { if (e.key === "Escape") close(); };
2242 back.addEventListener("click", (e) => { if (e.target !== vid) close(); });
2243 vid.onerror = () => { close(); if (onDownloadFallback) onDownloadFallback(); };
2244 document.addEventListener("keydown", onKey);
2245 document.body.appendChild(back);
2246 vid.play().catch(() => {});
2247}
2248
2249// The attachment blob ids a message references (clip + poster + images/files),
2250// reported to the host on delete so the server can GC them.
2251function attachmentBlobIds(m) {
2252 const ids = [];
2253 for (const a of (m && m.attachments) || []) {
2254 if (a && a.id) ids.push(a.id);
2255 if (a && a.poster) ids.push(a.poster);
2256 }
2257 return ids;
2258}
2259
2260// An animated GIF plays in the feed, and a click pauses/resumes it in place —
2261// the click is the toggle, not a lightbox opener (the message menu still holds
2262// the other actions). Pausing swaps the img's src for a canvas-rendered still
2263// of the GIF's first frame (the "default image" drawImage yields for animated
2264// sources — no re-decode of the whole animation); resuming restores the blob
2265// URL. prefers-reduced-motion starts the GIF frozen.
2266function wireGifToggle(img, url) {
2267 let still = null; // the frozen frame's data URL, rendered once on first pause
2268 const freeze = () => {
2269 if (!still) {
2270 if (!img.naturalWidth) return; // not decoded yet — nothing to freeze
2271 const c = document.createElement("canvas");
2272 c.width = img.naturalWidth;
2273 c.height = img.naturalHeight;
2274 c.getContext("2d").drawImage(img, 0, 0);
2275 still = c.toDataURL();
2276 }
2277 img.src = still;
2278 img.classList.add("gif-paused");
2279 };
2280 img.onclick = () => {
2281 if (img.classList.contains("gif-paused")) {
2282 img.src = url;
2283 img.classList.remove("gif-paused");
2284 } else {
2285 freeze();
2286 }
2287 };
2288 if (matchMedia("(prefers-reduced-motion: reduce)").matches) {
2289 if (img.complete && img.naturalWidth) freeze();
2290 else img.addEventListener("load", freeze, { once: true });
2291 }
2292}
2293
2294
2295// The original click-to-play YouTube card (.yt-card): thumbnail from the video
2296// id, swapped for the privacy-friendly nocookie embed on tap.
2297function buildYouTubeCard(yt) {
2298 const card = document.createElement("div");
2299 card.className = "yt-card";
2300 const btn = document.createElement("button");
2301 btn.type = "button";
2302 btn.className = "yt-poster";
2303 btn.setAttribute("aria-label", "Play video");
2304 const img = document.createElement("img");
2305 img.className = "yt-thumb";
2306 img.alt = "";
2307 img.loading = "lazy";
2308 img.src = `https://i.ytimg.com/vi/${yt.id}/hqdefault.jpg`;
2309 img.onload = () => {
2310 if (img.naturalWidth <= 120) card.classList.add("yt-bare"); // dead/private video placeholder
2311 };
2312 img.onerror = () => card.classList.add("yt-bare");
2313 const play = document.createElement("span");
2314 play.className = "yt-play";
2315 play.innerHTML = icon("play"); // static in-code SVG from the host (safe)
2316 btn.append(img, play);
2317 btn.onclick = () => {
2318 const frame = document.createElement("iframe");
2319 frame.className = "yt-frame";
2320 frame.src = `https://www.youtube-nocookie.com/embed/${yt.id}?autoplay=1&rel=0` + (yt.start ? `&start=${yt.start}` : "");
2321 frame.allow = "autoplay; encrypted-media; fullscreen; picture-in-picture";
2322 frame.allowFullscreen = true;
2323 // The app-wide Referrer-Policy is no-referrer, but YouTube rejects
2324 // referrer-less embeds (error 153, "Video player configuration error").
2325 // Send just the origin for this iframe -- never the page path.
2326 frame.referrerPolicy = "origin";
2327 frame.title = "YouTube video";
2328 btn.replaceWith(frame);
2329 };
2330 card.appendChild(btn);
2331 return card;
2332}
2333
2334// The OpenGraph card (.link-preview), fetched through the host. A successful
2335// preview is cached per URL so a bubble re-render never refetches; a failed or
2336// empty unfurl is evicted below so a later render retries (the original never
2337// cached and refetched every time).
2338function addLinkPreview(content, url) {
2339 if (!host || !host.unfurl) return;
2340 if (!unfurls.has(url)) unfurls.set(url, host.unfurl(url).catch(() => null));
2341 unfurls.get(url).then((pv) => {
2342 // A rejection resolves to null here, and the host itself may return null or
2343 // {ok:false} for a transient failure. Don't cache that miss — drop it so a
2344 // later render (e.g. reopening the thread) refetches and may show the card.
2345 if (!pv || !pv.ok || (!pv.title && !pv.image)) {
2346 unfurls.delete(url);
2347 return;
2348 }
2349 // Usable preview but the bubble was torn down before it arrived: keep the
2350 // cache entry so the next render of this URL reuses it.
2351 if (!content.isConnected) return;
2352 const card = document.createElement("a");
2353 card.className = "link-preview";
2354 card.href = url;
2355 card.target = "_blank";
2356 card.rel = "noopener noreferrer";
2357 if (pv.image) {
2358 const img = document.createElement("img");
2359 img.className = "lp-image";
2360 img.alt = "";
2361 img.src = pv.image;
2362 img.onerror = () => img.remove();
2363 card.appendChild(img);
2364 }
2365 const body = document.createElement("div");
2366 body.className = "lp-body";
2367 if (pv.title) {
2368 const t = document.createElement("div");
2369 t.className = "lp-title";
2370 t.textContent = pv.title;
2371 body.appendChild(t);
2372 }
2373 if (pv.description) {
2374 const d = document.createElement("div");
2375 d.className = "lp-desc";
2376 d.textContent = pv.description;
2377 body.appendChild(d);
2378 }
2379 try {
2380 const h = document.createElement("div");
2381 h.className = "lp-host";
2382 // .host (not .hostname) so an explicit non-default port shows, matching
2383 // the original feed's link-preview label.
2384 h.textContent = new URL(url).host;
2385 body.appendChild(h);
2386 } catch {}
2387 card.appendChild(body);
2388 content.appendChild(card);
2389 });
2390}
2391
2392// ---- skeleton: feed, banners, staging, composer (original markup) -----------
2393
2394// buildSkeleton constructs the once-per-mount frame with the original composer
2395// structure and class names — .composer-reply / .composer-edit banners, the
2396// floating .composer-preview staging strip, and the .composer form — so
2397// style.css styles them identically to the original ones (which are hidden in
2398// app-mode). Send emits the original untyped payload; editing re-sends the full
2399// original payload with new text through the host bridge.
2400function buildSkeleton(el, ctx) {
2401 const root = document.createElement("div");
2402 root.className = "chatapp-root";
2403
2404
2405 const feed = document.createElement("div");
2406 feed.className = "feed chatapp-feed"; // .feed for original descendant rules; .chatapp-feed mirrors #feed's layout
2407 // The wrapper bounds the reply-thread overlay to exactly the feed's box (it
2408 // dims the transcript, never the composer). Feed selectors everywhere are
2409 // depth-independent (.chatapp-feed querySelectors, no child combinators), so
2410 // the extra level is invisible to the rest of the shell + native probes.
2411 const feedWrap = document.createElement("div");
2412 feedWrap.className = "chatapp-feedwrap";
2413 feedWrap.appendChild(feed);
2414
2415 // Jump-to-latest: the classic chat affordance for getting back to the
2416 // newest message without hunting for it — shown/hidden by the feed's
2417 // "scroll" listener below (and re-checked after each render, since new
2418 // messages can widen the gap without a scroll event).
2419 const jumpBtn = document.createElement("button");
2420 jumpBtn.type = "button";
2421 jumpBtn.className = "feed-jump-btn hidden";
2422 // aria-label carries the accessible name; title mirrors it as a mouse-hover
2423 // tooltip (icon-only button — see docs/accessibility.md's icon-label rule).
2424 jumpBtn.setAttribute("aria-label", "Jump to latest");
2425 jumpBtn.title = "Jump to latest";
2426 jumpBtn.innerHTML = icon("chevronDown");
2427 jumpBtn.onclick = () => feedGlideToBottom(feed, false);
2428 feedWrap.appendChild(jumpBtn);
2429
2430 const float = document.createElement("div");
2431 float.className = "composer-float";
2432 const preview = document.createElement("div");
2433 preview.className = "composer-preview hidden";
2434 float.appendChild(preview);
2435
2436 // The typeahead is a drop-UP that OVERLAYS the feed (not a flex band that shoves
2437 // the messages up as you type), so it lives inside .composer-float — the
2438 // zero-height anchor at the top of the composer stack, exactly like the
2439 // attachment preview above. Positioned absolutely in CSS.
2440 const taBox = document.createElement("div");
2441 taBox.className = "typeahead hidden";
2442 taBox.setAttribute("role", "listbox");
2443 float.appendChild(taBox);
2444
2445 const replyBanner = document.createElement("div");
2446 replyBanner.className = "composer-reply hidden";
2447 const editBanner = document.createElement("div");
2448 editBanner.className = "composer-edit hidden";
2449
2450 const compose = document.createElement("form");
2451 compose.className = "composer";
2452 compose.autocomplete = "off";
2453 const attach = document.createElement("button");
2454 attach.type = "button";
2455 attach.className = "attach-btn";
2456 attach.setAttribute("aria-label", "Add a photo or file");
2457 attach.textContent = "+";
2458 const fileInput = document.createElement("input");
2459 fileInput.type = "file";
2460 fileInput.multiple = true;
2461 fileInput.accept = "image/*,video/mp4,video/quicktime,video/webm,.pdf,.txt,.md,.markdown,.csv,.json,.doc,.docx,.xls,.xlsx,.ppt,.pptx";
2462 fileInput.hidden = true;
2463 const field = document.createElement("div");
2464 field.className = "composer-field";
2465 // The inset field label ("Your name:") — sits inside the input's box, pinned
2466 // to the first line, with the text indented past it. Decorative to a screen
2467 // reader (the textarea's aria-label carries the same words).
2468 const fieldLabel = document.createElement("span");
2469 fieldLabel.className = "composer-label";
2470 fieldLabel.setAttribute("aria-hidden", "true");
2471 fieldLabel.hidden = true;
2472 // The labelled ask's dismiss X (#1375): back to a regular input without
2473 // answering. It borrows the send button's slot while that's free (typing
2474 // swaps it for Send; Escape still dismisses). Dismissing disarms the
2475 // pending answer too, so a later plain message is an ordinary message, not
2476 // a swallowed reply to a question the user waved off — the parked step
2477 // re-arms on the next resume, exactly like a reload.
2478 const labelDismiss = document.createElement("button");
2479 labelDismiss.type = "button";
2480 labelDismiss.className = "composer-label-x";
2481 labelDismiss.setAttribute("aria-label", "Dismiss question");
2482 labelDismiss.hidden = true;
2483 labelDismiss.innerHTML =
2484 '<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>';
2485 const dismissComposerAsk = () => {
2486 cancelAnswer(ctx.threadId); // the hook clears the label + hint with it
2487 composer.focus();
2488 };
2489 labelDismiss.addEventListener("click", dismissComposerAsk);
2490 const refreshLabelDismiss = () => {
2491 labelDismiss.hidden = !(composerLabels.get(ctx.threadId) || "") || !send.hidden;
2492 };
2493 // When plain text runs the app's default command (typing into To-do adds a
2494 // to-do — see defaultCommandFor), the placeholder must SAY so: the implicit
2495 // routing is only honest if the composer advertises it before the first send.
2496 const defaultCmd = () => defaultCommandFor(ctx.threadId);
2497 const basePlaceholder = () => {
2498 const dc = defaultCmd();
2499 return (dc && dc.app.composerPlaceholder) || "Message";
2500 };
2501 // The message input itself lives behind the composer facade (composer.js) —
2502 // chat.js never touches an element's .value directly, so the rich backend
2503 // can slot in behind the same handle. The handler bodies are function
2504 // declarations further down (hoisted; they run long after the skeleton is
2505 // fully built, so the consts they close over are all live by then).
2506 const inEmbed = !!(host && host.embed);
2507 const composerOpts = {
2508 placeholder: composerHints.get(ctx.threadId) || basePlaceholder(),
2509 formatAsYouType: formatAsYouTypePref(),
2510 onInput: () => onComposerInput(),
2511 onKeydown: (e) => {
2512 // Escape dismisses an active labelled ask (#1375) — the keyboard twin
2513 // of the X. Only while the pill shows; otherwise Escape keeps its
2514 // usual meanings (close typeahead, cancel edit…) downstream.
2515 if (e.key === "Escape" && (composerLabels.get(ctx.threadId) || "")) {
2516 e.preventDefault();
2517 dismissComposerAsk();
2518 return;
2519 }
2520 onComposerKeydown(e);
2521 },
2522 // Caret moves (click / arrows) can enter or leave a token too — re-check.
2523 onKeyup: (e) => {
2524 if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) updateTypeahead();
2525 },
2526 onClick: () => updateTypeahead(),
2527 // The original keyboard-inset rule (.composer.editing on coarse pointers)
2528 // keys off focus — mirror it so the composer sits on the keyboard.
2529 onFocus: () => compose.classList.add("editing"),
2530 onBlur: () => {
2531 compose.classList.remove("editing");
2532 setTimeout(closeTypeahead, 100);
2533 },
2534 onPaste: (e) => onComposerPaste(e),
2535 // Rich-backend file routes (ProseMirror owns paste/drop on its element).
2536 onPasteFiles: (files) => stageFiles(files),
2537 onDropFiles: (files) => stageFiles(files),
2538 onOversizePaste: () => stageError("That's too long for a message — attach it as a file instead."),
2539 };
2540 const composer =
2541 pmModule && !inEmbed && !appChatReadOnly(ctx.threadId) && !classicComposerPref()
2542 ? adoptComposer(pmModule.createRichComposer(composerOpts))
2543 : createComposer(composerOpts);
2544 const applyFieldLabel = () => {
2545 const label = composerLabels.get(ctx.threadId) || "";
2546 fieldLabel.textContent = label;
2547 fieldLabel.hidden = !label;
2548 composer.setAriaLabel(label ? label.replace(/[::]\s*$/, "") : "Message");
2549 refreshLabelDismiss();
2550 if (!label) {
2551 composer.setIndent(0);
2552 return;
2553 }
2554 // Indent the text past the pill — measured after layout so the width is real.
2555 requestAnimationFrame(() => {
2556 if (!fieldLabel.hidden) composer.setIndent(fieldLabel.offsetWidth + 12 + 8);
2557 });
2558 };
2559 refreshComposerHint.set(ctx.threadId, () => {
2560 const attach = pending.length ? composerAttachHints.get(ctx.threadId) : "";
2561 composer.setPlaceholder(attach || composerHints.get(ctx.threadId) || basePlaceholder());
2562 applyFieldLabel();
2563 });
2564 const send = document.createElement("button");
2565 send.type = "submit";
2566 send.className = "send-btn";
2567 send.setAttribute("aria-label", "Send");
2568 send.hidden = true;
2569 send.innerHTML =
2570 '<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>';
2571 field.append(fieldLabel, composer.el, labelDismiss, send);
2572 compose.append(attach, fileInput, field);
2573 // A default-command thread takes text only — with no one to read a file, an
2574 // attachment is a dead end, so the affordance goes (stageFiles gates paste and
2575 // drop the same way).
2576 attach.hidden = !!defaultCmd();
2577
2578
2579 root.append(feedWrap, float, replyBanner, editBanner, compose);
2580 el.appendChild(root);
2581
2582 // Keep the feed pinned to the newest message when the composer, its staged
2583 // reply/edit banner, or the preview strip grows and steals vertical space —
2584 // they're in-flow siblings, so the flex feed shrinks and this observer fires.
2585 // (The original re-pins the same way; render() alone can't, since a resize
2586 // isn't a state change.) Track "at bottom" from real scrolls so the decision
2587 // reflects the state *before* the feed shrank — reading the gap inside the
2588 // callback would already show it past the threshold and wrongly bail. The
2589 // `< 60` matches render()'s pin check.
2590 let feedAtBottom = true;
2591 const updateFeedScrollState = () => {
2592 const gap = feed.scrollHeight - feed.scrollTop - feed.clientHeight;
2593 feedAtBottom = gap < 60;
2594 jumpBtn.classList.toggle("hidden", gap < JUMP_BUTTON_GAP);
2595 };
2596 feed.addEventListener("scroll", updateFeedScrollState, { passive: true });
2597 // render() re-checks after every message-list update too — new messages
2598 // widen the gap (or a glide-to-bottom closes it) without firing "scroll".
2599 root._updateJumpBtn = updateFeedScrollState;
2600 new ResizeObserver(() => {
2601 if (!feedAtBottom) return;
2602 // WebKit restores the pre-resize scrollTop at *layout* time — which lands
2603 // after this callback AND after a single rAF — so one re-assert loses the
2604 // race and strands the feed the growth-delta (~one composer line) above the
2605 // bottom (Chrome doesn't do this). Re-pin across a few frames so a
2606 // correction always lands after the restoration, whenever it happens. The
2607 // scroll listener keeps feedAtBottom true through the restoration (its gap
2608 // stays under the 60px threshold), so this bails only if the reader
2609 // genuinely scrolls up mid-window.
2610 let frame = 0;
2611 const pin = () => {
2612 if (!feedAtBottom) return; // reader scrolled away — stop fighting them
2613 feed.scrollTop = feed.scrollHeight;
2614 if (++frame < 4) requestAnimationFrame(pin);
2615 };
2616 pin();
2617 }).observe(feed);
2618
2619 // ---- composer state (per mount; a fresh open rebuilds it clean) ----
2620 let replyTo = null; // { id, name, snippet } — the original payload shape
2621 let editing = null; // the item being edited
2622 let editBaseline = ""; // getMarkdown() right after edit-load (the ↓-undo comparator)
2623 let pending = []; // staged attachments: { att | null (uploading), name, isImage }
2624 let sendQueued = false; // Enter landed mid-upload — auto-send once the uploads finish
2625 let staging = 0; // stageFiles batches in flight — a queued send waits for the whole batch
2626 // A remount mid-conversation must restore the inset label; it needs layout
2627 // (the width measure) and `pending` above, so it runs here, post-append.
2628 applyComposerHint(ctx.threadId);
2629
2630 const refreshBanners = () => {
2631 replyBanner.textContent = "";
2632 replyBanner.classList.add("hidden");
2633 editBanner.textContent = "";
2634 editBanner.classList.add("hidden");
2635 if (editing) {
2636 editBanner.append("Editing message");
2637 const x = document.createElement("button");
2638 x.className = "banner-x";
2639 x.type = "button";
2640 x.textContent = "×";
2641 x.onclick = cancelEdit;
2642 editBanner.appendChild(x);
2643 editBanner.classList.remove("hidden");
2644 } else if (replyTo) {
2645 const bar = document.createElement("div");
2646 bar.className = "reply-bar";
2647 const ic = document.createElement("span");
2648 ic.className = "reply-bar-icon";
2649 ic.innerHTML = icon("reply");
2650 const body = document.createElement("div");
2651 body.className = "reply-bar-body";
2652 const who = document.createElement("div");
2653 who.className = "reply-bar-name";
2654 who.textContent = "Replying to " + (replyTo.name || "");
2655 const txt = document.createElement("div");
2656 txt.className = "reply-bar-text";
2657 txt.textContent = replyTo.snippet || "";
2658 body.append(who, txt);
2659 const x = document.createElement("button");
2660 x.type = "button";
2661 x.className = "reply-bar-cancel";
2662 x.innerHTML = icon("x");
2663 x.setAttribute("aria-label", "Cancel reply");
2664 // While the thread view is open its staged reply IS the view's composer
2665 // state — cancelling it means leaving the thread, so close the whole view
2666 // (which clears the reply) rather than stranding an open thread whose
2667 // sends would land outside it.
2668 x.onclick = () => (threadSid ? closeThread() : cancelReply());
2669 bar.append(ic, body, x);
2670 replyBanner.appendChild(bar);
2671 replyBanner.classList.remove("hidden");
2672 }
2673 };
2674 if (inEmbed) root.classList.add("embed"); // the native shell draws the composer
2675 // The owning app declared its chat a read-only stream — no composer to draw.
2676 if (appChatReadOnly(ctx.threadId)) root.classList.add("readonly");
2677
2678 const cancelReply = () => {
2679 replyTo = null;
2680 refreshBanners();
2681 if (inEmbed) host.native({ replying: null });
2682 };
2683 const cancelEdit = () => {
2684 editing = null;
2685 refreshBanners();
2686 if (inEmbed) host.native({ editing: null });
2687 // An edit inside an open thread view borrowed the composer (startEdit clears
2688 // replyTo); hand it back so the next send still continues the thread.
2689 if (threadSid) stageThreadReply();
2690 };
2691
2692 // Staged attachments as original .preview-chip tiles: a spinner while
2693 // uploading, then a thumbnail (image) or a file chip, each with its remove.
2694 const refreshStaged = () => {
2695 if (inEmbed) host.native({ staged: pending.length > 0 }); // enables the native send button
2696 applyComposerHint(ctx.threadId); // an attach-gated hint appears/disappears with the staged files
2697 // The strip takes real layout space, so the feed shrinks when it appears —
2698 // keep the newest messages (and any action chips) in view, not under the fold.
2699 if (feedNearBottom(feed, 160)) requestAnimationFrame(() => feedGlideToBottom(feed, true));
2700 preview.textContent = "";
2701 preview.classList.toggle("hidden", pending.length === 0);
2702 for (const p of pending) {
2703 const chip = document.createElement("div");
2704 chip.className = "preview-chip";
2705 if (!p.att) {
2706 const label = document.createElement("span");
2707 label.className = "preview-name";
2708 if (p.isVideo && typeof p.progress === "number") {
2709 // Determinate upload progress for the (large) clip: percentage + a bar,
2710 // updated in place by the onProgress callback (see stageFiles).
2711 chip.classList.add("preview-uploading");
2712 label.textContent = "Uploading " + Math.round(p.progress * 100) + "%";
2713 const bar = document.createElement("div");
2714 bar.className = "preview-progress";
2715 const fill = document.createElement("div");
2716 fill.className = "preview-progress-fill";
2717 fill.style.width = Math.round(p.progress * 100) + "%";
2718 bar.appendChild(fill);
2719 chip.append(label, bar);
2720 p._fill = fill;
2721 p._label = label;
2722 } else {
2723 const spin = document.createElement("span");
2724 spin.className = "preview-spinner";
2725 spin.setAttribute("aria-hidden", "true");
2726 label.textContent = p.isImage ? "Adding photo…" : p.isVideo ? "Adding video…" : "Adding file…";
2727 chip.append(spin, label);
2728 }
2729 } else if (p.isImage) {
2730 const img = document.createElement("img");
2731 img.alt = p.att.name || "photo";
2732 if (host) host.loadBlob(ctx.threadId, p.att).then((url) => url && (img.src = url));
2733 chip.appendChild(img);
2734 } else if (p.isVideo) {
2735 // Show the poster thumbnail with a small play glyph, so a staged clip
2736 // reads as a video rather than a generic file.
2737 chip.classList.add("preview-video");
2738 const img = document.createElement("img");
2739 img.alt = p.att.name || "video";
2740 if (host && p.att.poster) {
2741 host.loadBlob(ctx.threadId, { id: p.att.poster, type: "image/jpeg" }).then((url) => url && (img.src = url));
2742 }
2743 const play = document.createElement("span");
2744 play.className = "preview-play";
2745 play.innerHTML = icon("play");
2746 chip.append(img, play);
2747 } else {
2748 const ic = document.createElement("span");
2749 ic.className = "preview-file";
2750 ic.textContent = fileIcon(p.att.name);
2751 const nm = document.createElement("span");
2752 nm.className = "preview-name";
2753 nm.textContent = p.att.name || "file";
2754 chip.append(ic, nm);
2755 }
2756 const x = document.createElement("button");
2757 x.className = "preview-remove";
2758 x.type = "button";
2759 x.setAttribute("aria-label", "Remove attachment");
2760 x.innerHTML = icon("x");
2761 x.onclick = () => {
2762 pending = pending.filter((q) => q !== p);
2763 refreshStaged();
2764 refreshSend();
2765 // Removing a chip can resolve a queued send either way: nothing left to
2766 // send cancels it; removing the last still-uploading chip releases it.
2767 if (sendQueued && composer.isEmpty() && !pending.length) setSendQueued(false);
2768 else fireQueuedSend();
2769 };
2770 chip.appendChild(x);
2771 preview.appendChild(chip);
2772 }
2773 };
2774 // The send button shows with text or staged files, and disables while an
2775 // upload is in flight — the original rules exactly. It also greys while the
2776 // socket is down (a send would be dropped), mirroring the original composer.
2777 const refreshSend = () => {
2778 send.hidden = composer.isEmpty() && pending.length === 0;
2779 send.disabled = pending.some((p) => !p.att) || !connected;
2780 refreshLabelDismiss(); // the X borrows Send's slot — they never overlap
2781 };
2782 // Enter during an upload queues the send instead of dropping it: the button
2783 // swaps its arrow for a spinner (.send-waiting) and the message goes out the
2784 // moment the last upload lands. Typing again, a failed upload, or emptying
2785 // the composer cancels the queue — the auto-send must never fire on content
2786 // that no longer matches what was queued.
2787 const setSendQueued = (on) => {
2788 sendQueued = on;
2789 send.classList.toggle("send-waiting", on);
2790 refreshSend();
2791 };
2792 const fireQueuedSend = () => {
2793 if (!sendQueued || staging > 0 || pending.some((p) => !p.att)) return;
2794 setSendQueued(false);
2795 compose.requestSubmit();
2796 };
2797
2798 composers.set(ctx.threadId, {
2799 refreshSend,
2800 startReply(m) {
2801 editing = null;
2802 // Replying to a reply continues its thread: target the TOP message.
2803 replyTo = replyTarget(((ctx.getState() || {}).msgs) || [], m);
2804 refreshBanners();
2805 // Embedded: the native shell draws the reply banner and focuses its own
2806 // composer (this one is hidden).
2807 if (inEmbed) host.native({ replying: { name: replyTo.name || "", snippet: replyTo.snippet || "" } });
2808 else composer.focus();
2809 },
2810 startEdit(m) {
2811 replyTo = null;
2812 editing = m;
2813 composer.setMarkdown(m.text, { notify: true }); // resize + reveal send
2814 editBaseline = composer.getMarkdown(); // ↓-undo compares against this, not raw bytes
2815 refreshBanners();
2816 if (inEmbed) host.native({ editing: { text: m.text } });
2817 if (!inEmbed) composer.focus();
2818 },
2819 });
2820
2821 // Matches maxUploadBytes (main.go) / the original composer's MAX_UPLOAD — used
2822 // only to word the "too big" message; the host bridge does the real limiting.
2823 const MAX_UPLOAD = 25 * 1024 * 1024;
2824 const MAX_VIDEO_UPLOAD = 50 * 1024 * 1024; // clips get a larger cap (see shell.js)
2825 // alert() would freeze an embedded webview (modal blocks the native message
2826 // channel) — route the error to the shell instead.
2827 const stageError = (msg) => {
2828 if (inEmbed) host.native({ error: msg });
2829 else alert(msg);
2830 };
2831 // One staging path — attach button, paste, and drag-drop all land here. The
2832 // wrapper counts in-flight batches so a queued send only fires once the whole
2833 // drop/paste has staged — mid-batch, later files aren't in `pending` yet, and
2834 // firing early would send the message without them.
2835 const stageFiles = async (files) => {
2836 if (!host || !host.upload) return;
2837 if (defaultCmd()) return; // text-only surface — see the hidden attach button
2838 staging++;
2839 try {
2840 await stageFilesBatch(files);
2841 } finally {
2842 staging--;
2843 fireQueuedSend();
2844 }
2845 };
2846 const stageFilesBatch = async (files) => {
2847 for (const file of files) {
2848 const isImage = !!(file.type && file.type.startsWith("image/"));
2849 const isVideo = !!(file.type && file.type.startsWith("video/"));
2850 // Any file type stages — size is the only gate (the bridge enforces it).
2851 const entry = { att: null, isImage, isVideo };
2852 pending.push(entry);
2853 refreshStaged();
2854 refreshSend();
2855 const att = await host.upload(ctx.threadId, file, (p) => {
2856 // First measurable byte → build the bar once (a refresh); after that update
2857 // it in place so we don't rebuild the whole strip on every progress tick.
2858 const first = typeof entry.progress !== "number";
2859 entry.progress = p;
2860 if (first || !entry._fill || !entry._fill.isConnected) refreshStaged();
2861 else {
2862 entry._fill.style.width = Math.round(p * 100) + "%";
2863 if (entry._label) entry._label.textContent = "Uploading " + Math.round(p * 100) + "%";
2864 }
2865 });
2866 if (!pending.includes(entry)) continue; // removed while uploading
2867 if (att && typeof att === "object") entry.att = att;
2868 else {
2869 pending = pending.filter((q) => q !== entry);
2870 // A rejected upload cancels any queued send — the message would go out
2871 // missing an attachment the sender believed was included.
2872 if (sendQueued) setSendQueued(false);
2873 // The bridge returns a reason string (not a bare null) so each rejection
2874 // gets the original composer's distinct wording.
2875 const msg = att === "photo" ? "Couldn't add that photo. Try a smaller one."
2876 : att === "size" ? `Couldn't add that file (max ${formatBytes(MAX_UPLOAD)}).`
2877 : att === "video" ? `Couldn't add that video (max ${formatBytes(MAX_VIDEO_UPLOAD)}).`
2878 : "Couldn't add that file.";
2879 stageError(msg);
2880 }
2881 refreshStaged();
2882 refreshSend();
2883 }
2884 };
2885 attach.onclick = () => fileInput.click();
2886 fileInput.onchange = () => {
2887 const files = [...(fileInput.files || [])];
2888 fileInput.value = "";
2889 stageFiles(files);
2890 };
2891
2892 // ---- command panel: the transient results surface a command opens ----
2893 // (ctx.panel — see the command-panels section of apps.js.) In-flow, never
2894 // floating: it sits above the composer stack by default, or pinned above the
2895 // feed with position: "top", taking layout space either way. One per thread —
2896 // opening another replaces it; its ✕, Escape, or the app's panel.close()
2897 // dismisses it. Client-only chrome: nothing here is sent or folded.
2898 const panelBox = document.createElement("section");
2899 panelBox.className = "cmd-panel hidden";
2900 panelBox.setAttribute("role", "region");
2901 let panelSpec = null;
2902 const panelOpen = () => !!panelSpec;
2903 const renderPanelBody = (spec, body) => {
2904 body.replaceChildren();
2905 // The handle a panel renders against: dismiss it, redraw it, and act on
2906 // the composer it sits above — insert text at the caret, or stage
2907 // attachments through the same path the attach button / paste / drop use.
2908 const panel = {
2909 threadId: ctx.threadId,
2910 close: () => hidePanel(),
2911 refresh: () => {
2912 if (panelSpec === spec && body.isConnected) renderPanelBody(spec, body);
2913 },
2914 insertText: (t) => {
2915 const text = String(t == null ? "" : t);
2916 if (!text) return;
2917 // Embedded: the native shell owns the visible composer — forward the
2918 // insertion (a shell that doesn't handle composerInsert ignores it).
2919 if (inEmbed) return host.native({ composerInsert: text });
2920 composer.insertAtCaret(text);
2921 },
2922 attachFiles: (files) => stageFiles([...(files || [])]),
2923 };
2924 try {
2925 spec.render(body, panel);
2926 } catch (e) {
2927 // A panel must never take down the pane — contain the error in place.
2928 console.error(`command panel (${(spec.sender && spec.sender.name) || "app"}) render failed:`, e);
2929 body.replaceChildren();
2930 const p = document.createElement("p");
2931 p.textContent = "This panel hit an error and couldn't load.";
2932 body.appendChild(p);
2933 }
2934 };
2935 // Whether the panel eases open/shut: the app's call (animate: false opts
2936 // out), gated on reduced-motion — and skipped in the command-host embed,
2937 // where the panel is the whole page and the NATIVE shell animates the
2938 // frame instead (an in-page ease inside a just-revealed frame reads as a
2939 // flash, not a slide).
2940 // (The side column skips the ease outright — its reveal is horizontal, and
2941 // a height roll inside a full-height column reads as a glitch.)
2942 const panelEases = (spec) =>
2943 spec && spec.animate !== false && spec.position !== "side" && motionOK() && !document.body.classList.contains("command-host");
2944 const clearPanelEase = () => {
2945 panelBox.style.removeProperty("height");
2946 panelBox.style.removeProperty("overflow");
2947 panelBox.style.removeProperty("transition");
2948 };
2949 const showPanel = (spec) => {
2950 const prev = panelSpec;
2951 panelSpec = spec && typeof spec.render === "function" ? spec : null;
2952 clearPanelEase(); // a mid-animation replace starts clean
2953 // Replacing (or closing) a panel tells the outgoing one — best-effort.
2954 if (prev && prev.onClose) {
2955 try {
2956 prev.onClose();
2957 } catch (e) {
2958 console.error(e);
2959 }
2960 }
2961 panelBox.replaceChildren();
2962 panelBox.classList.toggle("hidden", !panelSpec);
2963 panelBox.classList.toggle("cmd-panel--top", !!(panelSpec && panelSpec.position === "top"));
2964 panelBox.classList.toggle("cmd-panel--side", !!(panelSpec && panelSpec.position === "side"));
2965 // The side placement reserves a right-hand gutter on the root (padding,
2966 // so the column occupies real layout space and nothing renders under it).
2967 root.classList.toggle("has-side-panel", !!(panelSpec && panelSpec.position === "side"));
2968 if (!panelSpec) {
2969 notifyCommandPanel(ctx.threadId, null);
2970 return;
2971 }
2972 // Every placement is in-flow: "top" pins it above the feed (under the
2973 // shared-app surface); "side" is a full-height right column (a vertical
2974 // sidebar — list-shaped content wants a tall, narrow box, not a wide,
2975 // short band; on narrow screens its CSS falls back to the bottom band);
2976 // the default sits at the bottom of the pane, above the composer stack
2977 // (before the zero-height .composer-float anchor).
2978 root.insertBefore(panelBox, panelSpec.position === "top" ? feedWrap : float);
2979 const head = document.createElement("div");
2980 head.className = "cmd-panel-head";
2981 const s = panelSpec.sender || {};
2982 if (s.glyph) {
2983 const g = document.createElement("span");
2984 g.textContent = s.glyph;
2985 head.appendChild(g);
2986 }
2987 const title = document.createElement("span");
2988 title.className = "cmd-panel-title";
2989 title.textContent = panelSpec.title || s.name || "";
2990 const x = document.createElement("button");
2991 x.type = "button";
2992 x.className = "cmd-panel-x";
2993 x.setAttribute("aria-label", "Dismiss");
2994 x.innerHTML = icon("x");
2995 x.onclick = () => hidePanel();
2996 // headExtra: a spec-provided slot in the title row, between the title and
2997 // the dismiss — for a panel's one standing control (Memory's "Forget…"),
2998 // so it sits inline with the name instead of eating body space.
2999 if (typeof panelSpec.headExtra === "function") {
3000 const extra = document.createElement("span");
3001 extra.className = "cmd-panel-extra";
3002 try {
3003 panelSpec.headExtra(extra);
3004 } catch (e) {
3005 console.error(e);
3006 }
3007 head.append(title, extra, x);
3008 } else {
3009 head.append(title, x);
3010 }
3011 const body = document.createElement("div");
3012 body.className = "cmd-panel-body";
3013 panelBox.append(head, body);
3014 renderPanelBody(panelSpec, body);
3015 notifyCommandPanel(ctx.threadId, panelSpec);
3016 // Ease up from nothing to the panel's natural height (see panelEases).
3017 if (panelEases(panelSpec)) {
3018 const h = panelBox.offsetHeight;
3019 if (h) {
3020 panelBox.style.overflow = "hidden";
3021 panelBox.style.height = "0px";
3022 void panelBox.offsetHeight; // commit the start height
3023 panelBox.style.transition = "height 0.22s ease";
3024 panelBox.style.height = h + "px";
3025 panelBox.addEventListener("transitionend", clearPanelEase, { once: true });
3026 setTimeout(clearPanelEase, 350); // backstop if transitionend never fires
3027 }
3028 }
3029 // A bottom panel takes its space from the feed — keep the newest in view.
3030 if (feedNearBottom(feed, 160)) requestAnimationFrame(() => feedGlideToBottom(feed, true));
3031 };
3032 // Dismissal rolls the panel down before tearing it out, unless the app (or
3033 // the OS) asked for instant. panelClosing keeps a second Escape (or the ✕)
3034 // from re-arming the roll-up mid-flight; a NEW panel opening mid-animation
3035 // wins — showPanel resets the inline styles, and the captured spec check
3036 // keeps the stale finish() from tearing the newcomer down.
3037 let panelClosing = false;
3038 const hidePanel = () => {
3039 if (!panelSpec || panelClosing) return;
3040 if (!panelEases(panelSpec) || !panelBox.isConnected) return showPanel(null);
3041 panelClosing = true;
3042 const closing = panelSpec;
3043 let finished = false;
3044 const finish = () => {
3045 if (finished) return;
3046 finished = true;
3047 panelClosing = false;
3048 clearPanelEase();
3049 if (panelSpec === closing) showPanel(null);
3050 };
3051 const startH = panelBox.offsetHeight;
3052 if (!startH) return finish();
3053 panelBox.style.overflow = "hidden";
3054 panelBox.style.height = startH + "px";
3055 void panelBox.offsetHeight;
3056 panelBox.style.transition = "height 0.2s ease";
3057 panelBox.style.height = "0px";
3058 panelBox.addEventListener("transitionend", finish, { once: true });
3059 setTimeout(finish, 320);
3060 };
3061 // Programmatic closes (ctx.closePanel) take the same eased path as ✕/Escape.
3062 cmdPanels.set(ctx.threadId, (spec) => (spec ? showPanel(spec) : hidePanel()));
3063 function onComposerPaste(e) {
3064 const files = [...((e.clipboardData && e.clipboardData.files) || [])];
3065 if (files.length) {
3066 e.preventDefault();
3067 stageFiles(files);
3068 }
3069 // Blockquote continuation: pasting multi-line text onto a bare "> " line
3070 // prefixes every line after the first with "> " so the whole quote stays a
3071 // quote — matches the original composer's paste behaviour.
3072 if (!files.length && composer.kind === "plain") {
3073 const textItem = [...(e.clipboardData?.items || [])].find((i) => i.type === "text/plain");
3074 if (textItem) {
3075 const start = composer.getCaret();
3076 const beforeCursor = composer.getText().substring(0, start);
3077 const currentLine = beforeCursor.split("\n").pop();
3078 if (/^>\s*$/.test(currentLine)) {
3079 e.preventDefault();
3080 textItem.getAsString((pastedText) => {
3081 const blockquoted = pastedText.split("\n").map((line, i) => i === 0 ? line : "> " + line).join("\n");
3082 composer.replaceRange(start, composer.getSelectionEnd(), blockquoted);
3083 });
3084 }
3085 }
3086 }
3087 }
3088 // Drag-drop overlay affordance (.drop-overlay — the same class the original
3089 // pane uses, so it looks identical). The overlay element exists in *both* modes;
3090 // only the web drop *listeners* are web-only (see below).
3091 const dropOverlay = document.createElement("div");
3092 dropOverlay.className = "drop-overlay hidden";
3093 dropOverlay.textContent = "Drop a photo or file to send";
3094 root.appendChild(dropOverlay);
3095 // The native macOS shell (Eleven) drives the overlay during a Finder drag:
3096 // WebKit swallows the drag events natively (see the listener note below), so the
3097 // page's own dragenter/leave never fire in the app. Selector-based so it always
3098 // targets the connected root's overlay — survives thread switches / the
3099 // instant-restore path. (DropWebView in ChatFeedWebView.swift calls this.)
3100 window.__lchatDropOverlay = (show) => {
3101 const o = document.querySelector(".chatapp-root .drop-overlay");
3102 if (o) o.classList.toggle("hidden", !show);
3103 };
3104 // The native wrapper intercepts Finder file-drops itself — a WKWebView doesn't
3105 // hand the page the dropped files on macOS, and merely *registering* a web drop
3106 // handler makes WebKit claim the drop, bypassing the native path, so nothing
3107 // uploads. So skip the web drop listeners in the native app (html.native); there
3108 // the shell delivers files via __lchatAddFiles and shows the overlay via
3109 // __lchatDropOverlay above.
3110 if (!document.documentElement.classList.contains("native")) {
3111 let dragDepth = 0;
3112 root.addEventListener("dragenter", (e) => {
3113 if (!e.dataTransfer || ![...e.dataTransfer.types].includes("Files")) return;
3114 dragDepth++;
3115 dropOverlay.classList.remove("hidden");
3116 });
3117 root.addEventListener("dragleave", () => {
3118 if (--dragDepth <= 0) {
3119 dragDepth = 0;
3120 dropOverlay.classList.add("hidden");
3121 }
3122 });
3123 root.addEventListener("dragover", (e) => {
3124 if (e.dataTransfer && [...e.dataTransfer.types].includes("Files")) e.preventDefault();
3125 });
3126 root.addEventListener("drop", (e) => {
3127 dragDepth = 0;
3128 dropOverlay.classList.add("hidden");
3129 const files = [...((e.dataTransfer && e.dataTransfer.files) || [])];
3130 if (files.length) {
3131 e.preventDefault();
3132 stageFiles(files);
3133 }
3134 });
3135 }
3136 // The native macOS shell (LChatMac) can't hand Finder file-drops to the page —
3137 // a WKWebView doesn't deliver them and the web drop listeners above are skipped
3138 // in native mode — so it fills the page's static #file-input and dispatches
3139 // `change` (see ChatFeedWebView's __lchatAddFiles). The live composer uses its
3140 // own input, so expose a handler that pulls the files off that static input
3141 // into this thread's staging path; render() wires it to #file-input for the
3142 // thread on screen. (A plain browser never reaches this — its drops land on the
3143 // web listeners above.)
3144 root._dropFiles = () => {
3145 const el = document.getElementById("file-input");
3146 if (!el) return;
3147 const files = [...(el.files || [])];
3148 el.value = "";
3149 if (files.length) stageFiles(files);
3150 };
3151
3152 // ---- :emoji / @mention typeahead — the original menu, verbatim ----
3153 // composerToken (pure) detects the in-progress token at the caret; the host
3154 // ranks the candidates (host.typeaheadItems — the same function that feeds
3155 // the original menu and the native strip, so all three rank identically).
3156 let typeahead = null; // { kind, start, items, index } or null
3157 const closeTypeahead = () => {
3158 typeahead = null;
3159 taBox.classList.add("hidden");
3160 taBox.innerHTML = "";
3161 };
3162 const acceptTypeahead = () => {
3163 if (!typeahead) return;
3164 const it = typeahead.items[typeahead.index];
3165 const caret = composer.getCaret();
3166 const start = typeahead.start;
3167 closeTypeahead();
3168 // replaceRange notifies (resize + send-visibility + token re-check) and
3169 // restores focus — the original splice, behind the facade.
3170 composer.replaceRange(start, caret, it.insert);
3171 };
3172 const renderTa = () => {
3173 if (!typeahead) return;
3174 taBox.innerHTML = "";
3175 typeahead.items.forEach((it, i) => {
3176 const row = document.createElement("button");
3177 row.type = "button";
3178 row.className = "ta-item" + (i === typeahead.index ? " active" : "");
3179 row.setAttribute("role", "option");
3180 row.setAttribute("aria-selected", String(i === typeahead.index));
3181 if (typeahead.kind === "command") {
3182 const n = document.createElement("span");
3183 n.className = "ta-name";
3184 n.textContent = it.command + (it.args ? " " + it.args : "");
3185 row.appendChild(n);
3186 if (it.desc) {
3187 const d = document.createElement("span");
3188 d.className = "ta-desc";
3189 d.textContent = it.desc;
3190 row.appendChild(d);
3191 }
3192 } else if (typeahead.kind === "emoji") {
3193 const g = document.createElement("span");
3194 g.className = "ta-emoji";
3195 g.textContent = it.glyph;
3196 const c = document.createElement("span");
3197 c.className = "ta-code";
3198 c.textContent = ":" + it.code + ":";
3199 row.append(g, c);
3200 } else {
3201 // A group-added app in the @ typeahead wears its glyph avatar (there's
3202 // no roster photo to load); people keep the original avatar element.
3203 if (it.glyph) {
3204 const g = document.createElement("span");
3205 g.className = "avatar app-avatar";
3206 g.textContent = it.glyph;
3207 row.appendChild(g);
3208 } else if (host && host.taAvatar) row.appendChild(host.taAvatar(it.name, it.userId));
3209 const n = document.createElement("span");
3210 n.className = "ta-name";
3211 n.textContent = it.name;
3212 row.appendChild(n);
3213 }
3214 // pointerdown fires before the textarea blurs, so the tap isn't lost.
3215 row.addEventListener("pointerdown", (e) => {
3216 e.preventDefault();
3217 typeahead.index = i;
3218 acceptTypeahead();
3219 });
3220 taBox.appendChild(row);
3221 });
3222 taBox.classList.remove("hidden");
3223 };
3224 const moveTypeahead = (dir) => {
3225 if (!typeahead) return;
3226 const n = typeahead.items.length;
3227 typeahead.index = (typeahead.index + dir + n) % n;
3228 renderTa();
3229 taBox.children[typeahead.index]?.scrollIntoView({ block: "nearest" });
3230 };
3231 const updateTypeahead = () => {
3232 const tok = composerToken(composer.getText(), composer.getCaret());
3233 if (!tok || !host || !host.typeaheadItems) return closeTypeahead();
3234 const items = host.typeaheadItems(tok);
3235 if (!items.length) return closeTypeahead();
3236 typeahead = { kind: tok.kind, start: tok.start, items, index: 0 };
3237 renderTa();
3238 };
3239
3240 // Auto-grow + send-button reveal + typing beats. Emitting only on keystrokes
3241 // let the receiver's indicator lapse during a mid-message pause, so we mirror
3242 // the original composer: `lastBeat` tracks the last keystroke (while there's
3243 // text), and tickTyping — driven by a render-scoped interval (see render) —
3244 // re-emits a beat every TYPING_INTERVAL_MS through a pause and issues the idle
3245 // stop once the keystrokes dry up. Each beat carries TYPING_EXPIRY_S, so a
3246 // single one outlives the interval. An explicit expiry-0 beat clears the
3247 // indicator now on empty / send / idle.
3248 let lastBeat = 0; // ms timestamp of the last keystroke while text was present (0 = not typing)
3249 let lastEmit = 0; // ms timestamp of the last beat we actually sent
3250 let beating = false; // a beat receivers are currently showing
3251 const sendBeat = (expiry) => {
3252 lastEmit = Date.now();
3253 ctx.emitEphemeral({ type: N.ev("typing"), expiry });
3254 };
3255 const beatTyping = () => {
3256 lastBeat = Date.now();
3257 // First keystroke of a burst beats now (once per burst, not per keystroke —
3258 // the old 4s throttle's intent); the ticker sustains it through the pause.
3259 if (!beating) {
3260 beating = true;
3261 sendBeat(TYPING_EXPIRY_S);
3262 }
3263 };
3264 const stopTypingBeat = () => {
3265 lastBeat = 0;
3266 if (!beating) return; // nothing in flight — don't emit a spurious expiry-0
3267 beating = false;
3268 sendBeat(0);
3269 };
3270 // Keyed off absolute timestamps (not fire-count), so render() re-registering
3271 // this interval every draw can't break it — it just has to fire once past the
3272 // threshold to re-beat, or to stop once the keystrokes have gone idle.
3273 const tickTyping = () => {
3274 if (!beating) return;
3275 const now = Date.now();
3276 if (now - lastBeat >= TYPING_INTERVAL_MS) stopTypingBeat(); // idle since the last keystroke
3277 else if (now - lastEmit >= TYPING_INTERVAL_MS) sendBeat(TYPING_EXPIRY_S); // sustain the indicator
3278 };
3279 composers.get(ctx.threadId).tickTyping = tickTyping; // render() re-arms the ticker each draw
3280 function onComposerInput() {
3281 // (Auto-grow already ran inside the facade.) The composer just grew,
3282 // shrinking the flex feed. Re-pin to the newest message *here*,
3283 // synchronously in the same layout — the ResizeObserver below also
3284 // re-pins, but only on a later frame, which leaves one painted frame
3285 // stranded a line short of the bottom (the visible "jump" on WebKit / the
3286 // Mac app). Guard on feedAtBottom so typing never yanks a reader who
3287 // scrolled up.
3288 if (feedAtBottom) feed.scrollTop = feed.scrollHeight;
3289 if (sendQueued) setSendQueued(false); // still composing — don't auto-send under them
3290 refreshSend();
3291 updateTypeahead();
3292 // A "/command" in progress is private — never announce typing for it (and
3293 // clear any beat already sent if the text just became a command).
3294 const v = composer.getText();
3295 if (v && v[0] !== "/") beatTyping();
3296 else if (lastBeat) stopTypingBeat();
3297 }
3298 function onComposerKeydown(e) {
3299 // The typeahead grabs the nav keys first when it's open (original order).
3300 if (typeahead) {
3301 if (e.key === "ArrowDown") return e.preventDefault(), moveTypeahead(1);
3302 if (e.key === "ArrowUp") return e.preventDefault(), moveTypeahead(-1);
3303 if (e.key === "Enter" || e.key === "Tab") return e.preventDefault(), acceptTypeahead();
3304 if (e.key === "Escape") return e.preventDefault(), closeTypeahead();
3305 }
3306 // Desktop Enter sends (Shift+Enter newlines); touch Enter newlines. A
3307 // code fence is the one context that explicitly means "multi-line
3308 // content", so Enter newlines there too. The isComposing guard stops a
3309 // Japanese/Korean composition-confirm from sending early (a pre-existing
3310 // textarea quirk, fixed while we're here).
3311 if (shouldSend(e, coarse) && !composer.inCodeBlock() && !e.isComposing) {
3312 e.preventDefault();
3313 compose.requestSubmit();
3314 return;
3315 }
3316 if (e.key === "Escape" && sendQueued) {
3317 setSendQueued(false); // un-queue first; a second Escape cancels reply/edit
3318 return;
3319 }
3320 // An open command panel is the most recently summoned layer — Escape peels
3321 // it before the thread view / reply / edit below.
3322 if (e.key === "Escape" && panelOpen()) {
3323 hidePanel();
3324 return;
3325 }
3326 // An open thread view closes on Escape (dropping its staged reply) — unless
3327 // an edit is in flight, which the branch below cancels first (cancelEdit
3328 // re-stages the thread reply, so a second Escape then closes the view).
3329 if (e.key === "Escape" && threadSid && !editing) {
3330 closeThread();
3331 return;
3332 }
3333 if (e.key === "Escape" && (editing || replyTo)) {
3334 const wasEditing = !!editing;
3335 cancelReply();
3336 cancelEdit();
3337 if (wasEditing) {
3338 composer.setText("");
3339 refreshSend();
3340 }
3341 }
3342 // ↑ on an empty composer edits your latest still-editable message.
3343 if (e.key === "ArrowUp" && !composer.getText() && !editing) {
3344 const state = ctx.getState();
3345 const mine = ((state && state.msgs) || [])
3346 .filter((m) => ownMessage({ uid: m.uid, name: m.name }, ctx.me, ctx.meName) && m.text.trim() && Date.now() - m.ts < EDIT_WINDOW_MS)
3347 .pop();
3348 if (mine) {
3349 e.preventDefault();
3350 composers.get(ctx.threadId).startEdit(mine);
3351 }
3352 }
3353 // ↓ undoes an accidental ↑-to-edit: only while unchanged and with no line
3354 // below the cursor to navigate to (the original rule).
3355 if (e.key === "ArrowDown" && editing) {
3356 const afterCursor = composer.getText().slice(composer.getSelectionEnd());
3357 if (composer.getMarkdown() === editBaseline && !afterCursor.includes("\n")) {
3358 e.preventDefault();
3359 composer.setText("");
3360 cancelEdit();
3361 refreshSend();
3362 }
3363 }
3364 }
3365
3366 compose.onsubmit = (ev) => {
3367 ev.preventDefault();
3368 if (pending.some((p) => !p.att)) {
3369 setSendQueued(true); // still uploading — send as soon as it lands
3370 return;
3371 }
3372 // Two readings of the composer: commands parse the PLAIN text (a command
3373 // is not prose), the wire carries the serialized markdown. On the plain
3374 // backend the two are identical bytes, exactly as before.
3375 const plain = emojify(composer.getText().trim());
3376 const text = emojify(composer.getMarkdown().trim()); // :tada: → 🎉, like the original composer
3377 const resetInput = () => {
3378 composer.clear();
3379 refreshSend();
3380 stopTypingBeat(); // command / edit / send all clear the field — end the beat now
3381 clearComposerHint(ctx.threadId); // the question was answered — back to "Message" now, not at the reply
3382 };
3383 // /me is the one command that BROADCASTS a real message — an action-style
3384 // line ("Alice waves"), not a bubble — instead of answering privately, so
3385 // it's handled here rather than through runCommand (see parseMeCommand).
3386 if (!editing) {
3387 const action = parseMeCommand(plain);
3388 if (action !== null) {
3389 if (!action) return resetInput(); // bare "/me" — nothing to say
3390 ctx.emit({ text: `${ctx.meName || "Someone"} ${action}`, action: true }).then((sent) => {
3391 if (!sent) return;
3392 pinNext.add(ctx.threadId);
3393 resetInput();
3394 });
3395 return;
3396 }
3397 }
3398 // A slash command (mini-app command, /help, or an unknown "/foo") is handled
3399 // instead of sending — it answers in place with a private "only visible to you"
3400 // reply that stacks in the feed and persists (addLocalNotice). Skipped while
3401 // editing, like the original composer.
3402 if (!editing && runCommand(plain, ctx.threadId, (r) => addLocalNotice(ctx, r))) {
3403 resetInput();
3404 return;
3405 }
3406 // "@To-do buy milk" in a chat with apps added: the mention addresses the
3407 // app, the text runs its default command — an EVENT into this thread (shown
3408 // as an action line), not a message. Leading-@ only; a mention mid-text is
3409 // ordinary conversation. See runAppMention.
3410 if (!editing && !replyTo && !pending.length && plain && plain[0] === "@" && runAppMention(plain, ctx.threadId, (r) => addLocalNotice(ctx, r))) {
3411 resetInput();
3412 return;
3413 }
3414 // Plain text in a default-command thread runs the app's default command
3415 // instead of sending a message nobody reads — typing "buy milk" into To-do
3416 // just adds it (defaultCommandFor gates this to app-threads with no bot;
3417 // slash text never falls through here, a "/" always means command).
3418 if (!editing && !replyTo && plain && plain[0] !== "/" && !pending.length) {
3419 const dc = defaultCmd();
3420 if (dc) {
3421 const typed = plain;
3422 runCommand(dc.name + " " + typed, ctx.threadId, (r) => {
3423 addLocalNotice(ctx, r);
3424 // A reply with no `short` is the command PUSHING BACK ("when should
3425 // I remind you?"), not a confirmation — put the typed text back so
3426 // it can be fixed, not retyped (matches the native .rejected path).
3427 if (r && !r.short && !composer.getText()) {
3428 composer.setText(typed);
3429 refreshSend();
3430 }
3431 });
3432 resetInput();
3433 return;
3434 }
3435 }
3436 if (editing) {
3437 // An empty edit just cancels; otherwise only clear once the edit actually
3438 // reaches the server — host.edit resolves false on a down socket, so the
3439 // typed changes survive to be resent after reconnecting.
3440 if (!text || !host || !host.edit) {
3441 resetInput();
3442 cancelEdit();
3443 return;
3444 }
3445 Promise.resolve(host.edit(ctx.threadId, editing.sid, { ...editing.raw, text })).then((sent) => {
3446 if (!sent) return;
3447 resetInput();
3448 cancelEdit();
3449 });
3450 return;
3451 }
3452 if (!text && !pending.length) return;
3453 const payload = { text };
3454 if (pending.length) payload.attachments = pending.map((p) => p.att);
3455 if (replyTo) payload.replyTo = replyTo;
3456 // Only clear the composer once the send actually reaches the server: ctx.emit
3457 // resolves false when the socket is down/reconnecting, so keep the text, staged
3458 // files, and reply target intact to resend — never silently drop what was typed.
3459 ctx.emit(payload).then((sent) => {
3460 if (!sent) return;
3461 // Snap to the newest on the echo's re-render — your message should land in
3462 // view even if you'd scrolled up to reply. Set now (well before the server
3463 // round-trip); render() clears it.
3464 pinNext.add(ctx.threadId);
3465 resetInput();
3466 pending = [];
3467 // Sending from an open thread view keeps it open and re-stages the reply
3468 // to its root — the new reply folds in live and the next send continues
3469 // the same thread, like iMessage.
3470 if (threadSid) stageThreadReply();
3471 else cancelReply();
3472 refreshStaged();
3473 composer.focus();
3474 // A scripted conversation awaiting an answer in this thread is told what
3475 // was sent — text AND attachments (the setup photo step takes a picture as
3476 // its answer). The message itself already sent normally; it's in the
3477 // transcript either way.
3478 deliverAnswer(ctx.threadId, payload, (r) => addLocalNotice(ctx, r));
3479 });
3480 };
3481
3482 // Scroll-back pagination: nearing the top folds the previous history page
3483 // (through the host, which owns tokens + decryption), then restores the
3484 // reader's place by anchoring the scroll offset across the prepend.
3485 let loadingOlder = false;
3486 let exhausted = false;
3487 feed.addEventListener("scroll", async () => {
3488 if (loadingOlder || exhausted || feed.scrollTop > 60) return;
3489 const state = ctx.getState();
3490 const oldest = state && state.msgs.length ? state.msgs[0].sid : 0;
3491 if (!oldest || !host || !host.loadOlder) return;
3492 loadingOlder = true;
3493 const prevHeight = feed.scrollHeight;
3494 const n = await host.loadOlder(ctx.threadId, oldest);
3495 if (n === 0) exhausted = true;
3496 feed.scrollTop += feed.scrollHeight - prevHeight;
3497 loadingOlder = false;
3498 });
3499
3500 // ---- reply-thread view: tap a quote or a "N Replies" tally to lift that
3501 // thread over the transcript, iMessage-style ----
3502 // The overlay occupies the feed's box only (see .chatapp-feedwrap), so the
3503 // real composer below stays live with its reply state staged to the thread's
3504 // root — sending from it continues the thread, and the new reply folds into
3505 // the open view (render() → root._refreshThread re-derives from current
3506 // state each fold; the view never caches its list).
3507 let threadSid = 0; // the open thread's root sid (0 = closed)
3508 let threadFallback = null; // { id, name, snippet } — header stand-in while the root isn't loaded
3509 let threadEl = null; // { ov, count, rootBox, note, body } while open
3510 let threadPaging = false; // paging history backward looking for the root
3511
3512 // Stage the composer's reply on the thread's root — the whole point of the
3513 // view's composer contract ("Replying to <root author>"). Mirrors startReply
3514 // (a reply displaces an in-flight edit; native shells mirror the banner) but
3515 // skips the focus on touch, where popping the keyboard would cover the
3516 // thread the reader just opened.
3517 const stageThreadReply = () => {
3518 editing = null;
3519 const msgs = ((ctx.getState() || {}).msgs) || [];
3520 const rootMsg = msgs.find((m) => m.sid === threadSid);
3521 replyTo = rootMsg
3522 ? replyTarget(msgs, rootMsg)
3523 : { id: threadSid, name: (threadFallback && threadFallback.name) || "", snippet: (threadFallback && threadFallback.snippet) || "" };
3524 refreshBanners();
3525 if (inEmbed) host.native({ replying: { name: replyTo.name || "", snippet: replyTo.snippet || "" } });
3526 else if (!coarse) composer.focus();
3527 };
3528
3529 const closeThread = () => {
3530 const sid = threadSid;
3531 if (!sid && !threadEl) return;
3532 threadSid = 0;
3533 threadFallback = null;
3534 if (threadEl) {
3535 threadEl.ov.remove();
3536 threadEl = null;
3537 }
3538 // Leaving the thread drops its staged reply — never an unrelated one staged
3539 // since (an edit clears replyTo itself, so that survives untouched).
3540 if (sid && replyTo && replyTo.id === sid) cancelReply();
3541 };
3542
3543 const buildThread = () => {
3544 const ov = document.createElement("div");
3545 ov.className = "chatapp-thread";
3546 const dim = document.createElement("div");
3547 dim.className = "chatapp-thread-dim";
3548 dim.onclick = () => closeThread(); // tapping the dimmed transcript leaves the thread
3549 const panel = document.createElement("div");
3550 panel.className = "chatapp-thread-panel";
3551 panel.setAttribute("role", "dialog");
3552 panel.setAttribute("aria-label", "Reply thread");
3553 const head = document.createElement("div");
3554 head.className = "chatapp-thread-head";
3555 const title = document.createElement("div");
3556 title.className = "chatapp-thread-title";
3557 title.textContent = "Thread";
3558 const count = document.createElement("div");
3559 count.className = "chatapp-thread-count";
3560 const x = document.createElement("button");
3561 x.type = "button";
3562 x.className = "chatapp-thread-x";
3563 x.innerHTML = icon("x"); // static in-code SVG from the host (safe)
3564 x.setAttribute("aria-label", "Close thread");
3565 x.onclick = () => closeThread();
3566 head.append(title, count, x);
3567 const rootBox = document.createElement("div");
3568 rootBox.className = "chatapp-thread-rootmsg";
3569 const note = document.createElement("div");
3570 note.className = "chatapp-thread-note hidden";
3571 const body = document.createElement("div");
3572 body.className = "chatapp-thread-msgs";
3573 panel.append(head, rootBox, note, body);
3574 ov.append(dim, panel);
3575 return { ov, count, rootBox, note, body };
3576 };
3577
3578 // Thread rows reuse the feed's exact bubble machinery — renderItem +
3579 // patchBubble under keyedList — so reactions, attachments, long-press
3580 // actions, hover strips and edits all keep working inside the view (and a
3581 // reaction patches in place instead of reloading a photo).
3582 const threadKeyed = (container, items) =>
3583 keyedList(container, items, {
3584 key: (m) => m.id,
3585 rev: (m) => m.vrev + mutedRev(m, ctx) + mentionRev(m),
3586 srev: (m) => m.srev,
3587 render: (m) => renderItem(m, ctx),
3588 update: (node, m) => patchBubble(node, m, ctx),
3589 });
3590
3591 const refreshThread = () => {
3592 if (!threadSid || !threadEl) return;
3593 const msgs = ((ctx.getState() || {}).msgs) || [];
3594 const items = threadItems(msgs, threadSid);
3595 // The whole thread got deleted under the open view — nothing left to show.
3596 if (!items.length && !threadPaging) return closeThread();
3597 const rootLoaded = !!(items.length && items[0].sid === threadSid);
3598 const replies = rootLoaded ? items.slice(1) : items;
3599 threadEl.count.textContent = replies.length === 1 ? "1 reply" : replies.length + " replies";
3600 if (rootLoaded) {
3601 const m = items[0];
3602 if (threadEl.rootBox.dataset.mode !== "msg") {
3603 threadEl.rootBox.textContent = ""; // swap out the snapshot fallback
3604 threadEl.rootBox.dataset.mode = "msg";
3605 }
3606 // The pinned root is a real keyed bubble, sans separator and sans its own
3607 // tally (the replies ARE the panel). Same soft-rev split as viewItems so
3608 // a reaction patches the row in place, never rebuilding the bubble.
3609 const reactSig = (m.reactions || []).map((r) => `${r.emoji}${r.count}`).join(",");
3610 threadKeyed(threadEl.rootBox, [{ ...m, timeSep: null, cont: false, replyCount: 0, replyRoot: 0, vrev: `${m.rev}|root`, srev: reactSig }]);
3611 } else if (threadEl.rootBox.dataset.mode !== "fall") {
3612 threadEl.rootBox.textContent = "";
3613 threadEl.rootBox.dataset.mode = "fall";
3614 // Root not loaded (yet): the quoted snapshot stands in as the header.
3615 const fall = document.createElement("div");
3616 fall.className = "chatapp-thread-rootfall";
3617 const who = document.createElement("div");
3618 who.className = "reply-quote-name";
3619 who.textContent = (threadFallback && threadFallback.name) || "";
3620 const txt = document.createElement("div");
3621 txt.className = "chatapp-thread-fallsnippet";
3622 txt.textContent = (threadFallback && threadFallback.snippet) || "";
3623 fall.append(who, txt);
3624 threadEl.rootBox.appendChild(fall);
3625 }
3626 const noteText = threadPaging
3627 ? "Loading the start of this thread…"
3628 : rootLoaded ? "" : "The original message isn't available — showing its replies.";
3629 threadEl.note.textContent = noteText;
3630 threadEl.note.classList.toggle("hidden", !noteText);
3631 // Follow the newest reply like the feed does, but only when already there —
3632 // a reader scrolled up a long thread isn't yanked down by a live arrival.
3633 const follow = threadEl.body.scrollHeight - threadEl.body.scrollTop - threadEl.body.clientHeight < 80 || !threadEl.body.childElementCount;
3634 threadKeyed(threadEl.body, viewItems(replies));
3635 if (follow) threadEl.body.scrollTop = threadEl.body.scrollHeight;
3636 };
3637 root._refreshThread = refreshThread;
3638
3639 // Page history backward until the thread's root is loaded — bounded, so a
3640 // reply deep in a huge history can't spin the loader forever. Shares the
3641 // scroll-back path's loadingOlder/exhausted guards and keeps the dimmed
3642 // transcript's scroll anchored across each prepend, like scroll-back does.
3643 const THREAD_PAGE_CAP = 5;
3644 const pageInRoot = async () => {
3645 if (threadPaging || loadingOlder) return;
3646 const sid = threadSid;
3647 threadPaging = true;
3648 refreshThread(); // show the inline loading note straight away
3649 for (let i = 0; i < THREAD_PAGE_CAP && threadSid === sid && !exhausted; i++) {
3650 const msgs = ((ctx.getState() || {}).msgs) || [];
3651 if (msgs.some((m) => m.sid === sid)) break;
3652 const oldest = msgs.length ? msgs[0].sid : 0;
3653 // Pages are contiguous: once we've loaded past the root's sid without
3654 // finding it, the root was deleted — more paging can't surface it.
3655 if (!oldest || oldest < sid || !host || !host.loadOlder) break;
3656 loadingOlder = true; // keep the feed's own scroll-back handler out
3657 const prevHeight = feed.scrollHeight;
3658 const n = await host.loadOlder(ctx.threadId, oldest);
3659 feed.scrollTop += feed.scrollHeight - prevHeight;
3660 loadingOlder = false;
3661 if (n === 0) {
3662 exhausted = true;
3663 break;
3664 }
3665 }
3666 threadPaging = false;
3667 if (threadSid !== sid) return; // closed / re-opened elsewhere while paging
3668 refreshThread();
3669 // The root may have just loaded — refresh the banner to its live snapshot,
3670 // but only while the staged reply is still this thread's (not an edit).
3671 if (!editing && replyTo && replyTo.id === sid) stageThreadReply();
3672 };
3673
3674 const openThread = (rootSid, ref) => {
3675 if (!rootSid) return;
3676 if (threadSid === rootSid && threadEl) return; // already open on this root
3677 closeThread();
3678 threadSid = rootSid;
3679 threadFallback = ref || null;
3680 threadEl = buildThread();
3681 feedWrap.appendChild(threadEl.ov);
3682 stageThreadReply();
3683 // Render before any paging: the view must show what's loaded even when
3684 // pageInRoot bails (e.g. a scroll-back already in flight). When paging does
3685 // run, its own refresh follows synchronously, so no wrong-note flash.
3686 refreshThread();
3687 const msgs = ((ctx.getState() || {}).msgs) || [];
3688 if (!msgs.some((m) => m.sid === rootSid)) pageInRoot();
3689 };
3690 threads.set(ctx.threadId, { open: openThread, close: closeThread, isOpen: () => !!threadSid });
3691
3692 // Keyboard shortcuts, mirroring the original feed: with an empty composer,
3693 // cmd/ctrl-T opens the message menu on the latest message from someone else
3694 // (number keys then toggle reactions, Escape dismisses — openMessagePicker)
3695 // and cmd/ctrl-R starts a reply to the latest message. Escape also cancels
3696 // an armed reply/edit even when the composer isn't focused.
3697 const latest = (othersOnly) => {
3698 const msgs = (ctx.getState() || {}).msgs || [];
3699 for (let i = msgs.length - 1; i >= 0; i--) {
3700 if (othersOnly && msgs[i].uid && msgs[i].uid === ctx.me) continue;
3701 return msgs[i];
3702 }
3703 return null;
3704 };
3705 // Stashed on the root so render() can re-point the shared dispatcher at
3706 // whichever mount is live now — including the instant-restore path, which
3707 // re-attaches this parked root without rebuilding the skeleton.
3708 root._hotkeys = (e) => {
3709 if (!root.isConnected) return;
3710 if ((e.metaKey || e.ctrlKey) && e.key === "t" && !composer.getText()) {
3711 e.preventDefault();
3712 const m = latest(true);
3713 if (m) openMessagePicker(m, ctx, feed.querySelector(`.msg[data-id="${m.sid}"]`) || feed);
3714 }
3715 if ((e.metaKey || e.ctrlKey) && e.key === "r" && !composer.getText()) {
3716 e.preventDefault();
3717 const m = latest(false);
3718 if (m) composers.get(ctx.threadId).startReply(m);
3719 }
3720 // Escape dismisses an open command panel when the composer isn't focused
3721 // (the focused case is in onComposerKeydown), with the same layer checks as
3722 // the thread-view close below — one Escape peels one layer, never two.
3723 if (e.key === "Escape" && panelOpen() && !e.defaultPrevented && !composer.hasFocus() && !document.querySelector(".emoji-picker, .msg-sheet, .lightbox")) {
3724 e.preventDefault();
3725 hidePanel();
3726 return;
3727 }
3728 // Escape closes an open thread view when the composer isn't focused (the
3729 // focused case is handled in onComposerKeydown). Skipped when a layer that
3730 // owns Escape is up — the picker preventDefaults before us (capture phase),
3731 // the sheet/lightbox are still in the DOM when we run — so one Escape peels
3732 // one layer, never two.
3733 if (e.key === "Escape" && threadSid && !editing && !e.defaultPrevented && !composer.hasFocus() && !document.querySelector(".emoji-picker, .msg-sheet, .lightbox")) {
3734 e.preventDefault();
3735 closeThread();
3736 return;
3737 }
3738 if (e.key === "Escape" && !composer.hasFocus() && (replyTo || editing) && !document.querySelector(".emoji-picker")) {
3739 e.preventDefault();
3740 const wasEditing = !!editing;
3741 cancelReply();
3742 cancelEdit();
3743 if (wasEditing) {
3744 composer.setText("");
3745 refreshSend();
3746 }
3747 }
3748 };
3749
3750 // Desktop opens ready to type, like the original feed.
3751 if (!coarse && !inEmbed) composer.focus();
3752
3753 // Embedded: the native composer drives THIS composer through the shell's
3754 // window.lchatNative bridge (see installNativeBridge in chat.js) — text,
3755 // typing beats, staged photos, and reply cancels all land in the same code
3756 // paths the hidden web composer uses, so behaviour can't fork.
3757 //
3758 // Stashed on the root (like _hotkeys) rather than registered here: the host
3759 // keeps a SINGLE native dispatcher, so render() re-points it at whichever
3760 // mount is live now — including the instant-restore path that re-attaches a
3761 // parked root without rebuilding — so a stale thread never gets the events.
3762 if (inEmbed && host.registerNative) {
3763 root._native = {
3764 sendText(text) {
3765 if (pending.some((p) => !p.att)) return false; // still uploading
3766 composer.setText(text || "");
3767 if (composer.isEmpty() && !pending.length) return false;
3768 compose.requestSubmit();
3769 stopTypingBeat();
3770 return true;
3771 },
3772 typing(hasText) {
3773 if (hasText) beatTyping();
3774 else stopTypingBeat();
3775 },
3776 stage(file) {
3777 stageFiles([file]);
3778 },
3779 cancelReply() {
3780 // The native banner's ✕ means the same as the web one: leaving an open
3781 // thread view, not just dropping its staged reply.
3782 if (threadSid) closeThread();
3783 else cancelReply();
3784 },
3785 cancelEdit() {
3786 cancelEdit();
3787 },
3788 didAttach(force) {
3789 const atBottom = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 60;
3790 if (force || atBottom) feed.scrollTop = feed.scrollHeight;
3791 },
3792 };
3793 }
3794
3795 return root;
3796}
3797