| 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 | |
| 46 | 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): |
| 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 |
| 51 | import assert from "node:assert/strict"; |
| 52 | import { extractEntries, bucketByMonth, monthLabel, ThreadMediaIndex } from "../web/static/threadmedia.js"; |
| 53 | |
| 54 | let failures = 0; |
| 55 | async 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 | |
| 65 | const meta = { id: 42, ts: 1754000000000 }; |
| 66 | |
| 67 | await 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 | |
| 80 | await 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 | |
| 85 | await 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 | |
| 91 | await 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 | |
| 97 | await 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 | |
| 101 | await 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 | |
| 112 | await 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 | |
| 131 | await 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 | |
| 142 | await 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 | |
| 151 | process.exit(failures ? 1 : 0); |
| 152 | ``` |
| 153 | |
| 154 | - [ ] **Step 2: Run tests, verify they fail** |
| 155 | |
| 156 | Run: `node test/threadmedia.test.mjs` |
| 157 | Expected: 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. |
| 168 | import { 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). |
| 173 | export 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 | |
| 198 | export 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. |
| 204 | export 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 | |
| 215 | export 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 | |
| 280 | Run: `node test/threadmedia.test.mjs` โ expected: all PASS, exit 0. |
| 281 | Run: `node --check web/static/threadmedia.js` |
| 282 | |
| 283 | - [ ] **Step 5: Add to the suite and commit** |
| 284 | |
| 285 | 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). |
| 286 | |
| 287 | ```bash |
| 288 | git add web/static/threadmedia.js test/threadmedia.test.mjs package.json |
| 289 | git 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 | |
| 302 | 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`). |
| 303 | |
| 304 | - [ ] **Step 2: Verify** |
| 305 | |
| 306 | Run: `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 |
| 311 | git add web/static/searchui.js |
| 312 | git 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 | |
| 325 | 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. |
| 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). |
| 335 | import { ThreadMediaIndex, bucketByMonth } from "/static/threadmedia.js"; |
| 336 | import { allMessages as storeAllMessages, getBackfillMark as storeBackfillMark, runBackfill as storeRunBackfill } from "/static/store.js"; |
| 337 | import { decrypt } from "/static/crypto.js"; |
| 338 | import { threadKeys, threadName } from "/static/shellstate.js"; |
| 339 | import { mutedPeople, blockedPeople } from "/static/moderation.js"; |
| 340 | import { formatBytes, relTime } from "/static/format.js"; |
| 341 | import { openLightbox } from "/static/lightbox.js"; |
| 342 | import { decodeBlurhash } from "/static/blurhash.js"; |
| 343 | import { 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. |
| 348 | let deps = { loadBlob: async () => null, unfurl: async () => null }; |
| 349 | export function wireMediaView(d) { |
| 350 | deps = { ...deps, ...d }; |
| 351 | } |
| 352 | |
| 353 | export 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). |
| 363 | export function mediaInvalidate(tid, sid) { |
| 364 | mediaIdx.invalidate(tid, sid); |
| 365 | } |
| 366 | |
| 367 | const hiddenUids = () => new Set([...mutedPeople, ...blockedPeople]); |
| 368 | |
| 369 | // Tabs are DATA so a future Pinned tab is one array entry (spec ยง Non-goals). |
| 370 | const TABS = [ |
| 371 | { id: "media", label: "Media", render: renderMediaTab }, |
| 372 | { id: "links", label: "Links", render: renderLinksTab }, |
| 373 | { id: "docs", label: "Docs", render: renderDocsTab }, |
| 374 | ]; |
| 375 | |
| 376 | let open = null; // { tid, root, walk: AbortController|null, tab } โ one overlay at a time |
| 377 | |
| 378 | export 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 | |
| 472 | function openIs(tid) { |
| 473 | return open && open.tid === tid; |
| 474 | } |
| 475 | |
| 476 | export 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 | |
| 486 | function 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. |
| 495 | async 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. |
| 527 | function 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. |
| 542 | let tileObserver = null; |
| 543 | function 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 | |
| 560 | function 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 | |
| 616 | function 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 | |
| 649 | function 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. |
| 686 | function 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 | |
| 749 | 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. |
| 750 | |
| 751 | - [ ] **Step 3: Verify + commit** |
| 752 | |
| 753 | Run: `node --check web/static/mediaview.js` โ passes. |
| 754 | |
| 755 | ```bash |
| 756 | git add web/static/mediaview.js web/static/style.css |
| 757 | git 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 | |
| 772 | In `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 | |
| 780 | 1. With the other module imports (near `import { scheduleMessageSearch, ... }` at shell.js:157): |
| 781 | |
| 782 | ```js |
| 783 | import { wireMediaView, openMediaView, closeMediaView, mediaInvalidate } from "/static/mediaview.js"; // Media/Links/Docs overlay |
| 784 | ``` |
| 785 | |
| 786 | 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): |
| 787 | |
| 788 | ```js |
| 789 | wireMediaView({ 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 | |
| 794 | 3. Kebab handler, beside the others (shell.js:3116): |
| 795 | |
| 796 | ```js |
| 797 | document.getElementById("thread-media").onclick = () => { |
| 798 | closeTMenu(); |
| 799 | openMediaView(activeId); |
| 800 | }; |
| 801 | ``` |
| 802 | |
| 803 | 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: |
| 804 | |
| 805 | ```js |
| 806 | const mediaItem = document.getElementById("thread-media"); |
| 807 | if (mediaItem) mediaItem.style.display = chatAppFor(id) ? "" : "none"; |
| 808 | ``` |
| 809 | |
| 810 | 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. |
| 811 | |
| 812 | - [ ] **Step 3: Changelog entry** |
| 813 | |
| 814 | Create `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 |
| 818 | conversation to browse all the photos and videos, links, and files ever shared |
| 819 | there, grouped by month. The first open quietly fetches the chat's older |
| 820 | history (you can stop it); after that it's instant. Tap any item to open it, |
| 821 | or use โฉ to jump to where it was shared. |
| 822 | ``` |
| 823 | |
| 824 | - [ ] **Step 4: Verify + commit** |
| 825 | |
| 826 | 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). |
| 827 | |
| 828 | ```bash |
| 829 | git add web/pages/index.html web/static/shell.js changelog/2026-08-05-media-links-docs.md |
| 830 | git 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). |
| 857 | import assert from "node:assert/strict"; |
| 858 | import { stepIndex, dragIntent } from "../web/static/lightbox.js"; |
| 859 | |
| 860 | let failures = 0; |
| 861 | function test(name, fn) { |
| 862 | try { fn(); console.log("PASS " + name); } catch (e) { console.log("FAIL " + name + " โ " + e.message); failures++; } |
| 863 | } |
| 864 | |
| 865 | test("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 | |
| 871 | test("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 | |
| 876 | test("a fast horizontal flick navigates even at small distance", () => { |
| 877 | assert.equal(dragIntent(-40, 5, -0.7, 0), "nav-next"); |
| 878 | }); |
| 879 | |
| 880 | test("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 | |
| 885 | process.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). |
| 896 | export function stepIndex(i, delta, len) { |
| 897 | return Math.max(0, Math.min(len - 1, i + delta)); |
| 898 | } |
| 899 | |
| 900 | export 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 |
| 913 | git add web/static/lightbox.js test/lightbox.test.mjs package.json |
| 914 | git 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 | |
| 927 | Change 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 | |
| 942 | Replace 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 | |
| 946 | Add 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 | |
| 979 | 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`: |
| 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 |
| 997 | git add web/static/lightbox.js web/static/style.css |
| 998 | git 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 | |
| 1011 | In `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 | |
| 1030 | and 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 |
| 1037 | git add web/static/mediaview.js |
| 1038 | git commit -m "web: media grid opens the lightbox in collection mode" |
| 1039 | ``` |
| 1040 | |
| 1041 | Open 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 | |
| 1047 | 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. |
| 1048 | |