| 1 | // The "Media, links and docs" overlay (spec: docs/superpowers/specs/ |
| 2 | // 2026-08-05-media-links-docs-design.md): a full-pane surface over the open |
| 3 | // thread with Media | Links | Docs tabs, fed by threadmedia.js over the local |
| 4 | // store, completed by an auto-run resumable backfill (marks shared with |
| 5 | // search — either feature finishing the walk finishes it for both). |
| 6 | import { ThreadMediaIndex, bucketByMonth, filterEntries } from "/static/threadmedia.js"; |
| 7 | import { allMessages as storeAllMessages, getBackfillMark as storeBackfillMark, runBackfill as storeRunBackfill } from "/static/store.js"; |
| 8 | import { decrypt } from "/static/crypto.js"; |
| 9 | import { threadKeys, threadName } from "/static/shellstate.js"; |
| 10 | import { mutedPeople, blockedPeople } from "/static/moderation.js"; |
| 11 | import { formatBytes, relTime } from "/static/format.js"; |
| 12 | import { openLightbox } from "/static/lightbox.js"; |
| 13 | import { decodeBlurhash } from "/static/blurhash.js"; |
| 14 | import { jumpToMessage, fetchMessagePage } from "/static/searchui.js"; |
| 15 | |
| 16 | // Shell behaviours injected at boot (Task 4): the blob loader (decrypt + |
| 17 | // object-URL cache) and the unfurl fetcher — the same functions the chat |
| 18 | // app's host bridge hands out. |
| 19 | let deps = { loadBlob: async () => null, unfurl: async () => null }; |
| 20 | export function wireMediaView(d) { |
| 21 | deps = { ...deps, ...d }; |
| 22 | } |
| 23 | |
| 24 | export const mediaIdx = new ThreadMediaIndex(async (row, tid) => { |
| 25 | try { |
| 26 | return await decrypt(threadKeys[tid], row.blob); |
| 27 | } catch { |
| 28 | return null; |
| 29 | } |
| 30 | }); |
| 31 | |
| 32 | // Live edit/delete frames re-extract / drop (Task 4 calls this beside the |
| 33 | // store's own applyEditRow/deleteMessageRow mirrors). Repaint an open overlay |
| 34 | // on the same thread so the entry drops live (spec: "drops the entries |
| 35 | // live"), not just on next open. |
| 36 | export function mediaInvalidate(tid, sid) { |
| 37 | mediaIdx.invalidate(tid, sid); |
| 38 | if (openIs(tid)) open.repaint(); |
| 39 | } |
| 40 | |
| 41 | // A mute/block change can hide or reveal entries (hiddenUids). shell.js's |
| 42 | // single onModerationChange sink already does its own repaint (see |
| 43 | // moderation.js — onModerationChange(fn) REPLACES the sink, so mediaview.js |
| 44 | // must not call it directly); shell.js calls this from inside that same |
| 45 | // callback instead. Repaint if the overlay is open, on whichever thread. |
| 46 | export function mediaModerationChanged() { |
| 47 | if (open) open.repaint(); |
| 48 | } |
| 49 | |
| 50 | const hiddenUids = () => new Set([...mutedPeople, ...blockedPeople]); |
| 51 | |
| 52 | // Tabs are DATA so a future Pinned tab is one array entry (spec § Non-goals). |
| 53 | const TABS = [ |
| 54 | { id: "media", label: "Media", render: renderMediaTab }, |
| 55 | { id: "links", label: "Links", render: renderLinksTab }, |
| 56 | { id: "docs", label: "Docs", render: renderDocsTab }, |
| 57 | ]; |
| 58 | |
| 59 | let open = null; // { tid, root, walk: AbortController|null, tab, query } — one overlay at a time |
| 60 | |
| 61 | // Unfurl titles the Links tab has already fetched, so search can match a |
| 62 | // link by its title too (a title only exists once its row has scrolled into |
| 63 | // view — search never triggers fetches of its own). Session-lifetime memo, |
| 64 | // like the shell's own unfurl cache. |
| 65 | const linkTitles = new Map(); |
| 66 | |
| 67 | export async function openMediaView(tid) { |
| 68 | closeMediaView(); |
| 69 | const host = document.getElementById("thread-view"); |
| 70 | if (!host || !threadKeys[tid]) return; |
| 71 | const root = document.createElement("div"); |
| 72 | root.className = "media-overlay"; |
| 73 | root.setAttribute("role", "dialog"); |
| 74 | root.setAttribute("aria-modal", "false"); // the chat stays beneath, inert to pointer only |
| 75 | root.setAttribute("aria-label", `Media, links and docs — ${threadName[tid] || "Chat"}`); |
| 76 | open = { tid, root, walk: null, tab: "media", query: "", returnFocus: document.activeElement }; |
| 77 | |
| 78 | const head = document.createElement("div"); |
| 79 | head.className = "media-head"; |
| 80 | const back = document.createElement("button"); |
| 81 | back.className = "icon-btn media-back"; |
| 82 | back.setAttribute("aria-label", "Back to chat"); |
| 83 | back.textContent = "‹"; |
| 84 | back.onclick = () => closeMediaView(); |
| 85 | const title = document.createElement("div"); |
| 86 | title.className = "media-title"; |
| 87 | title.textContent = "Media, links and docs"; |
| 88 | head.append(back, title); |
| 89 | |
| 90 | const seg = document.createElement("div"); |
| 91 | seg.className = "media-tabs"; |
| 92 | seg.setAttribute("role", "tablist"); |
| 93 | seg.setAttribute("aria-label", "Kind"); |
| 94 | // Search within the open tab — sender, URL/title, filename (filterEntries). |
| 95 | // Filters as you type; the field survives repaints (it's built once, here). |
| 96 | const search = document.createElement("div"); |
| 97 | search.className = "media-search"; |
| 98 | const searchInput = document.createElement("input"); |
| 99 | searchInput.type = "search"; |
| 100 | searchInput.placeholder = "Search"; |
| 101 | searchInput.setAttribute("aria-label", "Search media, links and docs"); |
| 102 | searchInput.autocomplete = "off"; |
| 103 | searchInput.oninput = () => { |
| 104 | open.query = searchInput.value; |
| 105 | paintBody(); |
| 106 | }; |
| 107 | search.appendChild(searchInput); |
| 108 | const body = document.createElement("div"); |
| 109 | body.className = "media-body"; |
| 110 | // ONE polite live region for the walk's narration; sits in layout (never a |
| 111 | // toast), holds the Stop control beside the text. |
| 112 | const progress = document.createElement("div"); |
| 113 | progress.className = "media-progress hidden"; |
| 114 | const progressText = document.createElement("span"); |
| 115 | progressText.setAttribute("aria-live", "polite"); |
| 116 | const stop = document.createElement("button"); |
| 117 | stop.type = "button"; |
| 118 | stop.className = "media-stop"; |
| 119 | stop.textContent = "Stop"; |
| 120 | stop.onclick = () => open && open.walk && open.walk.abort(); |
| 121 | progress.append(progressText, stop); |
| 122 | |
| 123 | root.append(head, seg, search, progress, body); |
| 124 | host.appendChild(root); |
| 125 | document.addEventListener("keydown", onOverlayKey); |
| 126 | back.focus(); // focus lands in the overlay; Escape/back returns it |
| 127 | |
| 128 | const paintTabs = () => { |
| 129 | const counts = mediaIdx.counts(tid, { hiddenUids: hiddenUids() }); |
| 130 | seg.replaceChildren(); |
| 131 | for (const t of TABS) { |
| 132 | const b = document.createElement("button"); |
| 133 | b.type = "button"; |
| 134 | b.className = "media-tab" + (open.tab === t.id ? " on" : ""); |
| 135 | b.setAttribute("role", "tab"); |
| 136 | b.setAttribute("aria-selected", String(open.tab === t.id)); |
| 137 | b.id = `media-tab-${t.id}`; |
| 138 | const n = counts[t.id]; |
| 139 | b.textContent = n ? `${t.label} · ${n.toLocaleString()}` : t.label; |
| 140 | b.onclick = () => { |
| 141 | open.tab = t.id; |
| 142 | paintTabs(); |
| 143 | paintBody(); |
| 144 | }; |
| 145 | seg.appendChild(b); |
| 146 | } |
| 147 | }; |
| 148 | const paintBody = () => { |
| 149 | const t = TABS.find((x) => x.id === open.tab); |
| 150 | // Every full repaint rebuilds tiles from scratch (tab switch, live |
| 151 | // invalidation, each backfill page) — drop the old observer first so it |
| 152 | // doesn't keep watching nodes that are about to be detached (leak). |
| 153 | disconnectTileObserver(); |
| 154 | body.replaceChildren(); |
| 155 | body.setAttribute("role", "tabpanel"); |
| 156 | body.setAttribute("aria-labelledby", `media-tab-${open.tab}`); |
| 157 | const all = mediaIdx.tab(tid, open.tab, { hiddenUids: hiddenUids() }); |
| 158 | const entries = filterEntries(all, open.query, (u) => linkTitles.get(u)); |
| 159 | if (!entries.length) { |
| 160 | const empty = document.createElement("div"); |
| 161 | empty.className = "media-empty"; |
| 162 | empty.textContent = all.length |
| 163 | ? "Nothing matches." |
| 164 | : { media: "No photos or videos shared yet.", links: "No links shared yet.", docs: "No files shared yet." }[open.tab]; |
| 165 | body.appendChild(empty); |
| 166 | return; |
| 167 | } |
| 168 | t.render(body, entries, tid); |
| 169 | }; |
| 170 | open.repaint = () => { |
| 171 | // The auto-walk repaints the body on every backfill page. Each repaint |
| 172 | // tears down and rebuilds the DOM, which would otherwise punt keyboard |
| 173 | // focus to <body> mid-walk — capture by a stable key and restore the |
| 174 | // equivalent element afterwards. |
| 175 | const focusKey = captureFocusKey(root); |
| 176 | paintTabs(); |
| 177 | paintBody(); |
| 178 | restoreFocus(root, focusKey); |
| 179 | }; |
| 180 | |
| 181 | // Instant: what the store already holds. Then the one-time walk (below). |
| 182 | await mediaIdx.ensureThread(tid, await storeAllMessages(tid)); |
| 183 | if (!openIs(tid)) return; |
| 184 | open.repaint(); |
| 185 | autoBackfill(tid, progress, progressText); |
| 186 | } |
| 187 | |
| 188 | function openIs(tid) { |
| 189 | return open && open.tid === tid; |
| 190 | } |
| 191 | |
| 192 | // A stable key for whatever's focused inside the overlay, so a repaint |
| 193 | // (which tears down and rebuilds the DOM) can find the equivalent element |
| 194 | // afterwards. Tabs/back/stop key off fixed classes/ids; grid/list items key |
| 195 | // off the source message id (`data-sid`, set by each tab renderer) plus |
| 196 | // whether the focused control was the item's primary control or its |
| 197 | // sibling "Show in chat" jump button. |
| 198 | function captureFocusKey(root) { |
| 199 | const active = document.activeElement; |
| 200 | if (!active || !root.contains(active)) return null; |
| 201 | if (active.id && active.id.startsWith("media-tab-")) return { type: "tab", id: active.id }; |
| 202 | if (active.classList.contains("media-back")) return { type: "back" }; |
| 203 | if (active.classList.contains("media-stop")) return { type: "stop" }; |
| 204 | const itemEl = active.closest(".media-item[data-sid]"); |
| 205 | if (itemEl) return { type: "item", sid: itemEl.dataset.sid, jump: active.classList.contains("media-jump") }; |
| 206 | return null; |
| 207 | } |
| 208 | |
| 209 | function restoreFocus(root, key) { |
| 210 | if (!key) return; |
| 211 | let target = null; |
| 212 | if (key.type === "tab") target = document.getElementById(key.id); |
| 213 | else if (key.type === "back") target = root.querySelector(".media-back"); |
| 214 | else if (key.type === "stop") target = root.querySelector(".media-stop"); |
| 215 | else if (key.type === "item") { |
| 216 | const itemEl = root.querySelector(`.media-item[data-sid="${CSS.escape(key.sid)}"]`); |
| 217 | if (itemEl) target = key.jump ? itemEl.querySelector(".media-jump") : itemEl.querySelector(".media-tile, .media-link-row, .media-doc-row"); |
| 218 | } |
| 219 | if (target) { |
| 220 | target.focus(); |
| 221 | return; |
| 222 | } |
| 223 | // The previously-focused element is gone (its entry dropped out — deleted |
| 224 | // or muted mid-walk). Only steal focus back if the browser actually lost |
| 225 | // it to <body>; if something else already has focus, leave it alone. |
| 226 | if (document.activeElement === document.body) root.querySelector(".media-back")?.focus(); |
| 227 | } |
| 228 | |
| 229 | export function closeMediaView() { |
| 230 | if (!open) return; |
| 231 | if (open.walk) open.walk.abort(); |
| 232 | document.removeEventListener("keydown", onOverlayKey); |
| 233 | disconnectTileObserver(); |
| 234 | const focus = open.returnFocus; |
| 235 | open.root.remove(); |
| 236 | open = null; |
| 237 | if (focus && focus.isConnected) focus.focus(); |
| 238 | } |
| 239 | |
| 240 | function onOverlayKey(e) { |
| 241 | // A lightbox or message action sheet stacked on top owns Escape first — |
| 242 | // same guard chat.js uses (see its keydown handlers) — otherwise closing |
| 243 | // the top layer also closes the media overlay underneath it. |
| 244 | if (e.key === "Escape" && open && !document.querySelector(".lightbox, .msg-sheet")) { |
| 245 | e.preventDefault(); |
| 246 | // Escape in a non-empty search clears the search first (the field's own |
| 247 | // convention); a second Escape closes the overlay. |
| 248 | const input = open.root.querySelector(".media-search input"); |
| 249 | if (input && document.activeElement === input && input.value) { |
| 250 | input.value = ""; |
| 251 | open.query = ""; |
| 252 | open.repaint(); |
| 253 | return; |
| 254 | } |
| 255 | closeMediaView(); |
| 256 | } |
| 257 | } |
| 258 | |
| 259 | // Auto-run the resumable per-thread walk so the tabs become COMPLETE, not |
| 260 | // just cached (spec § Completeness). Quiet when already complete. |
| 261 | async function autoBackfill(tid, progress, progressText) { |
| 262 | const mark = await storeBackfillMark(tid); |
| 263 | if (!openIs(tid) || (mark && mark.complete)) return; |
| 264 | const controller = new AbortController(); |
| 265 | open.walk = controller; |
| 266 | progress.classList.remove("hidden"); |
| 267 | progressText.textContent = "Loading earlier messages…"; |
| 268 | try { |
| 269 | await storeRunBackfill(tid, (before) => fetchMessagePage(tid, before), { |
| 270 | signal: controller.signal, |
| 271 | onPage: async ({ oldestAt, rows }) => { |
| 272 | await mediaIdx.ensureThread(tid, rows || []); |
| 273 | if (!openIs(tid)) return; |
| 274 | const d = oldestAt ? new Date(oldestAt * 1000) : null; |
| 275 | progressText.textContent = d |
| 276 | ? `Loading earlier messages… back to ${d.toLocaleDateString([], { month: "long", year: "numeric" })}` |
| 277 | : "Loading earlier messages…"; |
| 278 | open.repaint(); |
| 279 | }, |
| 280 | }); |
| 281 | } finally { |
| 282 | if (openIs(tid)) { |
| 283 | open.walk = null; |
| 284 | progress.classList.add("hidden"); |
| 285 | open.repaint(); |
| 286 | } |
| 287 | } |
| 288 | } |
| 289 | |
| 290 | // ---- the three tab renderers ---------------------------------------------- |
| 291 | |
| 292 | // Month sections wrap each tab the same way; the item painter differs. |
| 293 | function monthSections(body, entries, paintItem, containerClass) { |
| 294 | for (const bucket of bucketByMonth(entries)) { |
| 295 | const h = document.createElement("div"); |
| 296 | h.className = "media-month"; |
| 297 | h.textContent = bucket.label; |
| 298 | body.appendChild(h); |
| 299 | const wrap = document.createElement("div"); |
| 300 | wrap.className = containerClass; |
| 301 | for (const e of bucket.items) wrap.appendChild(paintItem(e)); |
| 302 | body.appendChild(wrap); |
| 303 | } |
| 304 | } |
| 305 | |
| 306 | // Lazy blob loading: tiles carry their blurhash/aspect immediately; the blob |
| 307 | // decrypts only when scrolled into view. One observer per repaint — see |
| 308 | // disconnectTileObserver, called before every rebuild so detached tiles from |
| 309 | // the previous paint (tab switch, live invalidation, each backfill page) |
| 310 | // aren't retained by a still-watching observer. |
| 311 | let tileObserver = null; |
| 312 | function observeTile(el, onVisible) { |
| 313 | if (!tileObserver) { |
| 314 | tileObserver = new IntersectionObserver( |
| 315 | (ents) => { |
| 316 | for (const en of ents) { |
| 317 | if (!en.isIntersecting) continue; |
| 318 | tileObserver.unobserve(en.target); |
| 319 | en.target._onVisible && en.target._onVisible(); |
| 320 | } |
| 321 | }, |
| 322 | { rootMargin: "200px" }, |
| 323 | ); |
| 324 | } |
| 325 | el._onVisible = onVisible; |
| 326 | tileObserver.observe(el); |
| 327 | } |
| 328 | function disconnectTileObserver() { |
| 329 | if (!tileObserver) return; |
| 330 | tileObserver.disconnect(); |
| 331 | tileObserver = null; |
| 332 | } |
| 333 | |
| 334 | function renderMediaTab(body, entries, tid) { |
| 335 | monthSections( |
| 336 | body, |
| 337 | entries, |
| 338 | (e) => { |
| 339 | const a = e.att; |
| 340 | const tile = document.createElement("button"); |
| 341 | tile.type = "button"; |
| 342 | tile.className = "media-tile"; |
| 343 | const isVideo = (a.type || "").startsWith("video/"); |
| 344 | tile.setAttribute("aria-label", `${isVideo ? "Video" : "Photo"} from ${e.name || "someone"}, ${relTime(Math.floor(e.ts / 1000))}`); |
| 345 | const img = document.createElement("img"); |
| 346 | img.alt = ""; |
| 347 | img.draggable = false; |
| 348 | if (a.w && a.h) img.style.aspectRatio = `${a.w} / ${a.h}`; |
| 349 | // BlurHash ghost until the blob decrypts — same recipe as the feed's |
| 350 | // photo bubbles (chat.js), painted as the img's background. |
| 351 | if (a.bh) { |
| 352 | try { |
| 353 | const pw = 32, ph = a.w && a.h ? Math.max(1, Math.round((32 * a.h) / a.w)) : 32; |
| 354 | const px = decodeBlurhash(a.bh, pw, ph); |
| 355 | if (px) { |
| 356 | const c = document.createElement("canvas"); |
| 357 | c.width = pw; |
| 358 | c.height = ph; |
| 359 | c.getContext("2d").putImageData(new ImageData(px, pw, ph), 0, 0); |
| 360 | img.style.backgroundImage = `url(${c.toDataURL()})`; |
| 361 | img.style.backgroundSize = "cover"; |
| 362 | } |
| 363 | } catch { /* a bad hash keeps the flat placeholder */ } |
| 364 | } |
| 365 | tile.appendChild(img); |
| 366 | if (isVideo && a.dur) { |
| 367 | const badge = document.createElement("span"); |
| 368 | badge.className = "media-dur"; |
| 369 | const s = Math.max(0, Math.round(a.dur)); |
| 370 | badge.textContent = `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; |
| 371 | tile.appendChild(badge); |
| 372 | } |
| 373 | observeTile(tile, async () => { |
| 374 | // Videos show their (much smaller) poster frame; images their blob. |
| 375 | const att = isVideo && a.poster ? { id: a.poster, type: "image/jpeg" } : a; |
| 376 | const url = await deps.loadBlob(tid, att); |
| 377 | if (url) img.src = url; |
| 378 | }); |
| 379 | tile.dataset.sid = e.sid; |
| 380 | tile.dataset.att = a.id; |
| 381 | tile.onclick = async () => { |
| 382 | // The whole tab's current media list becomes the swipe collection — |
| 383 | // videos ride their poster still, same as the single-tap path below. |
| 384 | const all = mediaIdx.tab(tid, "media", { hiddenUids: hiddenUids() }); |
| 385 | const items = all.map((x) => { |
| 386 | const xa = x.att; |
| 387 | const still = (xa.type || "").startsWith("video/") && xa.poster ? { id: xa.poster, type: "image/jpeg" } : xa; |
| 388 | return { |
| 389 | load: () => deps.loadBlob(tid, still), |
| 390 | source: () => document.querySelector(`.media-tile[data-sid="${x.sid}"][data-att="${xa.id}"] img`), |
| 391 | }; |
| 392 | }); |
| 393 | const index = all.findIndex((x) => x.sid === e.sid && x.att.id === a.id); |
| 394 | // A tile can outlive its index entry — e.g. the message got deleted |
| 395 | // or its sender muted between paint and this tap resolving. |
| 396 | if (index < 0) return; |
| 397 | const url = await items[index].load(); |
| 398 | if (url) openLightbox(url, img, { collection: { items, index } }); |
| 399 | }; |
| 400 | // The jump button is a SIBLING of the tile, never a descendant — a |
| 401 | // <button> inside a <button> is invalid HTML (nested-interactive) and |
| 402 | // axe/VoiceOver flatten or hide the inner control. |
| 403 | const item = document.createElement("div"); |
| 404 | item.className = "media-item"; |
| 405 | item.dataset.sid = String(e.sid); |
| 406 | item.appendChild(tile); |
| 407 | addShowInChat(item, tid, e.sid); |
| 408 | return item; |
| 409 | }, |
| 410 | "media-grid", |
| 411 | ); |
| 412 | } |
| 413 | |
| 414 | function renderLinksTab(body, entries, tid) { |
| 415 | monthSections( |
| 416 | body, |
| 417 | entries, |
| 418 | (e) => { |
| 419 | const row = document.createElement("a"); |
| 420 | row.className = "media-link-row"; |
| 421 | row.href = e.url; |
| 422 | row.target = "_blank"; |
| 423 | row.rel = "noopener noreferrer"; |
| 424 | const urlEl = document.createElement("span"); |
| 425 | urlEl.className = "media-link-url"; |
| 426 | urlEl.textContent = e.url; |
| 427 | const who = document.createElement("span"); |
| 428 | who.className = "media-item-who"; |
| 429 | who.textContent = `${e.name || "Someone"} · ${relTime(Math.floor(e.ts / 1000))}`; |
| 430 | row.append(urlEl, who); |
| 431 | // The unfurl card fills in lazily; a failed unfurl leaves the plain row. |
| 432 | observeTile(row, async () => { |
| 433 | const card = await deps.unfurl(e.url); |
| 434 | if (!card || !card.ok || !card.title) return; |
| 435 | linkTitles.set(e.url, card.title); |
| 436 | const t = document.createElement("span"); |
| 437 | t.className = "media-link-title"; |
| 438 | t.textContent = card.title; |
| 439 | row.prepend(t); |
| 440 | }); |
| 441 | // Sibling, not descendant — see the note in renderMediaTab. |
| 442 | const item = document.createElement("div"); |
| 443 | item.className = "media-item"; |
| 444 | item.dataset.sid = String(e.sid); |
| 445 | item.appendChild(row); |
| 446 | addShowInChat(item, tid, e.sid); |
| 447 | return item; |
| 448 | }, |
| 449 | "media-list", |
| 450 | ); |
| 451 | } |
| 452 | |
| 453 | function renderDocsTab(body, entries, tid) { |
| 454 | monthSections( |
| 455 | body, |
| 456 | entries, |
| 457 | (e) => { |
| 458 | const a = e.att; |
| 459 | const row = document.createElement("a"); |
| 460 | row.className = "media-doc-row"; |
| 461 | row.href = "#"; |
| 462 | row.download = a.name || "file"; |
| 463 | row.addEventListener("click", (ev) => { |
| 464 | if (row.getAttribute("href") === "#") ev.preventDefault(); |
| 465 | }); |
| 466 | const nm = document.createElement("span"); |
| 467 | nm.className = "media-doc-name"; |
| 468 | nm.textContent = a.name || "file"; |
| 469 | const sub = document.createElement("span"); |
| 470 | sub.className = "media-item-who"; |
| 471 | sub.textContent = [a.size ? formatBytes(a.size) : "", `${e.name || "Someone"} · ${relTime(Math.floor(e.ts / 1000))}`] |
| 472 | .filter(Boolean) |
| 473 | .join(" · "); |
| 474 | row.append(nm, sub); |
| 475 | // The blob decrypts on approach so the tap is an instant save. |
| 476 | observeTile(row, async () => { |
| 477 | const url = await deps.loadBlob(tid, a); |
| 478 | if (url) row.href = url; |
| 479 | }); |
| 480 | // Sibling, not descendant — see the note in renderMediaTab. |
| 481 | const item = document.createElement("div"); |
| 482 | item.className = "media-item"; |
| 483 | item.dataset.sid = String(e.sid); |
| 484 | item.appendChild(row); |
| 485 | addShowInChat(item, tid, e.sid); |
| 486 | return item; |
| 487 | }, |
| 488 | "media-list", |
| 489 | ); |
| 490 | } |
| 491 | |
| 492 | // Every item's second affordance: land on the source message. A visible |
| 493 | // button (≥24px target) that's a SIBLING of the tile/row (never nested |
| 494 | // inside it — a button-in-a-button or button-in-an-anchor is invalid HTML |
| 495 | // and axe/screen readers flatten or hide the inner control), so it's |
| 496 | // reachable by keyboard and screen reader alike. `el` is the non-interactive |
| 497 | // `.media-item` wrapper each renderer builds around its primary control. |
| 498 | function addShowInChat(el, tid, sid) { |
| 499 | const b = document.createElement("button"); |
| 500 | b.type = "button"; |
| 501 | b.className = "media-jump"; |
| 502 | b.setAttribute("aria-label", "Show in chat"); |
| 503 | b.textContent = "↩"; |
| 504 | b.onclick = (e) => { |
| 505 | e.preventDefault(); |
| 506 | e.stopPropagation(); |
| 507 | closeMediaView(); |
| 508 | jumpToMessage(tid, sid); |
| 509 | }; |
| 510 | el.appendChild(b); |
| 511 | } |
| 512 | |