// Chat, as a mini-app — the existing chat retooled onto the framework, not a // parallel one. It speaks the wire protocol we already have (the composer // emits an original-shaped untyped payload) AND renders the original feed's exact // markup: every element uses the same class names and structure appendMessage // builds, so style.css styles both feeds identically — hover-revealed times // and action icons, reply quotes, separators, reactions — with no duplicated // CSS. If the two ever look different, it's a bug here, not a style fork. // // The host (chat.js) feeds the app everything the original feed consumes: // // chat.msg ← each stored message's decrypted payload (text, attachment // metadata, replyTo), history / live / scroll-back alike // chat.react ← the server's reaction frames / load-time reaction lists // chat.edit ← edit frames (server-verified author), decrypted // chat.delete ← delete frames (server-verified author, hard-deleted) // // Those four are hostEvents — host-only fold handlers, never wire types — so a // member cannot spoof a reaction/edit/delete by posting a typed message. // Everything outbound (send, react, edit, unsend, upload, unfurl, older pages) // goes back through the original machinery via the narrow host bridge below. // Typing is the one wire type the app owns, as a relay-only ephemeral event. // // This is the DEFAULT chat experience for group/DM threads; a device can // switch back per-device (⋮ menu → "Use original chat" — see chat.js). import { defineApp, namespace, keyedList, runCommand, runAppMention, messageActionsFor, commandCatalog, buildLocalNotice, renderApp, deliverAnswer, cancelAnswer, setAnswerCancelledHook, parseMeCommand, defaultCommandFor, appChatReadOnly, setCommandPanelHost, notifyCommandPanel, motionOK } from "../apps.js"; import { replySnippet, formatBytes, emojiOnlyCount, firstLink, youtubeId, typingSummary } from "../format.js"; import { openLightbox } from "../lightbox.js"; import { parseMessage, renderTree, plainText as mdPlainText } from "../markdown.js"; import { createComposer, adoptComposer, classicComposerPref, formatAsYouTypePref } from "../composer.js"; // The rich (ProseMirror) composer backend loads via a primed dynamic import — // fetching starts at module load, mounts long after — and never in the native // shells' webviews (they draw their own composer; the hidden one stays the // cheap textarea). If the import fails, the factory quietly falls back to the // plain backend: chat always works. let pmModule = null; // (typeof guard: the event-vector tests import this module under Node.) if (typeof document !== "undefined" && !document.documentElement.classList.contains("native")) { import("../composer-pm.js").then((m) => (pmModule = m)).catch(() => null); } import { renderCard, CARD_CSS } from "../cards.js"; import { emojify } from "../emoji.js"; import { fileIcon, shouldSend, composerToken, ownMessage, sameSender } from "../ui.js"; import { decodeBlurhash } from "../blurhash.js"; // The host bridge: chat.js injects the pieces that are server mechanisms, // key-holding machinery, or host state — the app never touches crypto/tokens. // react(threadId, sid, emoji, on, targetUid, targetText) // encrypted react/unreact event // del(threadId, sid) original delete frame (server-verified) // edit(threadId, sid, payload) re-encrypt + original edit frame // loadBlob(threadId, att) → url fetch + decrypt an attachment blob // upload(threadId, file) → att | null downscale/allow-list/encrypt/upload // unfurl(url) → preview | null the OpenGraph card data // loadOlder(threadId, beforeSid) → n fold an older history page // mentionNames() / mention(label, el) roster labels + the member menu // isGroup(threadId) / agentKind(name) sender-name + AI/BOT-badge rules // icons the original in-code SVG icon strings let host = null; export function setHost(h) { host = h; } const REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🎉"]; // mirrors allowedReactions (hub.go) const EDIT_WINDOW_MS = 15 * 60 * 1000; // mirrors editWindow (hub.go); server enforces it const TIME_SEPARATOR_GAP_MS = 15 * 60 * 1000; // mirrors the original feed's separator rule // Typing beats, mirroring the original composer: while you're typing we re-emit a // beat every TYPING_INTERVAL_MS so the receiver's indicator survives a pause, and // each beat expires after TYPING_EXPIRY_S — comfortably longer than the interval, // so a single late/dropped beat doesn't blink the indicator out. const TYPING_INTERVAL_MS = 10000; const TYPING_EXPIRY_S = 15; const coarse = typeof matchMedia !== "undefined" && matchMedia("(pointer: coarse)").matches; // Event TYPES only: N.ev("msg") → "chat.msg", etc. (chat reuses the original // feed's shared CSS classes — .msg/.bubble/.feed/… — which stay literal.) const N = namespace("chat"); // Per-thread presence: name → epoch ms until which they count as typing. // Ephemeral events never fold, so this lives outside reducer state — it's // transient UI, allowed to differ between devices. const typing = new Map(); // threadId → Map(name → until) const refreshTyping = new Map(); // threadId → fn redrawing the typing row // Private command replies ("only visible to you") live in the feed as CLIENT-ONLY // rows: never sent, gone on reload, and interleaved with real messages so they // stack like chat bubbles and persist (no auto-dismiss). Each carries a `seq` just // above the newest message at creation time, so a later real message falls below // it. render() folds these into its keyedList (see the notice branch in renderItem). const localNotices = new Map(); // threadId → [{ id, seq, sender, text, actions }] const refreshFeed = new Map(); // threadId → fn re-rendering this chat pane const mountedCtx = new Map(); // threadId → live app ctx (for shell-injected notices) const pendingNotices = new Map(); // threadId → [spec] queued until that thread mounts let noticeSeq = 0; // monotonic: unique ids + a stable tie-break within a thread // appendNotice injects a private "only visible to you" notice into a thread's feed // from OUTSIDE a command (the shell uses it when an action chip answers in place — // the welcome's "Take the tour", and the tour continuing after it navigates you to // the Eleven DM). If the target thread isn't mounted yet (we just navigated to it // and its feed is still loading), the notice is queued and flushed when it mounts. export function appendNotice(threadId, spec) { const ctx = mountedCtx.get(threadId); if (ctx) { addLocalNotice(ctx, spec); return; } const q = pendingNotices.get(threadId) || []; q.push(spec); pendingNotices.set(threadId, q); } // setNoticeTyping shows/clears a typing row for a SCRIPTED speaker (the setup // conversation's "Eleven is typing…" beat before each reply). Local-only — it // rides the same transient typing state as real members' beats, never the wire. // The until-cap self-expires a beat whose clear got lost. export function setNoticeTyping(threadId, name, on) { let m = typing.get(threadId); if (!m) typing.set(threadId, (m = new Map())); if (on) m.set(name, Date.now() + 10000); else m.delete(name); const refresh = refreshTyping.get(threadId); if (refresh) refresh(); } // addLocalNotice records a command reply for `threadId` and re-renders the pane so // it appears immediately. Anchored just above the current newest message so it // sits where you typed it and never jumps around as the conversation continues. // The composer placeholder follows the conversation: a scripted question can // carry a `hint` ("e.g. Finn Wolfhard") that replaces "Message" the moment the // question renders, or an `attachHint` ("Optional: describe your photo") that // applies only while attachments are staged — a photo question shouldn't // relabel the box before there's a photo. A `label` ("Your name:") renders as // an inset field label INSIDE the input — the Mail/Messages "To:" pattern — // so the ask survives even when the placeholder is hidden by typed text. // Sending resets everything to "Message"; the next question sets its own. // Keyed like refreshFeed. const composerHints = new Map(); const composerAttachHints = new Map(); const composerLabels = new Map(); const refreshComposerHint = new Map(); function applyComposerHint(threadId) { const fn = refreshComposerHint.get(threadId); if (fn) fn(); } function clearComposerHint(threadId) { composerHints.set(threadId, ""); composerAttachHints.set(threadId, ""); composerLabels.set(threadId, ""); applyComposerHint(threadId); } // A cancelled ask never strands its pill (#1375) — whoever cancelled it. setAnswerCancelledHook((threadId) => clearComposerHint(threadId)); // Feed-follow with intent: feedGlideToBottom eases towards the (still // growing) bottom, and while it's in flight feedNearBottom stays true — an // instantaneous scrollTop read mid-glide looks "scrolled away" and used to make // the typing row skip its pin. A user wheel/touch cancels the chase. const feedGlides = new WeakSet(); const feedGlideCancels = new WeakSet(); export function feedNearBottom(feed, slack = 80) { return feedGlides.has(feed) || feed.scrollHeight - feed.scrollTop - feed.clientHeight < slack; } // How far the reader has to scroll up before the "jump to latest" button // appears — deliberately much wider than feedNearBottom's 60px auto-pin // slack, so the button doesn't show for merely reading the newest message. const JUMP_BUTTON_GAP = 400; export function feedGlideToBottom(feed, jump) { if (!feedGlideCancels.has(feed)) { feedGlideCancels.add(feed); const cancel = () => feedGlides.delete(feed); // Cancel on SCROLL INTENT only — wheel, or an actual touch DRAG. A bare // touchstart is any tap in the feed (action chips included), and cancelling // on those broke the follow for every chip-tapped step on touch devices. feed.addEventListener("wheel", cancel, { passive: true }); feed.addEventListener("touchmove", cancel, { passive: true }); } if (jump || matchMedia("(prefers-reduced-motion: reduce)").matches) { feedGlides.delete(feed); feed.scrollTop = feed.scrollHeight; return; } if (feedGlides.has(feed)) return; // the running glide chases new growth itself feedGlides.add(feed); // Also detect intervention we DON'T get events for (scrollbar drag, PageUp, // arrow keys): if scrollTop is meaningfully below where we last placed it, the // user moved it — bail so we never fight them back down. let lastSet = feed.scrollTop; const step = () => { if (!feedGlides.has(feed)) return; // cancelled by the user (wheel/touch) if (feed.scrollTop < lastSet - 12) { feedGlides.delete(feed); return; } const target = feed.scrollHeight - feed.clientHeight; const d = target - feed.scrollTop; if (Math.abs(d) < 2) { feed.scrollTop = target; feedGlides.delete(feed); return; } feed.scrollTop += d * 0.16; lastSet = feed.scrollTop; requestAnimationFrame(step); }; requestAnimationFrame(step); } function addLocalNotice(ctx, spec) { composerHints.set(ctx.threadId, spec.hint || ""); composerAttachHints.set(ctx.threadId, spec.attachHint || ""); composerLabels.set(ctx.threadId, spec.label || ""); applyComposerHint(ctx.threadId); // Hint-only (a persisted question with no chips): the placeholder was the // whole point — don't add an empty row to the feed. if (!spec.text && !spec.img && !(spec.actions && spec.actions.length)) return; const msgs = (ctx.getState() && ctx.getState().msgs) || []; const maxSid = msgs.length ? msgs[msgs.length - 1].sid : 0; const list = localNotices.get(ctx.threadId) || []; noticeSeq += 1; // maxSid + a tiny monotonic fraction: strictly between this sid and the next, so // ordering vs. integer message sids is unambiguous and creation order is kept. // `at` (creation time) drives the loud→compact collapse in buildLocalNotice: a // fresh reply shows the full card then calms down; one rebuilt long after it // landed (thread reopened) starts compact. `short` is the terse one-liner text. 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() }); localNotices.set(ctx.threadId, list); // A notice is always a response to something the reader just did (a command, // an action chip, a scripted step) — snap to it like a local send, even if // they'd scrolled up. // A full notice snaps the feed like a local send (pinNext → instant); a // chips-only row rides the smooth glide of the bubble it belongs to — the // extra instant jump right after a glide is what reads as jank. if (spec.text || spec.img) pinNext.add(ctx.threadId); const refresh = refreshFeed.get(ctx.threadId); if (refresh) refresh(); } function typingNames(threadId, exceptName) { const m = typing.get(threadId); if (!m) return []; const now = Date.now(); const out = []; for (const [name, until] of m) { if (until <= now) m.delete(name); else if (name && name !== exceptName) out.push(name); } return out; } // Command panels: the transient results surface a slash command can open // (ctx.panel — see the command-panels section of apps.js). Each mounted pane // registers its controller in buildSkeleton; the one injected host routes an // open/close to the pane it belongs to. Returning false when the thread has no // mounted chat surface lets the command fall back to a plain reply. const cmdPanels = new Map(); // threadId → (spec|null) => void setCommandPanelHost((threadId, spec) => { const show = cmdPanels.get(threadId); if (!show) return false; show(spec); return true; }); // Which reactions are mine, keyed "sid|emoji" — seeded from the load-time mine // flags and kept current by local toggles, exactly like the original feed's // myReactions set (live reaction frames carry counts only). const myReactions = new Set(); // The composer controller for each mounted thread, so bubble actions (Reply, // Edit) can reach the composer they share a pane with. const composers = new Map(); // threadId → { refreshSend(), startReply(item), startEdit(item) } // The reply-thread view controller for each mounted thread, so bubble taps (a // quote card, a "N Replies" tally) can lift that thread over the pane they live // in. Built alongside the composer in buildSkeleton, overwritten per mount. const threads = new Map(); // threadId → { open(rootSid, ref), close(), isOpen() } // Threads whose next render should snap to the newest message even if the reader // had scrolled up — set when you send from the composer (a reply included), since // there's no optimistic echo: your message only lands on the server round-trip's // re-render, by which time render()'s scroll-position check has forgotten you just // sent. render() consumes (and clears) this so a send always brings your message // into view, like every familiar chat client. const pinNext = new Set(); // threadId // Whether the socket is currently open. chat.js flips this on ws open/close so // each composer can grey its send button while a message can't reach the server // — a send while down is kept in the composer, never dropped (see compose.onsubmit). let connected = true; export function setConnected(v) { connected = v; for (const c of composers.values()) c.refreshSend(); } // Successful unfurl results cached by URL so a bubble re-render doesn't // refetch; a failed or empty unfurl is evicted (see addLinkPreview) so a later // render retries rather than staying blank until a full reload. const unfurls = new Map(); // url → Promise // One document-level hotkey listener, dispatching to the currently-active // mount's handler. render() re-points this on every open — including the // instant-restore path where buildSkeleton is skipped — and only ever to a // connected root, so a parked/torn-down pane never hijacks the shortcuts. let hotkeys = null; if (typeof document !== "undefined") { document.addEventListener("keydown", (e) => { if (hotkeys) hotkeys(e); }); } // ---- time helpers (the original feed's exact rules) ------------------------- function formatTime(ms) { return new Date(ms).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); } function sameDay(a, b) { const da = new Date(a); const db = new Date(b); return da.getFullYear() === db.getFullYear() && da.getMonth() === db.getMonth() && da.getDate() === db.getDate(); } // "Today" / "Yesterday" / a weekday (past week) / a date — the separator label. function dayLabel(ms) { const now = Date.now(); if (sameDay(ms, now)) return "Today"; if (sameDay(ms, now - 86400000)) return "Yesterday"; const d = new Date(ms); if (now - ms < 7 * 86400000) return d.toLocaleDateString([], { weekday: "long" }); const sameYear = new Date(now).getFullYear() === d.getFullYear(); return d.toLocaleDateString([], sameYear ? { month: "short", day: "numeric" } : { year: "numeric", month: "short", day: "numeric" }); } // viewItems derives the render list from folded messages, with the original // feed's exact grouping rules: a .time-sep when a message opens the feed, the // calendar day changes (dated), or >15 min passed (time only); `cont` (tighter // spacing + hidden repeated name) for a same-sender run with no separator // between; and each message's reply tally. Pure and exported for tests. // // Two revs, so keyedList rebuilds a bubble only when it truly must: `vrev` is the // HARD rev — content (text/edit) and neighbour-derived layout (separator, run) — // and any change to it re-renders the whole bubble. `srev` is the SOFT rev — // reactions and the reply tally — and a change to it patches only those rows in // place (see patchBubble), never rebuilding, so a decrypted photo's and a // playing YouTube embed survive a reaction or an incoming reply landing on it. // rootRef walks a reply pointer to its thread's TOP message: new replies point // straight at the root, so this is one hop; legacy chains (a reply quoting // another reply) are chased through the loaded window. Returns the deepest ref // reachable — its .id is the root's sid when the chain resolves, or the last // known pointer when the parent scrolled out of the loaded window. Hop-capped // so a hostile payload can't cycle forever. function rootRef(bySid, reply) { let ref = reply; for (let hops = 0; hops < 100; hops++) { const parent = bySid.get(ref.id); if (!parent || !parent.reply || !parent.reply.id) return ref; ref = parent.reply; } return ref; } // replyTarget: the replyTo a reply to `m` should carry. Replying to any message // in a thread continues that thread, so the ref always names the thread's TOP // message — never the tapped reply. When the root is loaded, its name/snippet // are snapshotted fresh; an unloaded root falls back to the deepest quoted ref // we can see. Pure and exported for tests (mirrored natively by // ChatFeedModel.replyTarget — pinned by the reply_thread_grouping vector). export function replyTarget(msgs, m) { if (!m.reply || !m.reply.id) return { id: m.sid, name: m.name, snippet: replySnippet(m.raw) }; const bySid = new Map(msgs.map((x) => [x.sid, x])); const ref = rootRef(bySid, m.reply); const root = bySid.get(ref.id); return root ? { id: root.sid, name: root.name, snippet: replySnippet(root.raw) } : { id: ref.id || 0, name: ref.name || "", snippet: ref.snippet || "" }; } // threadItems: the reply thread rooted at `rootSid` — the root message (when // loaded) followed by every message whose RESOLVED thread root is that sid, in // server order. Legacy chains (a reply quoting another reply) resolve // transitively via rootRef, so they land in the same thread as their ancestors; // when the root itself has scrolled out of the loaded window the replies still // fold (the view renders the quoted snapshot as its header instead). Pure and // exported for tests — the thread view re-derives this from current state on // every fold, never a cached list. export function threadItems(msgs, rootSid) { const bySid = new Map(msgs.map((m) => [m.sid, m])); const out = []; const root = bySid.get(rootSid); if (root) out.push(root); for (const m of msgs) { if (m.reply && m.reply.id && rootRef(bySid, m.reply).id === rootSid) out.push(m); } return out.sort((a, b) => a.sid - b.sid); } // Message order: confirmed messages by server id (the stable identity), with any // still-pending optimistic copies (sid 0) after all of them, ordered by send time // — so a just-sent bubble sits at the bottom until its echo slots it into place. function bySidPendingLast(a, b) { if (a.sid && b.sid) return a.sid - b.sid; if (!a.sid && !b.sid) return a.ts - b.ts; return a.sid ? -1 : 1; } export function viewItems(msgs) { // Reply threads group on the resolved ROOT message: every reply tallies on // its thread's top message (legacy chains resolve transitively), and each // reply carries its root sid (`replyRoot`) so taps/jumps land on the thread. const bySid = new Map(msgs.map((m) => [m.sid, m])); const replyCounts = new Map(); // thread root sid → messages in its thread const roots = new Map(); // reply sid → its thread's root sid for (const m of msgs) { if (m.reply && m.reply.id) { const root = rootRef(bySid, m.reply).id; roots.set(m.sid, root); replyCounts.set(root, (replyCounts.get(root) || 0) + 1); } } const out = []; let prev = null; for (const m of msgs) { let timeSep = null; if (!prev) timeSep = { withDay: true }; // the oldest message opens with a dated separator else { const newDay = !sameDay(m.ts, prev.ts); if (newDay || m.ts - prev.ts > TIME_SEPARATOR_GAP_MS) timeSep = { withDay: newDay }; } const cont = !timeSep && sameSender(prev, m); const replyCount = replyCounts.get(m.sid) || 0; // The soft rev is derived purely from reactions + tally (never myReactions — // viewItems stays pure/exported/tested); a chip's count moves on every // react/unreact, so this signature captures mine-flips too. const reactSig = (m.reactions || []).map((r) => `${r.emoji}${r.count}`).join(","); out.push({ ...m, timeSep, cont, replyCount, replyRoot: roots.get(m.sid) || 0, vrev: `${m.rev}|${timeSep ? (timeSep.withDay ? "D" : "t") : ""}|${cont ? 1 : 0}`, // Delivery state rides the SOFT rev: the pending→slow→confirmed transitions // patch the bubble in place (data-id, the "sending…" chip, action affordances) // without rebuilding it — so an optimistic photo never flashes on confirm. srev: `${reactSig}|${replyCount}|${m.pending ? (m.slow ? "slow" : "send") : "ok"}|${m.sid ? 1 : 0}`, }); prev = m; } return out; } export const app = defineApp({ name: "chat", title: "Chat", glyph: "💬", places: [], // host-mounted for chat threads — never in the Apps menu snapshot: false, // state IS the message log; snapshotting would duplicate it incremental: true, // reconcile the pane in place — appends, not rebuilds initialState: () => ({ msgs: [] }), // The one wire type the app owns: typing, relay-only (handled by the // ephemeral hook below). The no-op fold claims the type; if a hostile client // stores one as a real message, it folds to nothing. events: { [N.ev("typing")]: (s) => s, }, // Host-only folds: the existing protocol, translated. Messages key on the // server message id (the original identity) and stay sorted by it, so a // scroll-back page folded later still lands before newer messages. `raw` // keeps the original payload fields (attachments with w/h, replyTo, name…) // so an edit can rebuild the full payload exactly like the original composer // ({ ...orig, text }). `rev` is the HARD rev: it bumps only on a content change // (edit) so keyedList rebuilds that bubble. A reaction does NOT bump it — // reactions ride viewItems' soft rev and patch the row in place (see patchBubble), // so reacting never recreates the bubble's photo or its playing YouTube embed. hostEvents: { [N.ev("msg")]: (s, e, meta) => { const text = typeof e.text === "string" ? e.text : ""; const atts = Array.isArray(e.attachments) ? e.attachments.filter((a) => a && a.id) : []; const sid = (meta && meta.id) || 0; const cid = typeof e._cid === "string" ? e._cid : ""; const pending = !!(meta && meta.pending); // A server message needs its id; an optimistic local echo has none yet // (it's pending, keyed only by cid until its own broadcast confirms it). if ((!text.trim() && !atts.length) || (!sid && !pending)) return s; if (sid && s.msgs.some((m) => m.sid === sid)) return s; // re-fold — already present // Our own echo confirming an optimistic copy: upgrade that pending record // IN PLACE — adopt the server id, drop the pending flags — keeping its // stable keyedList id (its bubble is patched, never rebuilt, so a photo or // playing embed never flashes). A second optimistic fold of the same cid // (no sid yet) is a no-op. if (cid) { const idx = s.msgs.findIndex((m) => m.cid === cid); if (idx >= 0) { if (!sid) return s; const msgs = s.msgs.slice(); msgs[idx] = { ...msgs[idx], sid, ts: e.ts || msgs[idx].ts, pending: false, slow: false }; msgs.sort(bySidPendingLast); return { msgs }; } } const raw = { name: e.name || "", text, ts: e.ts || ((meta && meta.ts) || 0) }; if (e.uid) raw.uid = e.uid; if (atts.length) raw.attachments = atts.map((a) => ({ ...a })); if (e.replyTo) raw.replyTo = { ...e.replyTo }; if (Array.isArray(e.actions) && e.actions.length) raw.actions = e.actions; // helper chips (host-whitelisted at render) if (e.card && typeof e.card === "object") raw.card = e.card; // bot-authored card — validated at render (cards.js) if (typeof e.key === "string" && e.key) raw.key = e.key; // scripted-line dedup key (one-Eleven welcome, shell.js) if (e.action) raw.action = true; // keeps a /me line rendering as an action line if you edit it if (e.warn) raw.warn = true; // an admin warning keeps its framing through edits too const msgs = [ ...s.msgs, { // A pending optimistic copy keys on its cid for life (stable across the // upgrade to a real sid), so its bubble is never destroyed/recreated. id: sid ? "m" + sid : "c" + cid, sid, cid, pending, // shown, but not yet acknowledged by the server slow: false, // flipped true by chat.pending after 5s unconfirmed text, name: raw.name, uid: e.uid || "", ts: raw.ts, attachments: raw.attachments || [], reply: e.replyTo ? { id: e.replyTo.id || 0, name: e.replyTo.name || "", snippet: e.replyTo.snippet || "" } : null, actions: raw.actions || [], card: raw.card || null, action: !!e.action, // an app-thread /me-style action line, not a bubble // An admin warning (issue #578): same bubble, distinct system framing. // It rides inside the ciphertext — the server can't tell a warning // from a "hello"; it only audits that one was issued. warn: !!e.warn, reactions: [], edited: false, raw, rev: 0, }, ]; msgs.sort(bySidPendingLast); return { msgs }; }, // A still-unconfirmed optimistic send, 5s on: mark it slow so the bubble // surfaces a subtle "sending…" state (host-fed by shell.js's markSlow timer). [N.ev("pending")]: (s, e) => { const cid = typeof e._cid === "string" ? e._cid : ""; if (!cid) return s; const idx = s.msgs.findIndex((m) => m.cid === cid && m.pending); if (idx < 0) return s; const msgs = s.msgs.slice(); msgs[idx] = { ...msgs[idx], slow: true }; return { msgs }; }, [N.ev("reaction")]: (s, e, meta, ctx) => { if (e.targetSid === undefined || e.emoji === undefined || e.uid === undefined || e.on === undefined) return s; const me = ctx && ctx.me; const msgs = s.msgs.map((m) => { if (m.sid !== e.targetSid) return m; const reactionEvents = m.reactionEvents || []; let found = false; const updated = reactionEvents.map((ev) => { if (ev.emoji === e.emoji && ev.uid === e.uid) { found = true; return { ...ev, on: e.on, ts: e.ts }; } return ev; }); if (!found && e.on) { updated.push({ emoji: e.emoji, uid: e.uid, on: true, ts: e.ts }); } const currentReactions = updated.filter((ev) => ev.on); const derived = []; const emojiMap = new Map(); for (const ev of currentReactions) { if (!emojiMap.has(ev.emoji)) { emojiMap.set(ev.emoji, []); } emojiMap.get(ev.emoji).push(ev.uid); } for (const [emoji, uids] of emojiMap) { derived.push({ emoji, count: uids.length, mine: uids.includes(me) }); } derived.sort((a, b) => a.emoji.localeCompare(b.emoji)); return { ...m, reactions: derived, reactionEvents: updated, rev: m.rev + 1 }; }); return { msgs }; }, [N.ev("edit")]: (s, e) => ({ msgs: s.msgs.map((m) => { if (m.sid !== e.msgId) return m; const text = typeof e.text === "string" ? e.text : m.text; return { ...m, text, raw: { ...m.raw, text }, edited: true, rev: m.rev + 1 }; }), }), [N.ev("delete")]: (s, e) => ({ msgs: s.msgs.filter((m) => m.sid !== e.msgId) }), }, // Typing: relay-only, never stored or folded. The server echoes your own beat // back to you, so drop it here rather than at display time — matching by uid is // robust even in a fresh thread, where myName() stays null until one of your // messages folds into view. A uid-less legacy beat falls back to the screen // name, mirroring the original feed's `payload.name === session.name` guard. ephemeral(e, ctx) { if (e.type !== N.ev("typing") || !e.name) return; if (e.uid ? e.uid === ctx.me : e.name === ctx.meName) return; let m = typing.get(ctx.threadId); if (!m) typing.set(ctx.threadId, (m = new Map())); const secs = Math.min(Math.max(Number(e.expiry) || 0, 0), 60); if (secs === 0) m.delete(e.name); else m.set(e.name, Date.now() + secs * 1000); const refresh = refreshTyping.get(ctx.threadId); if (refresh) refresh(); }, // Almost no CSS of its own: the feed content is original markup styled by // style.css. What remains is layout glue — the pane loses its app padding so // the feed/composer sit exactly like the original ones, the item wrapper is // display:contents so .msg/.time-sep participate in the feed's flex layout // as if they were direct children, and .chatapp-feed mirrors the #feed rule // (the one original selector that's id-based, so unreusable). styles: ` #app-pane:has(> .chatapp-root) { padding: 0; gap: 0; } .chatapp-root { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; width: 100%; position: relative; } .chatapp-item { display: contents; } /* Embedded in the native shell: the shell draws its own composer + banners, so the app shows only the feed (mirrors the original .native-embed layout). */ .chatapp-root.embed > .composer, .chatapp-root.embed .typeahead, .chatapp-root.embed > .composer-reply, .chatapp-root.embed > .composer-edit { display: none; } /* A read-only chat view (the owning app declares chatReadOnly — the Admin app's notice stream): the app posts here, the user doesn't, so no composer at all. */ .chatapp-root.readonly > .composer, .chatapp-root.readonly .typeahead, .chatapp-root.readonly > .composer-reply, .chatapp-root.readonly > .composer-edit { display: none; } /* Embedded: keep the attachment preview visible (a staged photo shows just above the native composer) and give the feed the native safe-area insets — mirrors the original feed's .native-embed rules. */ .native-embed .chatapp-feed { padding-top: calc(18px + var(--native-top-inset, 0px)); padding-bottom: calc(18px + var(--native-bottom-inset, 0px)); } .native-embed .feed-jump-btn { bottom: calc(18px + var(--native-bottom-inset, 0px)); } .chatapp-feed { flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; padding: 18px; display: flex; flex-direction: column; gap: 4px; } .chatapp-feed::before { content: ""; margin-top: auto; } /* The feed's positioning wrapper: the reply-thread view overlays exactly this box (inset:0 in style.css), so it dims/covers the transcript but never the composer stack below — the real composer stays usable under the thread. */ .chatapp-feedwrap { flex: 1 1 auto; min-height: 0; min-width: 0; position: relative; display: flex; flex-direction: column; } /* Jump-to-latest: bottom-right of the feed, shown once the reader has scrolled a moderate distance up (see JUMP_BUTTON_GAP). Sits below the reply-thread overlay's z-index (30) so that view still covers it. */ .feed-jump-btn { position: absolute; right: 18px; bottom: 18px; z-index: 20; width: 40px; height: 40px; padding: 0; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; background: var(--bubble-them); color: var(--ink); box-shadow: 0 6px 20px var(--shadow); transition: opacity 0.15s ease, transform 0.15s ease; } .feed-jump-btn svg { width: 20px; height: 20px; } .feed-jump-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } @media (prefers-reduced-motion: reduce) { .feed-jump-btn { transition: none; } } ` + CARD_CSS, // Incremental render: the skeleton (feed / banners / composer) is built once // and kept; each state change only reconciles the message list via keyedList // (the composite vrev re-renders a bubble when its content or its // neighbour-derived presentation changes). Scroll stays pinned to the bottom // unless the reader scrolled up. render(state, el, ctx) { let root = el.querySelector(":scope > .chatapp-root"); if (!root) { root = buildSkeleton(el, ctx); } // Stamp the mount with its thread so a stray refresh can tell whether the // pane still belongs to it (see doRefresh). #app-pane is shared across // threads, so an async welcome/tour step firing after you've navigated // away would otherwise re-render its thread into whatever's on screen now. root.dataset.thread = ctx.threadId; // Point the single document-level hotkey listener at THIS mount. On the // instant-restore path buildSkeleton is skipped, so this — not the build — // is what keeps the shortcuts live after switching threads. Guard on // isConnected so a parked thread re-rendering off-screen can't steal the // active pane's shortcuts. if (root.isConnected) hotkeys = root._hotkeys; // Same for the native shell's composer bridge: registerNative sets one // dispatcher in the host, so without re-pointing it here a native // send/typing/attach after revisiting a chat would route to the last-built // (now detached) thread. Guarded on isConnected — like the hotkeys re-point // above — so a parked pane re-rendering off-screen can't grab the bridge. if (root.isConnected && root._native && host && host.registerNative) { host.registerNative(root._native); } // Same again for the native macOS shell's Finder file-drop: it fills the // page's single static #file-input and fires `change`, so point that input at // the thread on screen (not the last-built one). Idempotent .onchange = so // re-rendering doesn't stack listeners. (The classic composer that used to own // #file-input is gone, so nothing else is listening.) if (root.isConnected && root._dropFiles) { const fileDropInput = document.getElementById("file-input"); if (fileDropInput) fileDropInput.onchange = root._dropFiles; } // A closed DM (issue #578): the server refuses new traffic, so the // composer gives way to an honest banner with the one useful action — // Reopen. Re-evaluated every render, so the dm_closed/dm_reopened frames // flip the pane live. paintClosedState(root, ctx); // Let addLocalNotice re-render this pane when a command reply comes in — the // reply isn't folded state, so nothing else would. Points at the live mount // (like the hotkeys/native re-points above), self-removed on teardown. const doRefresh = () => { // A parked thread's refresh (a late welcome/tour notice, a scripted // typing beat) must not paint into the shared #app-pane once another // thread has taken it over — that's how Eleven's onboarding messages // leaked into the chat you'd navigated to. The notice is already stored // in localNotices and paints when this thread is next opened; skip the // live render when the pane no longer belongs to us. const cur = el.querySelector(":scope > .chatapp-root"); if (cur && cur.dataset.thread && cur.dataset.thread !== ctx.threadId) return; renderApp(ctx.threadId, "chat", el); }; refreshFeed.set(ctx.threadId, doRefresh); // Keep the live mount ctx so the shell can inject a private notice into this // thread (e.g. the welcome DM's "Take the tour" chip starting Eleven's tour). mountedCtx.set(ctx.threadId, ctx); // Flush any notices that were queued for this thread before it mounted (the // tour navigating to the Eleven DM appends its next step while the feed loads). const queued = pendingNotices.get(ctx.threadId); if (queued) { pendingNotices.delete(ctx.threadId); for (const spec of queued) addLocalNotice(ctx, spec); } ctx.onCleanup(() => { if (refreshFeed.get(ctx.threadId) === doRefresh) refreshFeed.delete(ctx.threadId); if (mountedCtx.get(ctx.threadId) === ctx) mountedCtx.delete(ctx.threadId); }); const feed = root.querySelector(".chatapp-feed"); // A local send arms pinNext: snap to the newest even if we'd scrolled up. // Set.delete both reads and clears it, so it only forces this one render. const forcePin = pinNext.delete(ctx.threadId); const wasAtBottom = forcePin || feedNearBottom(feed, 60) || !feed.childElementCount; const msgs = (state && state.msgs) || []; // A sender's message arriving means they've stopped typing — clear their // row immediately rather than waiting out the beat expiry (original rule). const last = msgs[msgs.length - 1]; if (last && last.name && Date.now() - last.ts < 10000) { const tm = typing.get(ctx.threadId); if (tm) tm.delete(last.name); } const firstBuild = !feed.childElementCount; const items = viewItems(msgs); // Client-only command replies ("only visible to you") ride in the feed as keyed // rows, interleaved by their creation-time seq (see addLocalNotice) so they // stack like bubbles and stay put as the conversation continues. Merge + sort // only when some exist, so a normal feed keeps its plain message order. const notices = localNotices.get(ctx.threadId); if (notices && notices.length) { // In a solo thread (a persona conversation like setup — no other members) // "Only visible to you" is implicit, so the tag stays off. const solo = !!(host && host.soloThread && host.soloThread(ctx.threadId)); for (const n of notices) items.push({ id: n.id, notice: n, seq: n.seq, solo, vrev: "notice", srev: "" }); items.sort((a, b) => (a.notice ? a.seq : a.sid) - (b.notice ? b.seq : b.sid)); // Notices stack in sender runs like regular messages: consecutive notices // from the same speaker keep one header (name + badge), the rest continue. let prevNotice = null; for (const it of items) { if (it.notice) { it.cont = !!(prevNotice && prevNotice.sender && it.notice.sender && prevNotice.sender.name === it.notice.sender.name); // Spent chips (their step resolved — a.stale()) fold into the vrev so // the keyed renderer rebuilds the row when a later render pass finds // them newly stale; without this a resolved chip stays looking live. const spent = (it.notice.actions || []).reduce((n, a) => n + (a && a.stale && a.stale() ? 1 : 0), 0); it.vrev = `notice|${it.cont ? 1 : 0}|${it.solo ? 1 : 0}|${spent}`; prevNotice = it.notice; } else { prevNotice = null; } } } // The bot's intro ("This is the start of your chat history with To-do…") is a // PERSISTENT first item — a keyed row that scrolls up as the conversation and // its action lines grow, marking the beginning, rather than a vanishing // empty-state. host.chatIntro is null for a normal chat (no intro row). const introText = host && host.chatIntro && host.chatIntro(ctx.threadId); // vrev tracks the text so a dynamic intro (an open group's live "N people // here") re-renders when it changes; static intros keep a stable rev. if (introText) items.unshift({ id: "chatapp-intro", intro: true, text: introText, vrev: "intro|" + introText }); keyedList(feed, items, { key: (m) => m.id, rev: (m) => m.vrev + mutedRev(m, ctx) + mentionRev(m), srev: (m) => m.srev, render: (m) => renderItem(m, ctx), update: (node, m) => patchBubble(node, m, ctx), }); if (wasAtBottom) { // History builds — and a local send from scrolled-up (forcePin) — jump // straight to the newest; a plain live append glides (the original ease), // respecting reduced-motion. A send jumps rather than gliding the whole // history so it lands instantly on your message, not after a long scroll. feedGlideToBottom(feed, firstBuild || forcePin); } if (root._updateJumpBtn) root._updateJumpBtn(); // An open reply-thread view folds from this same state — re-derive and // re-render it every state change so new replies/reactions/edits land live // (it reads current state via threadItems, never a cached list). No-op when // closed; stashed on the root like _hotkeys so the instant-restore path // (which skips buildSkeleton) keeps the live mount's view current. if (root._refreshThread) root._refreshThread(); // The in-feed typing row, exactly like the original feed: a .typing element // kept as the feed's last child (below the newest message). keyedList only // manages keyed children, so it never touches this row. const myName = () => { const mine = msgs.find((m) => m.uid && m.uid === ctx.me); return mine ? mine.name : null; }; const drawTyping = () => { const names = typingNames(ctx.threadId, myName()); let row = feed.querySelector(":scope > .typing"); if (!names.length) { if (row) row.remove(); return; } if (!row) { row = document.createElement("div"); row.setAttribute("aria-live", "polite"); } const dm = !(host && host.isGroup && host.isGroup(ctx.threadId)); const label = typingSummary(names); // Only rebuild the contents on a real change — recreating the dots on // every ~10s beat would visibly restart their animation (original rule). if (row.dataset.mode !== (dm ? "bubble" : "text") || row.dataset.label !== label) { row.dataset.mode = dm ? "bubble" : "text"; row.dataset.label = label; row.className = dm ? "typing typing-bubble" : "typing"; row.textContent = ""; // textContent: names are untrusted if (dm) { for (let i = 0; i < 3; i++) { const dot = document.createElement("span"); dot.className = "dot"; row.appendChild(dot); } const sr = document.createElement("span"); // dots say nothing to a screen reader sr.className = "sr-only"; sr.textContent = label; row.appendChild(sr); } else { row.textContent = label; } } const atBottom = feedNearBottom(feed); feed.appendChild(row); // (re-)appending keeps it below the newest message if (atBottom) feedGlideToBottom(feed); }; refreshTyping.set(ctx.threadId, drawTyping); drawTyping(); ctx.interval(drawTyping, 1000); ctx.onCleanup(() => { if (refreshTyping.get(ctx.threadId) === drawTyping) refreshTyping.delete(ctx.threadId); }); // Keep the composer's outbound typing beat alive across a mid-message pause. // Like drawTyping this has to live on the render path, not in buildSkeleton: // the framework clears every interval before each render, so a once-per-mount // ticker would be torn down here and never re-armed (state lives in the // composer, so re-registering the timer each draw is harmless). const composer = composers.get(ctx.threadId); if (composer && composer.tickTyping) ctx.interval(composer.tickTyping, 1000); }, }); // Muted-message stubs the user tapped open this session (by sid). Deliberately // not persisted: reopening the app re-folds — a mute means muted. const revealedMuted = new Set(); // Muted-person folding is render-level state, not folded state — mix it into // the keyed-list rev so muting, unmuting, or revealing rebuilds exactly the // rows it changes and nothing else (m0 = folded stub, m1 = revealed). function mutedRev(m, ctx) { if (!m.uid || !ctx || m.uid === ctx.me || !host || !host.mutedPerson || !host.mutedPerson(m.uid)) return ""; return revealedMuted.has(m.sid) ? "|m1" : "|m0"; } // A message renders its @mentions against the roster AT RENDER TIME // (renderRichText reads host.mentionNames() fresh) — but keyedList only calls // render() once per key; an already-rendered bubble is never revisited unless // its rev changes. A person who joins (or is newly added to a group) after a // message mentioning them already rendered stays plain, unbolded text // forever, until something touches that row (issue #249). repaintMentionsOf // marks exactly the messages that need one (sids only); mentionRev reads that // mark so keyedList rebuilds those rows and leaves everything else alone — // bumping a global rev would force-rebuild the whole visible feed for a // change that's almost always irrelevant to it. const mentionDirty = new Set(); function mentionRev(m) { return mentionDirty.has(m.sid) ? "|mention" : ""; } // Pure: which of the last `cap` messages newly reference one of `names` — // "newly" meaning not already in `alreadyDirty`, so a repeat call over the // same feed finds nothing and triggers nothing. (An already-dirty row's rev // is the fixed "|mention" tag, so a second name resolving in the same message // couldn't rebuild it anyway — skipping it loses no repaint.) Exported for // the Node tests. export function newlyDirtyMentionSids(msgs, names, alreadyDirty, cap = 100) { const out = []; for (const m of (msgs || []).slice(-cap)) { if (m.text && !alreadyDirty.has(m.sid) && names.some((n) => m.text.includes("@" + n))) out.push(m.sid); } return out; } // repaintMentionsOf(names): one or more roster labels just became known (a // fresh joiner, or a label that finished decrypting) — called from shell.js's // decryptRosterLabels. Scans each mounted thread's last 100 messages for ones // that actually reference a newly-known name and marks only those dirty, then // asks the thread to re-render — a small, targeted repaint instead of a // blanket one (issue #249). export function repaintMentionsOf(names) { if (!names || !names.length) return; // Iterate a SNAPSHOT: refresh() re-renders, and a re-render tears down the // old mount (deleting its mountedCtx entry) before the new one re-adds it. // A delete + re-add lands at the end of the Map's iteration order, so a // live iterator here would visit the same thread again — and, with the // same @name still in its feed, again forever: the 2026-07-17 hard freeze // on web boot. The newly-dirty gate below is the second lock on that door. for (const ctx of [...mountedCtx.values()]) { const msgs = (ctx.getState() && ctx.getState().msgs) || []; const fresh = newlyDirtyMentionSids(msgs, names, mentionDirty); if (!fresh.length) continue; for (const sid of fresh) mentionDirty.add(sid); const refresh = refreshFeed.get(ctx.threadId); if (refresh) refresh(); } } // Swap the composer for the closed-conversation banner while the host says // this DM is closed (issue #578). The banner occupies the composer's layout // slot — inline, never floating over the feed. function paintClosedState(root, ctx) { const closed = !!(host && host.dmClosed && host.dmClosed(ctx.threadId)); const compose = root.querySelector(".composer"); const float = root.querySelector(".composer-float"); let bar = root.querySelector(":scope > .dm-closed-bar"); if (!closed) { if (bar) bar.remove(); if (compose) compose.classList.remove("hidden"); if (float) float.classList.remove("hidden"); return; } if (compose) compose.classList.add("hidden"); if (float) float.classList.add("hidden"); if (bar) return; // already painted; keep any inline error it shows bar = document.createElement("div"); bar.className = "dm-closed-bar"; const text = document.createElement("span"); text.textContent = "This conversation is closed."; bar.appendChild(text); const reopen = document.createElement("button"); reopen.type = "button"; reopen.textContent = "Reopen"; reopen.onclick = async () => { reopen.disabled = true; const r = host && host.reopenDM ? await host.reopenDM(ctx.threadId) : { ok: false }; if (r && r.ok) return; // the host repaints; the banner unmounts itself reopen.disabled = false; // "dms closed" = their DM door (issue #578); anything else is transient. text.textContent = r && String(r.error).includes("dms closed") ? "They're not accepting direct messages right now." : "Couldn't reopen — try again."; }; bar.appendChild(reopen); root.appendChild(bar); } // ---- bubbles: the original feed's exact markup ------------------------------ // renderItem builds one feed row inside a display:contents wrapper, so the // optional .time-sep and the .msg land in the feed's flex flow exactly like // original siblings. Structure and class names mirror appendMessage verbatim. function renderItem(m, ctx) { const item = document.createElement("div"); item.className = "chatapp-item"; if (m.timeSep) { const sep = document.createElement("div"); sep.className = "time-sep"; if (m.timeSep.withDay) { const day = document.createElement("strong"); day.textContent = dayLabel(m.ts); sep.appendChild(day); sep.appendChild(document.createTextNode(" " + formatTime(m.ts))); } else { sep.textContent = formatTime(m.ts); } item.appendChild(sep); } // The persistent "start of your chat history" intro row (keyed first). if (m.intro) { const el = document.createElement("div"); el.className = "chatapp-intro"; el.textContent = m.text; item.appendChild(el); return item; } // A private "only visible to you" command reply — a client-only feed row that // persists and stacks like a bubble (see addLocalNotice / buildLocalNotice). if (m.notice) { item.appendChild(buildLocalNotice(m.notice, { cont: !!m.cont, solo: !!m.solo })); return item; } // An app-thread action ("added 'milk'") — a centered /me-style line, not a // bubble: a record of your interaction with the bot in the chat history. if (m.action) { const line = document.createElement("div"); line.className = "chatapp-action"; line.textContent = m.text; item.appendChild(line); return item; } // A muted member's message (issue #578) folds to a quiet stub — visible and // tappable rather than silently gone, because the honest framing of a mute // is "you stop seeing them", not "they stop existing". Reveal is per-message // and per-session. Your own messages never fold. if (m.uid && m.uid !== ctx.me && host && host.mutedPerson && host.mutedPerson(m.uid) && !revealedMuted.has(m.sid)) { const stub = document.createElement("div"); stub.className = "muted-stub"; const label = document.createElement("span"); label.className = "muted-stub-label"; label.textContent = "Muted message"; stub.appendChild(label); const show = document.createElement("button"); show.type = "button"; show.className = "muted-stub-show"; show.textContent = "Show"; show.setAttribute("aria-label", "Show muted message"); show.onclick = () => { revealedMuted.add(m.sid); item.replaceWith(renderItem(m, ctx)); // re-render this one row in place }; stub.appendChild(show); item.appendChild(stub); return item; } // Mirror the original feed's attribution (ownMessage): prefer the inline uid, // but fall back to a screen-name match so your own legacy (uid-less) messages // still render as yours — right-aligned, name hidden in group runs, editable. const mine = ownMessage({ uid: m.uid, name: m.name }, ctx.me, ctx.meName); const wrap = document.createElement("div"); wrap.className = "msg " + (mine ? "me" : "them") + (m.cont ? " cont" : ""); if (Date.now() - m.ts < 3000) wrap.classList.add("appear"); // live arrivals ease in, history doesn't wrap.dataset.id = m.sid || ""; // its server id (empty while pending) — lets a reply quote scroll back here wrap.dataset.t = m.ts; wrap.dataset.sender = m.name; // Tag with the resolved thread root (not the quoted id) so the "N Replies" // jump on the root finds legacy chain replies too. if (m.reply && m.reply.id) wrap.dataset.replyTo = m.replyRoot || m.reply.id; // The header shows the sender's CURRENT roster name (looked up by the uid // inside the ciphertext), falling back to the name snapshot the sender // stamped into the payload. The lookup heals messages sent before a member // picked a name — permanently nameless snapshots — and retitles history // after a rename; the snapshot still covers uid-less legacy messages and // members who've since left the roster. const senderName = (m.uid && host && host.memberName && host.memberName(m.uid)) || m.name; if (!mine && senderName && host && host.isGroup && host.isGroup(ctx.threadId) && !m.cont) { const nm = document.createElement("div"); nm.className = "name"; nm.style.display = "flex"; // center the name text with its AI/BOT badge nm.style.alignItems = "center"; nm.textContent = senderName; const kind = host.agentKind && host.agentKind(senderName); if (kind) nm.appendChild(agentTag(kind)); wrap.appendChild(nm); } // The bubble + its quote/image/preview + hover icons live in .content (the // positioning context for the hover affordances); reactions sit below it. const content = document.createElement("div"); content.className = "content"; // An admin warning (issue #578) keeps the ordinary bubble but announces // itself — a formal notice should never read like small talk. if (m.warn) { wrap.classList.add("warn"); const w = document.createElement("div"); w.className = "warn-tag"; w.textContent = "⚠️ A notice from an admin"; content.appendChild(w); } if (m.reply && (m.reply.name || m.reply.snippet)) { const q = document.createElement("button"); q.type = "button"; q.className = "reply-quote"; const who = document.createElement("div"); who.className = "reply-quote-name"; who.textContent = m.reply.name; const txt = document.createElement("div"); txt.className = "reply-quote-text"; txt.textContent = m.reply.snippet; q.append(who, txt); q.onclick = () => { // A quote tap opens the whole thread, anchored on its resolved ROOT (the // quoted ref may name a mid-chain parent on a legacy reply). replyTarget // also hands us the root's name/snippet — the header fallback if the root // has scrolled out of the loaded window. Old scroll-jump kept as a belt // for a mount without thread controls. const t = threads.get(ctx.threadId); const ref = replyTarget(((ctx.getState() || {}).msgs) || [], m); if (t && ref.id) t.open(ref.id, ref); else scrollToMessage(wrap, m.reply.id); }; content.appendChild(q); } for (const a of m.attachments) { if (a.type && a.type.startsWith("image/")) { const img = document.createElement("img"); // A GIF's click belongs to its play/pause toggle, not the lightbox — // .msg-gif swaps the zoom-in cursor for a plain pointer. img.className = a.type === "image/gif" ? "msg-image msg-gif" : "msg-image"; img.alt = a.name || "photo"; // Reserve the image's final box before the blob decrypts (see the original // appendMessage note) so the feed doesn't jump as photos pop in. if (a.w && a.h) { img.style.aspectRatio = `${a.w} / ${a.h}`; const scale = Math.min(260 / a.w, 320 / a.h, 1); img.style.width = Math.round(a.w * scale) + "px"; } // The BlurHash ghost of the photo while its blob downloads + decrypts // (attachments[].bh, inside the encrypted payload): decoded into a tiny // canvas and painted as the element's background — the decoded pixels // simply cover it, and matching colours make the swap read gently. if (a.bh) { try { const pw = 32, ph = a.w && a.h ? Math.max(1, Math.round((32 * a.h) / a.w)) : 24; const px = decodeBlurhash(a.bh, pw, ph); if (px) { const c = document.createElement("canvas"); c.width = pw; c.height = ph; c.getContext("2d").putImageData(new ImageData(px, pw, ph), 0, 0); img.style.backgroundImage = `url(${c.toDataURL()})`; img.style.backgroundSize = "cover"; } } catch { /* a bad hash keeps the flat placeholder */ } } if (host) { host.loadBlob(ctx.threadId, a).then((url) => { if (!url) return; img.src = url; if (a.type === "image/gif") wireGifToggle(img, url); else img.onclick = () => openLightbox(url, img); }); } content.appendChild(img); } else if (a.type && a.type.startsWith("video/")) { content.appendChild(buildVideoCard(a, ctx)); } else { content.appendChild(buildFileCard(a, ctx)); } } // A message carrying a valid card renders the card as its body; the text is // that card's plain fallback and would be pure duplication next to it. A // card that fails to normalize (unknown schema/block — fail closed, // cards.js) renders null and the text shows as usual. const cardEl = m.card ? renderCard(m.card) : null; if (m.text.trim() && !cardEl) { const bubble = document.createElement("div"); const jumbo = !m.attachments.length && emojiOnlyCount(m.text) > 0; bubble.className = jumbo ? "bubble jumbo-emoji" : "bubble"; renderRichText(bubble, m.text); content.appendChild(bubble); const link = firstLink(m.text); const yt = link && youtubeId(link); if (yt) content.appendChild(buildYouTubeCard(yt)); else if (link) addLinkPreview(content, link); } if (cardEl) content.appendChild(cardEl); // Helper-proposed one-tap action chips ("Switch to dark" …): the host // whitelists, labels, and runs them (they're host machinery — theme, // profile); the app mounts the returned row in the original position. if (m.actions && m.actions.length && host && host.actionChips) { const chips = host.actionChips(m.actions); if (chips) content.appendChild(chips); } wrap.appendChild(content); // Reactions below the bubble (buildReactionsRow seeds the load-time "mine" // flags). It's the SAME builder patchBubble reuses, so a later reaction // reconciles this row in place rather than rebuilding the whole bubble. const reactions = buildReactionsRow(m, ctx); if (reactions) wrap.appendChild(reactions); if (m.replyCount) wrap.appendChild(buildReplyCount(m, wrap, ctx)); if (m.edited) { const e = document.createElement("div"); e.className = "edited-tag"; e.textContent = "Edited"; wrap.appendChild(e); } // A still-pending optimistic send withholds its interactive actions — you can't // react to, reply to, link, or edit a message that hasn't been sent yet — and // wears a subtle status the CSS only reveals once it's `.slow` (5s unconfirmed). // On confirmation patchBubble promotes it: attaches these actions with the real // server id, no rebuild. A confirmed message gets them straight away. if (m.pending) { wrap.dataset.pending = "1"; wrap.classList.add("pending"); if (m.slow) wrap.classList.add("slow"); wrap.appendChild(buildSendStatus()); } else { attachActions(wrap, content, m, ctx); } item.appendChild(wrap); return item; } // The interactive message actions — desktop's hover strip (react / reply / edit / // copy-link) plus its press-and-hold, or touch's swipe-reply + long-press picker. // Split out of renderItem so patchBubble can attach them to a bubble that first // rendered while pending (actions withheld) the moment its echo confirms it — with // the now-real server id, and without rebuilding the bubble (no photo flash). function attachActions(wrap, content, m, ctx) { if (coarse) { attachSwipeReply(wrap, m, ctx); attachTouchPicker(wrap, m, ctx); return; } // Desktop: the hover strip beside the bubble — action icons + exact time, // revealed on hover (see .msg-actions in style.css). const mine = ownMessage({ uid: m.uid, name: m.name }, ctx.me, ctx.meName); const actions = document.createElement("div"); actions.className = "msg-actions"; actions.appendChild(hoverBtn("react-hover", "React", icon("react"), (e) => openMessagePicker(m, ctx, e.currentTarget))); actions.appendChild( hoverBtn("reply-hover", "Reply", icon("reply"), () => { const ctl = composers.get(ctx.threadId); if (ctl) ctl.startReply(m); }), ); const remaining = m.ts + EDIT_WINDOW_MS - Date.now(); if (mine && m.text.trim() && remaining > 0) { const editBtn = hoverBtn("edit-hover", "Edit", icon("edit"), () => { const ctl = composers.get(ctx.threadId); if (ctl) ctl.startEdit(m); }); actions.appendChild(editBtn); setTimeout(() => editBtn.remove(), remaining); // drop it when the window lapses } // The exact-time hover affordance doubles as a "Copy link" trigger (issue // #893) — the Slack/Discord convention. A