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