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