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