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