// 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: rows // folded, 0 = the server says history // ends here, -1 = the FETCH FAILED // (never latch `exhausted` on -1) // 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); // NB for anyone adding a scroll of their own: while this set holds the feed, // step() writes scrollTop EVERY frame and feedNearBottom reports "at bottom" // regardless of geometry — so a smooth scrollIntoView elsewhere is cancelled // on the next frame and each render re-arms the follow. Call feedCancelGlide // first (see revealMessage). // 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); } // Stop a running follow-the-bottom glide. The wheel/touchmove listeners above // do this when the reader intervenes; a scroll WE start has to do it // explicitly, because step() would otherwise overwrite scrollTop on its next // frame (an auto-behaviour write cancels an in-flight smooth scroll) and the // glide's presence keeps feedNearBottom true, re-arming the follow on every // subsequent render. export function feedCancelGlide(feed) { feedGlides.delete(feed); } 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. // paneComposer() hands out the docked pane's composer api while one is mounted // (null otherwise) — how setConnected and bubble actions reach the second // composer without a second registry. const threads = new Map(); // threadId → { open(rootSid, ref, opener), close(), isOpen(), paneComposer(), openForMessage(sid), reveal(sid) } // composerFor: the composer a bubble action (Reply, Edit, swipe-reply) lands // in — the docked thread pane's own when the acting bubble lives inside that // pane, else the thread's main composer. Decided on the DOM (is the anchor // inside `.chatapp-thread-panel--docked`?), not on state: the pane's rows come // from the same renderItem as the transcript's, so WHERE the bubble is is the // only thing that tells the two apart. The overlay has no pane composer // (paneComposer() is null), so it resolves to the main one — as before. function composerFor(anchor, ctx) { const t = threads.get(ctx.threadId); const pane = t && t.paneComposer && t.paneComposer(); if (pane && anchor && anchor.closest && anchor.closest(".chatapp-thread-panel--docked")) return pane; return composers.get(ctx.threadId); } // openReplyThread: the "Reply in thread" verb — open the thread `m` belongs // to (threadTarget: a message already in a thread continues it; anything else // roots a new thread on itself), docked or lifted per openThread's // width check. From a bubble inside the pane itself this is harmless: same // root, so openThread's already-open branch just refocuses. The opener is // where focus returns on close, so only a node that can take focus is worth // recording: press-and-hold and the cmd/ctrl-T hotkey anchor the picker on // the bubble (a plain div), and handing closeThread an unfocusable node would // swallow its main-composer fallback. function openReplyThread(m, ctx, anchor) { const t = threads.get(ctx.threadId); if (!t) return; const target = threadTarget(((ctx.getState() || {}).msgs) || [], m); if (!target.id) return; t.open(target.id, target, anchor && anchor.tabIndex >= 0 ? anchor : null); } // convertChain / convertToThread: the "Convert to thread" verb — take the // inline reply chain hanging under `m` out of the transcript and into a thread // rooted on `m`. The chain is derived here, on the converting client, and // shipped as an explicit id list (see the chat.convert fold): the list is what // everyone folds, so no two clients can disagree about what moved. // // convertChain is the same question the menus ask to decide whether to offer // the verb at all — a message with nothing under it never shows it, and neither // does a bubble inside a thread (nothing inline hangs off one). // // `anchor` is the bubble the menu opened on, and it settles the one case the // chain can't: a thread's ROOT is rendered inside the thread view too, and it // CAN still have inline replies out in the feed. Tidying those is something // you do while looking at the feed, so the verb stays out of the thread's own // menus — the same call the native containers make. function convertChain(m, ctx, anchor) { if (!m || !m.sid || !host || !host.convert) return []; if (anchor && anchor.closest && anchor.closest(".chatapp-thread-panel")) return []; return inlineChainIds(((ctx.getState() || {}).msgs) || [], m.sid); } // The confirm is deliberate, not ceremony: this reorganises the chat for // EVERYONE and, in v1, can't be undone (see convertConfirm). // // A conversion that can't reach the server has to SAY so. Nothing about a // failed one is visible — the chain simply stays where it was, which is // exactly what it looks like when nobody converted anything — so after // confirming a one-way act the reader would be left believing it happened. The // answer is an ordinary private notice in the feed (inline, "only visible to // you"), never a floating toast. function convertToThread(m, ctx, anchor) { const ids = convertChain(m, ctx, anchor); if (!ids.length) return; if (!confirm(convertConfirm(ids.length))) return; Promise.resolve(host.convert(ctx.threadId, m.sid, ids)).then((sent) => { if (!sent) addLocalNotice(ctx, { text: "Those replies didn't move — the connection dropped. Try again once you're back online.", short: "Couldn't move those replies" }); }); } // activeThreadPane: the open reply-thread controller for a mounted thread // (null when none is open) — how the shell's ▤ Views menu lists an open // thread as a view and puts focus back in it. openThread/closeThread dispatch // lchat:views-changed, so that menu repaints as the pane comes and goes. export function activeThreadPane(threadId) { const t = threads.get(threadId); return t && t.isOpen() ? t : null; } // threadListCount / openThreadList: the ▤ Threads row's two questions — is // there anything to list in this chat (0 hides the row, and with it the whole // menu when nothing else offers a view), and open the list. Kept as plain // exports beside activeThreadPane so the shell asks chat.js rather than // re-deriving threads from state it doesn't own. export function threadListCount(threadId) { const t = threads.get(threadId); return t && t.listCount ? t.listCount() : 0; } export function openThreadList(threadId) { const t = threads.get(threadId); return !!(t && t.openList && t.openList()); } // 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(); // The docked thread pane's composer isn't in `composers` (one entry per // thread, the main block's) — reach it through the threads registry so its // send button greys with the socket too. for (const t of threads.values()) { const pane = t.paneComposer && t.paneComposer(); if (pane) pane.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. // threadRootOf: the sid of the thread a message BELONGS to, or 0 for a message // that's in no thread. A thread exists only because someone chose "Reply in // thread", so membership is exactly the wire flag: a reply carrying // `reply.thread` points DIRECTLY at its thread's root (every thread send // derives its target from the root — see threadReplyTarget), so this is a // straight read, never a walk. Plain quote replies — the ordinary Reply verb, // and every reply in the history from before the two verbs split — belong to // no thread at all: an inline chain is a conversation in the feed, not a // thread nobody opened. function threadRootOf(m) { if (m.reply && m.reply.thread && m.reply.id) return m.reply.id; // …or the thread a CONVERSION moved it into after the fact (see // claimConverts). The wire flag is read first on purpose: a reply someone // composed in a thread belongs to that thread, and no conversion can pull it // out of one. return m.movedTo || 0; } // ---- converting an inline chain into a thread (the chat.convert fold) ------- // // Reply and Reply-in-thread are separate verbs, and EVERY reply in the history // from before that split is inline — so the way a long chain becomes a thread // is always after the fact. The messages themselves can't be rewritten to say // so: only their author may edit one, only for fifteen minutes, and rewriting N // messages would churn N authorships and timestamps to change a display rule. // // So a conversion is its own encrypted event — one message-shaped payload // saying "these ids now live in the thread rooted at R" — that every client // folds client-side, exactly as a reaction does. The server stores one more // opaque blob and learns nothing; a client too old to know the payload type // drops it and goes on showing the chain inline. // A server message id as the wire may carry it: a positive whole number, and // NOTHING a coercion would rescue. Deliberately not `Number(x) || 0` — Swift's // MessagePayload types root/ids as Int64, so `"400"` or `400.5` fails to decode // and native drops the whole payload. Coercing here would convert on web and do // nothing on native: the same messages, a different transcript per platform, // which the cross-platform contract calls a hard error. function isSid(n) { return Number.isSafeInteger(n) && n > 0; } // claimConverts: merge one convert event's claims into the map (sid → // { root, at }, where `at` is the CONVERT EVENT's own server id). // // Two people can convert overlapping chains, and the same event folds twice // routinely (history page + live echo, or a reconnect catch-up), so the merge // must be a pure function of the SET of events, never of their arrival order: // the OLDEST conversion (lowest `at`) owns a message, and re-folding one // changes nothing. Ties are impossible — `at` is a server message id. Returns // null when nothing changed, so the fold can hand back the same state. // Exported for tests: "the oldest conversion wins, and a repeat is a no-op" is // the rule that makes the fold order-independent, so it's pinned directly. // // The guards mirror Swift's exactly (see isSid): a payload native would drop is // dropped here too, whole or per id. export function claimConverts(claims, root, ids, at) { if (!isSid(root) || !isSid(at)) return null; let out = null; for (const sid of ids.slice(0, MAX_CONVERT_IDS)) { if (!isSid(sid) || sid === root) continue; // a root is never inside its own thread const prev = (out || claims)[sid]; if (prev && prev.at <= at) continue; // an older conversion already owns it if (!out) out = { ...claims }; out[sid] = { root, at }; } return out; } // convertedRoots: the claims resolved to the thread each message actually // lands in — a pure function of the claim map, so every client and every // arrival order derives the same answer. Exported for tests. // // Claims can nest: one conversion moves [b, c] under root a while another moves // [c] under root b. A claimed root is followed through to the thread IT ended // up in, so claim-rooted nesting flattens onto the outermost root. (That is // what's guaranteed — NOT "threads never nest". A reply someone composed in a // thread carries the wire flag, which is authoritative and can't be // re-pointed, so if a conversion moves the message that reply's thread is // rooted on, its thread ends up inside the converted one. Benign and reachable // — it renders from its root's bubble in the outer thread, exactly like any // thread — and only a race can produce it: a "Reply in thread" in flight while // someone converts the chain it lives in.) // // A cycle can only come from a malformed or hostile payload (a real reply's sid // is always higher than the message it quotes, so real chains can't loop) and // resolves to no conversion at all rather than hanging. export function convertedRoots(claims) { const out = new Map(); for (const key of Object.keys(claims || {})) { const sid = Number(key); let cur = claims[key].root; const seen = new Set([sid]); while (claims[cur] && !seen.has(cur)) { seen.add(cur); cur = claims[cur].root; } if (claims[cur]) continue; // cycled — leave every message in it inline if (cur && cur !== sid) out.set(sid, cur); } return out; } // convertHolds: does a conversion's claim on `m` actually hold? The sender's // id list is a CLAIM, not a fact, and the receiver checks it against the // message itself: a conversion moves a reply CHAIN, so each message it names // must really hang off the thread it's being moved into — its own quote is // either the root, or another message the conversions claimed. // // Without this the shipped primitive would be "move any N messages under any // root", which is a great deal worse than it sounds: a conversion renders no // bubble (its only trace is a transient chat-list line), the oldest claim wins // forever so no later event can undo it, and deleting the convert row isn't an // undo either — so one crafted payload could quietly hide a couple of hundred // unrelated messages under some old root, unattributably. Now such an id is // simply ignored and the message stays exactly where it is. // // Order-independent, and it needs no loaded window beyond the message itself: a // claimed message that hasn't arrived yet has no quote to check, so it stays // inline until it does — the same degradation as a thread reply whose root is // paged out. function convertHolds(m, claims) { const claim = claims[m.sid]; if (!claim) return false; const quoted = (m.reply && m.reply.id) || 0; return quoted === claim.root || !!claims[quoted]; } // applyConverts: stamp each message with the thread a conversion moved it into // (`movedTo` — the one field threadRootOf reads besides the wire flag). // Derived, never authoritative: the claim map is the state and this is its // projection onto the loaded window, re-run on every fold so a message that // arrives AFTER its own conversion (an older page paging in, an out-of-order // live frame) lands in the thread just the same. Messages it doesn't move keep // their identity, so nothing else re-renders. export function applyConverts(msgs, claims) { const roots = convertedRoots(claims); let changed = false; const out = msgs.map((m) => { const to = (m.sid && convertHolds(m, claims) && roots.get(m.sid)) || 0; if ((m.movedTo || 0) === to) return m; changed = true; return { ...m, movedTo: to }; }); return changed ? out : msgs; } // inlineChainIds: the inline reply chain hanging under `rootSid` — every // message that quotes it, everything that quotes those, and so on — which is // exactly what "Convert to thread" moves. Empty when there's nothing to move, // which is also the whole gate on offering the verb. // // This is the transitive quote walk the two-verb split deleted, back in the // one narrow place that needs it: deciding what a conversion covers. It is NOT // a membership rule — nothing here reads it to decide where a message renders // (that's threadRootOf, still a straight read) — and it runs on the CONVERTING // client only, at menu time, to build the event's id list. // // One forward pass suffices because `msgs` is in server order and a reply's sid // is always higher than the message it quotes: by the time the walk reaches a // reply, whatever it quotes has already been seen. Messages already living in a // thread are skipped, so the chain is only ever inline conversation — and a // bubble inside a thread pane has no chain at all, which is what keeps the verb // off the menus in there. export function inlineChainIds(msgs, rootSid) { if (!rootSid) return []; const root = msgs.find((m) => m.sid === rootSid); if (!root || threadRootOf(root)) return []; // unloaded, or already in a thread const chain = new Set([rootSid]); const out = []; for (const m of msgs) { if (!m.sid || threadRootOf(m)) continue; // pending copy, or already in a thread const quoted = (m.reply && m.reply.id) || 0; if (!quoted || !chain.has(quoted)) continue; chain.add(m.sid); out.push(m.sid); } return out; } // Does a conversion name this sid? The O(1) question the message fold asks // before paying for a re-stamp — cheap enough to ask on every arrival, and // false for every message in the overwhelming case of a chat where nobody has // converted anything. function converted(s, sid) { return !!(sid && s.converts && s.converts[sid]); } // A hostile client could name thousands of ids in one payload (the server caps // a blob at 64KB and can't read it), and the claim map is never pruned — so the // fold takes a generous slice rather than however many arrived. Far above any // real chain: a conversation with more than this many inline replies to one // message doesn't exist. Exported so the chat-list line counts what the fold // actually took, never what the payload asked for. export const MAX_CONVERT_IDS = 2000; // The one wording for the conversion's confirm, so web, Mac and iOS ask the // same question — pluralised and locale-grouped like every displayed count. // It says what everyone will see and that it's one-way, because it is: v1 has // no undo. export function convertConfirm(n) { const what = n === 1 ? "this reply" : `these ${n.toLocaleString()} replies`; return `Move ${what} into a thread? Everyone will see the conversation there instead of in the chat, and this can't be undone.`; } // quoteTarget: the replyTo a PLAIN reply to `m` carries — the exact message // clicked, nothing resolved. Replying to a reply quotes that reply; the chain // stacks inline in the feed and starts no thread. Pure and exported for tests // (mirrored natively by ChatFeedModel.quoteTarget — pinned by the // reply_thread_grouping vector's expected_reply_targets.quote). export function quoteTarget(m) { return { id: m.sid, name: m.name, snippet: replySnippet(m.raw) }; } // threadTarget: the replyTo a "Reply in thread" on `m` carries — the ROOT of // the thread the reply belongs in. A message already IN a thread continues // that thread (its root, snapshotted fresh when loaded, the quoted snapshot // until then); anything else — a plain message, a quote reply, a whole inline // chain — roots a NEW thread on ITSELF, because until someone chose this verb // there was no thread to join. Pure and exported for tests (mirrored natively // by ChatFeedModel.threadTarget). The `thread: true` wire flag is added at the // compose seam (threadReplyTarget), never here. export function threadTarget(msgs, m) { const rootSid = threadRootOf(m); if (!rootSid) return quoteTarget(m); const root = msgs.find((x) => x.sid === rootSid); if (root) return { id: root.sid, name: root.name, snippet: replySnippet(root.raw) }; // Root paged out. The frozen snapshot a FLAGGED reply carries is the root's // own (every thread send derives its ref from the root), so it stands in. A // CONVERTED message's ref is the message it quoted — which is not the root — // so there's nothing truthful to fall back on and the id travels alone: the // reply still lands in the right thread, and the view says the original // isn't available rather than naming the wrong message. const flagged = !!(m.reply && m.reply.thread); return { id: rootSid, name: (flagged && m.reply.name) || "", snippet: (flagged && m.reply.snippet) || "" }; } // threadItems: the reply thread rooted at `rootSid` — the root message (when // loaded) followed by every message whose thread root is that sid, in server // order. Thread membership is the flag and a direct pointer (threadRootOf): // an inline quote chain hanging off the same root is NOT part of the thread, // it's ordinary feed conversation. When the root itself has scrolled out of // the loaded window the replies still fold — the view's list simply starts at // the oldest loaded reply, under its note. 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) { if (!rootSid) return []; // 0 is the pending-copy key, never a thread root const out = []; const root = msgs.find((m) => m.sid === rootSid); if (root) out.push(root); for (const m of msgs) { if (threadRootOf(m) === rootSid) out.push(m); } // Same order as the feed: confirmed by sid, a still-pending optimistic reply // (sid 0) LAST. A plain by-sid sort put the pending copy first, ahead of the // root — and for the whole server round-trip the thread view read "root not // loaded": the count jumped, the "original message isn't available" note // flashed, the root sat below the reply answering it, then all snapped back // on the echo — the blink on every reply sent from the thread. return out.sort(bySidPendingLast); } // threadListItems: every thread in the loaded window, newest activity first — // what the ▤ Threads list shows. One row per thread ROOT: the root's author + // snippet (the line you recognise it by), how many replies it holds, and the // message that last landed in it. // // Membership is threadRootOf — the same read the "N Replies" tally makes, so // the count on a row and the count on its root can never disagree. Plain // quote replies therefore never appear: an inline chain is a conversation // already visible in the feed, not a thread anyone opened, and it has no // thread to come back to. // // Ordering is by LAST REPLY, not root age: the list answers "where is the // conversation", so this morning's root that nobody answered sits below a // week-old one someone replied to a minute ago. `last` is picked (and the // rows sorted) with the feed's own comparator, so a still-pending optimistic // reply counts as the newest thing in its thread — as it visibly is. // // A thread whose root has scrolled out of the loaded window keeps its row, // headed by the quoted snapshot its replies carry — exactly the fallback the // thread view's own header uses (`rootLoaded` says which one a row is). // Pure and exported for tests. export function threadListItems(msgs) { const bySid = bySidIndex(msgs); const byRoot = new Map(); // root sid → row for (const m of msgs) { const rootSid = threadRootOf(m); if (!rootSid) continue; let row = byRoot.get(rootSid); if (!row) { const root = bySid.get(rootSid); // Unloaded root: only a FLAGGED reply's ref is the root's own snapshot // (see threadTarget) — a converted message quotes something else, so its // row is headed by nothing rather than by the wrong message. const stand = m.reply && m.reply.thread ? m.reply : {}; row = { rootSid, rootLoaded: !!root, name: root ? root.name || "" : stand.name || "", snippet: root ? replySnippet(root.raw) : stand.snippet || "", count: 0, last: m, }; byRoot.set(rootSid, row); } row.count++; if (bySidPendingLast(row.last, m) < 0) row.last = m; } return [...byRoot.values()].sort((a, b) => bySidPendingLast(b.last, a.last)); } // threadListSignature: everything the rendered list actually shows — order, // each row's identity, its tally, and the line it's headed by (which changes // when an unloaded root pages in, or when someone edits it). The open panel // compares this before repainting, so a fold that leaves the list looking // identical never rebuilds its buttons under a keyboard user. Exported for // tests: "a repaint happens exactly when the list changed" is a rule worth // pinning, not a coincidence of string building. export function threadListSignature(rows) { return rows.map((r) => `${r.rootSid}:${r.count}:${r.name}:${r.snippet}`).join("|"); } // bySidIndex: the loaded window keyed by sid — what inMainFeed checks a thread // reply's root against. Exported so callers outside viewItems (jump fallback, // hotkeys, ↑-to-edit, tests) build the same index rather than each re-deriving // "is the root loaded". export function bySidIndex(msgs) { return new Map(msgs.map((m) => [m.sid, m])); } // inMainFeed: whether a message renders in the main transcript. A reply // composed IN a thread (`reply.thread` — the pane's or overlay's composer, the // "Reply in thread" verb) lives only in that thread, Slack-style: the // transcript shows just its root's "N Replies" tally. Plain quote replies // (the flag absent — every message from before the flag, and the ordinary // Reply verb) stay inline exactly as they always did. // // ROOT-AWARE: the hide only holds while the reply's root is in the loaded // window (`bySid` — the index viewItems already builds). The tally lives on // the root's bubble, so with the root paged out (a fresh reply to an // old message, cold-opening a busy chat) hiding the reply would leave it // rendered NOWHERE until the reader blind-scrolled back past the root. So it // degrades to an ordinary inline bubble — quote card and all — and collapses // into the tally the moment the root pages in. Pure and exported: the feed // filter, the jump fallbacks and the event vectors all read this one // predicate (native mirrors it — pinned by message_in_thread and // thread_reply_root_unloaded). export function inMainFeed(m, bySid) { // Flagged but pointing nowhere resolvable is a feed bubble, same as // unflagged: id 0 is the pending-copy key, so without threadRootOf's guard // a ref-less thread reply would hide itself behind any in-flight send — // and no root tally would carry it. (Native's threadRoots skips id 0 // for the same reason; the two predicates must agree.) const root = threadRootOf(m); if (!root) return true; return !bySid.has(root); } // 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; } // `mainFeed: true` is the transcript's call: replies that live only in their // thread (inMainFeed false — root loaded) drop out of the list — they still // tally on their root, and the separators + sender runs are derived over // what's actually shown, so a hidden reply never breaks a run or strands a // day separator. The thread view calls it plain (every reply shows there). export function viewItems(msgs, { mainFeed = false } = {}) { // The "N Replies" tally counts a root's THREAD — only the replies someone // deliberately made in it (threadRootOf: the `reply.thread` flag). An // ordinary inline reply contributes nothing: it's already visible right // there in the feed, so a tally on top of it would be counting the same // conversation twice and would read as a thread nobody started. Each thread // reply carries its root sid (`replyRoot`) so taps/jumps land on the thread; // tallies run over EVERY message, because a thread reply counts even though // the transcript never lists it (the tally is its only trace there). const bySid = bySidIndex(msgs); const replyCounts = new Map(); // thread root sid → messages in its thread const roots = new Map(); // thread reply sid → its thread's root sid for (const m of msgs) { const root = threadRootOf(m); if (root) { roots.set(m.sid, root); replyCounts.set(root, (replyCounts.get(root) || 0) + 1); } } const out = []; let prev = null; for (const m of msgs) { if (mainFeed && !inMainFeed(m, bySid)) continue; // lives in its (loaded) thread; `prev` stays the last VISIBLE message 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; // A THREAD reply's own view of its thread's tally: the SAME number the // root's "N Replies" shows, so a quote card that doubles as a thread link // can never disagree with the root it points at (threadLinkCount decides // whether it wears one). 0 for a plain inline reply — its card is a quote // and a jump, never a way into a thread. const threadCount = replyCounts.get(roots.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, threadCount, 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. // The quote card's thread link is soft too (patchBubble repaints it in // place), and only a reply ever carries one — so the segment stays absent // on everything else and a root's signature is unchanged. srev: `${reactSig}|${replyCount}|${m.pending ? (m.slow ? "slow" : "send") : "ok"}|${m.sid ? 1 : 0}` + (threadCount ? `|t${threadCount}` : ""), }); prev = m; } return out; } // How one history page's result reads — the distinction every backward walk // turns on, pulled out pure so it's pinned by a test rather than re-decided at // each call site. `n` is host.loadOlder's return: rows folded, 0 = the SERVER // says history ends here, -1 = the FETCH FAILED. // // "end" and "error" must never be conflated. Only "end" may latch `exhausted` // (which kills scroll-back for the rest of the mount) or answer a jump with // "that message isn't available any more" — an offline blip or a 502 during a // deploy is neither the start of history nor a deletion, so it stays // retryable. export function pageResult(n) { if (n < 0) return "error"; if (n === 0) return "end"; return "page"; } // The inline wording for a jump that couldn't land, by how the backward walk // ended (pageBackTo's outcome). "" for "found" — nothing to say. Three // genuinely different answers, because "gone" is final and "capped" means // keep scrolling; the reader is never left tapping a control that does // nothing. Pure + exported so the Node suite pins it against its Swift twin, // ChatFeedModel.jumpReachNote — web, Mac and iOS must say the same thing. export function jumpReachNote(reach) { if (reach === "capped") return "That message is further back than this jump reaches — keep scrolling up for it."; if (reach === "gone") return "That message isn't available any more."; return ""; } // The one wording for a thread's size, wherever it's shown: the root's tally, // the quote-card thread link and the thread view's own header read as the same // thing because they ARE the same string. Pluralised, and grouped for the // locale (1,204 Replies). export function replyCountLabel(n) { return n === 1 ? "1 Reply" : `${n.toLocaleString()} Replies`; } // How many REPLIES a threadItems list holds — the list minus its root, when // the root is loaded (it's the first item; see threadItems). The thread view // renders root and replies as one list now, so "how many rows" and "how many // answers" are no longer the same number: a thread nobody has answered yet is // one row and must still read "0 Replies", never "1 Reply". Pure and exported // so that invariant is pinned by a test rather than re-derived at each render // site — native's twin is ChatFeedModel.threadReplyCount. export function threadReplyCount(items, rootSid) { if (!items.length) return 0; return items[0].sid === rootSid ? items.length - 1 : items.length; } // Does this reply's quote card double as a link into its thread, and with what // count? Only a THREAD reply ever wears one (an inline reply's threadCount is // 0 — its card quotes and jumps, nothing more), and only while its thread // holds more than just it: rendered inline because its root is paged out, its // card is the one way back into the conversation it belongs to. The number is // the thread's WHOLE tally (the same one the root wears), never "the others // besides me": two people looking at the same thread from different bubbles // must read the same size. Returns 0 for "no thread link". export function threadLinkCount(m) { const n = (m && m.threadCount) || 0; return n > 1 ? n : 0; } 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); // It has a real sid now, so a conversion could already name it. return { ...s, msgs: converted(s, sid) ? applyConverts(msgs, s.converts) : 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 || [], // `thread` (additive, cross-platform — see inMainFeed): the reply was // composed IN its thread, so it lives only there. Absent/false is a // quote reply that renders inline — old clients, existing history. reply: e.replyTo ? { id: e.replyTo.id || 0, name: e.replyTo.name || "", snippet: e.replyTo.snippet || "", thread: !!e.replyTo.thread } : 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); // A message can arrive AFTER the conversion that moves it (an older page // paging in, an out-of-order live frame, a reconnect catch-up): stamp it // so it lands in its thread rather than inline. ONLY when a conversion // actually names this sid — every other message's stamp is unchanged by // an arrival (a claim resolves against the claim map alone, never against // the loaded window), so a history page of a hundred rows does one O(1) // lookup each instead of a hundred full re-resolutions. return { ...s, msgs: converted(s, sid) ? applyConverts(msgs, s.converts) : msgs }; }, // An inline reply chain converted into a thread: the ids now live in the // thread rooted at `root`. Never a wire type — shell.js recognises the // encrypted "threadconvert" payload and synthesises this, exactly as it // does for reactions, so nobody can spoof one with a typed message. The // claim map IS the state; where each message renders is derived from it // (applyConverts → movedTo → threadRootOf), so folds in any order, and // repeated folds of the same event, settle on the same feed. [N.ev("convert")]: (s, e, meta) => { const at = (meta && meta.id) || 0; // the convert event's own server id const ids = Array.isArray(e.ids) ? e.ids : []; if (!ids.length) return s; // No coercion: claimConverts validates root/at/each id the way Swift's // decoder does, so a payload native drops is dropped here too. const converts = claimConverts(s.converts || {}, e.root, ids, at); if (!converts) return s; return { ...s, converts, msgs: applyConverts(s.msgs, converts) }; }, // 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 { ...s, 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 { ...s, msgs }; }, [N.ev("edit")]: (s, e) => ({ ...s, 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 }; }), }), // A deleted message leaves its conversion claim behind — harmless, since // nothing resolves it any more. Its neighbours are untouched: every claim // names its root directly, so deleting one message mid-chain never spills // the rest of the thread back into the feed. [N.ev("delete")]: (s, e) => ({ ...s, 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; } /* The docked thread pane's composer is nested (not a root child), so the two child-combinator sweeps above miss it — same rule, same reasons: embedded, the shell draws the composer; read-only, nobody replies. */ .chatapp-root.embed .chatapp-thread-composer, .chatapp-root.readonly .chatapp-thread-composer { 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; } /* The feed's inline status line — the transcript's twin of the thread view's .chatapp-thread-note, and deliberately NOT a floating toast: it sits in flow above the feed and takes real layout space (CLAUDE.md's no-toast rule). Today it answers a jump that has to page history back for its target, so a tap is never a silent no-op. */ .chatapp-feed-note { flex: 0 0 auto; padding: 8px 18px; font-size: 12px; color: var(--muted); border-bottom: 1px solid var(--border); } /* Empty = no layout, but deliberately NOT display:none. It's a role="status" live region: display:none takes the node out of the accessibility tree, and a region that is hidden when its text is written then revealed in the same tick is announced unreliably (often not at all). Collapsing to nothing keeps it in the tree, so the next update is announced from a node that was already there. */ .chatapp-feed-note:empty { padding: 0; border-bottom: 0; } /* 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; // The transcript: thread-only replies fold away into their root's tally // (an open thread view lists them — root._refreshThread below). const items = viewItems(msgs, { mainFeed: true }); // 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 ▤ Threads list folds from that same state: a new reply re-sorts it // and moves a count. Repaints only when what it shows actually changed // (its own signature check) — the rows are buttons, and a reader tabbing // them must not lose their place to someone else's message. if (root._refreshThreadList) root._refreshThreadList(); // 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); // The docked thread pane's composer beats typing too. Resolved lazily on // each tick, not captured: the pane opens and closes between renders (no // re-render marks either), so a captured handle would go stale — and with // no pane this is a 1s no-op, the same cost as the ticker above. ctx.interval(() => { const t = threads.get(ctx.threadId); const pane = t && t.paneComposer && t.paneComposer(); if (pane && pane.tickTyping) pane.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)); // Direct children only: a docked thread pane nests its own ".composer", // and this paint must never grab that one by first match. const compose = root.querySelector(":scope > .composer"); const float = root.querySelector(":scope > .composer-float"); const paneCompose = root.querySelector(".chatapp-thread-composer"); 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"); if (paneCompose) paneCompose.classList.remove("hidden"); return; } if (compose) compose.classList.add("hidden"); if (float) float.classList.add("hidden"); if (paneCompose) paneCompose.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 thread root for a thread reply (replyRoot), the quoted id for // a plain one — so the "N Replies" jump belt on a root finds its thread's // replies, and a plain quote still points at what it quotes. 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); paintQuoteThreadLink(q, m); q.onclick = () => { // Two verbs, two cards. A THREAD reply's card is the way into its thread // — it's only rendered inline because its root is paged out, so opening // the thread (threadTarget also hands us the root's name/snippet as the // header fallback) is the whole point; the card is the opener, so focus // returns to it when the view closes. A PLAIN reply's card is a quote, // and tapping a quote jumps to the message it quotes — the behaviour // that predates threads, and the one a reader expects when nothing here // said "thread". Scroll-jump also covers a mount with no thread controls. // // "In a thread" is threadRootOf, not the wire flag: a CONVERTED reply // rendered inline (its root paged out) wears the same thread tally on // its card — paintQuoteThreadLink reads the same rollup — so the card // must open the thread it advertises. Native asks the same question // (MessageBubble's replyQuote → threadRootID). const t = threads.get(ctx.threadId); const ref = threadRootOf(m) ? threadTarget(((ctx.getState() || {}).msgs) || [], m) : null; if (t && ref && ref.id) t.open(ref.id, ref, q); 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 / // reply-in-thread / 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 = composerFor(wrap, ctx); if (ctl) ctl.startReply(m); }), ); // Reply in thread, right beside Reply — the two are siblings, so they belong // side by side. It was menu-only (the picker + the touch sheet, both of which // keep it), and on desktop that menu opens from the *React* smiley: nobody // hunting for threads had a reason to click a smiley, so threads read as a // feature we hadn't built. The distinct glyph is what makes two neighbouring // reply verbs legible — see ICON_THREAD. The button is its own opener, so // closing the thread puts focus back on the icon that opened it. Inside the // thread view this button is hidden (style.css), the same self-pointing-row // rule as the quote card and the reply tally: the verb would resolve to the // thread you're already standing in. actions.appendChild( hoverBtn("thread-hover", "Reply in thread", icon("thread"), (e) => openReplyThread(m, ctx, e.currentTarget)), ); 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 = composerFor(wrap, ctx); 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