eleven / ocho Public

web: Media, links and docs — per-thread overlay (spec, plan, PR 1 of 3) (#1424)

* docs: Media, Links & Docs design spec

Approved design for the per-thread media/links/docs view: full-pane
overlay off the thread kebab, client-side extraction over decrypted
payloads (zero server changes), auto-backfill on open, lightbox
collection swipe, and the iOS/Mac parity plan. Pinned messages named
as a follow-up with an extensible tab bar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: Media/Links/Docs web implementation plan (PRs 1-2)

Task-by-task plan for the extractor + overlay PR and the lightbox
collection-swipe PR, grounded in the real seams (store.js backfill,
searchui decrypt/jump, shell host bridge, kebab wiring). Native plan
follows once web lands, using the extractor tests as parity fixtures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: threadmedia.js — pure media/links/docs extractor + index

* web: export jumpToMessage + fetchMessagePage for the media overlay

* web: mediaview.js — the Media/Links/Docs overlay

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: mediaview.js — fix nested-interactive Show-in-chat button

The overlay's per-item jump button was appended INSIDE each tile <button>
and link/doc <a> — invalid HTML (interactive content can't contain
interactive descendants), flagged nested-interactive by axe/Lighthouse,
and flattened or hidden by screen readers. Wrap each item in a
non-interactive .media-item div and append the jump button as a sibling
of the primary control instead; adjust .media-jump positioning/hover-
reveal CSS to key off the wrapper rather than the (no longer ancestor)
tile/row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: Media/Links/Docs kebab entry + shell wiring + changelog

* web: media overlay fix wave — escape guard, observer lifecycle, live repaint, focus (final review)

- onOverlayKey: ignore Escape when a stacked .lightbox/.msg-sheet owns it,
  same guard chat.js uses, so closing the top layer doesn't also close the
  media overlay underneath.
- tileObserver: disconnect+null before every full repaint (tab switch,
  invalidation, each backfill page) and on closeMediaView, so detached
  tiles aren't retained across pages.
- mediaInvalidate repaints an open overlay on the same thread so deletes/
  edits drop live, matching the spec. Added mediaModerationChanged(),
  called from shell.js's existing onModerationChange sink (mediaview.js
  can't register its own — that sink is single-slot and would replace
  shell's) so mutes/blocks repaint too.
- open.repaint now captures/restores focus by a stable key (tab id, back/
  stop button, or an item's data-sid) across the DOM rebuild each backfill
  page does, falling back to the back button only when the focused element
  is gone and focus fell to <body>.
- style.css: .media-stop gets min-height: 24px (borderline target size).
- spec: record v1 web reality for video tiles — poster still in the
  lightbox, not the in-grid player; playing rides Show in chat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell pushed by codex agent, operated by paul@keymail.dev 03eb67165494cdd26528edc8cec05ae383472b3d parent f5b9e17
11 files changed, +2063 −24
  • changelog/2026-08-05-media-links-docs.md +5 −0
  • docs/superpowers/plans/2026-08-05-media-links-docs-web.md +1047 −0
  • docs/superpowers/specs/2026-08-05-media-links-docs-design.md +237 −0
  • package.json +1 −1
  • test/threadmedia.test.mjs +103 −0
  • web/pages/index.html +1 −0
  • web/static/mediaview.js +461 −0
  • web/static/searchui.js +2 −2
  • web/static/shell.js +40 −21
  • web/static/style.css +52 −0
  • web/static/threadmedia.js +114 −0
diff --git a/changelog/2026-08-05-media-links-docs.md b/changelog/2026-08-05-media-links-docs.md
new file mode 100644
index 0000000..2785770
--- /dev/null
+++ b/changelog/2026-08-05-media-links-docs.md
@@ -0,0 +1,5 @@
+🤖 Every chat now has a "Media, links and docs" view — open the ⋮ menu in any
+conversation to browse all the photos and videos, links, and files ever shared
+there, grouped by month. The first open quietly fetches the chat's older
+history (you can stop it); after that it's instant. Tap any item to open it,
+or use ↩ to jump to where it was shared.
diff --git a/docs/superpowers/plans/2026-08-05-media-links-docs-web.md b/docs/superpowers/plans/2026-08-05-media-links-docs-web.md
new file mode 100644
index 0000000..30e608a
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-05-media-links-docs-web.md
@@ -0,0 +1,1047 @@
+# 🤖 Media, Links & Docs (web) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** A per-thread "Media, links and docs" overlay (Media | Links | Docs tabs) opened from the thread kebab, built entirely client-side over decrypted payloads, with auto-backfill on open — plus lightbox collection swipe. Spec: `docs/superpowers/specs/2026-08-05-media-links-docs-design.md`.
+
+**Architecture:** A new pure extractor + in-memory index (`web/static/threadmedia.js`, Node-tested) feeds a new overlay UI module (`web/static/mediaview.js`) that mounts over `#thread-view`. Data comes from the existing local store (`store.js` `allMessages` + `runBackfill`); decryption uses the same injected-`decryptRow` shape as `search.js`. Zero server changes; zero event-contract changes.
+
+**Tech Stack:** Vanilla ES modules, no build step. IndexedDB via `store.js` only. Tests are plain Node scripts under `test/`.
+
+**Scope:** This plan covers the two web PRs (PR 1: extractor + overlay; PR 2: lightbox collection swipe). The native iOS/Mac PR gets its own plan once this lands — its Swift parity fixtures are generated from this plan's extractor fixtures, so sequencing is deliberate.
+
+## Global Constraints
+
+- **E2EE rule:** the server only ever sees ciphertext. Nothing here talks to any new endpoint; the extracted index lives in memory only — never IndexedDB, never localStorage.
+- **Never `innerHTML` untrusted content** (filenames, URLs, titles, sender names) — build DOM nodes / `textContent`. `innerHTML` only for static in-code strings (e.g. `ICON_X` from `icons.js`).
+- **Never a floating toast** — progress/status render inline, occupying layout.
+- **WCAG 2.2 AA:** keyboard-operable, visible managed focus, labelled icon-only controls, ≥24px targets, `prefers-reduced-motion` respected, state never color alone.
+- **Counts pluralise and localise:** `toLocaleString()` on digits; "1 photo" / "2 photos" — never `n + " things"`.
+- **Node-testable modules use relative imports** (`import { tokenizeText } from "./format.js"`) — tests import `../web/static/<name>.js` directly and `/static/...` paths don't resolve in Node. No DOM access in these modules.
+- **JS isn't run by tests** beyond the Node suites: after editing any web file, run `node --check web/static/<file>.js`.
+- **Comment the why**, matching the surrounding density. New prose is 🤖-marked (changelog first line).
+- Branch per PR off `main` in a worktree; commits prefixed `web:` / `docs:`; squash-merge.
+
+---
+
+# PR 1 — extractor + overlay (branch: `media-links-docs`)
+
+### Task 1: `threadmedia.js` — pure extractor + in-memory index
+
+**Files:**
+- Create: `web/static/threadmedia.js`
+- Test: `test/threadmedia.test.mjs`
+- Modify: `package.json` (add the test to the `test` script chain, after `test/search.test.mjs`)
+
+**Interfaces:**
+- Consumes: `tokenizeText`, `blockTokens` from `./format.js` (both pure, import-free).
+- Produces (later tasks rely on these exact names):
+ - `extractEntries(data, meta) -> {media, links, docs} | null` — `data` = decrypted payload, `meta = {id, ts}` (`ts` in ms). Entries carry `{sid, ts, uid, name}` + `att` (media/docs) or `url` (links).
+ - `bucketByMonth(entries) -> [{key, label, items}]` — entries already sorted newest-first.
+ - `monthLabel(ts) -> "August 2026"`.
+ - `class ThreadMediaIndex` — `constructor(decryptRow)`, `async ensureThread(tid, rows)`, `tab(tid, name, {hiddenUids})`, `counts(tid, {hiddenUids})`, `invalidate(tid, sid)`, `dropThread(tid)`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `test/threadmedia.test.mjs` (same harness shape as `test/store.test.mjs` — local `test(name, fn)` printing PASS/FAIL, `process.exit(1)` on failure):
+
+```js
+// Media, Links & Docs: the pure extractor + in-memory index
+// (web/static/threadmedia.js). Node-run: node test/threadmedia.test.mjs
+import assert from "node:assert/strict";
+import { extractEntries, bucketByMonth, monthLabel, ThreadMediaIndex } from "../web/static/threadmedia.js";
+
+let failures = 0;
+async function test(name, fn) {
+ try {
+ await fn();
+ console.log("PASS " + name);
+ } catch (e) {
+ console.log("FAIL " + name + " — " + (e && e.message));
+ failures++;
+ }
+}
+
+const meta = { id: 42, ts: 1754000000000 };
+
+await test("classifies image, gif, video as media; other files as docs", () => {
+ const out = extractEntries({ uid: "u1", name: "Ada", text: "", ts: 1754000000000, attachments: [
+ { id: "b1", type: "image/jpeg", w: 100, h: 50 },
+ { id: "b2", type: "image/gif" },
+ { id: "b3", type: "video/mp4", poster: "p3", dur: 12 },
+ { id: "b4", type: "application/pdf", name: "notes.pdf", size: 900 },
+ ] }, meta);
+ assert.equal(out.media.length, 3);
+ assert.equal(out.docs.length, 1);
+ assert.equal(out.docs[0].att.name, "notes.pdf");
+ assert.equal(out.media[0].sid, 42);
+});
+
+await test("extracts EVERY link, not just the first", () => {
+ const out = extractEntries({ uid: "u1", name: "Ada", ts: 1, text: "see https://a.example and https://b.example" }, meta);
+ assert.deepEqual(out.links.map((l) => l.url), ["https://a.example", "https://b.example"]);
+});
+
+await test("links inside fenced code blocks don't extract (matches firstLink)", () => {
+ const out = extractEntries({ uid: "u1", name: "A", ts: 1, text: "```\nhttps://code.example\n```\nhttps://real.example" }, meta);
+ assert.equal(out.links.length, 1);
+ assert.equal(out.links[0].url, "https://real.example");
+});
+
+await test("app events, reactions, and entry-less messages return null", () => {
+ assert.equal(extractEntries({ type: "todo.add", text: "https://x.example" }, meta), null);
+ assert.equal(extractEntries({ uid: "u", name: "A", text: "plain words", ts: 1 }, meta), null);
+ assert.equal(extractEntries(null, meta), null);
+});
+
+await test("attachment without a blob id is skipped", () => {
+ assert.equal(extractEntries({ uid: "u", name: "A", text: "", ts: 1, attachments: [{ type: "image/png" }] }, meta), null);
+});
+
+await test("bucketByMonth groups consecutive same-month entries, keeps order", () => {
+ const aug = Date.UTC(2026, 7, 10), jul = Date.UTC(2026, 6, 1);
+ const buckets = bucketByMonth([
+ { sid: 3, ts: aug }, { sid: 2, ts: aug }, { sid: 1, ts: jul },
+ ]);
+ assert.equal(buckets.length, 2);
+ assert.equal(buckets[0].items.length, 2);
+ assert.equal(buckets[0].label, monthLabel(aug));
+ assert.equal(buckets[1].items[0].sid, 1);
+});
+
+await test("index folds rows once, sorts newest-first, filters hidden senders at query time", async () => {
+ const rows = [
+ { id: 1, created_at: 100, blob: "x" },
+ { id: 2, created_at: 200, blob: "y" },
+ { id: 2, created_at: 200, blob: "y" }, // duplicate fold — seen-set dedupes
+ { id: 3, created_at: 300, blob: "z", tombstone_of: 1 }, // tombstone row — skipped
+ ];
+ const payloads = {
+ 1: { uid: "muted", name: "M", ts: 100000, text: "", attachments: [{ id: "a", type: "image/png" }] },
+ 2: { uid: "ok", name: "O", ts: 200000, text: "", attachments: [{ id: "b", type: "image/png" }] },
+ };
+ const idx = new ThreadMediaIndex(async (row) => payloads[row.id] || null);
+ await idx.ensureThread("t1", rows);
+ await idx.ensureThread("t1", rows); // idempotent
+ assert.deepEqual(idx.tab("t1", "media", { hiddenUids: new Set() }).map((e) => e.sid), [2, 1]);
+ assert.deepEqual(idx.tab("t1", "media", { hiddenUids: new Set(["muted"]) }).map((e) => e.sid), [2]);
+ assert.equal(idx.counts("t1", { hiddenUids: new Set(["muted"]) }).media, 1);
+});
+
+await test("invalidate drops a message's entries and lets it re-fold (edit path)", async () => {
+ const payloads = { 5: { uid: "u", name: "A", ts: 1, text: "https://old.example" } };
+ const idx = new ThreadMediaIndex(async (row) => payloads[row.id]);
+ await idx.ensureThread("t1", [{ id: 5, created_at: 1, blob: "x" }]);
+ assert.equal(idx.tab("t1", "links", { hiddenUids: new Set() })[0].url, "https://old.example");
+ payloads[5] = { uid: "u", name: "A", ts: 1, text: "https://new.example" };
+ idx.invalidate("t1", 5);
+ await idx.ensureThread("t1", [{ id: 5, created_at: 1, blob: "x" }]);
+ assert.deepEqual(idx.tab("t1", "links", { hiddenUids: new Set() }).map((l) => l.url), ["https://new.example"]);
+});
+
+await test("decrypt failure on one row doesn't poison the rest", async () => {
+ const idx = new ThreadMediaIndex(async (row) => {
+ if (row.id === 1) throw new Error("bad key");
+ return { uid: "u", name: "A", ts: 1, text: "", attachments: [{ id: "b", type: "image/png" }] };
+ });
+ await idx.ensureThread("t1", [{ id: 1, created_at: 1, blob: "x" }, { id: 2, created_at: 2, blob: "y" }]);
+ assert.equal(idx.tab("t1", "media", { hiddenUids: new Set() }).length, 1);
+});
+
+process.exit(failures ? 1 : 0);
+```
+
+- [ ] **Step 2: Run tests, verify they fail**
+
+Run: `node test/threadmedia.test.mjs`
+Expected: FAIL — `Cannot find module .../web/static/threadmedia.js`
+
+- [ ] **Step 3: Implement `web/static/threadmedia.js`**
+
+```js
+// Media, Links & Docs — the pure extraction + in-memory per-thread index
+// behind the thread's media overlay (mediaview.js). Same posture as the
+// search index (search.js): rows decrypt lazily into MEMORY, never to disk —
+// the store deliberately holds only ciphertext at rest. decryptRow is
+// injected so this module stays free of keys and Node-testable.
+// Relative import: Node tests load this file directly.
+import { tokenizeText, blockTokens } from "./format.js";
+
+// One decrypted payload -> its media/links/docs entries, or null when the
+// message contributes nothing. Typed app events and reactions are invisible
+// here (same rule as search); tombstones never reach us (index skips them).
+export function extractEntries(data, meta) {
+ if (!data || data.type) return null;
+ const base = { sid: meta.id, ts: data.ts || meta.ts || 0, uid: data.uid || "", name: data.name || "" };
+ const media = [];
+ const docs = [];
+ const links = [];
+ for (const a of data.attachments || []) {
+ if (!a || !a.id) continue;
+ const t = a.type || "";
+ if (t.startsWith("image/") || t.startsWith("video/")) media.push({ ...base, att: a });
+ else docs.push({ ...base, att: a });
+ }
+ // Every link in the prose — the feed previews only the first (firstLink),
+ // but the Links tab is an inventory. Fenced code is skipped for the same
+ // reason firstLink skips it: a URL pasted as code isn't a shared link.
+ if (data.text) {
+ for (const b of blockTokens(data.text)) {
+ if (b.type === "code") continue;
+ for (const tok of tokenizeText(b.text)) if (tok.href) links.push({ ...base, url: tok.href });
+ }
+ }
+ if (!media.length && !docs.length && !links.length) return null;
+ return { media, links, docs };
+}
+
+export function monthLabel(ts) {
+ return new Date(ts).toLocaleDateString([], { month: "long", year: "numeric" });
+}
+
+// Group an already-sorted (newest-first) entry list into month sections.
+// Consecutive grouping is enough BECAUSE the input is sorted.
+export function bucketByMonth(entries) {
+ const out = [];
+ for (const e of entries) {
+ const d = new Date(e.ts || 0);
+ const key = d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0");
+ if (!out.length || out[out.length - 1].key !== key) out.push({ key, label: monthLabel(e.ts || 0), items: [] });
+ out[out.length - 1].items.push(e);
+ }
+ return out;
+}
+
+export class ThreadMediaIndex {
+ constructor(decryptRow) {
+ this.decryptRow = decryptRow; // async (row, tid) -> payload | null
+ this.threads = new Map(); // tid -> { seen:Set<sid>, media:[], links:[], docs:[] }
+ }
+
+ bucket(tid) {
+ let b = this.threads.get(tid);
+ if (!b) {
+ b = { seen: new Set(), media: [], links: [], docs: [] };
+ this.threads.set(tid, b);
+ }
+ return b;
+ }
+
+ // Fold rows not yet seen. Idempotent per sid; a failed decrypt still marks
+ // seen (same as search — a row that won't decrypt now won't decrypt later).
+ async ensureThread(tid, rows) {
+ const b = this.bucket(tid);
+ for (const row of rows || []) {
+ if (!row || !row.id || b.seen.has(row.id) || row.tombstone_of) continue;
+ b.seen.add(row.id);
+ let data = null;
+ try {
+ data = await this.decryptRow(row, tid);
+ } catch {}
+ if (!data) continue;
+ const ex = extractEntries(data, { id: row.id, ts: (row.created_at || 0) * 1000 });
+ if (!ex) continue;
+ b.media.push(...ex.media);
+ b.links.push(...ex.links);
+ b.docs.push(...ex.docs);
+ }
+ for (const k of ["media", "links", "docs"]) b[k].sort((x, y) => y.sid - x.sid);
+ }
+
+ // Moderation filters at QUERY time so un-muting needs no re-scan.
+ tab(tid, name, { hiddenUids }) {
+ return this.bucket(tid)[name].filter((e) => !hiddenUids.has(e.uid));
+ }
+
+ counts(tid, opts) {
+ return {
+ media: this.tab(tid, "media", opts).length,
+ links: this.tab(tid, "links", opts).length,
+ docs: this.tab(tid, "docs", opts).length,
+ };
+ }
+
+ // An edit re-extracts (links can change); a delete just never re-folds.
+ invalidate(tid, sid) {
+ const b = this.threads.get(tid);
+ if (!b) return;
+ b.seen.delete(sid);
+ for (const k of ["media", "links", "docs"]) b[k] = b[k].filter((e) => e.sid !== sid);
+ }
+
+ dropThread(tid) {
+ this.threads.delete(tid);
+ }
+}
+```
+
+- [ ] **Step 4: Run tests, verify they pass**
+
+Run: `node test/threadmedia.test.mjs` — expected: all PASS, exit 0.
+Run: `node --check web/static/threadmedia.js`
+
+- [ ] **Step 5: Add to the suite and commit**
+
+In `package.json`, insert `&& node test/threadmedia.test.mjs` immediately after `&& node test/search.test.mjs` in the `test` script. Run `npm test` (full chain passes).
+
+```bash
+git add web/static/threadmedia.js test/threadmedia.test.mjs package.json
+git commit -m "web: threadmedia.js — pure media/links/docs extractor + index"
+```
+
+### Task 2: export search's jump + page-fetch for reuse
+
+**Files:**
+- Modify: `web/static/searchui.js` (two keywords only)
+
+**Interfaces:**
+- Produces: `export async function jumpToMessage(tid, sid)` and `export async function fetchMessagePage(threadId, before)` — exact functions that already exist at `searchui.js:389` and `searchui.js:378`; Task 3 imports both.
+
+- [ ] **Step 1: Add `export` to the two declarations**
+
+In `web/static/searchui.js`, change `async function fetchMessagePage(` → `export async function fetchMessagePage(` and `async function jumpToMessage(` → `export async function jumpToMessage(`. No other change — their bodies already work off `wireSearch`-injected deps, which shell.js wires unconditionally at boot (`shell.js:217`).
+
+- [ ] **Step 2: Verify**
+
+Run: `node --check web/static/searchui.js` and `npm test` (both pass; nothing imports the new exports yet).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add web/static/searchui.js
+git commit -m "web: export jumpToMessage + fetchMessagePage for the media overlay"
+```
+
+### Task 3: `mediaview.js` — the overlay UI
+
+**Files:**
+- Create: `web/static/mediaview.js`
+- Modify: `web/static/style.css` (append the `.media-overlay` block at the end)
+
+**Interfaces:**
+- Consumes: `ThreadMediaIndex`, `bucketByMonth` (Task 1); `jumpToMessage`, `fetchMessagePage` (Task 2); `allMessages`, `getBackfillMark`, `runBackfill` from `/static/store.js`; `decrypt` from `/static/crypto.js`; `threadKeys`, `threadName` from `/static/shellstate.js`; `mutedPeople`, `blockedPeople` from `/static/moderation.js`; `formatBytes`, `relTime` from `/static/format.js`; `openLightbox` from `/static/lightbox.js`.
+- Produces (Task 4 relies on): `wireMediaView({ loadBlob, unfurl })`, `openMediaView(tid)`, `mediaIdx` (the shared `ThreadMediaIndex` instance), `mediaInvalidate(tid, sid)`.
+
+This module is DOM-heavy, so it is exercised by hand (Task 5) plus `node --check`; all decision logic already lives in Task 1's tested module.
+
+- [ ] **Step 1: Write `web/static/mediaview.js`**
+
+```js
+// 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 } 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).
+export function mediaInvalidate(tid, sid) {
+ mediaIdx.invalidate(tid, sid);
+}
+
+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 } — one overlay at a time
+
+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", 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");
+ 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, 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);
+ body.replaceChildren();
+ body.setAttribute("role", "tabpanel");
+ body.setAttribute("aria-labelledby", `media-tab-${open.tab}`);
+ const entries = mediaIdx.tab(tid, open.tab, { hiddenUids: hiddenUids() });
+ if (!entries.length) {
+ const empty = document.createElement("div");
+ empty.className = "media-empty";
+ empty.textContent = { 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 = () => {
+ paintTabs();
+ paintBody();
+ };
+
+ // 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;
+}
+
+export function closeMediaView() {
+ if (!open) return;
+ if (open.walk) open.walk.abort();
+ document.removeEventListener("keydown", onOverlayKey);
+ const focus = open.returnFocus;
+ open.root.remove();
+ open = null;
+ if (focus && focus.isConnected) focus.focus();
+}
+
+function onOverlayKey(e) {
+ if (e.key === "Escape" && open) {
+ e.preventDefault();
+ 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 overlay open.
+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 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.onclick = async () => {
+ const url = await deps.loadBlob(tid, isVideo && a.poster ? { id: a.poster, type: "image/jpeg" } : a);
+ if (url) openLightbox(url, img); // PR 2 upgrades this to collection swipe
+ };
+ addShowInChat(tile, tid, e.sid);
+ return tile;
+ },
+ "media-grid",
+ );
+}
+
+function renderLinksTab(body, entries, tid) {
+ monthSections(
+ body,
+ entries,
+ (e) => {
+ const row = document.createElement("a");
+ row.className = "media-link-row";
+ row.href = e.url;
+ row.target = "_blank";
+ row.rel = "noopener noreferrer";
+ const urlEl = document.createElement("span");
+ urlEl.className = "media-link-url";
+ urlEl.textContent = e.url;
+ const who = document.createElement("span");
+ who.className = "media-item-who";
+ who.textContent = `${e.name || "Someone"} · ${relTime(Math.floor(e.ts / 1000))}`;
+ row.append(urlEl, who);
+ // The unfurl card fills in lazily; a failed unfurl leaves the plain row.
+ observeTile(row, async () => {
+ const card = await deps.unfurl(e.url);
+ if (!card || !card.ok || !card.title) return;
+ const t = document.createElement("span");
+ t.className = "media-link-title";
+ t.textContent = card.title;
+ row.prepend(t);
+ });
+ addShowInChat(row, tid, e.sid);
+ return row;
+ },
+ "media-list",
+ );
+}
+
+function renderDocsTab(body, entries, tid) {
+ monthSections(
+ body,
+ entries,
+ (e) => {
+ const a = e.att;
+ const row = document.createElement("a");
+ row.className = "media-doc-row";
+ row.href = "#";
+ row.download = a.name || "file";
+ row.addEventListener("click", (ev) => {
+ if (row.getAttribute("href") === "#") ev.preventDefault();
+ });
+ const nm = document.createElement("span");
+ nm.className = "media-doc-name";
+ nm.textContent = a.name || "file";
+ const sub = document.createElement("span");
+ sub.className = "media-item-who";
+ sub.textContent = [a.size ? formatBytes(a.size) : "", `${e.name || "Someone"} · ${relTime(Math.floor(e.ts / 1000))}`]
+ .filter(Boolean)
+ .join(" · ");
+ row.append(nm, sub);
+ // The blob decrypts on approach so the tap is an instant save.
+ observeTile(row, async () => {
+ const url = await deps.loadBlob(tid, a);
+ if (url) row.href = url;
+ });
+ addShowInChat(row, tid, e.sid);
+ return row;
+ },
+ "media-list",
+ );
+}
+
+// Every item's second affordance: land on the source message. A visible
+// sibling button (≥24px target) rather than a long-press-only gesture, so
+// it's reachable by keyboard and screen reader too.
+function addShowInChat(el, tid, sid) {
+ const b = document.createElement("button");
+ b.type = "button";
+ b.className = "media-jump";
+ b.setAttribute("aria-label", "Show in chat");
+ b.textContent = "↩";
+ b.onclick = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ closeMediaView();
+ jumpToMessage(tid, sid);
+ };
+ el.appendChild(b);
+}
+```
+
+- [ ] **Step 2: Append the styles to `web/static/style.css`**
+
+```css
+/* ---- Media, links & docs overlay (mediaview.js) ---- */
+.media-overlay {
+ position: absolute;
+ inset: 0;
+ z-index: 30; /* same layer as the reply-thread overlay */
+ display: flex;
+ flex-direction: column;
+ background: var(--bg, #fff);
+}
+.thread-view { position: relative; } /* the overlay bounds to the thread pane */
+.media-head { display: flex; align-items: center; gap: 8px; padding: 10px 12px; }
+.media-title { font-weight: 600; }
+.media-tabs { display: flex; gap: 6px; padding: 0 12px 10px; }
+.media-tab {
+ padding: 6px 12px;
+ border-radius: 999px;
+ border: 1px solid var(--border, #ccc);
+ background: none;
+ cursor: pointer;
+ min-height: 32px;
+}
+.media-tab.on { background: var(--accent, #7c5cff); color: #fff; border-color: transparent; }
+.media-progress { display: flex; align-items: center; gap: 10px; padding: 6px 14px; font-size: 0.85em; opacity: 0.8; }
+.media-stop { border: none; background: none; text-decoration: underline; cursor: pointer; padding: 4px 8px; }
+.media-body { flex: 1; overflow-y: auto; padding: 0 12px 16px; }
+.media-month { font-size: 0.8em; opacity: 0.65; margin: 14px 2px 6px; }
+.media-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; }
+@media (min-width: 900px) { .media-grid { grid-template-columns: repeat(4, 1fr); } }
+.media-tile { position: relative; border: none; padding: 0; background: var(--bubble, #eee); border-radius: 6px; overflow: hidden; cursor: zoom-in; aspect-ratio: 1; }
+.media-tile img { width: 100%; height: 100%; object-fit: cover; display: block; }
+.media-dur { position: absolute; right: 6px; bottom: 6px; background: rgba(0, 0, 0, 0.65); color: #fff; font-size: 0.72em; padding: 1px 6px; border-radius: 4px; }
+.media-list { display: flex; flex-direction: column; gap: 6px; }
+.media-link-row, .media-doc-row { position: relative; display: flex; flex-direction: column; gap: 2px; padding: 10px 44px 10px 12px; border: 1px solid var(--border, #ddd); border-radius: 10px; text-decoration: none; color: inherit; }
+.media-link-title { font-weight: 600; }
+.media-link-url { font-size: 0.85em; opacity: 0.75; overflow-wrap: anywhere; }
+.media-doc-name { font-weight: 500; overflow-wrap: anywhere; }
+.media-item-who { font-size: 0.78em; opacity: 0.6; }
+.media-jump { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); min-width: 28px; min-height: 28px; border: none; background: none; cursor: pointer; opacity: 0.6; border-radius: 6px; }
+.media-tile .media-jump { top: auto; bottom: 4px; transform: none; background: rgba(0, 0, 0, 0.5); color: #fff; opacity: 0; }
+.media-tile:hover .media-jump, .media-tile:focus-within .media-jump { opacity: 1; }
+.media-jump:hover, .media-jump:focus-visible { opacity: 1; }
+.media-empty { text-align: center; opacity: 0.6; padding: 40px 0; }
+```
+
+Match the file's real custom-property names while pasting — open `web/static/style.css` and use the variables the thread pane already uses (search for `--bg` / the thread-view rules) rather than inventing new ones; the fallbacks above are placeholders for whatever the sheet actually defines.
+
+- [ ] **Step 3: Verify + commit**
+
+Run: `node --check web/static/mediaview.js` — passes.
+
+```bash
+git add web/static/mediaview.js web/static/style.css
+git commit -m "web: mediaview.js — the Media/Links/Docs overlay"
+```
+
+### Task 4: shell + kebab wiring, live invalidation, changelog
+
+**Files:**
+- Modify: `web/pages/index.html` (one button)
+- Modify: `web/static/shell.js` (import, wire, kebab handler, header paint, invalidation)
+- Create: `changelog/2026-08-05-media-links-docs.md`
+
+**Interfaces:**
+- Consumes: `wireMediaView`, `openMediaView`, `closeMediaView`, `mediaInvalidate` from Task 3.
+
+- [ ] **Step 1: Add the kebab item**
+
+In `web/pages/index.html`, directly after the `thread-members` button (line ~238), add:
+
+```html
+<button class="menu-item" id="thread-media" role="menuitem">Media, links and docs</button>
+```
+
+- [ ] **Step 2: Wire shell.js**
+
+1. With the other module imports (near `import { scheduleMessageSearch, ... }` at shell.js:157):
+
+```js
+import { wireMediaView, openMediaView, closeMediaView, mediaInvalidate } from "/static/mediaview.js"; // Media/Links/Docs overlay
+```
+
+2. Next to the `wireSearch({...})` call (shell.js:217), inject the same functions the host bridge uses (the arrow matches the bridge entry at shell.js:5321; find the bridge's `unfurl:` entry and pass the same underlying function):
+
+```js
+wireMediaView({ loadBlob: (threadId, att) => loadBlob(att.id, threadId, att.type), unfurl });
+```
+
+(If the shell's unfurl helper has a different local name, grep `unfurl:` in the host-bridge object and pass that function.)
+
+3. Kebab handler, beside the others (shell.js:3116):
+
+```js
+document.getElementById("thread-media").onclick = () => {
+ closeTMenu();
+ openMediaView(activeId);
+};
+```
+
+4. Visibility in `paintThreadHeader` (beside the `thread-apps` line, shell.js:3807): the item shows for every chat-feed thread — DMs included, so NOT in the group-only loop — and hides on app-threads:
+
+```js
+const mediaItem = document.getElementById("thread-media");
+if (mediaItem) mediaItem.style.display = chatAppFor(id) ? "" : "none";
+```
+
+5. Live invalidation: `grep -n "storeApplyEditRow\|storeDeleteMessageRow\|applyEditRow\|deleteMessageRow" web/static/shell.js` — beside each call that mirrors a live edit/delete frame into the store, add `mediaInvalidate(threadId, id)` with the same thread/message ids. Also close the overlay when the user navigates threads: in `openThread` (grep `function openThread`), add `closeMediaView()` at the top.
+
+- [ ] **Step 3: Changelog entry**
+
+Create `changelog/2026-08-05-media-links-docs.md`:
+
+```markdown
+🤖 Every chat now has a "Media, links and docs" view — open the ⋮ menu in any
+conversation to browse all the photos and videos, links, and files ever shared
+there, grouped by month. The first open quietly fetches the chat's older
+history (you can stop it); after that it's instant. Tap any item to open it,
+or use ↩ to jump to where it was shared.
+```
+
+- [ ] **Step 4: Verify + commit**
+
+Run: `node --check web/static/shell.js`, then `npm test` and `go test ./...` (all green — no server change, so Go is untouched but cheap to confirm).
+
+```bash
+git add web/pages/index.html web/static/shell.js changelog/2026-08-05-media-links-docs.md
+git commit -m "web: Media/Links/Docs kebab entry + shell wiring + changelog"
+```
+
+### Task 5: PR 1 verification
+
+- [ ] **Step 1: Full suites** — `npm test`, `go test ./...`, `node --check` on every touched JS file.
+- [ ] **Step 2: Manual verification on a live canary** (Paul's stored preference — `./deploy/ops canary create <slug>`, `ops canary deploy <slug>`): seed a chat with photos, a video, several links (one inside a code fence — must NOT appear), a PDF; confirm: kebab entry in a DM *and* a group; instant open; auto-backfill progress + Stop; month headers; counts localise; Escape closes and focus returns to the kebab; ↩ lands on the source message; muting a sender hides their items; an edited-out link disappears after the edit.
+- [ ] **Step 3: Open PR 1** with the spec linked; squash-merge target `main`.
+
+---
+
+# PR 2 — lightbox collection swipe (branch: `lightbox-collection`)
+
+### Task 6: pure navigation helpers
+
+**Files:**
+- Modify: `web/static/lightbox.js` (add two exported pure helpers at top)
+- Modify: `package.json` (add the test after `test/threadmedia.test.mjs`)
+- Test: `test/lightbox.test.mjs` (new)
+
+**Interfaces:**
+- Produces: `stepIndex(i, delta, len) -> number` (clamped, no wrap) and `dragIntent(tx, ty, vx, vy) -> "nav-next" | "nav-prev" | "dismiss" | "settle"` — Task 7 consumes both.
+
+- [ ] **Step 1: Write the failing tests**
+
+```js
+// Collection-swipe decision logic for the lightbox (web/static/lightbox.js).
+import assert from "node:assert/strict";
+import { stepIndex, dragIntent } from "../web/static/lightbox.js";
+
+let failures = 0;
+function test(name, fn) {
+ try { fn(); console.log("PASS " + name); } catch (e) { console.log("FAIL " + name + " — " + e.message); failures++; }
+}
+
+test("stepIndex clamps at both ends, no wrap", () => {
+ assert.equal(stepIndex(0, -1, 5), 0);
+ assert.equal(stepIndex(4, 1, 5), 4);
+ assert.equal(stepIndex(2, 1, 5), 3);
+});
+
+test("a mostly-horizontal committed drag navigates against the drag direction", () => {
+ assert.equal(dragIntent(-120, 10, -0.8, 0), "nav-next"); // dragged left → next
+ assert.equal(dragIntent(120, -10, 0.8, 0), "nav-prev");
+});
+
+test("a fast horizontal flick navigates even at small distance", () => {
+ assert.equal(dragIntent(-40, 5, -0.7, 0), "nav-next");
+});
+
+test("a mostly-vertical drag still dismisses; a small slow drag settles", () => {
+ assert.equal(dragIntent(20, 200, 0, 0.9), "dismiss");
+ assert.equal(dragIntent(10, 8, 0.01, 0.01), "settle");
+});
+
+process.exit(failures ? 1 : 0);
+```
+
+- [ ] **Step 2: Run — fails** (`stepIndex` not exported).
+
+- [ ] **Step 3: Implement in `lightbox.js`** (top of file, before `openLightbox`):
+
+```js
+// Pure decision helpers for collection mode, exported for Node tests.
+// A horizontal gesture navigates; vertical keeps the flick-dismiss. Thresholds
+// mirror the dismiss fling's own (speed 0.55 px/ms, reach 130px).
+export function stepIndex(i, delta, len) {
+ return Math.max(0, Math.min(len - 1, i + delta));
+}
+
+export function dragIntent(tx, ty, vx, vy) {
+ const horizontal = Math.abs(tx) > Math.abs(ty) * 1.5;
+ if (horizontal && (Math.abs(tx) > 80 || Math.abs(vx) > 0.5)) return tx < 0 ? "nav-next" : "nav-prev";
+ if (Math.hypot(vx, vy) >= 0.55 || Math.hypot(tx, ty) >= 130) return "dismiss";
+ return "settle";
+}
+```
+
+- [ ] **Step 4: Run tests — pass**; `node --check web/static/lightbox.js`; add to `package.json`; `npm test`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add web/static/lightbox.js test/lightbox.test.mjs package.json
+git commit -m "web: lightbox collection nav decision helpers"
+```
+
+### Task 7: collection mode in `openLightbox`
+
+**Files:**
+- Modify: `web/static/lightbox.js`, `web/static/style.css`
+
+**Interfaces:**
+- Produces: `openLightbox(url, source, opts = {})` — `opts.collection = { items, index }`, `items: [{ load: async () => url|null, source: () => Element|null, label?: string }]`. Existing two-arg callers are untouched.
+
+- [ ] **Step 1: Extend the signature + state**
+
+Change the declaration to `export function openLightbox(url, source, opts = {})` and add after the gesture-state block:
+
+```js
+ // ---- collection mode (opts.collection): swipe/arrow between items -------
+ const coll = opts.collection || null;
+ let collIndex = coll ? coll.index : 0;
+ const liveSource = () => {
+ // The fly-out targets the CURRENT item's tile when the grid still shows
+ // it; falls back to the original tap's element (or none — plain fade).
+ if (!coll) return source;
+ const el = coll.items[collIndex].source && coll.items[collIndex].source();
+ return el && el.isConnected ? el : source;
+ };
+```
+
+Replace every later read of `source` inside `runOpenAnim`, `animateClose`, and `sourceRadius` calls with `liveSource()` (the two-arg path returns `source` unchanged).
+
+- [ ] **Step 2: The navigation action**
+
+Add beside `springBack`:
+
+```js
+ // Move to a neighbour: quick fade + src swap on the ONE img element (the
+ // FLIP fly-in stays an open/close-only effect), neighbours preloading on
+ // approach so a swipe usually lands on a ready decrypted URL.
+ let navBusy = false;
+ const navTo = async (delta) => {
+ if (!coll || navBusy) return;
+ const next = stepIndex(collIndex, delta, coll.items.length);
+ if (next === collIndex) return springBack();
+ navBusy = true;
+ const url2 = await coll.items[next].load();
+ navBusy = false;
+ if (!url2 || closing) return springBack();
+ collIndex = next;
+ scale = 1; tx = 0; ty = 0; rot = 0;
+ img.style.transition = reduceMotion ? "none" : "opacity 0.15s";
+ img.style.opacity = "0";
+ setTimeout(() => {
+ img.src = url2;
+ img.style.transform = "translate3d(0,0,0)";
+ img.style.opacity = "1";
+ setBase();
+ paintNav();
+ const n = stepIndex(collIndex, delta, coll.items.length);
+ if (n !== collIndex) coll.items[n].load(); // preload the next neighbour
+ }, reduceMotion ? 0 : 150);
+ };
+```
+
+- [ ] **Step 3: Controls**
+
+In setup (after `back.appendChild(img)`), when `coll`: add two `button.lightbox-nav` elements (`‹` `›`, `aria-label` "Previous photo" / "Next photo", hidden at the ends — `paintNav()` toggles `.hidden` from `collIndex`), wire `onclick = () => navTo(∓1)`; extend `onKey` with `ArrowLeft/ArrowRight → navTo(∓1)` guarded by `coll`; preload both neighbours once after open. In `flingOrSettle`, before the dismiss-fling branch, consult the tested helper when `coll && !pinched && scale === 1`:
+
+```js
+ const intent = coll ? dragIntent(tx, ty, vx, vy) : null;
+ if (intent === "nav-next" || intent === "nav-prev") {
+ armSuppress();
+ navTo(intent === "nav-next" ? 1 : -1);
+ return;
+ }
+```
+
+(then the existing speed-0.55/130px dismiss logic runs unchanged for `"dismiss"`/`"settle"`/non-collection). CSS: `.lightbox-nav { position: fixed; top: 50%; …; min-width: 44px; min-height: 44px; }`, hidden on `(pointer: coarse)` (touch swipes instead) — append to `style.css` beside the lightbox rules.
+
+- [ ] **Step 4: Verify** — `node test/lightbox.test.mjs`, `node --check web/static/lightbox.js`, `npm test`; hand-check both existing single-image callers (feed photo, header avatar) are byte-identical in behaviour.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add web/static/lightbox.js web/static/style.css
+git commit -m "web: lightbox collection mode — swipe/arrows between photos"
+```
+
+### Task 8: media grid adopts collections
+
+**Files:**
+- Modify: `web/static/mediaview.js` (`renderMediaTab` tap handler)
+
+**Interfaces:**
+- Consumes: `openLightbox(url, source, { collection })` from Task 7.
+
+- [ ] **Step 1: Build the collection at tap time**
+
+In `renderMediaTab`, images (and video posters) share one collection over the tab's current entries. Replace the `tile.onclick` body:
+
+```js
+ tile.onclick = async () => {
+ 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);
+ const url = await items[index].load();
+ if (url) openLightbox(url, img, { collection: { items, index } });
+ };
+```
+
+and stamp each tile in the painter: `tile.dataset.sid = e.sid; tile.dataset.att = a.id;`.
+
+- [ ] **Step 2: Verify** — `node --check web/static/mediaview.js`; `npm test`; canary hand-check: tap a photo, arrow/swipe through the whole grid, close flies back to the *current* photo's tile.
+
+- [ ] **Step 3: Commit + PR 2**
+
+```bash
+git add web/static/mediaview.js
+git commit -m "web: media grid opens the lightbox in collection mode"
+```
+
+Open PR 2. No changelog entry — PR 1's entry covers the feature; swipe is part of it landing in the same deploy window.
+
+---
+
+## After PR 2
+
+Write the native plan (`docs/superpowers/plans/<date>-media-links-docs-native.md`) using this plan's merged extractor tests as the parity-fixture source: iOS + Mac in one PR, `MessageStore` queries + a Swift link tokenizer matching `format.js` semantics in LChatCore, QuickLook multi-item for collection swipe, `HistoryBackfill` on open. Its changelog entry waits for the release mint, per convention.
diff --git a/docs/superpowers/specs/2026-08-05-media-links-docs-design.md b/docs/superpowers/specs/2026-08-05-media-links-docs-design.md
new file mode 100644
index 0000000..75a5f9c
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-05-media-links-docs-design.md
@@ -0,0 +1,237 @@
+# 🤖 Media, Links & Docs — design
+
+A per-thread view that makes everything ever *shared* in a chat — pictures,
+links, files — quick to find, for any group or DM. The WhatsApp/Telegram
+"Media, Links, Docs" mental model, built within lchat's constraint that the
+server only ever holds opaque ciphertext.
+
+Status: approved design (Paul, 2026-08-05). Implementation plan to follow.
+
+## The constraint that shapes everything
+
+The server has **zero** metadata distinguishing a message with attachments or
+links — message rows are `{id, created_at, blob}` and the blob is AES-GCM
+ciphertext (`db.go` `Message`). `blobs.thread_id` exists only for GC and can't
+be joined back to a message. So this feature is **fully client-side over
+decrypted payloads**, and requires **no server changes at all**:
+
+- **Web** builds on `web/static/store.js` — the IndexedDB ciphertext cache:
+ `allMessages(tid)` for the cached sweep, `runBackfill` for the resumable
+ deep-history walk (already used by search), decrypting in memory with the
+ thread key exactly as `searchui.js` does.
+- **Native** is cheaper still: `MessageStore.swift` already persists decrypted
+ `MessagePayload` rows in protected SQLite, so the tabs are straight queries.
+
+The extracted index lives **in memory only on web** (like `SearchIndex`) —
+`store.js` deliberately keeps only ciphertext at rest, and a plaintext media
+index in IndexedDB would break that. The sealed-index upgrade path
+(`docs/sealed-search-index.md`) covers this feature too, later.
+
+## Goals
+
+- From any thread (group or DM), open a view listing all shared **media**
+ (images, GIFs, videos), **links**, and **docs** (other files), newest first.
+- Instant on reopen; complete after a one-time per-thread history walk.
+- Jump from any item back to its message in the chat.
+- Swipe/arrow between photos in the lightbox from the media grid.
+- Ships on web and native (iOS polished first, Mac in the same native change).
+
+## Non-goals (named follow-ups)
+
+- **Pinned messages.** Doesn't exist as a feature yet ("pin" today pins the
+ *thread* in the sidebar). Building it means a new folded event type
+ (`pin`/`unpin` riding encrypted messages like reactions), event-vector
+ entries on both platforms, message-menu UI, group-permission decisions, and
+ probably a pinned banner — its own spec. The overlay's **tab bar is
+ data-driven** precisely so a Pinned tab slots in with near-zero rework once
+ the event exists; the deferral costs nothing.
+- **Feed lightbox → thread collection.** Wiring the chat feed's photo taps
+ into the same thread-wide swipeable collection is a stated follow-up; this
+ change only makes the media grid use collection swipe.
+- **Persisted (sealed) extraction index** on web — follows the sealed search
+ index work.
+- Cross-thread ("all media everywhere") views, storage-management/"largest
+ files" views, media search.
+
+## UX (web)
+
+### Entry point
+
+A new **"Media, links and docs"** item in the thread kebab, between Members
+and Apps — a `<button class="menu-item" id="thread-media">` in
+`web/pages/index.html`, handler in `shell.js`, implementation exported from
+`groupactions.js` (which owns the kebab's whole surface). **Not group-gated**:
+it renders for DMs and groups alike (so it must not join the group-only id
+loop in `paintThreadHeader`). Hidden for app-takeover threads that have no
+chat feed.
+
+### The overlay
+
+A **full-pane overlay** bounded to the feed box, patterned on the
+reply-thread overlay in `apps/chat.js` (`threadSid`, z-index 30): the chat
+stays mounted beneath; closing returns to it with focus restored to the kebab.
+Header: back control, thread name, and a segmented control **Media | Links |
+Docs**, each segment showing its count (pluralised + locale-formatted per the
+counts convention). Tabs are **data** — an ordered array of
+`{id, label, render}` — not hardcoded markup, so future tabs (Pinned) are
+additive.
+
+Accessibility per the WCAG 2.2 AA bar: the overlay is keyboard-operable
+(tab order into the segmented control and grid/list, Escape closes, focus
+managed on open/close), tiles ≥24px targets with labelled controls, works in
+light and dark, and any progress animation respects `prefers-reduced-motion`.
+All untrusted strings (filenames, link URLs, unfurl titles) rendered via
+`textContent` — never `innerHTML`.
+
+### Media tab
+
+- 3–4 column grid (responsive), grouped under **month headers**
+ ("August 2026"), newest month first.
+- Tiles render **instantly from payload metadata** before any blob is
+ fetched: aspect ratio from `w`/`h`, BlurHash placeholder from `bh`. Blobs
+ decrypt lazily via an `IntersectionObserver` — only tiles scrolled into
+ view call `host.loadBlob` (memoized in `blobURLs`). Videos use their
+ smaller **poster** blob for the tile plus a duration badge (`dur`); GIFs
+ show their still.
+- Tap → lightbox with **collection swipe** (below). 🤖 v1 reality: videos
+ open their **poster still** in the lightbox, same as photos — there's no
+ in-grid video player yet. Playing the video rides **"Show in chat"** (jump
+ to the source message, which has the existing feed player). An in-grid
+ player that plays inline from the tile is a named follow-up, not v1 scope.
+
+### Links tab
+
+- One row **per link occurrence**: all links in a message via
+ `tokenizeText(text)` `href` tokens (not just `firstLink`), newest first.
+ The same URL posted twice is two rows.
+- Row: lazily-fetched unfurl card (`host.unfurl`, memoized; graceful
+ plain-URL row when unfurl fails), the URL, sender name, date.
+- Tap opens the link (same `target=_blank rel=noopener` discipline as the
+ feed).
+
+### Docs tab
+
+- Non-image/video attachments: file-type icon, name, size
+ (`formatBytes`), sender, date; newest first.
+- Tap downloads/previews exactly as `buildFileCard` does in the feed.
+
+### Show in chat
+
+Every item (long-press on touch, hover kebab on desktop — matching the feed's
+message-action split) offers **"Show in chat"**: closes the overlay and
+reuses search's `jumpToMessage` to page back to and flash the source message.
+
+### Completeness: auto-backfill on open
+
+Opening the overlay shows cached results **instantly**, then auto-runs the
+per-thread `runBackfill` in the background, streaming each older page through
+the extractor into the tabs. A progress line (inline, occupying layout — no
+floating toast) shows the walk ("Loading earlier messages… March 2024") with
+a **Stop** control. The walk is resumable and its marks are **shared with
+search** — either feature completing the walk completes it for both; once
+marked complete, opens are instant and total. A thread mid-walk that's closed
+resumes from its mark next open.
+
+## Data flow (web)
+
+New pure module `web/static/threadmedia.js` (`media.js` is taken by the
+image-processing helpers), Node-testable like `format.js`:
+
+- `extractEntries(data, meta)` — decrypted payload + `{id, ts}` →
+ `{media: [], links: [], docs: []}` classified entries. Rules:
+ - skip app events (`data.type`) and tombstones;
+ - attachments: `type` prefix `image/` (incl. GIFs) or `video/` → media,
+ everything else → docs;
+ - links: every `href` token from `tokenizeText(data.text)`;
+ - entries carry `{sid, ts, uid, name}` plus the attachment/link fields
+ needed to render without re-decrypting the row.
+- `class ThreadMediaIndex` — in-memory per-thread accumulator mirroring
+ `SearchIndex`'s shape: `ensureThread(tid, rows)` with an injected
+ `decryptRow`, a `seen` set for idempotent re-folds, `addPage(rows)` for
+ backfill streaming, month-bucketed sorted output per tab. Invalidation:
+ edits re-extract the row (links can change); deletes drop its entries;
+ moderation-hidden senders (muted/blocked, via `moderation.js`) are
+ filtered at query time so un-muting doesn't need a re-scan.
+
+The overlay UI itself lives in a new `web/static/mediaview.js` (keeping
+`groupactions.js` from growing another whole surface), registered through the
+existing verbs/imports seams; `groupactions.js` exports the kebab entry that
+opens it.
+
+## Lightbox collection swipe
+
+`lightbox.js`'s `openLightbox` gains an optional collection form:
+`openLightbox(url, source, { items, index })` — `items` an array of
+`{url | load(), source?}`. Behaviour:
+
+- Horizontal **swipe** on touch, **←/→** on desktop, moving through the
+ collection; the FLIP fly-in/out still anchors to the tapped tile, and
+ fly-out targets the *current* item's tile when it's in view.
+- Neighbours preload lazily (decrypt-on-approach, not the whole thread).
+- Single-URL callers are untouched — existing feed/avatar behaviour is
+ byte-for-byte the same path.
+
+## Native (iOS + Mac, one PR)
+
+- **Entry**: a "Media, Links & Docs" item in the thread menu
+ (`NativeChatScreen.swift`'s `Menu`), opening a screen (sheet on iOS,
+ window-pushed view on Mac) with the same three segmented tabs.
+- **Data**: queries over `MessageStore`'s decrypted payload rows — decode,
+ filter with the existing `isImage`/`isVideo` helpers and a link tokenizer
+ matching `format.js` semantics (shared in LChatCore so iOS and Mac can't
+ drift). Month grouping and ordering match web.
+- **Completeness**: `HistoryBackfill` auto-runs on open with the same
+ progress + stop affordance, sharing its existing marks.
+- **Viewing**: the media grid feeds QuickLook's multi-item data source —
+ collection swipe is native behaviour there; files reuse `FilePreview`,
+ videos the existing player. Blur-hash placeholders via `Blurhash.swift`;
+ tiles load through `BlobImageLoader`/`BlobDataCache`.
+- Accessibility: labels/traits on tiles, survives largest Dynamic Type.
+
+Logic that both platforms must agree on (classification rules, link
+extraction) is specified by the extractor's Node tests + a matching Swift
+test fixture — same discipline as the event vectors, but no vector-file
+change: **this feature adds no event types and never touches the event
+contract.**
+
+## Edge cases
+
+- **Deleted messages**: tombstones excluded; a delete while the overlay is
+ open drops the entries live (same fold pipeline).
+- **Edits**: re-extract; a link edited out disappears from Links.
+- **Moderation**: muted/blocked senders' items hidden, live via
+ `onModerationChange`.
+- **Sparse threads**: a thread with three photos in 10k messages still walks
+ the full history once — the progress line makes the wait legible, Stop
+ bails out, and the resumable mark means it never repeats.
+- **Huge threads**: `allMessages` caps at 50k rows; the media view inherits
+ that cap and the store's documented above-watermark gap. Acceptable for
+ v1; noted for the sealed-index follow-up.
+- **Ephemeral/relay-only frames** are never stored, so never appear.
+- **Memory**: grid blob URLs are the same memoized `blobURLs` the feed uses;
+ the IntersectionObserver keeps decryption proportional to what's viewed.
+
+## Testing
+
+- `test/threadmedia.test.mjs` — extractor unit tests: classification
+ (image/GIF/video/file), multi-link extraction, app-event/tombstone
+ skipping, edit/delete invalidation, month bucketing, idempotent re-folds.
+- Lightbox collection logic: pure index/navigation helpers split out and
+ Node-tested; gestures verified by hand.
+- Swift: fixture tests asserting classification + link extraction parity
+ with the extractor fixtures.
+- `npm test` + `go test ./...` stay green untouched (no server or event
+ changes). Manual verification per stored preference: live canary
+ (`ops canary create`), though a canary exercises only the web side.
+
+## Shipping
+
+PR series (small, each stands alone):
+
+1. **web: extractor + overlay** — `threadmedia.js`, `mediaview.js`, kebab
+ entry, auto-backfill, tests. Changelog entry rides this PR.
+2. **web: lightbox collection swipe** — `lightbox.js` collection support,
+ media grid adopts it. (Until this lands, PR 1's grid taps open the
+ single-image lightbox — the feed's exact current behaviour.)
+3. **native: iOS + Mac tabs** — shared LChatCore logic, both UIs. Changelog
+ entry held until the release is minted, per convention.
diff --git a/package.json b/package.json
index 7c50e4c..df634d7 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,6 @@
"private": true,
"type": "module",
"scripts": {
- "test": "node --test test/module-refs.test.mjs && node test/apps-dir.test.mjs && node test/crypto.test.mjs && node test/vaultcrypto.test.mjs && node test/integrity.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/markdown.test.mjs && node test/composer.test.mjs && node test/composer-vectors.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/appcatalog.test.mjs && node test/appcatalog-seed.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/views.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/prejoin.test.mjs && node --test test/invitehint.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/linear.test.mjs && node test/linear-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/sandbox-theme.test.mjs && node test/status-contract.test.mjs && node test/ledgerchain.test.mjs && node test/hardening-posture.test.mjs && node test/store.test.mjs && node test/memory.test.mjs && node test/search.test.mjs && node test/stripe-band-prices.test.mjs && node test/waterfall.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs && node home/test/fx.test.mjs && node home/test/sessionreuse.test.mjs && node home/test/joinreturn.test.mjs"
+ "test": "node --test test/module-refs.test.mjs && node test/apps-dir.test.mjs && node test/crypto.test.mjs && node test/vaultcrypto.test.mjs && node test/integrity.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/markdown.test.mjs && node test/composer.test.mjs && node test/composer-vectors.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/appcatalog.test.mjs && node test/appcatalog-seed.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/views.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/prejoin.test.mjs && node --test test/invitehint.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/linear.test.mjs && node test/linear-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/sandbox-theme.test.mjs && node test/status-contract.test.mjs && node test/ledgerchain.test.mjs && node test/hardening-posture.test.mjs && node test/store.test.mjs && node test/memory.test.mjs && node test/search.test.mjs && node test/threadmedia.test.mjs && node test/stripe-band-prices.test.mjs && node test/waterfall.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs && node home/test/fx.test.mjs && node home/test/sessionreuse.test.mjs && node home/test/joinreturn.test.mjs"
}
}
diff --git a/test/threadmedia.test.mjs b/test/threadmedia.test.mjs
new file mode 100644
index 0000000..952fa0c
--- /dev/null
+++ b/test/threadmedia.test.mjs
@@ -0,0 +1,103 @@
+// Media, Links & Docs: the pure extractor + in-memory index
+// (web/static/threadmedia.js). Node-run: node test/threadmedia.test.mjs
+import assert from "node:assert/strict";
+import { extractEntries, bucketByMonth, monthLabel, ThreadMediaIndex } from "../web/static/threadmedia.js";
+
+let failures = 0;
+async function test(name, fn) {
+ try {
+ await fn();
+ console.log("PASS " + name);
+ } catch (e) {
+ console.log("FAIL " + name + " — " + (e && e.message));
+ failures++;
+ }
+}
+
+const meta = { id: 42, ts: 1754000000000 };
+
+await test("classifies image, gif, video as media; other files as docs", () => {
+ const out = extractEntries({ uid: "u1", name: "Ada", text: "", ts: 1754000000000, attachments: [
+ { id: "b1", type: "image/jpeg", w: 100, h: 50 },
+ { id: "b2", type: "image/gif" },
+ { id: "b3", type: "video/mp4", poster: "p3", dur: 12 },
+ { id: "b4", type: "application/pdf", name: "notes.pdf", size: 900 },
+ ] }, meta);
+ assert.equal(out.media.length, 3);
+ assert.equal(out.docs.length, 1);
+ assert.equal(out.docs[0].att.name, "notes.pdf");
+ assert.equal(out.media[0].sid, 42);
+});
+
+await test("extracts EVERY link, not just the first", () => {
+ const out = extractEntries({ uid: "u1", name: "Ada", ts: 1, text: "see https://a.example and https://b.example" }, meta);
+ assert.deepEqual(out.links.map((l) => l.url), ["https://a.example", "https://b.example"]);
+});
+
+await test("links inside fenced code blocks don't extract (matches firstLink)", () => {
+ const out = extractEntries({ uid: "u1", name: "A", ts: 1, text: "```\nhttps://code.example\n```\nhttps://real.example" }, meta);
+ assert.equal(out.links.length, 1);
+ assert.equal(out.links[0].url, "https://real.example");
+});
+
+await test("app events, reactions, and entry-less messages return null", () => {
+ assert.equal(extractEntries({ type: "todo.add", text: "https://x.example" }, meta), null);
+ assert.equal(extractEntries({ uid: "u", name: "A", text: "plain words", ts: 1 }, meta), null);
+ assert.equal(extractEntries(null, meta), null);
+});
+
+await test("attachment without a blob id is skipped", () => {
+ assert.equal(extractEntries({ uid: "u", name: "A", text: "", ts: 1, attachments: [{ type: "image/png" }] }, meta), null);
+});
+
+await test("bucketByMonth groups consecutive same-month entries, keeps order", () => {
+ const aug = Date.UTC(2026, 7, 10), jul = Date.UTC(2026, 6, 1);
+ const buckets = bucketByMonth([
+ { sid: 3, ts: aug }, { sid: 2, ts: aug }, { sid: 1, ts: jul },
+ ]);
+ assert.equal(buckets.length, 2);
+ assert.equal(buckets[0].items.length, 2);
+ assert.equal(buckets[0].label, monthLabel(aug));
+ assert.equal(buckets[1].items[0].sid, 1);
+});
+
+await test("index folds rows once, sorts newest-first, filters hidden senders at query time", async () => {
+ const rows = [
+ { id: 1, created_at: 100, blob: "x" },
+ { id: 2, created_at: 200, blob: "y" },
+ { id: 2, created_at: 200, blob: "y" }, // duplicate fold — seen-set dedupes
+ { id: 3, created_at: 300, blob: "z", tombstone_of: 1 }, // tombstone row — skipped
+ ];
+ const payloads = {
+ 1: { uid: "muted", name: "M", ts: 100000, text: "", attachments: [{ id: "a", type: "image/png" }] },
+ 2: { uid: "ok", name: "O", ts: 200000, text: "", attachments: [{ id: "b", type: "image/png" }] },
+ };
+ const idx = new ThreadMediaIndex(async (row) => payloads[row.id] || null);
+ await idx.ensureThread("t1", rows);
+ await idx.ensureThread("t1", rows); // idempotent
+ assert.deepEqual(idx.tab("t1", "media", { hiddenUids: new Set() }).map((e) => e.sid), [2, 1]);
+ assert.deepEqual(idx.tab("t1", "media", { hiddenUids: new Set(["muted"]) }).map((e) => e.sid), [2]);
+ assert.equal(idx.counts("t1", { hiddenUids: new Set(["muted"]) }).media, 1);
+});
+
+await test("invalidate drops a message's entries and lets it re-fold (edit path)", async () => {
+ const payloads = { 5: { uid: "u", name: "A", ts: 1, text: "https://old.example" } };
+ const idx = new ThreadMediaIndex(async (row) => payloads[row.id]);
+ await idx.ensureThread("t1", [{ id: 5, created_at: 1, blob: "x" }]);
+ assert.equal(idx.tab("t1", "links", { hiddenUids: new Set() })[0].url, "https://old.example");
+ payloads[5] = { uid: "u", name: "A", ts: 1, text: "https://new.example" };
+ idx.invalidate("t1", 5);
+ await idx.ensureThread("t1", [{ id: 5, created_at: 1, blob: "x" }]);
+ assert.deepEqual(idx.tab("t1", "links", { hiddenUids: new Set() }).map((l) => l.url), ["https://new.example"]);
+});
+
+await test("decrypt failure on one row doesn't poison the rest", async () => {
+ const idx = new ThreadMediaIndex(async (row) => {
+ if (row.id === 1) throw new Error("bad key");
+ return { uid: "u", name: "A", ts: 1, text: "", attachments: [{ id: "b", type: "image/png" }] };
+ });
+ await idx.ensureThread("t1", [{ id: 1, created_at: 1, blob: "x" }, { id: 2, created_at: 2, blob: "y" }]);
+ assert.equal(idx.tab("t1", "media", { hiddenUids: new Set() }).length, 1);
+});
+
+process.exit(failures ? 1 : 0);
diff --git a/web/pages/index.html b/web/pages/index.html
index 7bd9682..ae12561 100644
--- a/web/pages/index.html
+++ b/web/pages/index.html
@@ -236,6 +236,7 @@
<button class="menu-item" id="thread-add" role="menuitem">Add people</button>
<button class="menu-item" id="thread-edit" role="menuitem">Edit group</button>
<button class="menu-item" id="thread-members" role="menuitem">View members</button>
+ <button class="menu-item" id="thread-media" role="menuitem">Media, links and docs</button>
<!-- "Apps", not "Add apps": the sheet manages two scopes now (this
chat + the whole space) and shows in DMs too. -->
<button class="menu-item" id="thread-apps" role="menuitem">Apps</button>
diff --git a/web/static/mediaview.js b/web/static/mediaview.js
new file mode 100644
index 0000000..f97b956
--- /dev/null
+++ b/web/static/mediaview.js
@@ -0,0 +1,461 @@
+// 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 } 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 } — one overlay at a time
+
+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", 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");
+ 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, 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 entries = mediaIdx.tab(tid, open.tab, { hiddenUids: hiddenUids() });
+ if (!entries.length) {
+ const empty = document.createElement("div");
+ empty.className = "media-empty";
+ empty.textContent = { 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 <body> 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 <body>; 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();
+ 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.onclick = async () => {
+ const url = await deps.loadBlob(tid, isVideo && a.poster ? { id: a.poster, type: "image/jpeg" } : a);
+ if (url) openLightbox(url, img); // PR 2 upgrades this to collection swipe
+ };
+ // The jump button is a SIBLING of the tile, never a descendant — a
+ // <button> inside a <button> is invalid HTML (nested-interactive) and
+ // axe/VoiceOver flatten or hide the inner control.
+ const item = document.createElement("div");
+ item.className = "media-item";
+ item.dataset.sid = String(e.sid);
+ item.appendChild(tile);
+ addShowInChat(item, tid, e.sid);
+ return item;
+ },
+ "media-grid",
+ );
+}
+
+function renderLinksTab(body, entries, tid) {
+ monthSections(
+ body,
+ entries,
+ (e) => {
+ const row = document.createElement("a");
+ row.className = "media-link-row";
+ row.href = e.url;
+ row.target = "_blank";
+ row.rel = "noopener noreferrer";
+ const urlEl = document.createElement("span");
+ urlEl.className = "media-link-url";
+ urlEl.textContent = e.url;
+ const who = document.createElement("span");
+ who.className = "media-item-who";
+ who.textContent = `${e.name || "Someone"} · ${relTime(Math.floor(e.ts / 1000))}`;
+ row.append(urlEl, who);
+ // The unfurl card fills in lazily; a failed unfurl leaves the plain row.
+ observeTile(row, async () => {
+ const card = await deps.unfurl(e.url);
+ if (!card || !card.ok || !card.title) return;
+ const t = document.createElement("span");
+ t.className = "media-link-title";
+ t.textContent = card.title;
+ row.prepend(t);
+ });
+ // Sibling, not descendant — see the note in renderMediaTab.
+ const item = document.createElement("div");
+ item.className = "media-item";
+ item.dataset.sid = String(e.sid);
+ item.appendChild(row);
+ addShowInChat(item, tid, e.sid);
+ return item;
+ },
+ "media-list",
+ );
+}
+
+function renderDocsTab(body, entries, tid) {
+ monthSections(
+ body,
+ entries,
+ (e) => {
+ const a = e.att;
+ const row = document.createElement("a");
+ row.className = "media-doc-row";
+ row.href = "#";
+ row.download = a.name || "file";
+ row.addEventListener("click", (ev) => {
+ if (row.getAttribute("href") === "#") ev.preventDefault();
+ });
+ const nm = document.createElement("span");
+ nm.className = "media-doc-name";
+ nm.textContent = a.name || "file";
+ const sub = document.createElement("span");
+ sub.className = "media-item-who";
+ sub.textContent = [a.size ? formatBytes(a.size) : "", `${e.name || "Someone"} · ${relTime(Math.floor(e.ts / 1000))}`]
+ .filter(Boolean)
+ .join(" · ");
+ row.append(nm, sub);
+ // The blob decrypts on approach so the tap is an instant save.
+ observeTile(row, async () => {
+ const url = await deps.loadBlob(tid, a);
+ if (url) row.href = url;
+ });
+ // Sibling, not descendant — see the note in renderMediaTab.
+ const item = document.createElement("div");
+ item.className = "media-item";
+ item.dataset.sid = String(e.sid);
+ item.appendChild(row);
+ addShowInChat(item, tid, e.sid);
+ return item;
+ },
+ "media-list",
+ );
+}
+
+// Every item's second affordance: land on the source message. A visible
+// button (≥24px target) that's a SIBLING of the tile/row (never nested
+// inside it — a button-in-a-button or button-in-an-anchor is invalid HTML
+// and axe/screen readers flatten or hide the inner control), so it's
+// reachable by keyboard and screen reader alike. `el` is the non-interactive
+// `.media-item` wrapper each renderer builds around its primary control.
+function addShowInChat(el, tid, sid) {
+ const b = document.createElement("button");
+ b.type = "button";
+ b.className = "media-jump";
+ b.setAttribute("aria-label", "Show in chat");
+ b.textContent = "↩";
+ b.onclick = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ closeMediaView();
+ jumpToMessage(tid, sid);
+ };
+ el.appendChild(b);
+}
diff --git a/web/static/searchui.js b/web/static/searchui.js
index f8d321e..6a88f1f 100644
--- a/web/static/searchui.js
+++ b/web/static/searchui.js
@@ -375,7 +375,7 @@ async function startDeepSearch(query, threadIds) {
}
// One history page for the backfill walk (before=0 ⇒ the most recent page).
-async function fetchMessagePage(threadId, before) {
+export async function fetchMessagePage(threadId, before) {
const q = before ? `&before=${before}&limit=200` : "&limit=200";
const r = await (
await fetch(`/api/messages?token=${encodeURIComponent(session.token)}&thread=${encodeURIComponent(threadId)}${q}`)
@@ -386,7 +386,7 @@ async function fetchMessagePage(threadId, before) {
// Open the hit's thread and land on the message: page history back (bounded,
// DOM-guided — the oldest rendered bubble's data-id is the cursor) until the
// sid is in the feed, then reuse the reply-jump's scroll + flash.
-async function jumpToMessage(tid, sid) {
+export async function jumpToMessage(tid, sid) {
await openThreadFn(tid);
const pane = document.getElementById("app-pane");
let feed = pane && pane.querySelector(".chatapp-feed");
diff --git a/web/static/shell.js b/web/static/shell.js
index 995a6a3..676a2f5 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -156,6 +156,7 @@ import { loadTiers, applyTiers, setTierShown, subscribeToLevel, cancelLevelSub,
import { sendScriptedBubble, sendTypedPayload, scriptedSaid, liveElevenAgent, maybeStartWelcome, openSetupQuickForm } from "/static/onboardingui.js";
import { verbs } from "/static/shellverbs.js"; // the behaviour seam (docs/shell-modules.md)
import { scheduleMessageSearch, wireSearch, searchIdx } from "/static/searchui.js"; // message search over the local store (#586)
+import { wireMediaView, openMediaView, closeMediaView, mediaInvalidate, mediaModerationChanged } from "/static/mediaview.js"; // Media/Links/Docs overlay
import { loadMemberApps, setMemberSeeder, setSurfaceRefresh, setMemberAdmin, recentLocalDeploy, pushTheme } from "/static/sandbox.js"; // member-built apps, run sandboxed (docs/member-apps.md)
import { THEME_META, THEME_ORDER, currentTheme, currentAppearance, setTheme, setAppearance, currentTextSize, setTextSize, onThemeApplied } from "/static/theme.js"; // applies the saved theme/text-size on import
import { session, roster, myUserId, threads, threadKeys, activeId, ws, setSession, setRoster, setMyUserId, setThreads, setActiveId, setWs, nicknames, setNicknames, threadName, instanceKey, instanceKeyB64, setInstanceKey, threadKeyB64, unread, hidden, dmClosed, embed, ENABLE_AI, ENABLE_BOTS, ENABLE_APPS, PLACES_UI, threadApp, tiers, setTiers, tierKeys, tierKeyB64, pendingJoinThread, setPendingJoinThread, ACTIVE_KEY, MAX_UPLOAD, pinned, muted, sealedBranding, setSealedBranding, brandingB, setBrandingB } from "/static/shellstate.js"; // the shared shell state (docs/shell-modules.md)
@@ -216,6 +217,7 @@ Object.assign(verbs, {
onThemeApplied(pushTheme); // live-retint mounted sandbox frames on theme change
wireSearch({ openThread, chatAppFor, chatAppLoadOlder, threadAvatar: threadRowAvatarEl }); // navigation + thread predicate + scroll-back paging + row avatars
+wireMediaView({ loadBlob: (threadId, att) => loadBlob(att.id, threadId, att.type), unfurl }); // Media/Links/Docs overlay: same blob loader + unfurl fetcher as the chat app's host bridge
// Ephemeral transport (ctx.emitEphemeral): same encryption as emit, but the
// envelope's `ephemeral` flag asks the server to relay-and-forget — no store,
@@ -235,6 +237,7 @@ setEphemeralTransport((threadId, event) => {
onModerationChange((threadsToo) => {
renderActiveSurfaces();
if (threadsToo) renderThreadList();
+ mediaModerationChanged(); // repaint an open Media/Links/Docs overlay too
});
const SESSION_KEY = "lchat_session";
@@ -3143,6 +3146,10 @@ function renderApp() {
closeTMenu();
showMembers();
};
+ document.getElementById("thread-media").onclick = () => {
+ closeTMenu();
+ openMediaView(activeId);
+ };
document.getElementById("thread-apps").onclick = () => {
closeTMenu();
manageGroupApps();
@@ -3832,6 +3839,10 @@ function paintThreadHeader(id) {
}
const appsItem = document.getElementById("thread-apps");
if (appsItem) appsItem.style.display = isGroup || (PLACES_UI && t && t.kind === "dm") ? "" : "none";
+ // Media/links/docs shows for every chat-feed thread (DMs included, unlike
+ // the group-only items above) and hides on app-threads, which have no feed.
+ const mediaItem = document.getElementById("thread-media");
+ if (mediaItem) mediaItem.style.display = chatAppFor(id) ? "" : "none";
document.getElementById("thread-pin").textContent = pinned[id] ? "Unpin chat" : "Pin chat";
document.getElementById("thread-mute").textContent = muted[id] ? "Unmute chat" : "Mute chat";
paintViewsMenu(id);
@@ -4052,6 +4063,7 @@ function refreshGroupAppsUI(id) {
}
async function openThread(id) {
+ closeMediaView(); // navigating threads dismisses any open Media/Links/Docs overlay (no-op if none is open)
// The space thread has no chat surface — a stray navigation to it (an old
// deep link, a stale saved id) opens its home instead of a ghost chat.
if (spaceThreadId && id === spaceThreadId) {
@@ -5274,6 +5286,31 @@ setScheduleTransport({
},
});
+// Fetch a link preview card, named (not inline) so the Media/Links/Docs
+// overlay's Links tab (mediaview.js) can reuse the exact same fetcher the
+// chat app's host bridge hands out below — one unfurl path, two callers.
+async function unfurl(url) {
+ // Prefer the shared fetch service when this instance points at one (docs/fetch.md):
+ // identity-free — no token rides along — and the card's image comes back
+ // as the service's signed proxy URL. Any transport failure falls through to
+ // the instance's own unfurler, so a fetch-service outage degrades to today's path
+ // (an ok:false answer is a real answer — the page had nothing — not
+ // a reason to re-ask the instance).
+ if (window.__fetchURL) {
+ try {
+ const r = await fetchServiceGET(`/api/unfurl?url=${encodeURIComponent(url)}`);
+ if (r && r.ok) return await r.json();
+ } catch {}
+ }
+ try {
+ return await (
+ await fetch(`/api/unfurl?token=${encodeURIComponent(session.token)}&url=${encodeURIComponent(url)}`)
+ ).json();
+ } catch {
+ return null;
+ }
+}
+
// The chat app's host bridge: everything that is a server mechanism or needs
// the keys/token rather than an event. Reactions/unsend/edit ride the exact WS
// frames the original feed sends (so author-verification and per-user dedupe
@@ -5346,27 +5383,7 @@ setChatAppHost({
// original feed uses for thumbnails and downloads).
loadBlob: (threadId, att, onProgress) => loadBlob(att.id, threadId, att.type, onProgress),
upload: (threadId, file, onProgress) => chatAppUpload(threadId, file, onProgress),
- unfurl: async (url) => {
- // Prefer the shared fetch service when this instance points at one (docs/fetch.md):
- // identity-free — no token rides along — and the card's image comes back
- // as the service's signed proxy URL. Any transport failure falls through to
- // the instance's own unfurler, so a fetch-service outage degrades to today's path
- // (an ok:false answer is a real answer — the page had nothing — not
- // a reason to re-ask the instance).
- if (window.__fetchURL) {
- try {
- const r = await fetchServiceGET(`/api/unfurl?url=${encodeURIComponent(url)}`);
- if (r && r.ok) return await r.json();
- } catch {}
- }
- try {
- return await (
- await fetch(`/api/unfurl?token=${encodeURIComponent(session.token)}&url=${encodeURIComponent(url)}`)
- ).json();
- } catch {
- return null;
- }
- },
+ unfurl,
loadOlder: (threadId, beforeSid) => chatAppLoadOlder(threadId, beforeSid),
// A member's current (client-decrypted) roster name by uid, "" when unknown —
// the bubble-header lookup, my own name for them winning (#577). Deliberately
@@ -7672,6 +7689,7 @@ function connectWS() {
// Chat app: an edit (server-verified author) replaces the message text in
// the fold; the original feed's in-place bubble update follows unchanged.
if (!embed) storeApplyEdit(msg.thread_id, msg.id, msg.blob, msg.edited_at).catch(() => {});
+ if (!embed) mediaInvalidate(msg.thread_id, msg.id); // drop the stale entry so the overlay re-extracts it (links can change) on next open
if (chatAppFor(msg.thread_id) && threadKeys[msg.thread_id]) {
try {
const nd = await decrypt(threadKeys[msg.thread_id], msg.blob);
@@ -7687,6 +7705,7 @@ function connectWS() {
// ciphertext too — deleting really deletes, here as on the server —
// and the in-memory search index forgets it the same moment.
if (!embed) storeDeleteMessage(msg.thread_id, msg.id).catch(() => {});
+ if (!embed) mediaInvalidate(msg.thread_id, msg.id); // hard-deleted upstream: drop it from the overlay's index too
searchIdx.dropMessage(msg.thread_id, msg.id);
if (chatAppFor(msg.thread_id)) {
foldHostEvent(msg.thread_id, "chat", { type: "chat.delete", msgId: msg.id });
diff --git a/web/static/style.css b/web/static/style.css
index 79552b7..ecc265b 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -2897,3 +2897,55 @@ body.command-host .chatapp-root > :not(.cmd-panel) { display: none !important; }
body.command-host .cmd-panel { border: none; box-shadow: none; } /* the native frame draws the border + shadow */
/* The shell grants the frame; the body scrolls within it (100vh = the frame). */
body.command-host .cmd-panel-body { max-height: calc(100vh - 40px); }
+
+/* ---- Media, links & docs overlay (mediaview.js) ---- */
+.media-overlay {
+ position: absolute;
+ inset: 0;
+ z-index: 30; /* same layer as the reply-thread overlay (.chatapp-thread) */
+ display: flex;
+ flex-direction: column;
+ background: var(--bg);
+}
+/* .thread-view already carries position: relative (base rule above); the
+ overlay bounds to it without a duplicate rule here. */
+.media-head { display: flex; align-items: center; gap: 8px; padding: 10px 12px; }
+.media-title { font-weight: 600; }
+.media-tabs { display: flex; gap: 6px; padding: 0 12px 10px; }
+.media-tab {
+ padding: 6px 12px;
+ border-radius: 999px;
+ border: 1px solid var(--border);
+ background: none;
+ cursor: pointer;
+ min-height: 32px;
+}
+.media-tab.on { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
+.media-progress { display: flex; align-items: center; gap: 10px; padding: 6px 14px; font-size: 0.85em; opacity: 0.8; }
+.media-stop { border: none; background: none; text-decoration: underline; cursor: pointer; padding: 4px 8px; min-height: 24px; }
+.media-body { flex: 1; overflow-y: auto; padding: 0 12px 16px; }
+.media-month { font-size: 0.8em; opacity: 0.65; margin: 14px 2px 6px; }
+.media-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; }
+@media (min-width: 900px) { .media-grid { grid-template-columns: repeat(4, 1fr); } }
+/* .media-item is the non-interactive wrapper every renderer builds around its
+ primary control (tile button, or link/doc anchor) so the "Show in chat"
+ jump button can sit beside it as a SIBLING rather than nested inside it —
+ a button inside a button/anchor is invalid HTML (nested-interactive) and
+ axe/screen readers flatten or hide the inner control. It owns the
+ positioning context .media-jump anchors to in both layouts. */
+.media-item { position: relative; }
+.media-grid .media-item { aspect-ratio: 1; } /* the grid cell; the tile fills it */
+.media-tile { position: relative; border: none; padding: 0; background: var(--bubble-them); border-radius: 6px; overflow: hidden; cursor: zoom-in; width: 100%; height: 100%; display: block; }
+.media-tile img { width: 100%; height: 100%; object-fit: cover; display: block; }
+.media-dur { position: absolute; right: 6px; bottom: 6px; background: rgba(0, 0, 0, 0.65); color: #fff; font-size: 0.72em; padding: 1px 6px; border-radius: 4px; }
+.media-list { display: flex; flex-direction: column; gap: 6px; }
+.media-link-row, .media-doc-row { display: flex; flex-direction: column; gap: 2px; padding: 10px 44px 10px 12px; border: 1px solid var(--border); border-radius: 10px; text-decoration: none; color: inherit; }
+.media-link-title { font-weight: 600; }
+.media-link-url { font-size: 0.85em; opacity: 0.75; overflow-wrap: anywhere; }
+.media-doc-name { font-weight: 500; overflow-wrap: anywhere; }
+.media-item-who { font-size: 0.78em; opacity: 0.6; }
+.media-jump { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); min-width: 28px; min-height: 28px; border: none; background: none; cursor: pointer; opacity: 0.6; border-radius: 6px; }
+.media-grid .media-jump { top: auto; bottom: 4px; transform: none; background: rgba(0, 0, 0, 0.5); color: #fff; opacity: 0; }
+.media-grid .media-item:hover .media-jump, .media-grid .media-item:focus-within .media-jump { opacity: 1; }
+.media-jump:hover, .media-jump:focus-visible { opacity: 1; }
+.media-empty { text-align: center; opacity: 0.6; padding: 40px 0; }
diff --git a/web/static/threadmedia.js b/web/static/threadmedia.js
new file mode 100644
index 0000000..dbecc2d
--- /dev/null
+++ b/web/static/threadmedia.js
@@ -0,0 +1,114 @@
+// Media, Links & Docs — the pure extraction + in-memory per-thread index
+// behind the thread's media overlay (mediaview.js). Same posture as the
+// search index (search.js): rows decrypt lazily into MEMORY, never to disk —
+// the store deliberately holds only ciphertext at rest. decryptRow is
+// injected so this module stays free of keys and Node-testable.
+// Relative import: Node tests load this file directly.
+import { tokenizeText, blockTokens } from "./format.js";
+
+// One decrypted payload -> its media/links/docs entries, or null when the
+// message contributes nothing. Typed app events and reactions are invisible
+// here (same rule as search); tombstones never reach us (index skips them).
+export function extractEntries(data, meta) {
+ if (!data || data.type) return null;
+ const base = { sid: meta.id, ts: data.ts || meta.ts || 0, uid: data.uid || "", name: data.name || "" };
+ const media = [];
+ const docs = [];
+ const links = [];
+ for (const a of data.attachments || []) {
+ if (!a || !a.id) continue;
+ const t = a.type || "";
+ if (t.startsWith("image/") || t.startsWith("video/")) media.push({ ...base, att: a });
+ else docs.push({ ...base, att: a });
+ }
+ // Every link in the prose — the feed previews only the first (firstLink),
+ // but the Links tab is an inventory. Fenced code is skipped for the same
+ // reason firstLink skips it: a URL pasted as code isn't a shared link.
+ if (data.text) {
+ for (const b of blockTokens(data.text)) {
+ if (b.type === "code") continue;
+ for (const tok of tokenizeText(b.text)) if (tok.href) links.push({ ...base, url: tok.href });
+ }
+ }
+ if (!media.length && !docs.length && !links.length) return null;
+ return { media, links, docs };
+}
+
+export function monthLabel(ts) {
+ return new Date(ts).toLocaleDateString([], { month: "long", year: "numeric" });
+}
+
+// Group an already-sorted (newest-first) entry list into month sections.
+// Consecutive grouping is enough BECAUSE the input is sorted.
+export function bucketByMonth(entries) {
+ const out = [];
+ for (const e of entries) {
+ const d = new Date(e.ts || 0);
+ const key = d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0");
+ if (!out.length || out[out.length - 1].key !== key) out.push({ key, label: monthLabel(e.ts || 0), items: [] });
+ out[out.length - 1].items.push(e);
+ }
+ return out;
+}
+
+export class ThreadMediaIndex {
+ constructor(decryptRow) {
+ this.decryptRow = decryptRow; // async (row, tid) -> payload | null
+ this.threads = new Map(); // tid -> { seen:Set<sid>, media:[], links:[], docs:[] }
+ }
+
+ bucket(tid) {
+ let b = this.threads.get(tid);
+ if (!b) {
+ b = { seen: new Set(), media: [], links: [], docs: [] };
+ this.threads.set(tid, b);
+ }
+ return b;
+ }
+
+ // Fold rows not yet seen. Idempotent per sid; a failed decrypt still marks
+ // seen (same as search — a row that won't decrypt now won't decrypt later).
+ async ensureThread(tid, rows) {
+ const b = this.bucket(tid);
+ for (const row of rows || []) {
+ if (!row || !row.id || b.seen.has(row.id) || row.tombstone_of) continue;
+ b.seen.add(row.id);
+ let data = null;
+ try {
+ data = await this.decryptRow(row, tid);
+ } catch {}
+ if (!data) continue;
+ const ex = extractEntries(data, { id: row.id, ts: (row.created_at || 0) * 1000 });
+ if (!ex) continue;
+ b.media.push(...ex.media);
+ b.links.push(...ex.links);
+ b.docs.push(...ex.docs);
+ }
+ for (const k of ["media", "links", "docs"]) b[k].sort((x, y) => y.sid - x.sid);
+ }
+
+ // Moderation filters at QUERY time so un-muting needs no re-scan.
+ tab(tid, name, { hiddenUids }) {
+ return this.bucket(tid)[name].filter((e) => !hiddenUids.has(e.uid));
+ }
+
+ counts(tid, opts) {
+ return {
+ media: this.tab(tid, "media", opts).length,
+ links: this.tab(tid, "links", opts).length,
+ docs: this.tab(tid, "docs", opts).length,
+ };
+ }
+
+ // An edit re-extracts (links can change); a delete just never re-folds.
+ invalidate(tid, sid) {
+ const b = this.threads.get(tid);
+ if (!b) return;
+ b.seen.delete(sid);
+ for (const k of ["media", "links", "docs"]) b[k] = b[k].filter((e) => e.sid !== sid);
+ }
+
+ dropThread(tid) {
+ this.threads.delete(tid);
+ }
+}