eleven / messenger Public
๐ค Native chat feed (Mac first)
> Update 2026-07-25 โ the takeover is COMPLETE on iOS too. NativeChatScreen > is the only chat surface (plain chats and app-thread Chat views alike); > ChatView โ the per-thread webview feed this document plans around โ is > deleted. Webviews on both native apps now carry only an app's own > interface (iOS AppThreadScreen/AppInterfacePane, Mac appInterfacePane); > chat never renders in a webview, by policy. The status note below is the > historical hand-off brief from the Mac takeover era โ read it as history.
> Status: THE TAKEOVER SHIPPED (2026-07-07) โ native chat is the Mac's > main surface: NativeChatView = native thread list (sidebar header with > search/new-chat/โฎ, pin/close context menus, live via an instance-wide > socket) beside NativeFeedView (the former debug-pane feed, extracted). The > webview is the fallback (View โธ Use Web App, โฅโW) and still owns mini-apps, > admin, profile, joining; identity-less instances fall through to it. Feed > parity held from the pane era (all live-verified): every message type + > composer with typeahead, uploads/progress, actions (reply/edit/delete/react > via the Messages-style right-click NSMenu), Show Times gutter, drag in/out, > block formatting, link previews, click-to-play YouTube (iframe-shell embed; > plays in the lightbox), scroll-back with mid-viewport anchor restore + > 8-rows-early prefetch, and no-flash thread switching via > NativeFeedModelCache (parked live models + prewarm โ the FeedWebCache rule, > now in LChatCore). iOS adoption started (2026-07-08 overnight): the > typeahead brain is native in ChatView (no JS round-trip), and > NativeChatScreen (shared pieces, phone container) ships behind the server > drawer's "Native chat (beta)" toggle โ webview ChatView stays the default. > Cross-impl gates: thread-key, push-blob, message-payload, attachment-bytes, > nativeโweb message, + format.js parity incl. blockTokens and youtubeId > (note: main's internal/e2ee Go port has its OWN vector corpus โ unify in the > crypto-spec follow-up). Next: iOS parity (Show Times, file export, > pinch-zoom, jump wave, native thread list?), themes beyond classic, the > NSScrollView container (transactional scroll anchoring), retiring the > shell.js dead feed + ChatFeedWebView once the fallback is unneeded. > Branch native-mac-feed. This note is the handoff brief. > Everything below the top heading is ๐ค-authored (see CLAUDE.md authorship).
Goal
Render the chat stream natively โ bubbles, reactions, replies, message types โ instead of hosting chat.js in a WKWebView. Start with the Mac app (LChatMac), prove each message type on-device, then bring iOS along. The payoff is a real Mac-assed feed (native scroll, selection, menus) with no webview emulation glue, and far more shared code between the Mac and iOS apps.
Non-goals for this branch: "App" (mini-app) views stay in WKWebView โ unchanged. iOS feed migration comes after Mac reaches parity. The formal cross-language crypto spec is a separate follow-up (see Crypto: spec is a follow-up).
The load-bearing constraint (unchanged)
E2E encryption is load-bearing (CLAUDE.md ยง"The one rule"). Going native does not weaken it: crypto stays 100% client-side, the server still only ever sees ciphertext. If anything native crypto (CryptoKit + Keychain) is a stronger story than JS-in-a-webview.
The one property we must preserve is "a single auditable crypto implementation" โ which becomes "two implementations (JS + Swift) that must never drift." The answer already exists: cross-impl test vectors (test/threadkey.test.mjs โ ios/LChatTests/thread-key-vector.json, checked by ECIESVectorTest). The rule for this branch:
> Every crypto path that goes native ships with a JSโSwift round-trip vector in > the same commit. Non-negotiable. The formal spec can wait; the vectors cannot. > A silently mis-decrypting client shows up as a garbled message, not a red test.
Where we are today (accurate โ the framing "thin webview shell" is out of date)
Both apps already have native chrome; neither renders the feed natively โ the message bubbles are chat.js in a webview on both.
iOS (LChat): native shell + native crypto, webview feed.
LChatApp.swift:RootViewโThreadListView(nativeUICollectionViewviaThreadListCollection+ThreadRow) โNavigationStackpushesChatView.ChatView.swift(806 lines) hosts a per-threadWKWebViewfromFeedWebCache, seeded with one thread key viawindow.__lchatEmbed;chat.jsrenders in embed mode. Native crypto (Crypto.swift:ECIES+GroupKey) is used for previews, the push extension (LChatNSE), and thread-key unwrap. Identity is a native P-256 keypair in the Keychain (SavedInstance/InstancesStore).ThreadStore,SharedThreadKeys,LChatAPI,AvatarLoaderare all native.
Mac (LChatMac): native SwiftUI shell (genuinely native, not Catalyst), webview feed, no native crypto at all.
LChatMacApp.swift(SwiftUI,.hiddenTitleBar) โRootView.swift(server rail + โK palette +NSMenus + Sparkle) โChatFeedWebView.swift(531 lines) hosts a per-instanceWKWebViewrunning the fullchat.jspage. All crypto lives in the webview (MacInstance.swiftheader says so).MacInstance.swiftdeliberately forks identity:SavedChat/Sessionwith the key living in the webview's storage โ not iOS's native-keypairSavedInstance.project.ymlconfirms Mac compiles onlyModels.swift,LChatAPI.swift,InviteLink.swiftโ notCrypto.swift.ChatFeedWebViewcarries ~530 lines of "make the webview feel native" glue (window drag, Finder file-drops,NSMenubridging, downloads, theme sync, confirm/alert panels, focus). This glue is exactly what native rendering deletes.
Shared today: almost nothing โ Mac shares Models/LChatAPI/InviteLink; LChatNSE shares Crypto/Models/SharedThreadKeys. There is no shared package.
Test wiring gap (fix early): ios/LChatTests/ has ECIESVectorTest, PushBlobDecryptTest, InviteLinkTest + thread-key-vector.json, but project.yml defines no LChatTests target (only LChatUITests). Confirm whether these actually run under xcodebuild test; if not, add a unit-test target โ they are the crypto safety net this whole plan leans on.
Decisions (locked โ from the design discussion)
- Schema = a data + action contract, not runtime SDUI. Formalize what a message is (fields) and what actions it affords, versioned. Do not build a runtime UI-description engine for the chat stream โ the message-type set is small and closed; a native renderer per type is simpler and is the whole point (native fidelity). SDUI's only possible home is the mini-app / future native-app-view story, which is out of scope here.
- Deploy tradeoff accepted for dev. We're iterating; each message type gets tested on the Mac app as it lands. The webview feed stays as the fallback until native reaches parity. Forward-compat for stale/un-updated native clients is deferred (dev-phase only) โ revisit before any real release.
- Mac stays native SwiftUI, not Catalyst.
LChatMacis already more Mac-assed than a Catalyst build would be. Kill the Catalyst path (commit 1). - Crypto spec is a follow-up, written from the proven native implementation, not ahead of it โ but the vectors land as we go (see the load-bearing rule above).
- One branch, incremental commits. PR โ "switch the Mac chat feed from WebView to native." Squash-merge. Prefix commits by area (
mac:,ios:,crypto:,docs:).
Target architecture (NetNewsWire, pushed one step further)
NetNewsWire shares a platform-neutral core (local Swift packages) and writes each UI natively per platform (AppKit on Mac, UIKit on iOS) โ no Catalyst. It renders article bodies in a shared WKWebView + shared CSS while the timeline/ sidebar chrome is native. That "native chrome, webview content surface" split is exactly our current shape โ and the model to keep for the app views.
We go one step further than NNW for the feed specifically (NNW predates mature SwiftUI, so it writes its timeline twice and shares no leaf views):
- Core (crypto, models, API, thread store, messageโview-model logic) โ shared Swift package. Pure NNW. No UIKit/AppKit imports.
- Bubble / reaction / reply leaf views โ shared SwiftUI, which renders on both iOS and Mac.
- Chrome + scroll container โ per-platform native, dropping to AppKit where SwiftUI can't hit Mac quality (below).
Where SwiftUI will bite (watch for these on Mac)
Build the feed in SwiftUI first; let bite points show themselves on-device; escape to AppKit reactively, not preemptively. Likely order:
- Text selection across bubbles. SwiftUI
Textselection is weak; Mac users expect NSTextView-grade select-and-copy across messages. This is the #1 "not a real Mac app" tell. - Scroll position control โ staying pinned to the bottom, programmatic "jump to newest", no jump when images finish loading. SwiftUI's scroll APIs still stutter with variable-height content + images.
Escape hatch: an AppKit NSScrollView/NSTableView (or NSCollectionView) scroll container via NSViewRepresentable, hosting the shared SwiftUI leaf bubbles via NSHostingView. You already have NSViewRepresentable muscle memory in ChatFeedWebView. Sharing leaf views does not oblige sharing the container.
The commit sequence
Work these in order. Each should build (xcodebuild iOS + LChatMac) and keep the app working; a native message type is only "done" when tested on-device.
Commit 1 โ kill the Catalyst build (pure deletion, no behavior change)
The confirmed cruft: the initial Mac approach was Catalyst (running the iOS LChat target on Mac), superseded by native LChatMac.
project.yml: removeSUPPORTS_MACCATALYST: YES(andDERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER) from theLChattarget.- Delete
ios/LChat/Views/MacTitlebar.swift(all#if targetEnvironment(macCatalyst); its type isMacWindowTitle). - Remove the
#if targetEnvironment(macCatalyst)branch inChatView.swift:194and the#if !targetEnvironment(macCatalyst)branches inJoinView.swift(keep the non-Catalyst side โ e.g. the QR scanner stays). - Keep the
macCatalyst 16.4bits in#available(...)checks (FeedWebCache.swift,JoinView.swift) โ those are OS-version floors, not target guards. - Verify:
cd ios && xcodegen generate && xcodebuild -scheme LChat buildand-scheme LChatMac buildboth succeed.
Commit 2 โ extract the shared Swift core (behavior-preserving)
Pull the platform-neutral core into a package/group all targets depend on (iOS LChat, LChatMac, LChatNSE): Crypto, Models, LChatAPI, ThreadStore, SharedThreadKeys, InviteLink, Keychain, and the platform-neutral parts of AvatarLoader. No behavior change; this is the foundation and the "share more" win. Also wire up the LChatTests unit-test target here so the crypto vectors run in CI.
Commit 3 โ Mac adopts native identity
Converge MacInstance's SavedChat/keys-in-webview onto iOS's SavedInstance (native P-256 keypair in the Keychain via InstancesStore). This is the load-bearing prerequisite: you cannot render the feed natively without native keys + native decrypt. Feed still webview at this point; nothing visible changes. This is where Mac gets its first full native crypto surface โ the exact surface the eventual spec will describe.
Commit 4 โ native WebSocket + decrypt โ native view-model (behind a flag)
Native WS connection + native message decrypt (GroupKey.decryptJSON on the message blob) feeding a native view-model, rendered beside ChatFeedWebView behind a flag so you can diff native vs webview live. Add the JSโSwift vector for message-body decrypt.
Commits 5+ โ native bubbles, one message type per commit
Each type is its own commit, tested on Mac, with a crypto vector where it touches a new path:
- Plain text bubble (own vs other, sender name, timestamp).
- Reactions โ remember these are separate
react/unreactwire frames (not in the message blob), folded onto a message by (message id, emoji, user). - Replies โ the
reply { id, name, snippet }field; tap-to-scroll to source. - Images โ attachments with
w/h, blob download +decryptBytes, lightbox. - Files / video โ file chips, downloads, video poster.
- System / action lines โ
{ sender, text, short, actions[] }; the/me-style action line and command-response chips.
Later (not this branch, or tail of it)
- Retire
ChatFeedWebViewonce native is at parity; keep it reachable as a fallback until confident. - Bring iOS along:
ChatView/FeedWebCacheโ native, reusing the shared SwiftUI leaf views. - App views stay
WKWebViewthroughout.
The data + action contract (v1 โ descriptive, formalize as we build)
The shape already exists implicitly; we're formalizing + versioning it, not inventing it. Sources of truth: web/static/apps/chat.js payload construction (~L264โ283) and system lines (~L105); hub.go event switch (~L253); ios/LChat/Models.swift MessagePayload.
Encrypted message payload (inside the blob):
{ v, uid, name, text, ts,
attachments: [{ id, name, type, w, h, poster? }],
reply: { id, name, snippet } | null,
edited, rev }- System/notice line:
{ sender, text, short, actions: [{ label, id }] | null }โ the/me-style action line and command-response chips. - Reactions are NOT in the blob โ they're separate
react/unreactframes, keyed (message id, emoji, user), folded onto the message at render time.
Wire events (hub.go): msg (blob; may be ephemeral for private command replies), edit (blob, editedAt), delete (messageId), react / unreact, typing (ephemeral relay-only), plus active / ping (presence/keepalive).
Action catalog (consistent across platforms): reply ยท react ยท copy ยท edit (own message, within EDIT_WINDOW_MS) ยท delete (own message). System lines carry their own actions[] chips.
Versioning: stamp v and define a compat policy. Unknown type/field โ graceful fallback (dev-phase: fall back to the webview for that thread rather than render wrong). Confirm the exact react frame shape (is the emoji encrypted?) from hub.go + chat.js before implementing reactions.
Crypto: spec is a follow-up (separate task)
Do not write the formal spec first. Build the native Mac client (which forces the complete native crypto surface into existence โ commits 3โ5), then write the spec as a description of code that already interops. But land vectors as you go (above). When it's time, the spec is three artifacts (see the design discussion):
- Byte-level wire spec โ AES-256-GCM (12-byte nonce, 16-byte tag,
ivโctโtag), ECDH P-256, X9.63 uncompressed pubkey (65 B), PKCS#8 private key, HKDF-SHA256 with empty salt and exactinfoconstants (lchat-thread-wrap,lchat-invite-*), all base64url no-padding. Crypto operates on bytes; JSON is above the layer. - Language-neutral vector corpus โ deterministic paths โ golden byte vectors; randomized paths (fresh nonce / ephemeral key) โ a decrypt-known-corpus + a
referenceDecrypt(impl.encrypt(pt)) == ptcheck; optional deterministic- ephemeral hook (test-only) for byte-reproducible ECIES. - Conformance-runner protocol โ a tiny per-impl CLI so one harness drives JS/Swift/Go/Rust identically.
Gotchas the spec must nail: base64url padding; PKCS#8 vs SEC1 vs raw; X9.63 uncompressed point; HKDF empty-salt semantics; GCM tag placement; exact info strings. This standard is for clients โ the server stays key-blind and implements none of it. Keep spec + vectors in-repo (generated beside crypto.js) until an external impl forces a split; name it v1 with a compat policy (changing an info string or the KDF is data-orphaning).
Testing / verification each commit
go build ./...&&go test ./...(server contract unchanged).npm test(crypto/ui/format) +node --check web/static/*.jsfor any JS touched.cd ios && xcodegen generate && xcodebuild -scheme LChat buildand-scheme LChatMac build.- Run the crypto vector target (add it in commit 2 if missing).
- Run it on the Mac (the point of Mac-first): build Release
LChatMac, exercise the new type against a real instance.
Conventions (CLAUDE.md โ don't skip)
- Work in this worktree (
../messenger-native-mac-feed, branchnative-mac-feed) โ not the shared main checkout. - Incremental commits are fine (they squash); prefix by area; end messages with the
Co-Authored-By:trailer for the assisting model. - User-visible changes get a
changelog/<date>-<slug>.mdentry (first line ๐ค). Retiring the webview feed / new native rendering is user-visible. - Never
innerHTMLuntrusted content; token-gate data endpoints; inline feedback, no floating toasts โ but most of this branch is Swift, so mainly: keep the E2E posture (only ever hand the view-model decrypted data; never leak the user private key; only one thread's key per context). - New Markdown prose is ๐ค; don't rewrite any ๐จ/๐ค-marked block.
- Squash-merge + delete branch; deploy is unrelated (web/server unchanged).