| 1 | # ๐ค Native chat feed (Mac first) |
| 2 | |
| 3 | > **Update 2026-07-25 โ the takeover is COMPLETE on iOS too.** NativeChatScreen |
| 4 | > is the only chat surface (plain chats and app-thread Chat views alike); |
| 5 | > `ChatView` โ the per-thread webview feed this document plans around โ is |
| 6 | > **deleted**. Webviews on both native apps now carry only an app's own |
| 7 | > interface (iOS `AppThreadScreen`/`AppInterfacePane`, Mac `appInterfacePane`); |
| 8 | > chat never renders in a webview, by policy. The status note below is the |
| 9 | > historical hand-off brief from the Mac takeover era โ read it as history. |
| 10 | |
| 11 | > Status: **THE TAKEOVER SHIPPED (2026-07-07)** โ native chat is the Mac's |
| 12 | > main surface: NativeChatView = native thread list (sidebar header with |
| 13 | > search/new-chat/โฎ, pin/close context menus, live via an instance-wide |
| 14 | > socket) beside NativeFeedView (the former debug-pane feed, extracted). The |
| 15 | > webview is the fallback (View โธ Use Web App, โฅโW) and still owns mini-apps, |
| 16 | > admin, profile, joining; identity-less instances fall through to it. Feed |
| 17 | > parity held from the pane era (all live-verified): every message type + |
| 18 | > composer with typeahead, uploads/progress, actions (reply/edit/delete/react |
| 19 | > via the Messages-style right-click NSMenu), Show Times gutter, drag in/out, |
| 20 | > block formatting, link previews, click-to-play YouTube (iframe-shell embed; |
| 21 | > plays in the lightbox), scroll-back with mid-viewport anchor restore + |
| 22 | > 8-rows-early prefetch, and no-flash thread switching via |
| 23 | > NativeFeedModelCache (parked live models + prewarm โ the FeedWebCache rule, |
| 24 | > now in LChatCore). **iOS adoption started (2026-07-08 overnight)**: the |
| 25 | > typeahead brain is native in ChatView (no JS round-trip), and |
| 26 | > NativeChatScreen (shared pieces, phone container) ships behind the server |
| 27 | > drawer's "Native chat (beta)" toggle โ webview ChatView stays the default. |
| 28 | > Cross-impl gates: thread-key, push-blob, message-payload, attachment-bytes, |
| 29 | > nativeโweb message, + format.js parity incl. blockTokens and youtubeId |
| 30 | > (note: main's internal/e2ee Go port has its OWN vector corpus โ unify in the |
| 31 | > crypto-spec follow-up). **Next: iOS parity (Show Times, file export, |
| 32 | > pinch-zoom, jump wave, native thread list?), themes beyond classic, the |
| 33 | > NSScrollView container (transactional scroll anchoring), retiring the |
| 34 | > shell.js dead feed + ChatFeedWebView once the fallback is unneeded.** |
| 35 | > Branch `native-mac-feed`. This note is the handoff brief. |
| 36 | > Everything below the top heading is ๐ค-authored (see CLAUDE.md authorship). |
| 37 | |
| 38 | ## Goal |
| 39 | |
| 40 | Render the **chat stream natively** โ bubbles, reactions, replies, message types |
| 41 | โ instead of hosting `chat.js` in a `WKWebView`. Start with the **Mac app** |
| 42 | (`LChatMac`), prove each message type on-device, then bring iOS along. The payoff |
| 43 | is a real Mac-assed feed (native scroll, selection, menus) with no webview |
| 44 | emulation glue, and far more shared code between the Mac and iOS apps. |
| 45 | |
| 46 | **Non-goals for this branch:** "App" (mini-app) views stay in `WKWebView` โ |
| 47 | unchanged. iOS feed migration comes *after* Mac reaches parity. The formal |
| 48 | cross-language crypto spec is a **separate follow-up** (see *Crypto: spec is a |
| 49 | follow-up*). |
| 50 | |
| 51 | ## The load-bearing constraint (unchanged) |
| 52 | |
| 53 | E2E encryption is load-bearing (CLAUDE.md ยง"The one rule"). Going native does |
| 54 | **not** weaken it: crypto stays 100% client-side, the server still only ever sees |
| 55 | ciphertext. If anything native crypto (CryptoKit + Keychain) is a *stronger* story |
| 56 | than JS-in-a-webview. |
| 57 | |
| 58 | The one property we must preserve is **"a single auditable crypto implementation"** |
| 59 | โ which becomes **"two implementations (JS + Swift) that must never drift."** The |
| 60 | answer already exists: cross-impl test vectors (`test/threadkey.test.mjs` โ |
| 61 | `ios/LChatTests/thread-key-vector.json`, checked by `ECIESVectorTest`). The rule |
| 62 | for this branch: |
| 63 | |
| 64 | > **Every crypto path that goes native ships with a JSโSwift round-trip vector in |
| 65 | > the same commit.** Non-negotiable. The formal spec can wait; the vectors cannot. |
| 66 | > A silently mis-decrypting client shows up as a garbled message, not a red test. |
| 67 | |
| 68 | ## Where we are today (accurate โ the framing "thin webview shell" is out of date) |
| 69 | |
| 70 | Both apps already have native *chrome*; **neither renders the feed natively** โ |
| 71 | the message bubbles are `chat.js` in a webview on both. |
| 72 | |
| 73 | **iOS (`LChat`):** native shell + native crypto, webview feed. |
| 74 | - `LChatApp.swift`: `RootView` โ `ThreadListView` (native `UICollectionView` via |
| 75 | `ThreadListCollection` + `ThreadRow`) โ `NavigationStack` pushes `ChatView`. |
| 76 | - `ChatView.swift` (806 lines) hosts a **per-thread `WKWebView` from |
| 77 | `FeedWebCache`**, seeded with one thread key via `window.__lchatEmbed`; `chat.js` |
| 78 | renders in embed mode. Native crypto (`Crypto.swift`: `ECIES` + `GroupKey`) is |
| 79 | used for previews, the push extension (`LChatNSE`), and thread-key unwrap. |
| 80 | Identity is a native P-256 keypair in the Keychain (`SavedInstance` / |
| 81 | `InstancesStore`). `ThreadStore`, `SharedThreadKeys`, `LChatAPI`, `AvatarLoader` |
| 82 | are all native. |
| 83 | |
| 84 | **Mac (`LChatMac`):** native SwiftUI shell (genuinely native, **not** Catalyst), |
| 85 | webview feed, **no native crypto at all**. |
| 86 | - `LChatMacApp.swift` (SwiftUI, `.hiddenTitleBar`) โ `RootView.swift` (server |
| 87 | rail + โK palette + `NSMenu`s + Sparkle) โ `ChatFeedWebView.swift` (531 lines) |
| 88 | hosts a **per-instance `WKWebView`** running the full `chat.js` page. **All |
| 89 | crypto lives in the webview** (`MacInstance.swift` header says so). |
| 90 | - `MacInstance.swift` deliberately forks identity: `SavedChat`/`Session` with the |
| 91 | key living in the webview's storage โ **not** iOS's native-keypair |
| 92 | `SavedInstance`. `project.yml` confirms Mac compiles only `Models.swift`, |
| 93 | `LChatAPI.swift`, `InviteLink.swift` โ **not** `Crypto.swift`. |
| 94 | - `ChatFeedWebView` carries ~530 lines of "make the webview feel native" glue |
| 95 | (window drag, Finder file-drops, `NSMenu` bridging, downloads, theme sync, |
| 96 | confirm/alert panels, focus). This glue is exactly what native rendering deletes. |
| 97 | |
| 98 | **Shared today:** almost nothing โ Mac shares `Models`/`LChatAPI`/`InviteLink`; |
| 99 | `LChatNSE` shares `Crypto`/`Models`/`SharedThreadKeys`. There is no shared package. |
| 100 | |
| 101 | **Test wiring gap (fix early):** `ios/LChatTests/` has `ECIESVectorTest`, |
| 102 | `PushBlobDecryptTest`, `InviteLinkTest` + `thread-key-vector.json`, but |
| 103 | `project.yml` defines **no `LChatTests` target** (only `LChatUITests`). Confirm |
| 104 | whether these actually run under `xcodebuild test`; if not, add a unit-test target |
| 105 | โ they are the crypto safety net this whole plan leans on. |
| 106 | |
| 107 | ## Decisions (locked โ from the design discussion) |
| 108 | |
| 109 | 1. **Schema = a data + action contract, not runtime SDUI.** Formalize *what a |
| 110 | message is* (fields) and *what actions it affords*, versioned. Do **not** build |
| 111 | a runtime UI-description engine for the chat stream โ the message-type set is |
| 112 | small and closed; a native renderer per type is simpler and is the whole point |
| 113 | (native fidelity). SDUI's only possible home is the mini-app / future |
| 114 | native-app-view story, which is out of scope here. |
| 115 | 2. **Deploy tradeoff accepted for dev.** We're iterating; each message type gets |
| 116 | tested on the Mac app as it lands. The webview feed **stays as the fallback** |
| 117 | until native reaches parity. Forward-compat for stale/un-updated native clients |
| 118 | is **deferred** (dev-phase only) โ revisit before any real release. |
| 119 | 3. **Mac stays native SwiftUI, not Catalyst.** `LChatMac` is already more |
| 120 | Mac-assed than a Catalyst build would be. **Kill the Catalyst path** (commit 1). |
| 121 | 4. **Crypto spec is a follow-up**, written from the *proven* native |
| 122 | implementation, not ahead of it โ but the **vectors land as we go** (see the |
| 123 | load-bearing rule above). |
| 124 | 5. **One branch, incremental commits.** PR โ *"switch the Mac chat feed from |
| 125 | WebView to native."* Squash-merge. Prefix commits by area (`mac:`, `ios:`, |
| 126 | `crypto:`, `docs:`). |
| 127 | |
| 128 | ## Target architecture (NetNewsWire, pushed one step further) |
| 129 | |
| 130 | NetNewsWire shares a platform-neutral **core** (local Swift packages) and writes |
| 131 | each UI **natively per platform** (AppKit on Mac, UIKit on iOS) โ no Catalyst. It |
| 132 | renders *article bodies* in a shared `WKWebView` + shared CSS while the *timeline/ |
| 133 | sidebar chrome* is native. That "native chrome, webview content surface" split is |
| 134 | exactly our current shape โ and the model to keep for the *app* views. |
| 135 | |
| 136 | We go one step further than NNW for the **feed** specifically (NNW predates mature |
| 137 | SwiftUI, so it writes its timeline twice and shares no leaf views): |
| 138 | |
| 139 | - **Core** (crypto, models, API, thread store, messageโview-model logic) โ |
| 140 | shared Swift package. Pure NNW. No UIKit/AppKit imports. |
| 141 | - **Bubble / reaction / reply leaf views** โ shared **SwiftUI**, which renders on |
| 142 | both iOS and Mac. |
| 143 | - **Chrome + scroll container** โ per-platform native, dropping to AppKit where |
| 144 | SwiftUI can't hit Mac quality (below). |
| 145 | |
| 146 | ### Where SwiftUI will bite (watch for these on Mac) |
| 147 | |
| 148 | Build the feed in SwiftUI first; let bite points show themselves on-device; escape |
| 149 | to AppKit **reactively**, not preemptively. Likely order: |
| 150 | |
| 151 | 1. **Text selection across bubbles.** SwiftUI `Text` selection is weak; Mac users |
| 152 | expect NSTextView-grade select-and-copy across messages. This is the #1 |
| 153 | "not a real Mac app" tell. |
| 154 | 2. **Scroll position control** โ staying pinned to the bottom, programmatic |
| 155 | "jump to newest", no jump when images finish loading. SwiftUI's scroll APIs |
| 156 | still stutter with variable-height content + images. |
| 157 | |
| 158 | Escape hatch: an AppKit `NSScrollView`/`NSTableView` (or `NSCollectionView`) scroll |
| 159 | *container* via `NSViewRepresentable`, hosting the shared SwiftUI leaf bubbles via |
| 160 | `NSHostingView`. You already have `NSViewRepresentable` muscle memory in |
| 161 | `ChatFeedWebView`. Sharing leaf views does **not** oblige sharing the container. |
| 162 | |
| 163 | ## The commit sequence |
| 164 | |
| 165 | Work these in order. Each should build (`xcodebuild` iOS + `LChatMac`) and keep |
| 166 | the app working; a native message type is only "done" when tested on-device. |
| 167 | |
| 168 | ### Commit 1 โ kill the Catalyst build (pure deletion, no behavior change) |
| 169 | |
| 170 | The confirmed cruft: the *initial* Mac approach was Catalyst (running the iOS |
| 171 | `LChat` target on Mac), superseded by native `LChatMac`. |
| 172 | |
| 173 | - `project.yml`: remove `SUPPORTS_MACCATALYST: YES` (and |
| 174 | `DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER`) from the `LChat` target. |
| 175 | - Delete `ios/LChat/Views/MacTitlebar.swift` (all `#if targetEnvironment(macCatalyst)`; |
| 176 | its type is `MacWindowTitle`). |
| 177 | - Remove the `#if targetEnvironment(macCatalyst)` branch in `ChatView.swift:194` |
| 178 | and the `#if !targetEnvironment(macCatalyst)` branches in `JoinView.swift` |
| 179 | (keep the non-Catalyst side โ e.g. the QR scanner stays). |
| 180 | - **Keep** the `macCatalyst 16.4` bits in `#available(...)` checks |
| 181 | (`FeedWebCache.swift`, `JoinView.swift`) โ those are OS-version floors, not |
| 182 | target guards. |
| 183 | - Verify: `cd ios && xcodegen generate && xcodebuild -scheme LChat build` and |
| 184 | `-scheme LChatMac build` both succeed. |
| 185 | |
| 186 | ### Commit 2 โ extract the shared Swift core (behavior-preserving) |
| 187 | |
| 188 | Pull the platform-neutral core into a package/group all targets depend on |
| 189 | (iOS `LChat`, `LChatMac`, `LChatNSE`): `Crypto`, `Models`, `LChatAPI`, |
| 190 | `ThreadStore`, `SharedThreadKeys`, `InviteLink`, `Keychain`, and the |
| 191 | platform-neutral parts of `AvatarLoader`. No behavior change; this is the |
| 192 | foundation and the "share more" win. Also **wire up the `LChatTests` unit-test |
| 193 | target** here so the crypto vectors run in CI. |
| 194 | |
| 195 | ### Commit 3 โ Mac adopts native identity |
| 196 | |
| 197 | Converge `MacInstance`'s `SavedChat`/keys-in-webview onto iOS's `SavedInstance` |
| 198 | (native P-256 keypair in the Keychain via `InstancesStore`). This is the |
| 199 | load-bearing prerequisite: **you cannot render the feed natively without native |
| 200 | keys + native decrypt.** Feed still webview at this point; nothing visible |
| 201 | changes. This is where Mac gets its *first* full native crypto surface โ the exact |
| 202 | surface the eventual spec will describe. |
| 203 | |
| 204 | ### Commit 4 โ native WebSocket + decrypt โ native view-model (behind a flag) |
| 205 | |
| 206 | Native WS connection + native message decrypt (`GroupKey.decryptJSON` on the |
| 207 | message blob) feeding a native view-model, rendered **beside** `ChatFeedWebView` |
| 208 | behind a flag so you can diff native vs webview live. Add the JSโSwift vector for |
| 209 | message-body decrypt. |
| 210 | |
| 211 | ### Commits 5+ โ native bubbles, one message type per commit |
| 212 | |
| 213 | Each type is its own commit, tested on Mac, with a crypto vector where it touches |
| 214 | a new path: |
| 215 | |
| 216 | 1. **Plain text** bubble (own vs other, sender name, timestamp). |
| 217 | 2. **Reactions** โ remember these are separate `react`/`unreact` wire frames |
| 218 | (not in the message blob), folded onto a message by (message id, emoji, user). |
| 219 | 3. **Replies** โ the `reply { id, name, snippet }` field; tap-to-scroll to source. |
| 220 | 4. **Images** โ attachments with `w/h`, blob download + `decryptBytes`, lightbox. |
| 221 | 5. **Files / video** โ file chips, downloads, video poster. |
| 222 | 6. **System / action lines** โ `{ sender, text, short, actions[] }`; the |
| 223 | `/me`-style action line and command-response chips. |
| 224 | |
| 225 | ### Later (not this branch, or tail of it) |
| 226 | |
| 227 | - Retire `ChatFeedWebView` once native is at parity; keep it reachable as a |
| 228 | fallback until confident. |
| 229 | - Bring **iOS** along: `ChatView`/`FeedWebCache` โ native, reusing the shared |
| 230 | SwiftUI leaf views. |
| 231 | - **App views stay `WKWebView`** throughout. |
| 232 | |
| 233 | ## The data + action contract (v1 โ descriptive, formalize as we build) |
| 234 | |
| 235 | The shape already exists implicitly; we're formalizing + versioning it, not |
| 236 | inventing it. **Sources of truth:** `web/static/apps/chat.js` payload construction |
| 237 | (~L264โ283) and system lines (~L105); `hub.go` event switch (~L253); |
| 238 | `ios/LChat/Models.swift` `MessagePayload`. |
| 239 | |
| 240 | **Encrypted message payload (inside the blob):** |
| 241 | ``` |
| 242 | { v, uid, name, text, ts, |
| 243 | attachments: [{ id, name, type, w, h, poster? }], |
| 244 | reply: { id, name, snippet } | null, |
| 245 | edited, rev } |
| 246 | ``` |
| 247 | - **System/notice line:** `{ sender, text, short, actions: [{ label, id }] | null }` |
| 248 | โ the `/me`-style action line and command-response chips. |
| 249 | - **Reactions are NOT in the blob** โ they're separate `react`/`unreact` frames, |
| 250 | keyed (message id, emoji, user), folded onto the message at render time. |
| 251 | |
| 252 | **Wire events (`hub.go`):** `msg` (blob; may be `ephemeral` for private command |
| 253 | replies), `edit` (blob, editedAt), `delete` (messageId), `react` / `unreact`, |
| 254 | `typing` (ephemeral relay-only), plus `active` / `ping` (presence/keepalive). |
| 255 | |
| 256 | **Action catalog (consistent across platforms):** `reply` ยท `react` ยท `copy` ยท |
| 257 | `edit` (own message, within `EDIT_WINDOW_MS`) ยท `delete` (own message). System |
| 258 | lines carry their own `actions[]` chips. |
| 259 | |
| 260 | **Versioning:** stamp `v` and define a compat policy. Unknown type/field โ |
| 261 | graceful fallback (dev-phase: fall back to the webview for that thread rather than |
| 262 | render wrong). Confirm the exact `react` frame shape (is the emoji encrypted?) from |
| 263 | `hub.go` + `chat.js` before implementing reactions. |
| 264 | |
| 265 | ## Crypto: spec is a follow-up (separate task) |
| 266 | |
| 267 | Do **not** write the formal spec first. Build the native Mac client (which forces |
| 268 | the complete native crypto surface into existence โ commits 3โ5), then write the |
| 269 | spec as a *description of code that already interops*. But land vectors as you go |
| 270 | (above). When it's time, the spec is three artifacts (see the design discussion): |
| 271 | |
| 272 | 1. **Byte-level wire spec** โ AES-256-GCM (12-byte nonce, 16-byte tag, |
| 273 | `ivโctโtag`), ECDH P-256, X9.63 uncompressed pubkey (65 B), PKCS#8 private key, |
| 274 | HKDF-SHA256 with **empty salt** and exact `info` constants (`lchat-thread-wrap`, |
| 275 | `lchat-invite-*`), all base64url no-padding. Crypto operates on **bytes**; JSON |
| 276 | is above the layer. |
| 277 | 2. **Language-neutral vector corpus** โ deterministic paths โ golden byte vectors; |
| 278 | randomized paths (fresh nonce / ephemeral key) โ a decrypt-known-corpus + a |
| 279 | `referenceDecrypt(impl.encrypt(pt)) == pt` check; optional deterministic- |
| 280 | ephemeral hook (test-only) for byte-reproducible ECIES. |
| 281 | 3. **Conformance-runner protocol** โ a tiny per-impl CLI so one harness drives |
| 282 | JS/Swift/Go/Rust identically. |
| 283 | |
| 284 | Gotchas the spec must nail: base64url padding; PKCS#8 vs SEC1 vs raw; X9.63 |
| 285 | uncompressed point; HKDF empty-salt semantics; GCM tag placement; exact `info` |
| 286 | strings. This standard is for **clients** โ the **server stays key-blind** and |
| 287 | implements none of it. Keep spec + vectors **in-repo** (generated beside |
| 288 | `crypto.js`) until an external impl forces a split; name it **v1** with a |
| 289 | compat policy (changing an `info` string or the KDF is data-orphaning). |
| 290 | |
| 291 | ## Testing / verification each commit |
| 292 | |
| 293 | - `go build ./...` && `go test ./...` (server contract unchanged). |
| 294 | - `npm test` (crypto/ui/format) + `node --check web/static/*.js` for any JS touched. |
| 295 | - `cd ios && xcodegen generate && xcodebuild -scheme LChat build` and |
| 296 | `-scheme LChatMac build`. |
| 297 | - Run the crypto vector target (add it in commit 2 if missing). |
| 298 | - **Run it on the Mac** (the point of Mac-first): build Release `LChatMac`, |
| 299 | exercise the new type against a real instance. |
| 300 | |
| 301 | ## Conventions (CLAUDE.md โ don't skip) |
| 302 | |
| 303 | - Work in **this worktree** (`../messenger-native-mac-feed`, branch |
| 304 | `native-mac-feed`) โ not the shared main checkout. |
| 305 | - Incremental commits are fine (they squash); prefix by area; end messages with |
| 306 | the `Co-Authored-By:` trailer for the assisting model. |
| 307 | - **User-visible changes get a `changelog/<date>-<slug>.md` entry** (first line |
| 308 | ๐ค). Retiring the webview feed / new native rendering is user-visible. |
| 309 | - Never `innerHTML` untrusted content; token-gate data endpoints; inline feedback, |
| 310 | no floating toasts โ but most of this branch is Swift, so mainly: keep the E2E |
| 311 | posture (only ever hand the view-model *decrypted* data; never leak the user |
| 312 | private key; only one thread's key per context). |
| 313 | - New Markdown prose is ๐ค; don't rewrite any ๐จ/๐ค-marked block. |
| 314 | - Squash-merge + delete branch; deploy is unrelated (web/server unchanged). |
| 315 | |