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 (native) — iOS + Mac, one PR

The native half the web plan hands off to (2026-08-05-media-links-docs-web.md § After PR 2; spec docs/superpowers/specs/2026-08-05-media-links-docs-design.md § Native). Both web PRs are merged (#1424, #1426); this PR is the third and final slice. Written alongside the implementation — it records the shape and the two decisions the spec left open, for the reviewer and the next reader.

Shared logic (LChatCore — iOS and Mac can't drift)

  • ThreadMedia.swift — the web extractor's twin (threadmedia.js): extractEntries / extractAll / filtered / monthLabel / bucketByMonth. Parity is pinned by ThreadMediaTests.swift, which mirrors test/threadmedia.test.mjs case-for-case (the discipline the spec names — no event vectors change, the feature adds no event types).
  • RichText.swift gains allLinks(_:) — firstLink's loop without the early return: every URL in order, fenced code skipped, quotes included.
  • MessageStore.allMessages(instance:thread:limit:) — the extraction sweep, capped at 50k rows like the web store's allMessages.
  • HistoryBackfill.start(instance:threadID:threadKind:threadKeyB64:) — a per-thread walk beside the instance-wide search walk, taking the caller's already-unwrapped ChatThread.threadKeyB64. Same resumable store marks: either feature finishing the walk finishes it for both. Its narration reads "Loading earlier messages… back to March 2025" (progressLabel(_:loading:)); the search walk's copy is unchanged.

One deliberate divergence from the web's extraction rule. The web skips typed app events (if (data.type) return null). Native can't: MessageStore rows have been through ChatFeedModel.renderable, which rewrites app events into type: "chat" projections with action: true — erasing the discriminator. So the native guard is isRenderableChat && action != true, which knowingly also skips a member's /me lines (the web's Links tab keeps a /me line's URL): the two shapes are indistinguishable in the store, and letting them through would fill Links with app-event noise. The cost — a /me line's rare URL missing from native Links — is accepted until stored rows carry a discriminator. Recorded in ThreadMedia.swift's header and tested.

iOS (ios/LChat/Views/MediaLinksDocsSheet.swift)

  • Kebab item "Media, Links & Docs" in NativeChatScreen's menu — DMs and groups alike (not inside the isGroup block), hidden on app threads (appView == nil && !thread.isApp, the web's chatAppFor gate). Opens a sheet: segmented Media | Links | Docs with localised counts, month sections, inline walk narration + Stop (never a toast).
  • Media grid: LazyVGrid, BlurHash ghost → BlobImageLoader thumbnail (video tiles show their poster + duration badge). Lazy by construction — LazyVGrid only materialises visible tiles (the web's IntersectionObserver, for free).
  • Collection swipe = Quick Look multi-item, staged lazily. The spec named QuickLook's multi-item data source; the open question was staging — previewItemAt is synchronous, and staging a whole grid up-front is unbounded. Resolution: a lazy data source (MediaCollectionPreview) that returns a nil-URL item (Quick Look's own loading placeholder), decrypts current±1 to a per-open temp dir, and swaps via refreshCurrentPreviewItem; staged files outside the current±3 window are burned as the user swipes on, and the whole dir on dismiss. Videos stage their real bytes so Quick Look plays them in place — but only when landed on, never as a neighbour prefetch (the app's standing "no whole-video decrypt for a thumbnail" rule; their tiles use posters).
  • Links: every occurrence, lazy unfurl title through the model's memoised fetcher; taps open the browser. Docs: emoji file icon, formatBytes, sender · time; tap stages + opens the existing QuickLookPreview.
  • "Show in chat" is each item's context menu (long-press; VoiceOver actions): dismisses the sheet and reuses the search hit's bounded page-back walk (pendingJump → jumpAndWave).

Mac (ios/LChatMac/MediaLinksDocsSheet.swift)

  • threadMenu item (all three menu surfaces at once), gated !t.isApp; fixed-size sheet, Discover's idiom.
  • The spec's "QuickLook multi-item" vs the Mac's anti-QuickLook stance (Lightbox.swift: decrypted bytes never touch disk uninvited) resolves in favour of the standing stance: photos open an in-sheet, in-memory collection viewer — ‹ › buttons + ←/→ keys, clamped at the ends (the web lightbox's stepIndex rule), BlobImageLoader full-size. A video opens as its poster there; playing rides "Show in chat" (the feed's in-memory player) — the web's v1 rule.
  • Docs save to ~/Downloads with Finder-style "(2)" de-collision — the Mac feed's own file behaviour — with an inline "Saved to Downloads" note.
  • "Show in chat" selects the thread and sets app.nativeJump (the search hit's path).

Search (follow-up, same day)

  • Search within a tab — web, iOS, Mac: one pure filterEntries / ThreadMedia.filtered(query:) (sender on every tab; URL + already-fetched unfurl title on Links; filename on Docs), Node- and Swift-tested.
  • Image search — native-only for v1 (Paul, 2026-08-28). ImageSearchIndex (LChatCore) runs Apple's Vision on device — VNClassifyImageRequest scene labels at Apple's ≥90%-precision idiom, plus VNRecognizeTextRequest OCR (the sleeper hit: screenshots, whiteboards, menus) — over each still once (videos → poster), persisted per blob in MessageStore's image_index (derived plaintext beside the decrypted payloads; never on a server; ver column so a recipe bump re-indexes). Runs on sheet open, one image at a time off the main actor, narrated inline with Stop, resumable. Search then matches what's in a photo through the same filtered(query:) via imageTextFor. Why native-only: the E2EE rule demands on-device inference; web has no free classifier — parity there means shipping a model (MobileNet/CLIP via CDN, 5–20 MB, against the no-build-step posture and the CSP). Revisit when a light model earns its weight.

Verification

  • swift test in ios/LChatCore: ThreadMediaTests (10) green; the one standing failure (FlowEngineTests.testQuickFormTrailingChip) pre-exists on main, untouched here.
  • ./tools/dev build mac green; iOS target compiles (xcodebuild -destination generic/platform=iOS, signing off).
  • Hand-check on device/Mac against a canary rides the next session on a machine with the iOS platform installed; changelog entry held for the release mint, per convention.