eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

๐Ÿค– 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):

// 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
// 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).

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
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
// 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
/* ---- 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.

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:

<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):
import { wireMediaView, openMediaView, closeMediaView, mediaInvalidate } from "/static/mediaview.js"; // Media/Links/Docs overlay
  1. 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):
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.)

  1. Kebab handler, beside the others (shell.js:3116):
document.getElementById("thread-media").onclick = () => {
  closeTMenu();
  openMediaView(activeId);
};
  1. 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:
const mediaItem = document.getElementById("thread-media");
if (mediaItem) mediaItem.style.display = chatAppFor(id) ? "" : "none";
  1. 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:

๐Ÿค– 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).

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
// 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):
// 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
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:

  // ---- 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:

  // 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:

    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
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:

      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
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.