// The "Media, links and docs" overlay (spec: docs/superpowers/specs/ // 2026-08-05-media-links-docs-design.md): a full-pane surface over the open // thread with Media | Links | Docs tabs, fed by threadmedia.js over the local // store, completed by an auto-run resumable backfill (marks shared with // search — either feature finishing the walk finishes it for both). import { ThreadMediaIndex, bucketByMonth, filterEntries } from "/static/threadmedia.js"; import { allMessages as storeAllMessages, getBackfillMark as storeBackfillMark, runBackfill as storeRunBackfill } from "/static/store.js"; import { decrypt } from "/static/crypto.js"; import { threadKeys, threadName } from "/static/shellstate.js"; import { mutedPeople, blockedPeople } from "/static/moderation.js"; import { formatBytes, relTime } from "/static/format.js"; import { openLightbox } from "/static/lightbox.js"; import { decodeBlurhash } from "/static/blurhash.js"; import { jumpToMessage, fetchMessagePage } from "/static/searchui.js"; // Shell behaviours injected at boot (Task 4): the blob loader (decrypt + // object-URL cache) and the unfurl fetcher — the same functions the chat // app's host bridge hands out. let deps = { loadBlob: async () => null, unfurl: async () => null }; export function wireMediaView(d) { deps = { ...deps, ...d }; } export const mediaIdx = new ThreadMediaIndex(async (row, tid) => { try { return await decrypt(threadKeys[tid], row.blob); } catch { return null; } }); // Live edit/delete frames re-extract / drop (Task 4 calls this beside the // store's own applyEditRow/deleteMessageRow mirrors). Repaint an open overlay // on the same thread so the entry drops live (spec: "drops the entries // live"), not just on next open. export function mediaInvalidate(tid, sid) { mediaIdx.invalidate(tid, sid); if (openIs(tid)) open.repaint(); } // A mute/block change can hide or reveal entries (hiddenUids). shell.js's // single onModerationChange sink already does its own repaint (see // moderation.js — onModerationChange(fn) REPLACES the sink, so mediaview.js // must not call it directly); shell.js calls this from inside that same // callback instead. Repaint if the overlay is open, on whichever thread. export function mediaModerationChanged() { if (open) open.repaint(); } const hiddenUids = () => new Set([...mutedPeople, ...blockedPeople]); // Tabs are DATA so a future Pinned tab is one array entry (spec § Non-goals). const TABS = [ { id: "media", label: "Media", render: renderMediaTab }, { id: "links", label: "Links", render: renderLinksTab }, { id: "docs", label: "Docs", render: renderDocsTab }, ]; let open = null; // { tid, root, walk: AbortController|null, tab, query } — one overlay at a time // Unfurl titles the Links tab has already fetched, so search can match a // link by its title too (a title only exists once its row has scrolled into // view — search never triggers fetches of its own). Session-lifetime memo, // like the shell's own unfurl cache. const linkTitles = new Map(); export async function openMediaView(tid) { closeMediaView(); const host = document.getElementById("thread-view"); if (!host || !threadKeys[tid]) return; const root = document.createElement("div"); root.className = "media-overlay"; root.setAttribute("role", "dialog"); root.setAttribute("aria-modal", "false"); // the chat stays beneath, inert to pointer only root.setAttribute("aria-label", `Media, links and docs — ${threadName[tid] || "Chat"}`); open = { tid, root, walk: null, tab: "media", query: "", returnFocus: document.activeElement }; const head = document.createElement("div"); head.className = "media-head"; const back = document.createElement("button"); back.className = "icon-btn media-back"; back.setAttribute("aria-label", "Back to chat"); back.textContent = "‹"; back.onclick = () => closeMediaView(); const title = document.createElement("div"); title.className = "media-title"; title.textContent = "Media, links and docs"; head.append(back, title); const seg = document.createElement("div"); seg.className = "media-tabs"; seg.setAttribute("role", "tablist"); seg.setAttribute("aria-label", "Kind"); // Search within the open tab — sender, URL/title, filename (filterEntries). // Filters as you type; the field survives repaints (it's built once, here). const search = document.createElement("div"); search.className = "media-search"; const searchInput = document.createElement("input"); searchInput.type = "search"; searchInput.placeholder = "Search"; searchInput.setAttribute("aria-label", "Search media, links and docs"); searchInput.autocomplete = "off"; searchInput.oninput = () => { open.query = searchInput.value; paintBody(); }; search.appendChild(searchInput); const body = document.createElement("div"); body.className = "media-body"; // ONE polite live region for the walk's narration; sits in layout (never a // toast), holds the Stop control beside the text. const progress = document.createElement("div"); progress.className = "media-progress hidden"; const progressText = document.createElement("span"); progressText.setAttribute("aria-live", "polite"); const stop = document.createElement("button"); stop.type = "button"; stop.className = "media-stop"; stop.textContent = "Stop"; stop.onclick = () => open && open.walk && open.walk.abort(); progress.append(progressText, stop); root.append(head, seg, search, progress, body); host.appendChild(root); document.addEventListener("keydown", onOverlayKey); back.focus(); // focus lands in the overlay; Escape/back returns it const paintTabs = () => { const counts = mediaIdx.counts(tid, { hiddenUids: hiddenUids() }); seg.replaceChildren(); for (const t of TABS) { const b = document.createElement("button"); b.type = "button"; b.className = "media-tab" + (open.tab === t.id ? " on" : ""); b.setAttribute("role", "tab"); b.setAttribute("aria-selected", String(open.tab === t.id)); b.id = `media-tab-${t.id}`; const n = counts[t.id]; b.textContent = n ? `${t.label} · ${n.toLocaleString()}` : t.label; b.onclick = () => { open.tab = t.id; paintTabs(); paintBody(); }; seg.appendChild(b); } }; const paintBody = () => { const t = TABS.find((x) => x.id === open.tab); // Every full repaint rebuilds tiles from scratch (tab switch, live // invalidation, each backfill page) — drop the old observer first so it // doesn't keep watching nodes that are about to be detached (leak). disconnectTileObserver(); body.replaceChildren(); body.setAttribute("role", "tabpanel"); body.setAttribute("aria-labelledby", `media-tab-${open.tab}`); const all = mediaIdx.tab(tid, open.tab, { hiddenUids: hiddenUids() }); const entries = filterEntries(all, open.query, (u) => linkTitles.get(u)); if (!entries.length) { const empty = document.createElement("div"); empty.className = "media-empty"; empty.textContent = all.length ? "Nothing matches." : { media: "No photos or videos shared yet.", links: "No links shared yet.", docs: "No files shared yet." }[open.tab]; body.appendChild(empty); return; } t.render(body, entries, tid); }; open.repaint = () => { // The auto-walk repaints the body on every backfill page. Each repaint // tears down and rebuilds the DOM, which would otherwise punt keyboard // focus to mid-walk — capture by a stable key and restore the // equivalent element afterwards. const focusKey = captureFocusKey(root); paintTabs(); paintBody(); restoreFocus(root, focusKey); }; // Instant: what the store already holds. Then the one-time walk (below). await mediaIdx.ensureThread(tid, await storeAllMessages(tid)); if (!openIs(tid)) return; open.repaint(); autoBackfill(tid, progress, progressText); } function openIs(tid) { return open && open.tid === tid; } // A stable key for whatever's focused inside the overlay, so a repaint // (which tears down and rebuilds the DOM) can find the equivalent element // afterwards. Tabs/back/stop key off fixed classes/ids; grid/list items key // off the source message id (`data-sid`, set by each tab renderer) plus // whether the focused control was the item's primary control or its // sibling "Show in chat" jump button. function captureFocusKey(root) { const active = document.activeElement; if (!active || !root.contains(active)) return null; if (active.id && active.id.startsWith("media-tab-")) return { type: "tab", id: active.id }; if (active.classList.contains("media-back")) return { type: "back" }; if (active.classList.contains("media-stop")) return { type: "stop" }; const itemEl = active.closest(".media-item[data-sid]"); if (itemEl) return { type: "item", sid: itemEl.dataset.sid, jump: active.classList.contains("media-jump") }; return null; } function restoreFocus(root, key) { if (!key) return; let target = null; if (key.type === "tab") target = document.getElementById(key.id); else if (key.type === "back") target = root.querySelector(".media-back"); else if (key.type === "stop") target = root.querySelector(".media-stop"); else if (key.type === "item") { const itemEl = root.querySelector(`.media-item[data-sid="${CSS.escape(key.sid)}"]`); if (itemEl) target = key.jump ? itemEl.querySelector(".media-jump") : itemEl.querySelector(".media-tile, .media-link-row, .media-doc-row"); } if (target) { target.focus(); return; } // The previously-focused element is gone (its entry dropped out — deleted // or muted mid-walk). Only steal focus back if the browser actually lost // it to ; if something else already has focus, leave it alone. if (document.activeElement === document.body) root.querySelector(".media-back")?.focus(); } export function closeMediaView() { if (!open) return; if (open.walk) open.walk.abort(); document.removeEventListener("keydown", onOverlayKey); disconnectTileObserver(); const focus = open.returnFocus; open.root.remove(); open = null; if (focus && focus.isConnected) focus.focus(); } function onOverlayKey(e) { // A lightbox or message action sheet stacked on top owns Escape first — // same guard chat.js uses (see its keydown handlers) — otherwise closing // the top layer also closes the media overlay underneath it. if (e.key === "Escape" && open && !document.querySelector(".lightbox, .msg-sheet")) { e.preventDefault(); // Escape in a non-empty search clears the search first (the field's own // convention); a second Escape closes the overlay. const input = open.root.querySelector(".media-search input"); if (input && document.activeElement === input && input.value) { input.value = ""; open.query = ""; open.repaint(); return; } closeMediaView(); } } // Auto-run the resumable per-thread walk so the tabs become COMPLETE, not // just cached (spec § Completeness). Quiet when already complete. async function autoBackfill(tid, progress, progressText) { const mark = await storeBackfillMark(tid); if (!openIs(tid) || (mark && mark.complete)) return; const controller = new AbortController(); open.walk = controller; progress.classList.remove("hidden"); progressText.textContent = "Loading earlier messages…"; try { await storeRunBackfill(tid, (before) => fetchMessagePage(tid, before), { signal: controller.signal, onPage: async ({ oldestAt, rows }) => { await mediaIdx.ensureThread(tid, rows || []); if (!openIs(tid)) return; const d = oldestAt ? new Date(oldestAt * 1000) : null; progressText.textContent = d ? `Loading earlier messages… back to ${d.toLocaleDateString([], { month: "long", year: "numeric" })}` : "Loading earlier messages…"; open.repaint(); }, }); } finally { if (openIs(tid)) { open.walk = null; progress.classList.add("hidden"); open.repaint(); } } } // ---- the three tab renderers ---------------------------------------------- // Month sections wrap each tab the same way; the item painter differs. function monthSections(body, entries, paintItem, containerClass) { for (const bucket of bucketByMonth(entries)) { const h = document.createElement("div"); h.className = "media-month"; h.textContent = bucket.label; body.appendChild(h); const wrap = document.createElement("div"); wrap.className = containerClass; for (const e of bucket.items) wrap.appendChild(paintItem(e)); body.appendChild(wrap); } } // Lazy blob loading: tiles carry their blurhash/aspect immediately; the blob // decrypts only when scrolled into view. One observer per repaint — see // disconnectTileObserver, called before every rebuild so detached tiles from // the previous paint (tab switch, live invalidation, each backfill page) // aren't retained by a still-watching observer. let tileObserver = null; function observeTile(el, onVisible) { if (!tileObserver) { tileObserver = new IntersectionObserver( (ents) => { for (const en of ents) { if (!en.isIntersecting) continue; tileObserver.unobserve(en.target); en.target._onVisible && en.target._onVisible(); } }, { rootMargin: "200px" }, ); } el._onVisible = onVisible; tileObserver.observe(el); } function disconnectTileObserver() { if (!tileObserver) return; tileObserver.disconnect(); tileObserver = null; } function renderMediaTab(body, entries, tid) { monthSections( body, entries, (e) => { const a = e.att; const tile = document.createElement("button"); tile.type = "button"; tile.className = "media-tile"; const isVideo = (a.type || "").startsWith("video/"); tile.setAttribute("aria-label", `${isVideo ? "Video" : "Photo"} from ${e.name || "someone"}, ${relTime(Math.floor(e.ts / 1000))}`); const img = document.createElement("img"); img.alt = ""; img.draggable = false; if (a.w && a.h) img.style.aspectRatio = `${a.w} / ${a.h}`; // BlurHash ghost until the blob decrypts — same recipe as the feed's // photo bubbles (chat.js), painted as the img's background. if (a.bh) { try { const pw = 32, ph = a.w && a.h ? Math.max(1, Math.round((32 * a.h) / a.w)) : 32; const px = decodeBlurhash(a.bh, pw, ph); if (px) { const c = document.createElement("canvas"); c.width = pw; c.height = ph; c.getContext("2d").putImageData(new ImageData(px, pw, ph), 0, 0); img.style.backgroundImage = `url(${c.toDataURL()})`; img.style.backgroundSize = "cover"; } } catch { /* a bad hash keeps the flat placeholder */ } } tile.appendChild(img); if (isVideo && a.dur) { const badge = document.createElement("span"); badge.className = "media-dur"; const s = Math.max(0, Math.round(a.dur)); badge.textContent = `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; tile.appendChild(badge); } observeTile(tile, async () => { // Videos show their (much smaller) poster frame; images their blob. const att = isVideo && a.poster ? { id: a.poster, type: "image/jpeg" } : a; const url = await deps.loadBlob(tid, att); if (url) img.src = url; }); tile.dataset.sid = e.sid; tile.dataset.att = a.id; tile.onclick = async () => { // The whole tab's current media list becomes the swipe collection — // videos ride their poster still, same as the single-tap path below. const all = mediaIdx.tab(tid, "media", { hiddenUids: hiddenUids() }); const items = all.map((x) => { const xa = x.att; const still = (xa.type || "").startsWith("video/") && xa.poster ? { id: xa.poster, type: "image/jpeg" } : xa; return { load: () => deps.loadBlob(tid, still), source: () => document.querySelector(`.media-tile[data-sid="${x.sid}"][data-att="${xa.id}"] img`), }; }); const index = all.findIndex((x) => x.sid === e.sid && x.att.id === a.id); // A tile can outlive its index entry — e.g. the message got deleted // or its sender muted between paint and this tap resolving. if (index < 0) return; const url = await items[index].load(); if (url) openLightbox(url, img, { collection: { items, index } }); }; // The jump button is a SIBLING of the tile, never a descendant — a //