| 1 | # π€ Media, Links & Docs β design |
| 2 | |
| 3 | A per-thread view that makes everything ever *shared* in a chat β pictures, |
| 4 | links, files β quick to find, for any group or DM. The WhatsApp/Telegram |
| 5 | "Media, Links, Docs" mental model, built within lchat's constraint that the |
| 6 | server only ever holds opaque ciphertext. |
| 7 | |
| 8 | Status: approved design (Paul, 2026-08-05). Implementation plan to follow. |
| 9 | |
| 10 | ## The constraint that shapes everything |
| 11 | |
| 12 | The server has **zero** metadata distinguishing a message with attachments or |
| 13 | links β message rows are `{id, created_at, blob}` and the blob is AES-GCM |
| 14 | ciphertext (`db.go` `Message`). `blobs.thread_id` exists only for GC and can't |
| 15 | be joined back to a message. So this feature is **fully client-side over |
| 16 | decrypted payloads**, and requires **no server changes at all**: |
| 17 | |
| 18 | - **Web** builds on `web/static/store.js` β the IndexedDB ciphertext cache: |
| 19 | `allMessages(tid)` for the cached sweep, `runBackfill` for the resumable |
| 20 | deep-history walk (already used by search), decrypting in memory with the |
| 21 | thread key exactly as `searchui.js` does. |
| 22 | - **Native** is cheaper still: `MessageStore.swift` already persists decrypted |
| 23 | `MessagePayload` rows in protected SQLite, so the tabs are straight queries. |
| 24 | |
| 25 | The extracted index lives **in memory only on web** (like `SearchIndex`) β |
| 26 | `store.js` deliberately keeps only ciphertext at rest, and a plaintext media |
| 27 | index in IndexedDB would break that. The sealed-index upgrade path |
| 28 | (`docs/sealed-search-index.md`) covers this feature too, later. |
| 29 | |
| 30 | ## Goals |
| 31 | |
| 32 | - From any thread (group or DM), open a view listing all shared **media** |
| 33 | (images, GIFs, videos), **links**, and **docs** (other files), newest first. |
| 34 | - Instant on reopen; complete after a one-time per-thread history walk. |
| 35 | - Jump from any item back to its message in the chat. |
| 36 | - Swipe/arrow between photos in the lightbox from the media grid. |
| 37 | - Ships on web and native (iOS polished first, Mac in the same native change). |
| 38 | |
| 39 | ## Non-goals (named follow-ups) |
| 40 | |
| 41 | - **Pinned messages.** Doesn't exist as a feature yet ("pin" today pins the |
| 42 | *thread* in the sidebar). Building it means a new folded event type |
| 43 | (`pin`/`unpin` riding encrypted messages like reactions), event-vector |
| 44 | entries on both platforms, message-menu UI, group-permission decisions, and |
| 45 | probably a pinned banner β its own spec. The overlay's **tab bar is |
| 46 | data-driven** precisely so a Pinned tab slots in with near-zero rework once |
| 47 | the event exists; the deferral costs nothing. |
| 48 | - **Feed lightbox β thread collection.** Wiring the chat feed's photo taps |
| 49 | into the same thread-wide swipeable collection is a stated follow-up; this |
| 50 | change only makes the media grid use collection swipe. |
| 51 | - **Persisted (sealed) extraction index** on web β follows the sealed search |
| 52 | index work. |
| 53 | - Cross-thread ("all media everywhere") views, storage-management/"largest |
| 54 | files" views, media search. |
| 55 | |
| 56 | ## UX (web) |
| 57 | |
| 58 | ### Entry point |
| 59 | |
| 60 | A new **"Media, links and docs"** item in the thread kebab, between Members |
| 61 | and Apps β a `<button class="menu-item" id="thread-media">` in |
| 62 | `web/pages/index.html`, handler in `shell.js`, implementation exported from |
| 63 | `groupactions.js` (which owns the kebab's whole surface). **Not group-gated**: |
| 64 | it renders for DMs and groups alike (so it must not join the group-only id |
| 65 | loop in `paintThreadHeader`). Hidden for app-takeover threads that have no |
| 66 | chat feed. |
| 67 | |
| 68 | ### The overlay |
| 69 | |
| 70 | A **full-pane overlay** bounded to the feed box, patterned on the |
| 71 | reply-thread overlay in `apps/chat.js` (`threadSid`, z-index 30): the chat |
| 72 | stays mounted beneath; closing returns to it with focus restored to the kebab. |
| 73 | Header: back control, thread name, and a segmented control **Media | Links | |
| 74 | Docs**, each segment showing its count (pluralised + locale-formatted per the |
| 75 | counts convention). Tabs are **data** β an ordered array of |
| 76 | `{id, label, render}` β not hardcoded markup, so future tabs (Pinned) are |
| 77 | additive. |
| 78 | |
| 79 | Accessibility per the WCAG 2.2 AA bar: the overlay is keyboard-operable |
| 80 | (tab order into the segmented control and grid/list, Escape closes, focus |
| 81 | managed on open/close), tiles β₯24px targets with labelled controls, works in |
| 82 | light and dark, and any progress animation respects `prefers-reduced-motion`. |
| 83 | All untrusted strings (filenames, link URLs, unfurl titles) rendered via |
| 84 | `textContent` β never `innerHTML`. |
| 85 | |
| 86 | ### Media tab |
| 87 | |
| 88 | - 3β4 column grid (responsive), grouped under **month headers** |
| 89 | ("August 2026"), newest month first. |
| 90 | - Tiles render **instantly from payload metadata** before any blob is |
| 91 | fetched: aspect ratio from `w`/`h`, BlurHash placeholder from `bh`. Blobs |
| 92 | decrypt lazily via an `IntersectionObserver` β only tiles scrolled into |
| 93 | view call `host.loadBlob` (memoized in `blobURLs`). Videos use their |
| 94 | smaller **poster** blob for the tile plus a duration badge (`dur`); GIFs |
| 95 | show their still. |
| 96 | - Tap β lightbox with **collection swipe** (below). π€ v1 reality: videos |
| 97 | open their **poster still** in the lightbox, same as photos β there's no |
| 98 | in-grid video player yet. Playing the video rides **"Show in chat"** (jump |
| 99 | to the source message, which has the existing feed player). An in-grid |
| 100 | player that plays inline from the tile is a named follow-up, not v1 scope. |
| 101 | |
| 102 | ### Links tab |
| 103 | |
| 104 | - One row **per link occurrence**: all links in a message via |
| 105 | `tokenizeText(text)` `href` tokens (not just `firstLink`), newest first. |
| 106 | The same URL posted twice is two rows. |
| 107 | - Row: lazily-fetched unfurl card (`host.unfurl`, memoized; graceful |
| 108 | plain-URL row when unfurl fails), the URL, sender name, date. |
| 109 | - Tap opens the link (same `target=_blank rel=noopener` discipline as the |
| 110 | feed). |
| 111 | |
| 112 | ### Docs tab |
| 113 | |
| 114 | - Non-image/video attachments: file-type icon, name, size |
| 115 | (`formatBytes`), sender, date; newest first. |
| 116 | - Tap downloads/previews exactly as `buildFileCard` does in the feed. |
| 117 | |
| 118 | ### Show in chat |
| 119 | |
| 120 | Every item (long-press on touch, hover kebab on desktop β matching the feed's |
| 121 | message-action split) offers **"Show in chat"**: closes the overlay and |
| 122 | reuses search's `jumpToMessage` to page back to and flash the source message. |
| 123 | |
| 124 | ### Completeness: auto-backfill on open |
| 125 | |
| 126 | Opening the overlay shows cached results **instantly**, then auto-runs the |
| 127 | per-thread `runBackfill` in the background, streaming each older page through |
| 128 | the extractor into the tabs. A progress line (inline, occupying layout β no |
| 129 | floating toast) shows the walk ("Loading earlier messages⦠March 2024") with |
| 130 | a **Stop** control. The walk is resumable and its marks are **shared with |
| 131 | search** β either feature completing the walk completes it for both; once |
| 132 | marked complete, opens are instant and total. A thread mid-walk that's closed |
| 133 | resumes from its mark next open. |
| 134 | |
| 135 | ## Data flow (web) |
| 136 | |
| 137 | New pure module `web/static/threadmedia.js` (`media.js` is taken by the |
| 138 | image-processing helpers), Node-testable like `format.js`: |
| 139 | |
| 140 | - `extractEntries(data, meta)` β decrypted payload + `{id, ts}` β |
| 141 | `{media: [], links: [], docs: []}` classified entries. Rules: |
| 142 | - skip app events (`data.type`) and tombstones; |
| 143 | - attachments: `type` prefix `image/` (incl. GIFs) or `video/` β media, |
| 144 | everything else β docs; |
| 145 | - links: every `href` token from `tokenizeText(data.text)`; |
| 146 | - entries carry `{sid, ts, uid, name}` plus the attachment/link fields |
| 147 | needed to render without re-decrypting the row. |
| 148 | - `class ThreadMediaIndex` β in-memory per-thread accumulator mirroring |
| 149 | `SearchIndex`'s shape: `ensureThread(tid, rows)` with an injected |
| 150 | `decryptRow`, a `seen` set for idempotent re-folds, `addPage(rows)` for |
| 151 | backfill streaming, month-bucketed sorted output per tab. Invalidation: |
| 152 | edits re-extract the row (links can change); deletes drop its entries; |
| 153 | moderation-hidden senders (muted/blocked, via `moderation.js`) are |
| 154 | filtered at query time so un-muting doesn't need a re-scan. |
| 155 | |
| 156 | The overlay UI itself lives in a new `web/static/mediaview.js` (keeping |
| 157 | `groupactions.js` from growing another whole surface), registered through the |
| 158 | existing verbs/imports seams; `groupactions.js` exports the kebab entry that |
| 159 | opens it. |
| 160 | |
| 161 | ## Lightbox collection swipe |
| 162 | |
| 163 | `lightbox.js`'s `openLightbox` gains an optional collection form: |
| 164 | `openLightbox(url, source, { items, index })` β `items` an array of |
| 165 | `{url | load(), source?}`. Behaviour: |
| 166 | |
| 167 | - Horizontal **swipe** on touch, **β/β** on desktop, moving through the |
| 168 | collection; the FLIP fly-in/out still anchors to the tapped tile, and |
| 169 | fly-out targets the *current* item's tile when it's in view. |
| 170 | - Neighbours preload lazily (decrypt-on-approach, not the whole thread). |
| 171 | - Single-URL callers are untouched β existing feed/avatar behaviour is |
| 172 | byte-for-byte the same path. |
| 173 | |
| 174 | ## Native (iOS + Mac, one PR) |
| 175 | |
| 176 | - **Entry**: a "Media, Links & Docs" item in the thread menu |
| 177 | (`NativeChatScreen.swift`'s `Menu`), opening a screen (sheet on iOS, |
| 178 | window-pushed view on Mac) with the same three segmented tabs. |
| 179 | - **Data**: queries over `MessageStore`'s decrypted payload rows β decode, |
| 180 | filter with the existing `isImage`/`isVideo` helpers and a link tokenizer |
| 181 | matching `format.js` semantics (shared in LChatCore so iOS and Mac can't |
| 182 | drift). Month grouping and ordering match web. |
| 183 | - **Completeness**: `HistoryBackfill` auto-runs on open with the same |
| 184 | progress + stop affordance, sharing its existing marks. |
| 185 | - **Viewing**: the media grid feeds QuickLook's multi-item data source β |
| 186 | collection swipe is native behaviour there; files reuse `FilePreview`, |
| 187 | videos the existing player. Blur-hash placeholders via `Blurhash.swift`; |
| 188 | tiles load through `BlobImageLoader`/`BlobDataCache`. |
| 189 | - Accessibility: labels/traits on tiles, survives largest Dynamic Type. |
| 190 | |
| 191 | Logic that both platforms must agree on (classification rules, link |
| 192 | extraction) is specified by the extractor's Node tests + a matching Swift |
| 193 | test fixture β same discipline as the event vectors, but no vector-file |
| 194 | change: **this feature adds no event types and never touches the event |
| 195 | contract.** |
| 196 | |
| 197 | ## Edge cases |
| 198 | |
| 199 | - **Deleted messages**: tombstones excluded; a delete while the overlay is |
| 200 | open drops the entries live (same fold pipeline). |
| 201 | - **Edits**: re-extract; a link edited out disappears from Links. |
| 202 | - **Moderation**: muted/blocked senders' items hidden, live via |
| 203 | `onModerationChange`. |
| 204 | - **Sparse threads**: a thread with three photos in 10k messages still walks |
| 205 | the full history once β the progress line makes the wait legible, Stop |
| 206 | bails out, and the resumable mark means it never repeats. |
| 207 | - **Huge threads**: `allMessages` caps at 50k rows; the media view inherits |
| 208 | that cap and the store's documented above-watermark gap. Acceptable for |
| 209 | v1; noted for the sealed-index follow-up. |
| 210 | - **Ephemeral/relay-only frames** are never stored, so never appear. |
| 211 | - **Memory**: grid blob URLs are the same memoized `blobURLs` the feed uses; |
| 212 | the IntersectionObserver keeps decryption proportional to what's viewed. |
| 213 | |
| 214 | ## Testing |
| 215 | |
| 216 | - `test/threadmedia.test.mjs` β extractor unit tests: classification |
| 217 | (image/GIF/video/file), multi-link extraction, app-event/tombstone |
| 218 | skipping, edit/delete invalidation, month bucketing, idempotent re-folds. |
| 219 | - Lightbox collection logic: pure index/navigation helpers split out and |
| 220 | Node-tested; gestures verified by hand. |
| 221 | - Swift: fixture tests asserting classification + link extraction parity |
| 222 | with the extractor fixtures. |
| 223 | - `npm test` + `go test ./...` stay green untouched (no server or event |
| 224 | changes). Manual verification per stored preference: live canary |
| 225 | (`ops canary create`), though a canary exercises only the web side. |
| 226 | |
| 227 | ## Shipping |
| 228 | |
| 229 | PR series (small, each stands alone): |
| 230 | |
| 231 | 1. **web: extractor + overlay** β `threadmedia.js`, `mediaview.js`, kebab |
| 232 | entry, auto-backfill, tests. Changelog entry rides this PR. |
| 233 | 2. **web: lightbox collection swipe** β `lightbox.js` collection support, |
| 234 | media grid adopts it. (Until this lands, PR 1's grid taps open the |
| 235 | single-image lightbox β the feed's exact current behaviour.) |
| 236 | 3. **native: iOS + Mac tabs** β shared LChatCore logic, both UIs. Changelog |
| 237 | entry held until the release is minted, per convention. |
| 238 | |