eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1# 🤖 Media, Links & Docs (native) — iOS + Mac, one PR
2
3The 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).
6Both web PRs are merged (#1424, #1426); this PR is the third and final slice.
7Written alongside the implementation — it records the shape and the two
8decisions 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
29typed app events (`if (data.type) return null`). Native can't: MessageStore
30rows have been through `ChatFeedModel.renderable`, which rewrites app events
31into `type: "chat"` projections with `action: true` — erasing the
32discriminator. So the native guard is `isRenderableChat && action != true`,
33which knowingly also skips a member's /me lines (the web's Links tab keeps
34a /me line's URL): the two shapes are indistinguishable in the store, and
35letting 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
37rows carry a discriminator. Recorded in `ThreadMedia.swift`'s header and
38tested.
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