eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1# πŸ€– Media, Links & Docs β€” design
2
3A per-thread view that makes everything ever *shared* in a chat β€” pictures,
4links, 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
6server only ever holds opaque ciphertext.
7
8Status: approved design (Paul, 2026-08-05). Implementation plan to follow.
9
10## The constraint that shapes everything
11
12The server has **zero** metadata distinguishing a message with attachments or
13links β€” message rows are `{id, created_at, blob}` and the blob is AES-GCM
14ciphertext (`db.go` `Message`). `blobs.thread_id` exists only for GC and can't
15be joined back to a message. So this feature is **fully client-side over
16decrypted 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
25The extracted index lives **in memory only on web** (like `SearchIndex`) β€”
26`store.js` deliberately keeps only ciphertext at rest, and a plaintext media
27index 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
60A new **"Media, links and docs"** item in the thread kebab, between Members
61and 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**:
64it renders for DMs and groups alike (so it must not join the group-only id
65loop in `paintThreadHeader`). Hidden for app-takeover threads that have no
66chat feed.
67
68### The overlay
69
70A **full-pane overlay** bounded to the feed box, patterned on the
71reply-thread overlay in `apps/chat.js` (`threadSid`, z-index 30): the chat
72stays mounted beneath; closing returns to it with focus restored to the kebab.
73Header: back control, thread name, and a segmented control **Media | Links |
74Docs**, each segment showing its count (pluralised + locale-formatted per the
75counts convention). Tabs are **data** β€” an ordered array of
76`{id, label, render}` β€” not hardcoded markup, so future tabs (Pinned) are
77additive.
78
79Accessibility 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
81managed on open/close), tiles β‰₯24px targets with labelled controls, works in
82light and dark, and any progress animation respects `prefers-reduced-motion`.
83All 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
120Every item (long-press on touch, hover kebab on desktop β€” matching the feed's
121message-action split) offers **"Show in chat"**: closes the overlay and
122reuses search's `jumpToMessage` to page back to and flash the source message.
123
124### Completeness: auto-backfill on open
125
126Opening the overlay shows cached results **instantly**, then auto-runs the
127per-thread `runBackfill` in the background, streaming each older page through
128the extractor into the tabs. A progress line (inline, occupying layout β€” no
129floating toast) shows the walk ("Loading earlier messages… March 2024") with
130a **Stop** control. The walk is resumable and its marks are **shared with
131search** β€” either feature completing the walk completes it for both; once
132marked complete, opens are instant and total. A thread mid-walk that's closed
133resumes from its mark next open.
134
135## Data flow (web)
136
137New pure module `web/static/threadmedia.js` (`media.js` is taken by the
138image-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
156The overlay UI itself lives in a new `web/static/mediaview.js` (keeping
157`groupactions.js` from growing another whole surface), registered through the
158existing verbs/imports seams; `groupactions.js` exports the kebab entry that
159opens 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
191Logic that both platforms must agree on (classification rules, link
192extraction) is specified by the extractor's Node tests + a matching Swift
193test fixture β€” same discipline as the event vectors, but no vector-file
194change: **this feature adds no event types and never touches the event
195contract.**
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
229PR series (small, each stands alone):
230
2311. **web: extractor + overlay** β€” `threadmedia.js`, `mediaview.js`, kebab
232 entry, auto-backfill, tests. Changelog entry rides this PR.
2332. **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.)
2363. **native: iOS + Mac tabs** β€” shared LChatCore logic, both UIs. Changelog
237 entry held until the release is minted, per convention.
238