# 🤖 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.
