eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

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
40Render 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
43is a real Mac-assed feed (native scroll, selection, menus) with no webview
44emulation 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` โ€”
47unchanged. iOS feed migration comes *after* Mac reaches parity. The formal
48cross-language crypto spec is a **separate follow-up** (see *Crypto: spec is a
49follow-up*).
50
51## The load-bearing constraint (unchanged)
52
53E2E 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
55ciphertext. If anything native crypto (CryptoKit + Keychain) is a *stronger* story
56than JS-in-a-webview.
57
58The one property we must preserve is **"a single auditable crypto implementation"**
59โ†’ which becomes **"two implementations (JS + Swift) that must never drift."** The
60answer already exists: cross-impl test vectors (`test/threadkey.test.mjs` โ†’
61`ios/LChatTests/thread-key-vector.json`, checked by `ECIESVectorTest`). The rule
62for 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
70Both apps already have native *chrome*; **neither renders the feed natively** โ€”
71the 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),
85webview 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
104whether 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
1091. **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.
1152. **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.
1193. **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).
1214. **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).
1245. **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
130NetNewsWire shares a platform-neutral **core** (local Swift packages) and writes
131each UI **natively per platform** (AppKit on Mac, UIKit on iOS) โ€” no Catalyst. It
132renders *article bodies* in a shared `WKWebView` + shared CSS while the *timeline/
133sidebar chrome* is native. That "native chrome, webview content surface" split is
134exactly our current shape โ€” and the model to keep for the *app* views.
135
136We go one step further than NNW for the **feed** specifically (NNW predates mature
137SwiftUI, 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
148Build the feed in SwiftUI first; let bite points show themselves on-device; escape
149to AppKit **reactively**, not preemptively. Likely order:
150
1511. **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.
1542. **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
158Escape 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
165Work these in order. Each should build (`xcodebuild` iOS + `LChatMac`) and keep
166the 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
170The 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
188Pull 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
191platform-neutral parts of `AvatarLoader`. No behavior change; this is the
192foundation and the "share more" win. Also **wire up the `LChatTests` unit-test
193target** here so the crypto vectors run in CI.
194
195### Commit 3 โ€” Mac adopts native identity
196
197Converge `MacInstance`'s `SavedChat`/keys-in-webview onto iOS's `SavedInstance`
198(native P-256 keypair in the Keychain via `InstancesStore`). This is the
199load-bearing prerequisite: **you cannot render the feed natively without native
200keys + native decrypt.** Feed still webview at this point; nothing visible
201changes. This is where Mac gets its *first* full native crypto surface โ€” the exact
202surface the eventual spec will describe.
203
204### Commit 4 โ€” native WebSocket + decrypt โ†’ native view-model (behind a flag)
205
206Native WS connection + native message decrypt (`GroupKey.decryptJSON` on the
207message blob) feeding a native view-model, rendered **beside** `ChatFeedWebView`
208behind a flag so you can diff native vs webview live. Add the JSโ†”Swift vector for
209message-body decrypt.
210
211### Commits 5+ โ€” native bubbles, one message type per commit
212
213Each type is its own commit, tested on Mac, with a crypto vector where it touches
214a new path:
215
2161. **Plain text** bubble (own vs other, sender name, timestamp).
2172. **Reactions** โ€” remember these are separate `react`/`unreact` wire frames
218 (not in the message blob), folded onto a message by (message id, emoji, user).
2193. **Replies** โ€” the `reply { id, name, snippet }` field; tap-to-scroll to source.
2204. **Images** โ€” attachments with `w/h`, blob download + `decryptBytes`, lightbox.
2215. **Files / video** โ€” file chips, downloads, video poster.
2226. **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
235The shape already exists implicitly; we're formalizing + versioning it, not
236inventing 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
253replies), `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
258lines carry their own `actions[]` chips.
259
260**Versioning:** stamp `v` and define a compat policy. Unknown type/field โ†’
261graceful fallback (dev-phase: fall back to the webview for that thread rather than
262render 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
267Do **not** write the formal spec first. Build the native Mac client (which forces
268the complete native crypto surface into existence โ€” commits 3โ€“5), then write the
269spec 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
2721. **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.
2772. **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.
2813. **Conformance-runner protocol** โ€” a tiny per-impl CLI so one harness drives
282 JS/Swift/Go/Rust identically.
283
284Gotchas the spec must nail: base64url padding; PKCS#8 vs SEC1 vs raw; X9.63
285uncompressed point; HKDF empty-salt semantics; GCM tag placement; exact `info`
286strings. This standard is for **clients** โ€” the **server stays key-blind** and
287implements 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
289compat 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