eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

πŸ€– 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.