| 1 | # 🤖 Media, Links & Docs (native) — iOS + Mac, one PR |
| 2 | |
| 3 | The native half the web plan hands off to |
| 4 | (`2026-08-05-media-links-docs-web.md` § After PR 2; spec |
| 5 | `docs/superpowers/specs/2026-08-05-media-links-docs-design.md` § Native). |
| 6 | Both web PRs are merged (#1424, #1426); this PR is the third and final slice. |
| 7 | Written alongside the implementation — it records the shape and the two |
| 8 | decisions the spec left open, for the reviewer and the next reader. |
| 9 | |
| 10 | ## Shared logic (LChatCore — iOS and Mac can't drift) |
| 11 | |
| 12 | - **`ThreadMedia.swift`** — the web extractor's twin (`threadmedia.js`): |
| 13 | `extractEntries` / `extractAll` / `filtered` / `monthLabel` / |
| 14 | `bucketByMonth`. Parity is pinned by `ThreadMediaTests.swift`, which |
| 15 | mirrors `test/threadmedia.test.mjs` case-for-case (the discipline the spec |
| 16 | names — no event vectors change, the feature adds no event types). |
| 17 | - **`RichText.swift`** gains `allLinks(_:)` — `firstLink`'s loop without the |
| 18 | early return: every URL in order, fenced code skipped, quotes included. |
| 19 | - **`MessageStore.allMessages(instance:thread:limit:)`** — the extraction |
| 20 | sweep, capped at 50k rows like the web store's `allMessages`. |
| 21 | - **`HistoryBackfill.start(instance:threadID:threadKind:threadKeyB64:)`** — |
| 22 | a per-thread walk beside the instance-wide search walk, taking the |
| 23 | caller's already-unwrapped `ChatThread.threadKeyB64`. Same resumable |
| 24 | store marks: either feature finishing the walk finishes it for both. |
| 25 | Its narration reads "Loading earlier messages… back to March 2025" |
| 26 | (`progressLabel(_:loading:)`); the search walk's copy is unchanged. |
| 27 | |
| 28 | **One deliberate divergence from the web's extraction rule.** The web skips |
| 29 | typed app events (`if (data.type) return null`). Native can't: MessageStore |
| 30 | rows have been through `ChatFeedModel.renderable`, which rewrites app events |
| 31 | into `type: "chat"` projections with `action: true` — erasing the |
| 32 | discriminator. So the native guard is `isRenderableChat && action != true`, |
| 33 | which knowingly also skips a member's /me lines (the web's Links tab keeps |
| 34 | a /me line's URL): the two shapes are indistinguishable in the store, and |
| 35 | letting them through would fill Links with app-event noise. The cost — a |
| 36 | /me line's rare URL missing from native Links — is accepted until stored |
| 37 | rows carry a discriminator. Recorded in `ThreadMedia.swift`'s header and |
| 38 | tested. |
| 39 | |
| 40 | ## iOS (`ios/LChat/Views/MediaLinksDocsSheet.swift`) |
| 41 | |
| 42 | - Kebab item "Media, Links & Docs" in `NativeChatScreen`'s menu — DMs and |
| 43 | groups alike (not inside the `isGroup` block), hidden on app threads |
| 44 | (`appView == nil && !thread.isApp`, the web's `chatAppFor` gate). Opens a |
| 45 | sheet: segmented Media | Links | Docs with localised counts, month |
| 46 | sections, inline walk narration + Stop (never a toast). |
| 47 | - Media grid: `LazyVGrid`, BlurHash ghost → `BlobImageLoader` thumbnail |
| 48 | (video tiles show their poster + duration badge). Lazy by construction — |
| 49 | LazyVGrid only materialises visible tiles (the web's |
| 50 | IntersectionObserver, for free). |
| 51 | - **Collection swipe = Quick Look multi-item, staged lazily.** The spec |
| 52 | named QuickLook's multi-item data source; the open question was staging — |
| 53 | `previewItemAt` is synchronous, and staging a whole grid up-front is |
| 54 | unbounded. Resolution: a lazy data source (`MediaCollectionPreview`) that |
| 55 | returns a nil-URL item (Quick Look's own loading placeholder), decrypts |
| 56 | current±1 to a per-open temp dir, and swaps via |
| 57 | `refreshCurrentPreviewItem`; staged files outside the current±3 window are |
| 58 | burned as the user swipes on, and the whole dir on dismiss. Videos stage |
| 59 | their real bytes so Quick Look plays them in place — but only when landed |
| 60 | on, never as a neighbour prefetch (the app's standing "no whole-video |
| 61 | decrypt for a thumbnail" rule; their tiles use posters). |
| 62 | - Links: every occurrence, lazy unfurl title through the model's memoised |
| 63 | fetcher; taps open the browser. Docs: emoji file icon, `formatBytes`, |
| 64 | sender · time; tap stages + opens the existing `QuickLookPreview`. |
| 65 | - "Show in chat" is each item's context menu (long-press; VoiceOver |
| 66 | actions): dismisses the sheet and reuses the search hit's bounded |
| 67 | page-back walk (`pendingJump` → `jumpAndWave`). |
| 68 | |
| 69 | ## Mac (`ios/LChatMac/MediaLinksDocsSheet.swift`) |
| 70 | |
| 71 | - `threadMenu` item (all three menu surfaces at once), gated `!t.isApp`; |
| 72 | fixed-size sheet, Discover's idiom. |
| 73 | - **The spec's "QuickLook multi-item" vs the Mac's anti-QuickLook stance** |
| 74 | (`Lightbox.swift`: decrypted bytes never touch disk uninvited) resolves in |
| 75 | favour of the standing stance: photos open an **in-sheet, in-memory |
| 76 | collection viewer** — ‹ › buttons + ←/→ keys, clamped at the ends (the |
| 77 | web lightbox's `stepIndex` rule), `BlobImageLoader` full-size. A video |
| 78 | opens as its poster there; playing rides "Show in chat" (the feed's |
| 79 | in-memory player) — the web's v1 rule. |
| 80 | - Docs save to ~/Downloads with Finder-style "(2)" de-collision — the Mac |
| 81 | feed's own file behaviour — with an inline "Saved to Downloads" note. |
| 82 | - "Show in chat" selects the thread and sets `app.nativeJump` (the search |
| 83 | hit's path). |
| 84 | |
| 85 | ## Search (follow-up, same day) |
| 86 | |
| 87 | - **Search within a tab** — web, iOS, Mac: one pure `filterEntries` / |
| 88 | `ThreadMedia.filtered(query:)` (sender on every tab; URL + already-fetched |
| 89 | unfurl title on Links; filename on Docs), Node- and Swift-tested. |
| 90 | - **Image search — native-only for v1 (Paul, 2026-08-28).** `ImageSearchIndex` |
| 91 | (LChatCore) runs Apple's Vision on device — `VNClassifyImageRequest` scene |
| 92 | labels at Apple's ≥90%-precision idiom, plus `VNRecognizeTextRequest` OCR |
| 93 | (the sleeper hit: screenshots, whiteboards, menus) — over each still once |
| 94 | (videos → poster), persisted per blob in MessageStore's `image_index` |
| 95 | (derived plaintext beside the decrypted payloads; never on a server; |
| 96 | `ver` column so a recipe bump re-indexes). Runs on sheet open, one image |
| 97 | at a time off the main actor, narrated inline with Stop, resumable. Search |
| 98 | then matches what's *in* a photo through the same `filtered(query:)` via |
| 99 | `imageTextFor`. **Why native-only:** the E2EE rule demands on-device |
| 100 | inference; web has no free classifier — parity there means shipping a |
| 101 | model (MobileNet/CLIP via CDN, 5–20 MB, against the no-build-step posture |
| 102 | and the CSP). Revisit when a light model earns its weight. |
| 103 | |
| 104 | ## Verification |
| 105 | |
| 106 | - `swift test` in `ios/LChatCore`: `ThreadMediaTests` (10) green; the one |
| 107 | standing failure (`FlowEngineTests.testQuickFormTrailingChip`) pre-exists |
| 108 | on main, untouched here. |
| 109 | - `./tools/dev build mac` green; iOS target compiles |
| 110 | (`xcodebuild -destination generic/platform=iOS`, signing off). |
| 111 | - Hand-check on device/Mac against a canary rides the next session on a |
| 112 | machine with the iOS platform installed; changelog entry **held for the |
| 113 | release mint**, per convention. |
| 114 | |