eleven / ocho Public
π€ 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,runBackfillfor the resumable deep-history walk (already used by search), decrypting in memory with the thread key exactly assearchui.jsdoes. - Native is cheaper still:
MessageStore.swiftalready persists decryptedMessagePayloadrows 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/unpinriding 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 frombh. Blobs decrypt lazily via anIntersectionObserverβ only tiles scrolled into view callhost.loadBlob(memoized inblobURLs). 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)hreftokens (not justfirstLink), 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=noopenerdiscipline as the feed).
Docs tab
- Non-image/video attachments: file-type icon, name, size (
formatBytes), sender, date; newest first. - Tap downloads/previews exactly as
buildFileCarddoes 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:typeprefiximage/(incl. GIFs) orvideo/β media, everything else β docs; - links: everyhreftoken fromtokenizeText(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 mirroringSearchIndex's shape:ensureThread(tid, rows)with an injecteddecryptRow, aseenset 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, viamoderation.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'sMenu), 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 existingisImage/isVideohelpers and a link tokenizer matchingformat.jssemantics (shared in LChatCore so iOS and Mac can't drift). Month grouping and ordering match web. - Completeness:
HistoryBackfillauto-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 viaBlurhash.swift; tiles load throughBlobImageLoader/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:
allMessagescaps 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
blobURLsthe 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):
- web: extractor + overlay β
threadmedia.js,mediaview.js, kebab entry, auto-backfill, tests. Changelog entry rides this PR. - web: lightbox collection swipe β
lightbox.jscollection support, media grid adopts it. (Until this lands, PR 1's grid taps open the single-image lightbox β the feed's exact current behaviour.) - native: iOS + Mac tabs β shared LChatCore logic, both UIs. Changelog entry held until the release is minted, per convention.