eleven / ocho Public
🤖 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 byThreadMediaTests.swift, which mirrorstest/threadmedia.test.mjscase-for-case (the discipline the spec names — no event vectors change, the feature adds no event types).RichText.swiftgainsallLinks(_:)—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'sallMessages.HistoryBackfill.start(instance:threadID:threadKind:threadKeyB64:)— a per-thread walk beside the instance-wide search walk, taking the caller's already-unwrappedChatThread.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 theisGroupblock), hidden on app threads (appView == nil && !thread.isApp, the web'schatAppForgate). Opens a sheet: segmented Media | Links | Docs with localised counts, month sections, inline walk narration + Stop (never a toast). - Media grid:
LazyVGrid, BlurHash ghost →BlobImageLoaderthumbnail (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 —
previewItemAtis 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 viarefreshCurrentPreviewItem; 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 existingQuickLookPreview. - "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)
threadMenuitem (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'sstepIndexrule),BlobImageLoaderfull-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 —VNClassifyImageRequestscene labels at Apple's ≥90%-precision idiom, plusVNRecognizeTextRequestOCR (the sleeper hit: screenshots, whiteboards, menus) — over each still once (videos → poster), persisted per blob in MessageStore'simage_index(derived plaintext beside the decrypted payloads; never on a server;vercolumn 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 samefiltered(query:)viaimageTextFor. 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 testinios/LChatCore:ThreadMediaTests(10) green; the one standing failure (FlowEngineTests.testQuickFormTrailingChip) pre-exists on main, untouched here../tools/dev build macgreen; 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.