# 🤖 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` (native `UICollectionView` via
  `ThreadListCollection` + `ThreadRow`) → `NavigationStack` pushes `ChatView`.
- `ChatView.swift` (806 lines) hosts a **per-thread `WKWebView` from
  `FeedWebCache`**, seeded with one thread key via `window.__lchatEmbed`; `chat.js`
  renders 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`, `AvatarLoader`
  are 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 + `NSMenu`s + Sparkle) → `ChatFeedWebView.swift` (531 lines)
  hosts a **per-instance `WKWebView`** running the full `chat.js` page. **All
  crypto lives in the webview** (`MacInstance.swift` header says so).
- `MacInstance.swift` deliberately forks identity: `SavedChat`/`Session` with the
  key living in the webview's storage — **not** iOS's native-keypair
  `SavedInstance`. `project.yml` confirms Mac compiles only `Models.swift`,
  `LChatAPI.swift`, `InviteLink.swift` — **not** `Crypto.swift`.
- `ChatFeedWebView` carries ~530 lines of "make the webview feel native" glue
  (window drag, Finder file-drops, `NSMenu` bridging, 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)

1. **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.
2. **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.
3. **Mac stays native SwiftUI, not Catalyst.** `LChatMac` is already more
   Mac-assed than a Catalyst build would be. **Kill the Catalyst path** (commit 1).
4. **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).
5. **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:

1. **Text selection across bubbles.** SwiftUI `Text` selection is weak; Mac users
   expect NSTextView-grade select-and-copy across messages. This is the #1
   "not a real Mac app" tell.
2. **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`: remove `SUPPORTS_MACCATALYST: YES` (and
  `DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER`) from the `LChat` target.
- Delete `ios/LChat/Views/MacTitlebar.swift` (all `#if targetEnvironment(macCatalyst)`;
  its type is `MacWindowTitle`).
- Remove the `#if targetEnvironment(macCatalyst)` branch in `ChatView.swift:194`
  and the `#if !targetEnvironment(macCatalyst)` branches in `JoinView.swift`
  (keep the non-Catalyst side — e.g. the QR scanner stays).
- **Keep** the `macCatalyst 16.4` bits in `#available(...)` checks
  (`FeedWebCache.swift`, `JoinView.swift`) — those are OS-version floors, not
  target guards.
- Verify: `cd ios && xcodegen generate && xcodebuild -scheme LChat build` and
  `-scheme LChatMac build` both 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:

1. **Plain text** bubble (own vs other, sender name, timestamp).
2. **Reactions** — remember these are separate `react`/`unreact` wire frames
   (not in the message blob), folded onto a message by (message id, emoji, user).
3. **Replies** — the `reply { id, name, snippet }` field; tap-to-scroll to source.
4. **Images** — attachments with `w/h`, blob download + `decryptBytes`, lightbox.
5. **Files / video** — file chips, downloads, video poster.
6. **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 `ChatFeedWebView` once 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 `WKWebView`** throughout.

## 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`/`unreact` frames,
  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):

1. **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 exact `info` constants (`lchat-thread-wrap`,
   `lchat-invite-*`), all base64url no-padding. Crypto operates on **bytes**; JSON
   is above the layer.
2. **Language-neutral vector corpus** — deterministic paths → golden byte vectors;
   randomized paths (fresh nonce / ephemeral key) → a decrypt-known-corpus + a
   `referenceDecrypt(impl.encrypt(pt)) == pt` check; optional deterministic-
   ephemeral hook (test-only) for byte-reproducible ECIES.
3. **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/*.js` for any JS touched.
- `cd ios && xcodegen generate && xcodebuild -scheme LChat build` and
  `-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`, branch
  `native-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>.md` entry** (first line
  🤖). Retiring the webview feed / new native rendering is user-visible.
- Never `innerHTML` untrusted 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).
