eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1# ๐Ÿค– Media, Links & Docs (web) Implementation Plan
2
3> **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.
4
5**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`.
6
7**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.
8
9**Tech Stack:** Vanilla ES modules, no build step. IndexedDB via `store.js` only. Tests are plain Node scripts under `test/`.
10
11**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.
12
13## Global Constraints
14
15- **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.
16- **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`).
17- **Never a floating toast** โ€” progress/status render inline, occupying layout.
18- **WCAG 2.2 AA:** keyboard-operable, visible managed focus, labelled icon-only controls, โ‰ฅ24px targets, `prefers-reduced-motion` respected, state never color alone.
19- **Counts pluralise and localise:** `toLocaleString()` on digits; "1 photo" / "2 photos" โ€” never `n + " things"`.
20- **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.
21- **JS isn't run by tests** beyond the Node suites: after editing any web file, run `node --check web/static/<file>.js`.
22- **Comment the why**, matching the surrounding density. New prose is ๐Ÿค–-marked (changelog first line).
23- Branch per PR off `main` in a worktree; commits prefixed `web:` / `docs:`; squash-merge.
24
25---
26
27# PR 1 โ€” extractor + overlay (branch: `media-links-docs`)
28
29### Task 1: `threadmedia.js` โ€” pure extractor + in-memory index
30
31**Files:**
32- Create: `web/static/threadmedia.js`
33- Test: `test/threadmedia.test.mjs`
34- Modify: `package.json` (add the test to the `test` script chain, after `test/search.test.mjs`)
35
36**Interfaces:**
37- Consumes: `tokenizeText`, `blockTokens` from `./format.js` (both pure, import-free).
38- Produces (later tasks rely on these exact names):
39 - `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).
40 - `bucketByMonth(entries) -> [{key, label, items}]` โ€” entries already sorted newest-first.
41 - `monthLabel(ts) -> "August 2026"`.
42 - `class ThreadMediaIndex` โ€” `constructor(decryptRow)`, `async ensureThread(tid, rows)`, `tab(tid, name, {hiddenUids})`, `counts(tid, {hiddenUids})`, `invalidate(tid, sid)`, `dropThread(tid)`.
43
44- [ ] **Step 1: Write the failing tests**
45
46Create `test/threadmedia.test.mjs` (same harness shape as `test/store.test.mjs` โ€” local `test(name, fn)` printing PASS/FAIL, `process.exit(1)` on failure):
47
48```js
49// Media, Links & Docs: the pure extractor + in-memory index
50// (web/static/threadmedia.js). Node-run: node test/threadmedia.test.mjs
51import assert from "node:assert/strict";
52import { extractEntries, bucketByMonth, monthLabel, ThreadMediaIndex } from "../web/static/threadmedia.js";
53
54let failures = 0;
55async function test(name, fn) {
56 try {
57 await fn();
58 console.log("PASS " + name);
59 } catch (e) {
60 console.log("FAIL " + name + " โ€” " + (e && e.message));
61 failures++;
62 }
63}
64
65const meta = { id: 42, ts: 1754000000000 };
66
67await test("classifies image, gif, video as media; other files as docs", () => {
68 const out = extractEntries({ uid: "u1", name: "Ada", text: "", ts: 1754000000000, attachments: [
69 { id: "b1", type: "image/jpeg", w: 100, h: 50 },
70 { id: "b2", type: "image/gif" },
71 { id: "b3", type: "video/mp4", poster: "p3", dur: 12 },
72 { id: "b4", type: "application/pdf", name: "notes.pdf", size: 900 },
73 ] }, meta);
74 assert.equal(out.media.length, 3);
75 assert.equal(out.docs.length, 1);
76 assert.equal(out.docs[0].att.name, "notes.pdf");
77 assert.equal(out.media[0].sid, 42);
78});
79
80await test("extracts EVERY link, not just the first", () => {
81 const out = extractEntries({ uid: "u1", name: "Ada", ts: 1, text: "see https://a.example and https://b.example" }, meta);
82 assert.deepEqual(out.links.map((l) => l.url), ["https://a.example", "https://b.example"]);
83});
84
85await test("links inside fenced code blocks don't extract (matches firstLink)", () => {
86 const out = extractEntries({ uid: "u1", name: "A", ts: 1, text: "```\nhttps://code.example\n```\nhttps://real.example" }, meta);
87 assert.equal(out.links.length, 1);
88 assert.equal(out.links[0].url, "https://real.example");
89});
90
91await test("app events, reactions, and entry-less messages return null", () => {
92 assert.equal(extractEntries({ type: "todo.add", text: "https://x.example" }, meta), null);
93 assert.equal(extractEntries({ uid: "u", name: "A", text: "plain words", ts: 1 }, meta), null);
94 assert.equal(extractEntries(null, meta), null);
95});
96
97await test("attachment without a blob id is skipped", () => {
98 assert.equal(extractEntries({ uid: "u", name: "A", text: "", ts: 1, attachments: [{ type: "image/png" }] }, meta), null);
99});
100
101await test("bucketByMonth groups consecutive same-month entries, keeps order", () => {
102 const aug = Date.UTC(2026, 7, 10), jul = Date.UTC(2026, 6, 1);
103 const buckets = bucketByMonth([
104 { sid: 3, ts: aug }, { sid: 2, ts: aug }, { sid: 1, ts: jul },
105 ]);
106 assert.equal(buckets.length, 2);
107 assert.equal(buckets[0].items.length, 2);
108 assert.equal(buckets[0].label, monthLabel(aug));
109 assert.equal(buckets[1].items[0].sid, 1);
110});
111
112await test("index folds rows once, sorts newest-first, filters hidden senders at query time", async () => {
113 const rows = [
114 { id: 1, created_at: 100, blob: "x" },
115 { id: 2, created_at: 200, blob: "y" },
116 { id: 2, created_at: 200, blob: "y" }, // duplicate fold โ€” seen-set dedupes
117 { id: 3, created_at: 300, blob: "z", tombstone_of: 1 }, // tombstone row โ€” skipped
118 ];
119 const payloads = {
120 1: { uid: "muted", name: "M", ts: 100000, text: "", attachments: [{ id: "a", type: "image/png" }] },
121 2: { uid: "ok", name: "O", ts: 200000, text: "", attachments: [{ id: "b", type: "image/png" }] },
122 };
123 const idx = new ThreadMediaIndex(async (row) => payloads[row.id] || null);
124 await idx.ensureThread("t1", rows);
125 await idx.ensureThread("t1", rows); // idempotent
126 assert.deepEqual(idx.tab("t1", "media", { hiddenUids: new Set() }).map((e) => e.sid), [2, 1]);
127 assert.deepEqual(idx.tab("t1", "media", { hiddenUids: new Set(["muted"]) }).map((e) => e.sid), [2]);
128 assert.equal(idx.counts("t1", { hiddenUids: new Set(["muted"]) }).media, 1);
129});
130
131await test("invalidate drops a message's entries and lets it re-fold (edit path)", async () => {
132 const payloads = { 5: { uid: "u", name: "A", ts: 1, text: "https://old.example" } };
133 const idx = new ThreadMediaIndex(async (row) => payloads[row.id]);
134 await idx.ensureThread("t1", [{ id: 5, created_at: 1, blob: "x" }]);
135 assert.equal(idx.tab("t1", "links", { hiddenUids: new Set() })[0].url, "https://old.example");
136 payloads[5] = { uid: "u", name: "A", ts: 1, text: "https://new.example" };
137 idx.invalidate("t1", 5);
138 await idx.ensureThread("t1", [{ id: 5, created_at: 1, blob: "x" }]);
139 assert.deepEqual(idx.tab("t1", "links", { hiddenUids: new Set() }).map((l) => l.url), ["https://new.example"]);
140});
141
142await test("decrypt failure on one row doesn't poison the rest", async () => {
143 const idx = new ThreadMediaIndex(async (row) => {
144 if (row.id === 1) throw new Error("bad key");
145 return { uid: "u", name: "A", ts: 1, text: "", attachments: [{ id: "b", type: "image/png" }] };
146 });
147 await idx.ensureThread("t1", [{ id: 1, created_at: 1, blob: "x" }, { id: 2, created_at: 2, blob: "y" }]);
148 assert.equal(idx.tab("t1", "media", { hiddenUids: new Set() }).length, 1);
149});
150
151process.exit(failures ? 1 : 0);
152```
153
154- [ ] **Step 2: Run tests, verify they fail**
155
156Run: `node test/threadmedia.test.mjs`
157Expected: FAIL โ€” `Cannot find module .../web/static/threadmedia.js`
158
159- [ ] **Step 3: Implement `web/static/threadmedia.js`**
160
161```js
162// Media, Links & Docs โ€” the pure extraction + in-memory per-thread index
163// behind the thread's media overlay (mediaview.js). Same posture as the
164// search index (search.js): rows decrypt lazily into MEMORY, never to disk โ€”
165// the store deliberately holds only ciphertext at rest. decryptRow is
166// injected so this module stays free of keys and Node-testable.
167// Relative import: Node tests load this file directly.
168import { tokenizeText, blockTokens } from "./format.js";
169
170// One decrypted payload -> its media/links/docs entries, or null when the
171// message contributes nothing. Typed app events and reactions are invisible
172// here (same rule as search); tombstones never reach us (index skips them).
173export function extractEntries(data, meta) {
174 if (!data || data.type) return null;
175 const base = { sid: meta.id, ts: data.ts || meta.ts || 0, uid: data.uid || "", name: data.name || "" };
176 const media = [];
177 const docs = [];
178 const links = [];
179 for (const a of data.attachments || []) {
180 if (!a || !a.id) continue;
181 const t = a.type || "";
182 if (t.startsWith("image/") || t.startsWith("video/")) media.push({ ...base, att: a });
183 else docs.push({ ...base, att: a });
184 }
185 // Every link in the prose โ€” the feed previews only the first (firstLink),
186 // but the Links tab is an inventory. Fenced code is skipped for the same
187 // reason firstLink skips it: a URL pasted as code isn't a shared link.
188 if (data.text) {
189 for (const b of blockTokens(data.text)) {
190 if (b.type === "code") continue;
191 for (const tok of tokenizeText(b.text)) if (tok.href) links.push({ ...base, url: tok.href });
192 }
193 }
194 if (!media.length && !docs.length && !links.length) return null;
195 return { media, links, docs };
196}
197
198export function monthLabel(ts) {
199 return new Date(ts).toLocaleDateString([], { month: "long", year: "numeric" });
200}
201
202// Group an already-sorted (newest-first) entry list into month sections.
203// Consecutive grouping is enough BECAUSE the input is sorted.
204export function bucketByMonth(entries) {
205 const out = [];
206 for (const e of entries) {
207 const d = new Date(e.ts || 0);
208 const key = d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0");
209 if (!out.length || out[out.length - 1].key !== key) out.push({ key, label: monthLabel(e.ts || 0), items: [] });
210 out[out.length - 1].items.push(e);
211 }
212 return out;
213}
214
215export class ThreadMediaIndex {
216 constructor(decryptRow) {
217 this.decryptRow = decryptRow; // async (row, tid) -> payload | null
218 this.threads = new Map(); // tid -> { seen:Set<sid>, media:[], links:[], docs:[] }
219 }
220
221 bucket(tid) {
222 let b = this.threads.get(tid);
223 if (!b) {
224 b = { seen: new Set(), media: [], links: [], docs: [] };
225 this.threads.set(tid, b);
226 }
227 return b;
228 }
229
230 // Fold rows not yet seen. Idempotent per sid; a failed decrypt still marks
231 // seen (same as search โ€” a row that won't decrypt now won't decrypt later).
232 async ensureThread(tid, rows) {
233 const b = this.bucket(tid);
234 for (const row of rows || []) {
235 if (!row || !row.id || b.seen.has(row.id) || row.tombstone_of) continue;
236 b.seen.add(row.id);
237 let data = null;
238 try {
239 data = await this.decryptRow(row, tid);
240 } catch {}
241 if (!data) continue;
242 const ex = extractEntries(data, { id: row.id, ts: (row.created_at || 0) * 1000 });
243 if (!ex) continue;
244 b.media.push(...ex.media);
245 b.links.push(...ex.links);
246 b.docs.push(...ex.docs);
247 }
248 for (const k of ["media", "links", "docs"]) b[k].sort((x, y) => y.sid - x.sid);
249 }
250
251 // Moderation filters at QUERY time so un-muting needs no re-scan.
252 tab(tid, name, { hiddenUids }) {
253 return this.bucket(tid)[name].filter((e) => !hiddenUids.has(e.uid));
254 }
255
256 counts(tid, opts) {
257 return {
258 media: this.tab(tid, "media", opts).length,
259 links: this.tab(tid, "links", opts).length,
260 docs: this.tab(tid, "docs", opts).length,
261 };
262 }
263
264 // An edit re-extracts (links can change); a delete just never re-folds.
265 invalidate(tid, sid) {
266 const b = this.threads.get(tid);
267 if (!b) return;
268 b.seen.delete(sid);
269 for (const k of ["media", "links", "docs"]) b[k] = b[k].filter((e) => e.sid !== sid);
270 }
271
272 dropThread(tid) {
273 this.threads.delete(tid);
274 }
275}
276```
277
278- [ ] **Step 4: Run tests, verify they pass**
279
280Run: `node test/threadmedia.test.mjs` โ€” expected: all PASS, exit 0.
281Run: `node --check web/static/threadmedia.js`
282
283- [ ] **Step 5: Add to the suite and commit**
284
285In `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).
286
287```bash
288git add web/static/threadmedia.js test/threadmedia.test.mjs package.json
289git commit -m "web: threadmedia.js โ€” pure media/links/docs extractor + index"
290```
291
292### Task 2: export search's jump + page-fetch for reuse
293
294**Files:**
295- Modify: `web/static/searchui.js` (two keywords only)
296
297**Interfaces:**
298- 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.
299
300- [ ] **Step 1: Add `export` to the two declarations**
301
302In `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`).
303
304- [ ] **Step 2: Verify**
305
306Run: `node --check web/static/searchui.js` and `npm test` (both pass; nothing imports the new exports yet).
307
308- [ ] **Step 3: Commit**
309
310```bash
311git add web/static/searchui.js
312git commit -m "web: export jumpToMessage + fetchMessagePage for the media overlay"
313```
314
315### Task 3: `mediaview.js` โ€” the overlay UI
316
317**Files:**
318- Create: `web/static/mediaview.js`
319- Modify: `web/static/style.css` (append the `.media-overlay` block at the end)
320
321**Interfaces:**
322- 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`.
323- Produces (Task 4 relies on): `wireMediaView({ loadBlob, unfurl })`, `openMediaView(tid)`, `mediaIdx` (the shared `ThreadMediaIndex` instance), `mediaInvalidate(tid, sid)`.
324
325This 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.
326
327- [ ] **Step 1: Write `web/static/mediaview.js`**
328
329```js
330// The "Media, links and docs" overlay (spec: docs/superpowers/specs/
331// 2026-08-05-media-links-docs-design.md): a full-pane surface over the open
332// thread with Media | Links | Docs tabs, fed by threadmedia.js over the local
333// store, completed by an auto-run resumable backfill (marks shared with
334// search โ€” either feature finishing the walk finishes it for both).
335import { ThreadMediaIndex, bucketByMonth } from "/static/threadmedia.js";
336import { allMessages as storeAllMessages, getBackfillMark as storeBackfillMark, runBackfill as storeRunBackfill } from "/static/store.js";
337import { decrypt } from "/static/crypto.js";
338import { threadKeys, threadName } from "/static/shellstate.js";
339import { mutedPeople, blockedPeople } from "/static/moderation.js";
340import { formatBytes, relTime } from "/static/format.js";
341import { openLightbox } from "/static/lightbox.js";
342import { decodeBlurhash } from "/static/blurhash.js";
343import { jumpToMessage, fetchMessagePage } from "/static/searchui.js";
344
345// Shell behaviours injected at boot (Task 4): the blob loader (decrypt +
346// object-URL cache) and the unfurl fetcher โ€” the same functions the chat
347// app's host bridge hands out.
348let deps = { loadBlob: async () => null, unfurl: async () => null };
349export function wireMediaView(d) {
350 deps = { ...deps, ...d };
351}
352
353export const mediaIdx = new ThreadMediaIndex(async (row, tid) => {
354 try {
355 return await decrypt(threadKeys[tid], row.blob);
356 } catch {
357 return null;
358 }
359});
360
361// Live edit/delete frames re-extract / drop (Task 4 calls this beside the
362// store's own applyEditRow/deleteMessageRow mirrors).
363export function mediaInvalidate(tid, sid) {
364 mediaIdx.invalidate(tid, sid);
365}
366
367const hiddenUids = () => new Set([...mutedPeople, ...blockedPeople]);
368
369// Tabs are DATA so a future Pinned tab is one array entry (spec ยง Non-goals).
370const TABS = [
371 { id: "media", label: "Media", render: renderMediaTab },
372 { id: "links", label: "Links", render: renderLinksTab },
373 { id: "docs", label: "Docs", render: renderDocsTab },
374];
375
376let open = null; // { tid, root, walk: AbortController|null, tab } โ€” one overlay at a time
377
378export async function openMediaView(tid) {
379 closeMediaView();
380 const host = document.getElementById("thread-view");
381 if (!host || !threadKeys[tid]) return;
382 const root = document.createElement("div");
383 root.className = "media-overlay";
384 root.setAttribute("role", "dialog");
385 root.setAttribute("aria-modal", "false"); // the chat stays beneath, inert to pointer only
386 root.setAttribute("aria-label", `Media, links and docs โ€” ${threadName[tid] || "Chat"}`);
387 open = { tid, root, walk: null, tab: "media", returnFocus: document.activeElement };
388
389 const head = document.createElement("div");
390 head.className = "media-head";
391 const back = document.createElement("button");
392 back.className = "icon-btn media-back";
393 back.setAttribute("aria-label", "Back to chat");
394 back.textContent = "โ€น";
395 back.onclick = () => closeMediaView();
396 const title = document.createElement("div");
397 title.className = "media-title";
398 title.textContent = "Media, links and docs";
399 head.append(back, title);
400
401 const seg = document.createElement("div");
402 seg.className = "media-tabs";
403 seg.setAttribute("role", "tablist");
404 seg.setAttribute("aria-label", "Kind");
405 const body = document.createElement("div");
406 body.className = "media-body";
407 // ONE polite live region for the walk's narration; sits in layout (never a
408 // toast), holds the Stop control beside the text.
409 const progress = document.createElement("div");
410 progress.className = "media-progress hidden";
411 const progressText = document.createElement("span");
412 progressText.setAttribute("aria-live", "polite");
413 const stop = document.createElement("button");
414 stop.type = "button";
415 stop.className = "media-stop";
416 stop.textContent = "Stop";
417 stop.onclick = () => open && open.walk && open.walk.abort();
418 progress.append(progressText, stop);
419
420 root.append(head, seg, progress, body);
421 host.appendChild(root);
422 document.addEventListener("keydown", onOverlayKey);
423 back.focus(); // focus lands in the overlay; Escape/back returns it
424
425 const paintTabs = () => {
426 const counts = mediaIdx.counts(tid, { hiddenUids: hiddenUids() });
427 seg.replaceChildren();
428 for (const t of TABS) {
429 const b = document.createElement("button");
430 b.type = "button";
431 b.className = "media-tab" + (open.tab === t.id ? " on" : "");
432 b.setAttribute("role", "tab");
433 b.setAttribute("aria-selected", String(open.tab === t.id));
434 b.id = `media-tab-${t.id}`;
435 const n = counts[t.id];
436 b.textContent = n ? `${t.label} ยท ${n.toLocaleString()}` : t.label;
437 b.onclick = () => {
438 open.tab = t.id;
439 paintTabs();
440 paintBody();
441 };
442 seg.appendChild(b);
443 }
444 };
445 const paintBody = () => {
446 const t = TABS.find((x) => x.id === open.tab);
447 body.replaceChildren();
448 body.setAttribute("role", "tabpanel");
449 body.setAttribute("aria-labelledby", `media-tab-${open.tab}`);
450 const entries = mediaIdx.tab(tid, open.tab, { hiddenUids: hiddenUids() });
451 if (!entries.length) {
452 const empty = document.createElement("div");
453 empty.className = "media-empty";
454 empty.textContent = { media: "No photos or videos shared yet.", links: "No links shared yet.", docs: "No files shared yet." }[open.tab];
455 body.appendChild(empty);
456 return;
457 }
458 t.render(body, entries, tid);
459 };
460 open.repaint = () => {
461 paintTabs();
462 paintBody();
463 };
464
465 // Instant: what the store already holds. Then the one-time walk (below).
466 await mediaIdx.ensureThread(tid, await storeAllMessages(tid));
467 if (!openIs(tid)) return;
468 open.repaint();
469 autoBackfill(tid, progress, progressText);
470}
471
472function openIs(tid) {
473 return open && open.tid === tid;
474}
475
476export function closeMediaView() {
477 if (!open) return;
478 if (open.walk) open.walk.abort();
479 document.removeEventListener("keydown", onOverlayKey);
480 const focus = open.returnFocus;
481 open.root.remove();
482 open = null;
483 if (focus && focus.isConnected) focus.focus();
484}
485
486function onOverlayKey(e) {
487 if (e.key === "Escape" && open) {
488 e.preventDefault();
489 closeMediaView();
490 }
491}
492
493// Auto-run the resumable per-thread walk so the tabs become COMPLETE, not
494// just cached (spec ยง Completeness). Quiet when already complete.
495async function autoBackfill(tid, progress, progressText) {
496 const mark = await storeBackfillMark(tid);
497 if (!openIs(tid) || (mark && mark.complete)) return;
498 const controller = new AbortController();
499 open.walk = controller;
500 progress.classList.remove("hidden");
501 progressText.textContent = "Loading earlier messagesโ€ฆ";
502 try {
503 await storeRunBackfill(tid, (before) => fetchMessagePage(tid, before), {
504 signal: controller.signal,
505 onPage: async ({ oldestAt, rows }) => {
506 await mediaIdx.ensureThread(tid, rows || []);
507 if (!openIs(tid)) return;
508 const d = oldestAt ? new Date(oldestAt * 1000) : null;
509 progressText.textContent = d
510 ? `Loading earlier messagesโ€ฆ back to ${d.toLocaleDateString([], { month: "long", year: "numeric" })}`
511 : "Loading earlier messagesโ€ฆ";
512 open.repaint();
513 },
514 });
515 } finally {
516 if (openIs(tid)) {
517 open.walk = null;
518 progress.classList.add("hidden");
519 open.repaint();
520 }
521 }
522}
523
524// ---- the three tab renderers ----------------------------------------------
525
526// Month sections wrap each tab the same way; the item painter differs.
527function monthSections(body, entries, paintItem, containerClass) {
528 for (const bucket of bucketByMonth(entries)) {
529 const h = document.createElement("div");
530 h.className = "media-month";
531 h.textContent = bucket.label;
532 body.appendChild(h);
533 const wrap = document.createElement("div");
534 wrap.className = containerClass;
535 for (const e of bucket.items) wrap.appendChild(paintItem(e));
536 body.appendChild(wrap);
537 }
538}
539
540// Lazy blob loading: tiles carry their blurhash/aspect immediately; the blob
541// decrypts only when scrolled into view. One observer per overlay open.
542let tileObserver = null;
543function observeTile(el, onVisible) {
544 if (!tileObserver) {
545 tileObserver = new IntersectionObserver(
546 (ents) => {
547 for (const en of ents) {
548 if (!en.isIntersecting) continue;
549 tileObserver.unobserve(en.target);
550 en.target._onVisible && en.target._onVisible();
551 }
552 },
553 { rootMargin: "200px" },
554 );
555 }
556 el._onVisible = onVisible;
557 tileObserver.observe(el);
558}
559
560function renderMediaTab(body, entries, tid) {
561 monthSections(
562 body,
563 entries,
564 (e) => {
565 const a = e.att;
566 const tile = document.createElement("button");
567 tile.type = "button";
568 tile.className = "media-tile";
569 const isVideo = (a.type || "").startsWith("video/");
570 tile.setAttribute("aria-label", `${isVideo ? "Video" : "Photo"} from ${e.name || "someone"}, ${relTime(Math.floor(e.ts / 1000))}`);
571 const img = document.createElement("img");
572 img.alt = "";
573 img.draggable = false;
574 if (a.w && a.h) img.style.aspectRatio = `${a.w} / ${a.h}`;
575 // BlurHash ghost until the blob decrypts โ€” same recipe as the feed's
576 // photo bubbles (chat.js), painted as the img's background.
577 if (a.bh) {
578 try {
579 const pw = 32, ph = a.w && a.h ? Math.max(1, Math.round((32 * a.h) / a.w)) : 32;
580 const px = decodeBlurhash(a.bh, pw, ph);
581 if (px) {
582 const c = document.createElement("canvas");
583 c.width = pw;
584 c.height = ph;
585 c.getContext("2d").putImageData(new ImageData(px, pw, ph), 0, 0);
586 img.style.backgroundImage = `url(${c.toDataURL()})`;
587 img.style.backgroundSize = "cover";
588 }
589 } catch { /* a bad hash keeps the flat placeholder */ }
590 }
591 tile.appendChild(img);
592 if (isVideo && a.dur) {
593 const badge = document.createElement("span");
594 badge.className = "media-dur";
595 const s = Math.max(0, Math.round(a.dur));
596 badge.textContent = `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
597 tile.appendChild(badge);
598 }
599 observeTile(tile, async () => {
600 // Videos show their (much smaller) poster frame; images their blob.
601 const att = isVideo && a.poster ? { id: a.poster, type: "image/jpeg" } : a;
602 const url = await deps.loadBlob(tid, att);
603 if (url) img.src = url;
604 });
605 tile.onclick = async () => {
606 const url = await deps.loadBlob(tid, isVideo && a.poster ? { id: a.poster, type: "image/jpeg" } : a);
607 if (url) openLightbox(url, img); // PR 2 upgrades this to collection swipe
608 };
609 addShowInChat(tile, tid, e.sid);
610 return tile;
611 },
612 "media-grid",
613 );
614}
615
616function renderLinksTab(body, entries, tid) {
617 monthSections(
618 body,
619 entries,
620 (e) => {
621 const row = document.createElement("a");
622 row.className = "media-link-row";
623 row.href = e.url;
624 row.target = "_blank";
625 row.rel = "noopener noreferrer";
626 const urlEl = document.createElement("span");
627 urlEl.className = "media-link-url";
628 urlEl.textContent = e.url;
629 const who = document.createElement("span");
630 who.className = "media-item-who";
631 who.textContent = `${e.name || "Someone"} ยท ${relTime(Math.floor(e.ts / 1000))}`;
632 row.append(urlEl, who);
633 // The unfurl card fills in lazily; a failed unfurl leaves the plain row.
634 observeTile(row, async () => {
635 const card = await deps.unfurl(e.url);
636 if (!card || !card.ok || !card.title) return;
637 const t = document.createElement("span");
638 t.className = "media-link-title";
639 t.textContent = card.title;
640 row.prepend(t);
641 });
642 addShowInChat(row, tid, e.sid);
643 return row;
644 },
645 "media-list",
646 );
647}
648
649function renderDocsTab(body, entries, tid) {
650 monthSections(
651 body,
652 entries,
653 (e) => {
654 const a = e.att;
655 const row = document.createElement("a");
656 row.className = "media-doc-row";
657 row.href = "#";
658 row.download = a.name || "file";
659 row.addEventListener("click", (ev) => {
660 if (row.getAttribute("href") === "#") ev.preventDefault();
661 });
662 const nm = document.createElement("span");
663 nm.className = "media-doc-name";
664 nm.textContent = a.name || "file";
665 const sub = document.createElement("span");
666 sub.className = "media-item-who";
667 sub.textContent = [a.size ? formatBytes(a.size) : "", `${e.name || "Someone"} ยท ${relTime(Math.floor(e.ts / 1000))}`]
668 .filter(Boolean)
669 .join(" ยท ");
670 row.append(nm, sub);
671 // The blob decrypts on approach so the tap is an instant save.
672 observeTile(row, async () => {
673 const url = await deps.loadBlob(tid, a);
674 if (url) row.href = url;
675 });
676 addShowInChat(row, tid, e.sid);
677 return row;
678 },
679 "media-list",
680 );
681}
682
683// Every item's second affordance: land on the source message. A visible
684// sibling button (โ‰ฅ24px target) rather than a long-press-only gesture, so
685// it's reachable by keyboard and screen reader too.
686function addShowInChat(el, tid, sid) {
687 const b = document.createElement("button");
688 b.type = "button";
689 b.className = "media-jump";
690 b.setAttribute("aria-label", "Show in chat");
691 b.textContent = "โ†ฉ";
692 b.onclick = (e) => {
693 e.preventDefault();
694 e.stopPropagation();
695 closeMediaView();
696 jumpToMessage(tid, sid);
697 };
698 el.appendChild(b);
699}
700```
701
702- [ ] **Step 2: Append the styles to `web/static/style.css`**
703
704```css
705/* ---- Media, links & docs overlay (mediaview.js) ---- */
706.media-overlay {
707 position: absolute;
708 inset: 0;
709 z-index: 30; /* same layer as the reply-thread overlay */
710 display: flex;
711 flex-direction: column;
712 background: var(--bg, #fff);
713}
714.thread-view { position: relative; } /* the overlay bounds to the thread pane */
715.media-head { display: flex; align-items: center; gap: 8px; padding: 10px 12px; }
716.media-title { font-weight: 600; }
717.media-tabs { display: flex; gap: 6px; padding: 0 12px 10px; }
718.media-tab {
719 padding: 6px 12px;
720 border-radius: 999px;
721 border: 1px solid var(--border, #ccc);
722 background: none;
723 cursor: pointer;
724 min-height: 32px;
725}
726.media-tab.on { background: var(--accent, #7c5cff); color: #fff; border-color: transparent; }
727.media-progress { display: flex; align-items: center; gap: 10px; padding: 6px 14px; font-size: 0.85em; opacity: 0.8; }
728.media-stop { border: none; background: none; text-decoration: underline; cursor: pointer; padding: 4px 8px; }
729.media-body { flex: 1; overflow-y: auto; padding: 0 12px 16px; }
730.media-month { font-size: 0.8em; opacity: 0.65; margin: 14px 2px 6px; }
731.media-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; }
732@media (min-width: 900px) { .media-grid { grid-template-columns: repeat(4, 1fr); } }
733.media-tile { position: relative; border: none; padding: 0; background: var(--bubble, #eee); border-radius: 6px; overflow: hidden; cursor: zoom-in; aspect-ratio: 1; }
734.media-tile img { width: 100%; height: 100%; object-fit: cover; display: block; }
735.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; }
736.media-list { display: flex; flex-direction: column; gap: 6px; }
737.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; }
738.media-link-title { font-weight: 600; }
739.media-link-url { font-size: 0.85em; opacity: 0.75; overflow-wrap: anywhere; }
740.media-doc-name { font-weight: 500; overflow-wrap: anywhere; }
741.media-item-who { font-size: 0.78em; opacity: 0.6; }
742.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; }
743.media-tile .media-jump { top: auto; bottom: 4px; transform: none; background: rgba(0, 0, 0, 0.5); color: #fff; opacity: 0; }
744.media-tile:hover .media-jump, .media-tile:focus-within .media-jump { opacity: 1; }
745.media-jump:hover, .media-jump:focus-visible { opacity: 1; }
746.media-empty { text-align: center; opacity: 0.6; padding: 40px 0; }
747```
748
749Match 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.
750
751- [ ] **Step 3: Verify + commit**
752
753Run: `node --check web/static/mediaview.js` โ€” passes.
754
755```bash
756git add web/static/mediaview.js web/static/style.css
757git commit -m "web: mediaview.js โ€” the Media/Links/Docs overlay"
758```
759
760### Task 4: shell + kebab wiring, live invalidation, changelog
761
762**Files:**
763- Modify: `web/pages/index.html` (one button)
764- Modify: `web/static/shell.js` (import, wire, kebab handler, header paint, invalidation)
765- Create: `changelog/2026-08-05-media-links-docs.md`
766
767**Interfaces:**
768- Consumes: `wireMediaView`, `openMediaView`, `closeMediaView`, `mediaInvalidate` from Task 3.
769
770- [ ] **Step 1: Add the kebab item**
771
772In `web/pages/index.html`, directly after the `thread-members` button (line ~238), add:
773
774```html
775<button class="menu-item" id="thread-media" role="menuitem">Media, links and docs</button>
776```
777
778- [ ] **Step 2: Wire shell.js**
779
7801. With the other module imports (near `import { scheduleMessageSearch, ... }` at shell.js:157):
781
782```js
783import { wireMediaView, openMediaView, closeMediaView, mediaInvalidate } from "/static/mediaview.js"; // Media/Links/Docs overlay
784```
785
7862. 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):
787
788```js
789wireMediaView({ loadBlob: (threadId, att) => loadBlob(att.id, threadId, att.type), unfurl });
790```
791
792(If the shell's unfurl helper has a different local name, grep `unfurl:` in the host-bridge object and pass that function.)
793
7943. Kebab handler, beside the others (shell.js:3116):
795
796```js
797document.getElementById("thread-media").onclick = () => {
798 closeTMenu();
799 openMediaView(activeId);
800};
801```
802
8034. 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:
804
805```js
806const mediaItem = document.getElementById("thread-media");
807if (mediaItem) mediaItem.style.display = chatAppFor(id) ? "" : "none";
808```
809
8105. 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.
811
812- [ ] **Step 3: Changelog entry**
813
814Create `changelog/2026-08-05-media-links-docs.md`:
815
816```markdown
817๐Ÿค– Every chat now has a "Media, links and docs" view โ€” open the โ‹ฎ menu in any
818conversation to browse all the photos and videos, links, and files ever shared
819there, grouped by month. The first open quietly fetches the chat's older
820history (you can stop it); after that it's instant. Tap any item to open it,
821or use โ†ฉ to jump to where it was shared.
822```
823
824- [ ] **Step 4: Verify + commit**
825
826Run: `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).
827
828```bash
829git add web/pages/index.html web/static/shell.js changelog/2026-08-05-media-links-docs.md
830git commit -m "web: Media/Links/Docs kebab entry + shell wiring + changelog"
831```
832
833### Task 5: PR 1 verification
834
835- [ ] **Step 1: Full suites** โ€” `npm test`, `go test ./...`, `node --check` on every touched JS file.
836- [ ] **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.
837- [ ] **Step 3: Open PR 1** with the spec linked; squash-merge target `main`.
838
839---
840
841# PR 2 โ€” lightbox collection swipe (branch: `lightbox-collection`)
842
843### Task 6: pure navigation helpers
844
845**Files:**
846- Modify: `web/static/lightbox.js` (add two exported pure helpers at top)
847- Modify: `package.json` (add the test after `test/threadmedia.test.mjs`)
848- Test: `test/lightbox.test.mjs` (new)
849
850**Interfaces:**
851- 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.
852
853- [ ] **Step 1: Write the failing tests**
854
855```js
856// Collection-swipe decision logic for the lightbox (web/static/lightbox.js).
857import assert from "node:assert/strict";
858import { stepIndex, dragIntent } from "../web/static/lightbox.js";
859
860let failures = 0;
861function test(name, fn) {
862 try { fn(); console.log("PASS " + name); } catch (e) { console.log("FAIL " + name + " โ€” " + e.message); failures++; }
863}
864
865test("stepIndex clamps at both ends, no wrap", () => {
866 assert.equal(stepIndex(0, -1, 5), 0);
867 assert.equal(stepIndex(4, 1, 5), 4);
868 assert.equal(stepIndex(2, 1, 5), 3);
869});
870
871test("a mostly-horizontal committed drag navigates against the drag direction", () => {
872 assert.equal(dragIntent(-120, 10, -0.8, 0), "nav-next"); // dragged left โ†’ next
873 assert.equal(dragIntent(120, -10, 0.8, 0), "nav-prev");
874});
875
876test("a fast horizontal flick navigates even at small distance", () => {
877 assert.equal(dragIntent(-40, 5, -0.7, 0), "nav-next");
878});
879
880test("a mostly-vertical drag still dismisses; a small slow drag settles", () => {
881 assert.equal(dragIntent(20, 200, 0, 0.9), "dismiss");
882 assert.equal(dragIntent(10, 8, 0.01, 0.01), "settle");
883});
884
885process.exit(failures ? 1 : 0);
886```
887
888- [ ] **Step 2: Run โ€” fails** (`stepIndex` not exported).
889
890- [ ] **Step 3: Implement in `lightbox.js`** (top of file, before `openLightbox`):
891
892```js
893// Pure decision helpers for collection mode, exported for Node tests.
894// A horizontal gesture navigates; vertical keeps the flick-dismiss. Thresholds
895// mirror the dismiss fling's own (speed 0.55 px/ms, reach 130px).
896export function stepIndex(i, delta, len) {
897 return Math.max(0, Math.min(len - 1, i + delta));
898}
899
900export function dragIntent(tx, ty, vx, vy) {
901 const horizontal = Math.abs(tx) > Math.abs(ty) * 1.5;
902 if (horizontal && (Math.abs(tx) > 80 || Math.abs(vx) > 0.5)) return tx < 0 ? "nav-next" : "nav-prev";
903 if (Math.hypot(vx, vy) >= 0.55 || Math.hypot(tx, ty) >= 130) return "dismiss";
904 return "settle";
905}
906```
907
908- [ ] **Step 4: Run tests โ€” pass**; `node --check web/static/lightbox.js`; add to `package.json`; `npm test`.
909
910- [ ] **Step 5: Commit**
911
912```bash
913git add web/static/lightbox.js test/lightbox.test.mjs package.json
914git commit -m "web: lightbox collection nav decision helpers"
915```
916
917### Task 7: collection mode in `openLightbox`
918
919**Files:**
920- Modify: `web/static/lightbox.js`, `web/static/style.css`
921
922**Interfaces:**
923- 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.
924
925- [ ] **Step 1: Extend the signature + state**
926
927Change the declaration to `export function openLightbox(url, source, opts = {})` and add after the gesture-state block:
928
929```js
930 // ---- collection mode (opts.collection): swipe/arrow between items -------
931 const coll = opts.collection || null;
932 let collIndex = coll ? coll.index : 0;
933 const liveSource = () => {
934 // The fly-out targets the CURRENT item's tile when the grid still shows
935 // it; falls back to the original tap's element (or none โ€” plain fade).
936 if (!coll) return source;
937 const el = coll.items[collIndex].source && coll.items[collIndex].source();
938 return el && el.isConnected ? el : source;
939 };
940```
941
942Replace every later read of `source` inside `runOpenAnim`, `animateClose`, and `sourceRadius` calls with `liveSource()` (the two-arg path returns `source` unchanged).
943
944- [ ] **Step 2: The navigation action**
945
946Add beside `springBack`:
947
948```js
949 // Move to a neighbour: quick fade + src swap on the ONE img element (the
950 // FLIP fly-in stays an open/close-only effect), neighbours preloading on
951 // approach so a swipe usually lands on a ready decrypted URL.
952 let navBusy = false;
953 const navTo = async (delta) => {
954 if (!coll || navBusy) return;
955 const next = stepIndex(collIndex, delta, coll.items.length);
956 if (next === collIndex) return springBack();
957 navBusy = true;
958 const url2 = await coll.items[next].load();
959 navBusy = false;
960 if (!url2 || closing) return springBack();
961 collIndex = next;
962 scale = 1; tx = 0; ty = 0; rot = 0;
963 img.style.transition = reduceMotion ? "none" : "opacity 0.15s";
964 img.style.opacity = "0";
965 setTimeout(() => {
966 img.src = url2;
967 img.style.transform = "translate3d(0,0,0)";
968 img.style.opacity = "1";
969 setBase();
970 paintNav();
971 const n = stepIndex(collIndex, delta, coll.items.length);
972 if (n !== collIndex) coll.items[n].load(); // preload the next neighbour
973 }, reduceMotion ? 0 : 150);
974 };
975```
976
977- [ ] **Step 3: Controls**
978
979In 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`:
980
981```js
982 const intent = coll ? dragIntent(tx, ty, vx, vy) : null;
983 if (intent === "nav-next" || intent === "nav-prev") {
984 armSuppress();
985 navTo(intent === "nav-next" ? 1 : -1);
986 return;
987 }
988```
989
990(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.
991
992- [ ] **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.
993
994- [ ] **Step 5: Commit**
995
996```bash
997git add web/static/lightbox.js web/static/style.css
998git commit -m "web: lightbox collection mode โ€” swipe/arrows between photos"
999```
1000
1001### Task 8: media grid adopts collections
1002
1003**Files:**
1004- Modify: `web/static/mediaview.js` (`renderMediaTab` tap handler)
1005
1006**Interfaces:**
1007- Consumes: `openLightbox(url, source, { collection })` from Task 7.
1008
1009- [ ] **Step 1: Build the collection at tap time**
1010
1011In `renderMediaTab`, images (and video posters) share one collection over the tab's current entries. Replace the `tile.onclick` body:
1012
1013```js
1014 tile.onclick = async () => {
1015 const all = mediaIdx.tab(tid, "media", { hiddenUids: hiddenUids() });
1016 const items = all.map((x) => {
1017 const xa = x.att;
1018 const still = (xa.type || "").startsWith("video/") && xa.poster ? { id: xa.poster, type: "image/jpeg" } : xa;
1019 return {
1020 load: () => deps.loadBlob(tid, still),
1021 source: () => document.querySelector(`.media-tile[data-sid="${x.sid}"][data-att="${xa.id}"] img`),
1022 };
1023 });
1024 const index = all.findIndex((x) => x.sid === e.sid && x.att.id === a.id);
1025 const url = await items[index].load();
1026 if (url) openLightbox(url, img, { collection: { items, index } });
1027 };
1028```
1029
1030and stamp each tile in the painter: `tile.dataset.sid = e.sid; tile.dataset.att = a.id;`.
1031
1032- [ ] **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.
1033
1034- [ ] **Step 3: Commit + PR 2**
1035
1036```bash
1037git add web/static/mediaview.js
1038git commit -m "web: media grid opens the lightbox in collection mode"
1039```
1040
1041Open PR 2. No changelog entry โ€” PR 1's entry covers the feature; swipe is part of it landing in the same deploy window.
1042
1043---
1044
1045## After PR 2
1046
1047Write 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.
1048