eleven / ocho Public

Fully-native Mac + iOS chat: SQLite message store, themes, settings, group tools, search (#288)

* docs: native chat feed design note

The handoff brief for switching the Mac app's chat feed from
chat.js-in-a-WKWebView to native rendering: current state of both apps,
locked decisions (data+action contract not SDUI, kill Catalyst, crypto
spec as a follow-up with per-commit JS<->Swift vectors), the NNW-derived
target architecture, and the commit sequence this branch works through.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: drop the Mac Catalyst build

The Catalyst build of the iOS target was the initial Mac approach,
superseded by the genuinely native LChatMac app. Remove the target
setting and the #if targetEnvironment(macCatalyst) forks (QR scanning
is now unconditional in the iOS target); the macCatalyst mentions left
behind are #available version floors, not target guards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: extract LChatCore — the shared Swift core, with the crypto vectors as swift-test

Pull the platform-neutral core (Crypto, Models, LChatAPI, InviteLink,
Keychain, SharedThreadKeys, ThreadStore) into a local Swift package all
three targets depend on, replacing the per-target shared-file listings.
The standalone swiftc-run vector gates become the package's XCTest suite,
so `swift test` in ios/LChatCore runs the cross-implementation crypto
fixtures natively on the Mac — the drift gate docs/native-chat.md leans on.

One rename rode along: struct Thread -> ChatThread, since an unqualified
Thread exported from a library collides with Foundation's in every
importing file. No behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: adopt the native SavedInstance identity model

The webview's localStorage session IS the identity — join.js stores
{token, name, userPub, userPriv}, exactly a SavedInstance minus the
origin. Extract it whenever a feed's own-origin page loads (the same
didFinish hook that already read the session for the join fallback) and
park it in LChatCore's InstancesStore (Keychain, file fallback on dev
builds). Same keys as the page — nothing re-keyed, nothing sent
anywhere — so every already-joined chat migrates the first time its
feed loads, and a fresh join is picked up by the same path. Signing out
removes the stored identity.

Nothing reads the native identity yet: it's the prerequisite for the
native feed (docs/native-chat.md commit 4 — native WS + decrypt).

Verified live: first launch logs 'stored native identity' for the
selected instance; relaunch stores nothing (unchanged session ==
no rewrite); store holds origin/name/keys as expected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: mark native-chat commits 1-3 landed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac,core: native WebSocket + message decrypt, diffable beside the webview

The first native use of the stored identity (docs/native-chat.md commit
4). LChatCore gains the read path: ChatSocket (the hub's fan-out frames
over wss with the same-origin header), ChatFeedModel (history via
/api/messages + live frames, every blob opened on-device with the
thread key, folded msg/edit/delete with upsert-by-id), and the full
message envelope on MessagePayload (type/ts/replyTo + attachment
dimensions) with acceptsMessageType mirrored from message.js.

The Mac gets a Debug ▸ Native Feed pane (⌥⌘D): the natively-decrypted
feed rendered beside the webview so the two pipelines can be diffed
live while native rendering grows one message type at a time.

The wire shape is pinned by a new cross-implementation vector:
test/messageblob.test.mjs (--emit) encrypts the full envelope with the
web crypto; MessagePayloadVectorTests decodes every field of the same
fixture in Swift. Both suites run it (npm test, swift test).

Verified live against team.elevenmessenger.com: history + live messages,
edits and deletes all mirror the webview feed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac,core: render the native feed with the shared MessageBubble leaf view

The first shared SwiftUI bubble (LChatCore) — the Mac debug pane renders
it now; the iOS feed adopts it later (share the leaf views, specialize
the container). Mirrors the web bubble's language: 18pt continuous
corners with the 6pt tail corner pointing at the sender, accent-filled
own messages, sender name once per run in groups (web-style run
grouping), hover tooltip for the timestamp, edited tag + reply hint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core: fold reactions into the native feed

History rows carry the per-emoji tallies (ReactionView, with mine
resolved per caller); the hub's live "reaction" frame rebroadcasts the
full tally after every toggle, without mine (one fan-out for the whole
thread) — so the fold preserves its own flag for emoji that survive.
Also fold edited_at from history, which live-only folding missed.
Read-only (display); no new crypto — emoji tallies are the documented
plaintext exception (db.go), so no new vector.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core: bubble colours come from the web theme (ChatTheme)

MessageBubble renders with the exact light-dark pairs from style.css's
classic theme (#5666f0 me-bubble, #eef0f6/#2a2e38 them, #7d8bff dark
accent) instead of the platform accent — plus reaction chips on the
bubble. ChatTheme transcribes the CSS variables verbatim and is the
seam instance-theme support plugs into later.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core,mac: reply quote cards, reply tallies, and jump-with-a-wave

The web's reply UX, native: the bordered quote card above a reply
(name + one-line snippet, right-aligned for own messages), the
link-coloured 'N Replies' tag under a replied-to parent, both live
(folded reply counts refresh on every mutation). Tapping either jumps
the pane — target scrolled to the top, where it waves for attention
(keyframed tiptoe wag; feet planted, no hop) instead of a highlight.

Jump only reaches the loaded history page; scroll-back pagination is
container work, later.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core,mac: native image attachments, sized like the web, with the bytes vector

Image attachments render natively: /api/blob fetch, on-device
decryptBytes, decode, cache (BlobImageLoader — AvatarLoader generalised
over the new PlatformImage typealias, the NNW-style seam; iOS's copy
folds into it at adoption time). Sizing mirrors chat.js exactly: the
payload's w/h reserve the box before the pixels arrive (no feed jump),
capped 260x320, 18pt corners, them-bubble placeholder. Non-image
attachments keep the 📎 marker until file cards land.

The binary path gets its own cross-implementation gate:
test/blobbytes.test.mjs (--emit) freezes an every-byte-value fixture
encrypted by the web's encryptBytes; AttachmentBytesVectorTests opens
it natively and round-trips the reverse, so a future native upload
provably opens on the web.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac,core: photo lightbox that zooms out of its bubble, over black

Clicking a photo springs it from its bubble frame (captured in window
coords at tap) to centre screen at actual size — scaled down only when
it exceeds the viewport minus a margin, never up — over a fade-to-black
scrim; Esc / Done / click zooms it back. App-level overlay (like the ⌘K
palette) rather than a sheet, which is what makes the geometry
animation possible. Deliberately in-memory, not QuickLook: QL would
silently write decrypted bytes to disk.

Also: the pane's List became ScrollView+LazyVStack — List's
NSTableView-backed rows clip their bounds, which beheaded the
jump-to-message wave.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: native-chat status — through the image message type

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core,mac: file chips + video cards, and a kinder reply jump

Non-image attachments render as the web's file chip (extension emoji,
name, formatBytes size — exact ui.js/format.js mirrors) — me-tinted on
own messages; click decrypts and saves straight to ~/Downloads with the
Finder-unique name + reveal, the webview downloads' convention. Videos
show their poster (own decrypted blob) with play badge + duration;
click downloads for now — in-place playback without plaintext on disk
is the next commit. The message-payload vector now pins the full
attachment shape (image + video, size/poster/dur).

Reply jumps got kinder: a target already fully in view just waves in
place (row-frame probes into a non-state store, so scroll ticks don't
re-render), and a scrolled-to target lands 6% down the viewport so the
wave never clips at the edge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core,mac: video lightbox — in-memory playback, progress ring, blob cache

Clicking a video decrypts it into memory and plays it in the zooming
lightbox with native AVPlayerView controls. The decrypted bytes never
touch disk: MemoryVideoAsset serves byte ranges to AVPlayer through a
custom-scheme resource loader (the native blob: URL). AVAsset.isPlayable
is the canPlayType probe — unplayable codecs fall back to a download.
NOT SwiftUI's VideoPlayer: its _AVKit_SwiftUI overlay aborts in the
Swift runtime on macOS 15 (getSuperclassMetadata) — crashed on first
render; wrapping AVKit's AVPlayerView directly sidesteps the overlay.

While a clip fetches, a radial progress ring sits flush inside the play
badge (delegate-based fetch — the one-shot async API can't observe
progress; the web's 150ms grace so fast loads never flash a ring) with
a status-bar line as the secondary cue. Decrypted blobs land in
BlobDataCache (in-memory LRU, ~256MB) so replaying is instant — RAM
only, same posture as the web's object-URL cache.

Text rendering parity rode along: bubbles at the web's effective 16px,
and AppleFontSmoothing=0 (the app-wide equivalent of the CSS
-webkit-font-smoothing: antialiased) so native strokes match the
webview's weight — measured identical ink heights after the fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core,mac: rich text, jumbo emoji, and action lines — the render surface is complete

RichText.swift mirrors format.js exactly (same regexes, same precedence,
links never formatted inside): bold, italic (incl. the underscore
word-boundary rule so file_names stay plain), code chips, strikes, and
links — http(s), www., and bare domains against the TLD allowlist so
node.js stays text — rendered as one AttributedString in the bubble's
ink. Emoji-only messages (1-3) go large and bubble-less (.jumbo-emoji);
/me-style app events render as centred muted action lines
(.chatapp-action). Pinned by 12 parity tests ported from
test/format.test.mjs (package suite now 18). Block-level fences and
quotes are the remaining formatting follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core,mac: the native composer — first native send

The write path goes native: ChatFeedModel.send builds the exact
shell.js envelope ({text} stamped with name/uid/ts, untyped like every
message the web sends today), encrypts it with GroupKey.encryptJSON and
frames it onto the native WebSocket; the hub's echo folds it back into
the list (no optimistic row, same as the web). The pane grows a
composer: Return sends / Option-Return newlines, and a down socket
keeps the draft — the send only clears what actually left (the web's
never-drop rule). Styled as the web's bordered pill, not a filled field.

The send path is pinned in BOTH directions now: messageblob
(web-encrypt to Swift-decrypt) and the new nativeblob gate — a fixture
recorded from real GroupKey.encryptJSON output (RECORD_VECTORS=1 swift
test) that crypto.js must decrypt in npm test. Verified live: a
native-sent message rendered in the webview, on the phone, and back in
the pane via its own echo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: composer at the web's proportions (16px text, taller pill)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core,mac: uploads, scroll-back pagination, and composer auto-focus

Uploads go native: the + button stages photos and allowlisted documents
— images take the web's exact path (2048 long-edge cap, JPEG 0.85
re-encode via a fresh ImageIO destination, so EXIF/GPS is stripped like
the canvas round-trip), everything is encrypted client-side and POSTed
to /api/upload with the metadata riding inside the message payload.
Staged chips show upload state; a message can't send with an upload
still in flight.

Scroll-back pagination folds the before= page when the reader lingers
at the top, re-anchoring so history grows upward. Two hard-won rules:
pages publish ONCE (per-message publishing re-diffed the whole list
per row — O(n²), froze on long chats), and the trigger self-gates
(ready flag + 0.5s cooldown, clocked from initial load) so a prepend
can't cascade-load the entire history or steal the initial
pin-to-bottom.

The composer takes focus when the app regains it, like the webview
shell. fileAllowed/processImage mirror ui.js/shell.js.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core,mac: queued send behind in-flight uploads + post-merge layering fix

Mirror the web's PR #269 natively: pressing send mid-upload queues it —
the arrow swaps for a spinner, the batch resolving releases it, and
unstaging chips cancels (nothing left) or releases (last uploading chip
removed) the queued send.

Merge fixup: main's PR #260 taught ThreadStore.markRead to clear
delivered notifications via PushManager — an app-target type the
now-shared core can't see. markRead calls a platform-neutral onMarkRead
hook instead; the iOS app points it at PushManager.clearDelivered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core,mac: determinate upload progress + locked field while a send is queued

Each staged chip shows a real progress ring (request-body bytes via a
per-task URLSession delegate — the upload twin of the download ring),
and the composer field disables while a queued send spins: what was
committed is what sends, no mid-flight edits racing the auto-send.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: native-chat status — feed + composer at parity

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core,mac: composer typeahead — commands, emoji, mentions — with a drift-gated brain

The typeahead brain goes native (ComposerTypeahead.swift), mirroring the
web's three pieces exactly: ui.js composerToken (which token the caret
sits in — /command at start only, :emoji needs a query, @/: triggers
must follow whitespace), shell.js typeaheadItems (identical ranking and
cutoffs; commands bot-gated; the native catalog is BOT_COMMANDS — the
floor the bot answers server-side, so it works without a webview), and
emoji.js (the curated map, emojiMatches ranking, and send-time emojify —
native sends now interpolate :tada: to the real glyph too).

The drift gates don't hand-copy expectations: the tests PARSE
web/static/emoji.js and bot-commands.js from the repo and diff every
entry against the Swift maps, so adding an emoji on the web fails swift
test until Swift follows. The parity suite earned its keep immediately —
JS includes("") is true, Swift contains("") is false (empty @query must
list everyone). 10 new tests; package suite 30.

TypeaheadStrip is a shared leaf view (iOS adopts it when it swaps its
JS-bridge typeahead for this brain): web row sizing (15px lines, 20px
emoji in a 24px slot), mention avatars via BlobImageLoader with the
stable-hue initial fallback, ↑↓/Tab/Return/Esc on the Mac composer,
click-to-pick. Composer + button sized to match send. Mac v1 assumes
caret-at-end; mid-text caret needs the NSTextView-backed field (iOS's
ComposerField pattern) — the natural shared composer container later.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core,mac: message actions — Messages-style right-click, reply/edit/delete/react

The full action catalog lands, behind right-click like Messages (the
web keeps its hover menus). The menu is a real NSMenu with the Tapback
row as a hosted custom view (NSMenuItem.view) — hover-scaling circles
for the six-emoji allowlist — then Reply…/Copy/Edit…/Delete… (red) and
Show Times. It coexists with drag-to-select text via a catcher NSView
whose hitTest claims only right/ctrl-clicks, letting left events fall
through to the text system (SwiftUI's .contextMenu and .textSelection
fight over right-click; this is the peace treaty). One MessageActions
catalog feeds every entry point, per the design doc.

The flows: reply stages the banner + sends replyTo (quote cards render
everywhere); edit pre-fills the composer and re-encrypts {…raw, text}
so attachments survive (15-min window, server-enforced); delete
broadcasts; reactions toggle with an optimistic flip corrected by the
tally broadcast. Chips: own reaction gets the accent-tinted fill, count
beside the emoji past 1, sized up to the web's presence.

Time got the Messages treatment too: dated separators in the feed
(chat.js viewItems rules exactly — dated on day change, time-only past
a 15-minute gap, runs reset at separators, Today/Yesterday/weekday/date
ladder), and "Show Times" slides every bubble left to reveal a
right-hand time gutter (session-only, never persisted). Hover-time
experiments removed. Pane widened to 480.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: Show Times slides without a single rewrap

Bubbles wrap to a max width that already accounts for the time gutter,
so toggling Show Times purely translates rows — the gutter slides in
and out, nothing reflows. Times start hidden every session (a peek,
not a mode).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: swipe-to-reveal times, spring-loaded, with bolted-down layout

Two-finger horizontal swipes over the feed drag the time gutter open
under the fingers (a local scroll-event monitor — an overlay NSView
would have to fight the scroll view for vertical events too), snapping
open/shut with a gentle spring on release. Two hard-won correctness
rules: the release event arrives with ZERO deltas, so it must be
handled before any horizontal-dominance guard (which silently ate it —
the gutter froze wherever it was left); and the time labels live in a
trailing OVERLAY, never in row layout, so the centring LazyVStack can't
shift their-side rows as the gutter grows — only your own bubbles
translate, by explicit offset, on a fixed-width stage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core,mac: typing indicators + drag-and-drop, both directions

Typing goes live both ways, on the web's exact contract: ephemeral
chat.typing beats (15s expiry, at most one every 10s, an explicit 0
clears on empty/send/idle; edits don't announce themselves), folded
with the web's rules (skip self by uid-else-name, clamp 0–60s, sweep
expiries) into a typingSummary line above the composer — and the
dedicated relay frame bots use is folded too.

Files drag IN from the Finder (the web's drop-overlay highlight,
staging through the same allowlist/downscale/encrypt as the + button)
and photos/videos drag OUT to the desktop: an NSItemProvider file
representation with a per-item suggestedName — Transferable's
FileRepresentation names its drag promise after the TYPE, landing a
file literally called "Data" — decrypting only when the drop commits,
the same deliberate-export posture as Save.

Staged images show real rounded thumbnails (the web's preview), flush
left with the + button, ✕ overlapping the corner and the upload ring
over the picture; file pills got bigger type.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core,mac: staged videos get poster thumbnails too

videoPosterImage extracts a frame from the in-memory clip (same loader
as playback, plaintext never on disk); the chip upgrades from ring-only
to the real frame when extraction lands, wearing a small play badge so
clips read as clips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core,mac: block formatting — fences and quotes, format.js-exact

blockTokens ported line-for-line (fences verbatim with language tag,
unterminated fences run to the end, inline backticks aren't fences,
consecutive quote lines merge, blank separators drop) with the eight
parity cases from test/format.test.mjs. Bubbles render the block layer
above the inline layer exactly like the web: fences as monospace on
the chip background with long lines scrolling, quotes with the quiet
left rule and dimmed ink.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core,mac: link previews — the unfurl card, native

firstLink (format.js mirror: first URL outside code fences) drives a
LinkPreviewCard under the bubble: GET /api/unfurl (the one deliberate
E2E exception — the server sees URLs, never message text), rendered at
the web's .lp-card proportions (280 max, 150 image cover-crop,
title/desc/host), click opens the browser, results cached per URL
including failures. Composer grew multi-line support (Shift/⌥Return,
~9 lines then scrolls) and inline code chips got side padding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core,mac: unfurl-fetch fix + click-to-play YouTube cards

The link-preview card never fetched: its .task hung off a view that's
EMPTY until the preview exists, and lifecycle modifiers never fire on
an empty view — a 1×1 clear placeholder anchors the fetch now. YouTube
links take precedence over the unfurl card (chat.js's rule): youtubeId
ported (watch/youtu.be/shorts/live/embed shapes, t=/start= down to
1h2m3s, parity-tested) driving the click-to-play card — the video's
own thumbnail + play badge, swapping to a contained youtube-nocookie
embed on click. That embed is the one webview the native feed keeps:
YouTube's player for YouTube's content, zero E2E surface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core,mac: YouTube plays in the lightbox, cursor polish

The YouTube card springs out of its bubble into the window-level
lightbox like photos and encrypted videos — one lightbox language for
all three media types — landing at the largest 16:9 the window allows,
autoplaying via the shared YouTubeEmbedView, zooming back on close.
Done buttons across the lightboxes pin the arrow cursor (they were
inheriting the text system's I-beam).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: scroll-back keeps your place and prefetches the seam away

Two fixes and a trick: anchor restoration pins whichever row was
nearest the viewport centre (the trigger row often materialises while
still ABOVE the viewport, so anchoring on it clamped to the top — the
jump); restoration runs after the prepend lays out; and pages prefetch
while the reader is still ~8 rows from the top, so at reading pace the
splice happens off-screen. Truly transactional offset compensation
needs the NSScrollView container — noted for that work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: THE TAKEOVER — native chat is the Mac's main surface

An instance with a native identity now opens as NativeChatView: a
native thread list (decrypted avatars with the stable-hue fallback,
previews, unread weight + dot, pins, Today-time/day labels, live via
one instance-wide socket — the hub fans every thread's frames to us,
so stored-message activity debounce-refreshes the sidebar) beside
NativeFeedView, which is the extracted, renamed debug-pane feed —
every message type, composer, typeahead, actions, lightboxes.

The webview steps down to fallback: View ▸ Use Web App (⌥⌘W) brings
back the full web client — still the only home for mini-apps, admin,
profile editing and joining — and an instance with no native identity
falls through to it automatically (which is also how a fresh join gets
its identity extracted). The Debug diff pane stays for development.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: overnight punch list — sidebar header, new chat, pin/close, polish

The sidebar gets the web's side-head: instance name, a native New Chat
popover (click a person → DM via ThreadStore.createChat with fresh
ECIES-wrapped keys, the server dedupes; group mode with multi-select +
optional name), a ⋮ menu (Open Web App / Refresh), and thread search —
which, like the web, also surfaces closed chats so search is how they
reopen. Rows pin/unpin and close/reopen from a context menu.

Also: opening a chat reliably lands at the newest message (pin on the
history page arriving, re-pinned a tick later once rows have real
heights); the permanent "N messages" line is gone (the bar only shows
transient busy/error feedback); and the Debug menu + diff pane retire —
the takeover surface IS the native feed now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core: fix the iOS build — UIKit's boundingRect requires context:

(The punch-list commit's build gate only checked the Mac scheme; this
slipped through. Both schemes verified green now.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: no flash between chats — parked feed models + prewarm

The native FeedWebCache: each thread's ChatFeedModel parks with its
folded messages AND live socket when you leave (start() is idempotent
for re-attach; the LRU cap's eviction is what stops a socket), and the
top chats prewarm after a thread-list load — so switching chats renders
instantly, exactly the web's prebuild trick.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: the typeahead brain goes native — no more webview round-trip

ChatView computes commands/emoji/mentions with LChatCore's
ComposerTypeahead (the format.js mirror the Mac uses) instead of asking
the embedded chat.js over the bridge per keystroke. iOS's real caret
makes it fully caret-aware — mid-text tokens work — with UTF-16 ↔
Character conversion at the field boundary, and the pick moves the
caret to the end of the insert. The horizontal chip strip stays; the
WebBridge typeahead plumbing is gone. First step of iOS adoption:
same brain, both platforms, one parity suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: the native feed arrives on the phone — behind "Native chat (beta)"

NativeChatScreen: the iOS twin of the Mac takeover's feed, from the
same shared pieces — ChatFeedModel (parked in NativeFeedModelCache,
now in LChatCore for both platforms), MessageBubble with its built-in
long-press context menu, the native typeahead brain, ComposerField —
in a phone-shaped container: full-screen photo viewer, full-screen
VideoPlayer for encrypted video (memory-only bytes, as everywhere),
PhotosPicker + document-picker uploads with staged progress chips,
reply/edit banners, typing line.

Opt-in via the server drawer's "Native chat (beta)" toggle;
ThreadDestination routes to it instead of the webview ChatView, which
stays the default until parity. Not yet: Show Times, file-chip export,
pinch-zoom, the jump wave.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: native-chat status — takeover shipped, iOS adoption started

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: NativeChatScreen adopts ChatView's floating-glass composer

The hand-rolled composer frame ballooned the field (a UIKit-backed
GrowingTextView self-sizes — constraining it with a SwiftUI flexible
frame blew it up to fill). Replaced with ChatView's proven row
verbatim: self-sizing field + send button sharing one glass capsule,
36pt glass + button, 44pt send target, floating pieces with no bar
slab; typeahead chips and reply/edit banners get the same glass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: prewarm native feeds, land at the newest message, no flash

The thread list's prebuild now warms the right cache for the surface
that will render: parked ChatFeedModels (folded messages + live
socket) when the native feed is on, webviews otherwise — so tapping a
visible chat attaches an already-built feed. And the land-at-top bug:
a prewarmed model arrives with messages already folded, so onAppear's
scrollTo raced first layout and lost — defaultScrollAnchor(.bottom)
anchors before layout ever happens, and doubles as prepend-friendly
anchoring during scroll-back.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: photo viewer gestures + organic message arrival

The full-screen photo viewer goes Photos-style: pinch to zoom (pan
while zoomed, double-tap toggle, spring back below 1:1) and swipe down
to dismiss — the photo follows the finger while the backdrop fades.
New messages spring up from beneath the composer (insertion transition
keyed on the LAST message id, so history prepends don't animate, and
armed only after the opening render settles so entering a chat never
animates the whole feed in).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core: reaction chips — solid web-posture pills, no more muddy overlap

The chips now match style.css .reaction-chip exactly: opaque fills
layered on --bubble-them (mine adds a 16% accent wash + the accent
ring), sitting BELOW the bubble with the web's small gap instead of
translucently overlapping its edge — the old fill picked up the bubble
color underneath and the overlap read as a collision against accent
bubbles. Shared MessageBubble, so Mac and iOS both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: apply the overnight code review — crash, dead feeds, yanked scroll, leaks

Multi-agent review of the branch (10 confirmed findings); the
mechanical seven fixed here, the three architectural ones go to the
morning plan:

- composerToken trapped on "/cmd" with the caret at 0 (chars[1..<0]) —
  now yields the web's empty query; regression test added.
- The "edit" frame never updated message.raw, so a second edit
  re-encrypted from the pre-first-edit payload, silently reverting it.
- ChatFeedModel.stop() left `socket` non-nil, making start() a
  permanent no-op — an evicted parked model froze its still-mounted
  view forever. stop() nils it, and NativeChatView keys the feed view
  on the model's identity so a rebuilt model is actually adopted.
- Pin-to-bottom is gated: only your own send or already-at-bottom
  scrolls (Mac uses the frame store; iOS leans on
  defaultScrollAnchor) — incoming messages no longer yank readers out
  of history.
- BlobImageLoader gets a 96 MB LRU (decoded photos are ~16 MB each;
  the uncapped dictionary was a slow-motion jetsam kill).
- The Show Times swipe monitor only acts for the on-screen pane —
  hidden opacity-0 instances shared the same frame and stole the
  swipe. FrameStore is a @StateObject now (one instance across struct
  re-inits, which the monitor closure requires anyway).
- MessageRightClick's Tapback row leaked one NSMenu per right-click
  (menu → item → hosting view → closure → menu); weak menu breaks it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: ⌘K palette works on the native surface

The active NativeChatView publishes its thread list to MacAppState
(the native equivalent of lchatPaletteItems) and consumes the
palette's open-thread requests; the palette prefers native rows and
never falls back to a stale hidden webview's. Deactivating clears only
your own rows (owner-guarded), so instance switches can't cross wires.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: E2E plaintext fallback removed; mini-apps render as their webview UI

Two of the morning-plan items:

InstancesStore is keychain-ONLY for writes now — the old fallback
wrote the instance list (user private keys included) as plaintext JSON
to Application Support whenever a build lacked the data-protection
entitlement. A failed keychain save just doesn't persist (the Mac
re-extracts identity from the webview session next launch); the legacy
file stays readable for migration and the first successful keychain
save deletes it.

Mini-app self-threads (To-do, Reminders, …) render as the app's own
web interface again — "Apps stay WKWebView", honored per-thread:
ChatThread gains isApp (kind "self" + app id in the encrypted
name_blob); the Mac takeover embeds AppThreadWebView (seeded like
iOS's FeedWebCache pages: device token + that one thread's key, never
the private key); iOS's native-feed flag routes app threads to the
webview ChatView.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: the UX punch list — gestures, header, menus, spacing

- Interactive keyboard dismiss: a downward drag that reaches the
  composer carries the keyboard away with the finger.
- Swipe-right on a bubble quick-replies (haptic at the threshold, the
  web's touch gesture); swipe-left peeks per-message times at the
  trailing edge, springing back on release.
- The Messages-style header returns: big avatar level with the bar
  buttons, name in a glass pill (ChatView's, reused).
- Corner ⋯ menu: Pin/Unpin, Mark as Unread/Read, Close chat. Thread
  rows grow a long-press menu (pin / unread toggle / close) and a
  leading swipe toggles read/unread, Mail-style — ThreadStore gains
  markUnread (lastSeen wound back behind the newest message).
- Context-menu preview hugs the bubble shape — no more rectangular
  background flash around bubble + reactions while the menu opens.
- Photo viewer: rotate while pinching, springs back level on release.
- Breathing room: slightly larger bubble gaps, taller list cells, and
  more air above time separators than below.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: the visual punch list — web-matched sidebar, resizable, avatar rail

- The thread list matches the web app: --sidebar background (#f8f9fc /
  #1a1d22) with the feed on --panel, your avatar + "Chats" in the
  side-head, and the web's round hover-highlight icon buttons.
- The list is resizable (drag the divider, persisted) and collapses to
  an avatar-only rail below the threshold — unread dot badges, accent
  ring on the selected chat, titles in tooltips, same context menu.
- The feed gets its header: chat avatar + title + ⋮ menu (Pin/Unpin,
  Mark as Unread/Read, Close) — one threadMenu shared by rows, rail
  and header.
- Rows use the web's relTime labels (now in LChatCore, shared with the
  iOS row) and the pin icon sits left of the time, matching iOS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: YouTube Done stops sound instantly; lightbox cursor holds the arrow

Done swaps the embed webview for a black stand-in before the zoom-back
(audio dies immediately; the stand-in animates out), and arrowCursor
uses continuous set() — WebKit re-asserts its cursor every mouse move,
so the old hover push/pop lost the fight and left the I-beam.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core,mac,ios: smaller recs — honest connected, live mark-read, cold-load loader

- ChatSocket reports "connected" from the delegate's didOpen (its own
  URLSession now), not on resume() — no more claiming a connection it
  hasn't made. stop() invalidates the session (which retains its
  delegate), and ChatFeedModel tears the socket down in deinit so a
  released model can't leak one.
- The open, foregrounded chat marks itself read as messages arrive
  (Mac: the active pane after a sidebar refresh; iOS: the pushed
  screen while the scene is active) — reading no longer accrues an
  unread badge.
- A cold, uncached chat hides its half-laid-out feed behind a loader
  until history has folded and pinned (the web's behaviour); prewarmed
  chats never see it. NSE checked: builds clean, no extension-safety
  warnings from LChatCore.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac,ios: feedback round — reply arrow, times in view, UIKit keyboard, squircles

iOS: the reply arrow fades in behind the bubble as you swipe (tinted +
haptic when armed); the times peek lands INSIDE the viewport with a
margin (they slid to the screen edge before); and interactive keyboard
dismiss goes through UIKit's keyboardDismissMode on the real
UIScrollView (ScrollViewIntrospect) — SwiftUI's modifier froze the
list while the keyboard tracked; UIKit scrolls both, the
Messages/WhatsApp behaviour. (True dismiss-from-composer-top needs the
composer as an inputAccessoryView — noted as the follow-up.)

Mac: icon-button hovers are Finder-style squircles with the glyphs
centered; the new-chat popover rows get avatars and the whole popover
tints to the web app's accent (#5666F0) instead of system blue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: avatar lightbox, flush header border, bottom-anchored feed

Header and feed-header avatars with a real photo click open in the
lightbox (zooming from the circle). The side-head is taller with its
bottom border flush against the search box (divider between them, the
web posture). And the land-at-top-on-open bug gets the same cure iOS
got: defaultScrollAnchor(.bottom) anchors before first layout — a
parked model's onAppear pin was racing layout and losing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac,ios: land flush at the newest message — verified pinning

One pin computes against pre-materialisation layout: rows above grow
to their real heights after the jump and the feed settles a message
short of the bottom. The Mac now re-pins until the frame store
CONFIRMS the last row is flush with the viewport bottom (bailing if
the chat moves on underneath); iOS re-pins on a stagger. Fixes the
one-chat-opens-scrolled-up inconsistency.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core,mac,ios: AI/BOT/APP badges, group header stats, sender-name hardening

The roster's helper kind ("ai" vs deterministic bot) reaches native now
(RosterEntry.kind), lighting up the web's badge language everywhere:
AI/BOT chips beside helper DMs and APP beside mini-app threads in both
thread lists (badge computed at fold time on ChatThread), kind-aware
chips in the Mac new-chat popover, and the group header gains the web's
subtitle — "2 members, 1 bot, 2 AI agents" (ui.js memberCountLabel,
ported) — under the title on the Mac feed header and inside the iOS
name pill. Sender names in group feeds are belt-and-braces gated on
members > 2 as well as kind, so a group shows names whatever kind it
reports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: matched 60pt headers, no border under Chats

The side-head loses its divider (search sits straight below), and both
headers share one fixed 60pt height — their bottom edges line up
across the divider, avatars 34pt on each side.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac,ios: bottom-sentinel pinning — the one-message-short bug, actually fixed

scrollTo(lastRow) computes against the row's ESTIMATED height while
it's unmaterialised, so a tall final message landed the feed one
message short — deterministically, every retry redoing the same wrong
math (which is why the verified re-pins couldn't save it). The pin
target is now a fixed 1pt sentinel BELOW the last row: its height is
exact, so sentinel-at-the-bottom is always truly the bottom, and
scrolling to it materialises the real rows on the way. Both platforms;
the frame-store verification stays as the backstop. Also: the chat
header sits 1pt shorter than the side-head per review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: kebab glyphs centered — menuStyle(.button) + plain button style

The borderless-button menu style added its own asymmetric insets
around the label; .button with a plain buttonStyle lays out our label
verbatim, so the ellipsis sits dead-centre in its box (side-head and
chat header both).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: unread pip centres vertically on the row

Moved out of the preview line's HStack into the row's outer stack, so
it centres against the whole row instead of hanging off the two-line
preview.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac,ios: no snap on open — the feed reveals only once verifiably pinned

The sentinel fixed the destination; the flash was the first frame
rendering at the LazyVStack's under-estimated offset before the pin
landed (worst in media-heavy chats). The feed now stays invisible
until the pin sequence completes — verified flush via the frame store
on the Mac, the staggered pins on iOS — so a parked chat blinks in
already at the bottom (a frame or two) and a cold one holds its loader
until it's truly seated. It just IS at the bottom.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: ⌘K shows the chats' real avatars on the native surface

The native palette rows render each thread's decrypted photo
(ThreadAvatar, with the stable-hue initials fallback); glyph/initials
stay for the webview path, which can't hand native avatar sources
across the bridge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: native companion split for mini-apps + clickable rail

Companion apps (To-do, Reminders — the appWantsChatView set, kept in
sync like appTitles) get the web's Chat | Both | View switch as a
native segmented control in the chat header: Chat is the NATIVE feed,
View is the app's webview full-pane, Both puts the webview in a
resizable right sidebar (drag the divider, width persisted). Takeover
apps keep the full-pane webview with no toggle — the companion shape
stays the app's choice, never imposed. ChatThread carries appId now;
the regular feed path folds into one nativeFeed(_:) builder.

Also: the collapsed avatar rail was completely unclickable — the
avatar-lightbox tap gesture swallowed every click even with no handler
attached. Avatars are transparent to clicks unless they actually open
the lightbox, so rail and row buttons get their taps back.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: the collapsed rail is clickable — the button gets its own hit shape

The click-transparent avatar (lightbox-only tap) was the rail button's
ENTIRE label, leaving the button with no hittable area; contentShape
on the label restores it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: app webview survives toggles, icon mode switch, viewport-aware bubbles

- AppWebViewCache parks each app thread's webview (LRU 6, seed-key
  checked): Chat | Both | View toggles reattach the same live page —
  no reload per click (a detached WKWebView keeps its page).
- The mode switch is icons now: a chat bubble, a split pane, and the
  app's own avatar as the View segment (hand-rolled segments — stock
  segmented controls can't host an avatar view).
- Bubble stage width follows the viewport: viewport minus padding/run
  spacer/time gutter, capped at a readable 660pt — bubbles spread out
  in a big window and still never resize when the gutter reveals.
  Quantised to 16pt steps so live divider drags don't re-wrap every
  row per pixel (the resize flash).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac,web,core: app faces + APPS in pickers, interface-view embeds, poster fix

- The companion split's webview showed the WEB chat beside the native
  one: app threads default to their chat view, and the embed page had
  no say. The embed contract gains a view hint (__lchatEmbed.view;
  shell.js applies it in bootEmbed) and the Mac seeds "interface" —
  NB: takes effect once the updated shell.js is DEPLOYED to instances.
- App threads show the app's emoji face everywhere (glyph map mirroring
  apps/*.js, kept in sync like appTitles) instead of YOUR avatar (a
  self-thread's member photo is you); ⌘K rows carry it too.
- The New-chat popover gains the web's APPS section: To-do, Reminders,
  Poll, Bookmarks, Whiteboard — open-or-create natively
  (ThreadStore.openOrCreateAppThread mirrors shell.js createAppThread:
  encrypted {app,title} name_blob, key wrapped only to us).
- YouTube posters no longer go black on chat return: a cached
  thumbnail loader replaces AsyncImage, which got stuck on its
  placeholder when rows reattached.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core,mac,ios: the typing indicator is the web's bouncing bubble, in-feed

Three softly bouncing dots in a them-side bubble at the bottom of the
feed (TypingBubble, shared; groups get the 'X is typing…' label above
it) — replacing the composer-inset text line whose background clipped
the bubble beneath it. Scrolls into view when the reader is at the
bottom.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: opening a chat focuses the composer

The FocusState write in onAppear raced the freshly-inserted (still
invisible pre-settle) field and lost — re-asserted a beat later and
again when the feed reveals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: keyboard lifts the feed; calmer row swipes

keyboardWillShow pins the sentinel (immediately + after the animation)
so the last message rises above the composer instead of hiding behind
the keyboard. Row swipe gestures engage later and demand stronger
horizontal dominance — their contention with vertical scrolling read
as scroll jank, worst with the keyboard up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: arrivals and the typing bubble stay visible with the keyboard up

Composing means you're at the conversation's tail — but the bottom
anchor doesn't hold through keyboard insets, so the typing bubble and
the reply itself were landing hidden behind the keyboard. Any new last
message or typing-bubble appearance now pins (animated) while the
composer is focused, alongside the existing own-send pin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac,ios: short chats read from the top

The bottom anchor bottom-aligned content shorter than the viewport;
the message stack now has a viewport-height floor, top-aligned, so a
two-message chat starts at the top while long chats keep every
bottom-pinning behaviour.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac,ios: scroll-back prefetch waits for the feed to settle

The opening pin-jump transiently materialises top rows, so loadOlder
prepended pages nobody asked for — each one churning layout hard
enough that scrolling felt dead for seconds after opening a chat.
Prefetch now waits until the feed is verifiably settled at the bottom.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: sticky photo physics — resistant rotation, everything springs home

Rotation is damped to ~half the fingers' angle (the photo has weight),
and releasing a pinch/rotate springs the whole pose — zoom, pan and
twist — back to rest, Instagram-peek style. Double-tap remains the way
to zoom and stay zoomed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: video radial ring, viewer swipe-dismiss + rotate, image fly-off exits

- Video cards get the Mac's radial download ring (blobData progress →
  the shared MessageBubble ring).
- The video viewer dismisses with a swipe: the player rides the finger
  while the backdrop fades, spring-back under the threshold. A rotate
  button turns the video sideways for orientation-locked phones
  (system rotation still applies when unlocked — verify on device).
- Lightbox exits fixed: the covers present with a CLEAR background and
  dismiss animation-less, so the image/player flies off and the
  backdrop fades — the whole lightbox no longer visibly swipes away.
- The settle pin window tightens to ~0.12s (each pin cancels a user
  scroll, so that window was the lingering opening scroll-delay).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: native message notifications + a fuller Chats menu

The app HAS asked for notification permission since launch — but the
local notifications were posted by the webview's chat.js, which the
takeover no longer mounts, so native mode had gone silent. The
sidebar's instance-wide socket now posts them natively: decrypt the
frame with the thread's key, suppress your own messages and everything
while the app is frontmost, tag with instance+thread so a tap can
route (the existing delegate handles it).

The Chats ⋮ menu fills in: New Chat…, Enable Notifications… (requests
when undecided; routes to System Settings once denied, since apps
can't re-prompt — labelled "Notifications Off" then), Open Web App,
Refresh. A proper settings panel comes later.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: the reply arrow is revealed by the swipe

It rides in from behind the bubble's edge tracking the finger —
sliding, growing and tinting as the drag arms — instead of sitting
hard-left at the margin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: reply/edit banner ✕ gets a real 44pt touch target

The bare ~20pt glyph (with no plain button style) needed several taps;
now a full 44×44 hit area with contentShape, the banner's padding
absorbed into it so the layout doesn't grow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core: history pages decrypt off the main actor

Found it: ChatFeedModel lives on the main actor, so folding a page
meant 200 AES-GCM opens + JSON decodes ON MAIN — the inconsistent
freeze when opening a chat, worst during the 5-chat prewarm burst.
Pages now decrypt in a detached userInitiated task and only the fold/
publish hops back to main; single live frames keep the cheap inline
path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core: page folds build off-main and merge with ONE sort

The remaining open-a-long-chat stall: per-message fold did a linear
search + full list re-sort for EACH of the 200 rows, on main, with fat
Message structs. The whole page now builds in the detached task and
main just dictionary-merges and sorts once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core: memoise rich-text parsing per message

Every visible bubble re-tokenised its text and rebuilt its
AttributedString on each body evaluation — every model publish and
every mid-scroll row materialisation paid it, reading as periodic
scroll hitches. Block and inline parses now memoise by text (crude
600-entry caps).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: TEMPORARY hitch logger (DEBUG) — nail the periodic scroll stall

CADisplayLink records every dropped frame to Documents/hitches.log
with breadcrumbs (model/store publishes, settle pins, row-swipe
activity) from the moments before. Pulled off the device with devicectl
copy; delete once the culprit is named.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: the feed stops observing ThreadStore — the hitch log's verdict

Documents/hitches.log (CADisplayLink diagnostic) showed every real
stall (34-88ms) trailing a ThreadStore publish: mark-reads, refreshes
and bot activity re-rendered the entire 200-row feed mid-scroll. The
screen only needs point-in-time store reads and method calls, so it
holds the store unobserved now; the feed renders from its own model.
(Also dropped the store.objectWillChange breadcrumb — onReceive itself
subscribes.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: the feed goes UIKit — UICollectionView container, SwiftUI bubbles

The hitch log's remaining stalls were all SwiftUI's ScrollView: 60Hz
on a 120Hz screen, ~125ms scrollTo-at-open (laying out everything from
the estimated position to the bottom in one transaction), 70-90ms row
construction mid-scroll with no reuse. The feed now lives in a
UICollectionView (the ThreadListCollection pattern): cell reuse,
ProMotion, exact contentOffset control. Cells still host the SHARED
SwiftUI MessageBubble via UIHostingConfiguration — visuals, gestures,
menus and media cards carry over verbatim (FeedRow).

What the controller now owns, replacing a pile of machinery: bottom
pinning (initial land + arrivals when near-bottom or own send — the
sentinel, settle passes, keyboard pins and defaultScrollAnchor all
die), prepend anchoring by exact height delta, the scroll-back trigger
(offset near top, not row materialisation), keyboard lift via
keyboardWillShow when at the tail, and UIKit's interactive keyboard
dismiss (ScrollViewIntrospect retired). Short chats read from the top
naturally. Gesture/download state moved to FeedUIState (a class the
visible cells observe) so drags and progress re-render cells, not the
screen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios,mac: full-bleed feed, bubble-anchored reply arrow, live list, gutters

- The iOS feed runs full-bleed behind the glass header and composer:
  SwiftUI's safe-area insets (header + composer + keyboard) flow into
  the controller as content insets, cells clear their opaque default
  background, and when the reader is at the tail the offset follows
  the keyboard's inset delta frame by frame (replacing the
  notification-based pin). The composer field reads translucent again
  because the feed actually passes beneath it.
- The reply arrow anchors to the BUBBLE's leading edge (it rides along
  as the bubble slides) — on your own right-aligned bubbles it was
  appearing at the screen edge.
- The times gutter tightens (64 → 48pt).
- The iOS thread list gets the Mac sidebar's live socket: a brand-new
  member's first message now surfaces without a pull-to-refresh.
- Mac: bubbles' left gutter aligns with the chat header's (leading 14).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: retire ScrollViewIntrospect — the UIKit container owns its scroll view

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: the group header pill breathes — more padding around the stats line

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: staged previews float — no material slab over the feed

The composer cluster's .bar background grew with the staged-image
chips and curtained the messages beneath; the pieces float now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: silent cell sizing, honest insets, direct bottom landing

Three feed fixes from Paul's round:
- selfSizingInvalidation = .disabled + snapshots applied without
  animation: hosted cells default to ANIMATED self-sizing, so every
  cell settling from its estimated height played a spring "catch-up" —
  a cascade at open (also the open-scroll delay), springs mid-scroll.
- ignoresSafeArea moved onto the GeometryReader: a proxy that ignores
  the safe area reports the insets being ignored; on the child it
  reported ~zero, so content slid beneath the composer and stayed
  obscured when the keyboard rose.
- Unanimated bottom-landing sets contentOffset directly (cheaper than
  scrollToItem's attribute walk) with one corrective pass.

Keyboard-slides-from-composer-top noted as the follow-up: it's the
composer-as-inputAccessoryView migration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: measured chrome insets + controller-owned keyboard; sizing un-broken

The safe-area proxy under-reported (device insets only — no composer,
no keyboard), so bubbles slid beneath the composer at rest and stayed
obscured when typing. The chrome is measured now: header and composer
heights via geometry probes, composed in the controller with the
keyboard overlap it tracks itself (keyboardWillChangeFrame, animated
with the keyboard's own curve, offset gluing when at the tail; the
composer floats above whichever is taller, home bar or keyboard).

Also reverts selfSizingInvalidation = .disabled — reused cells kept
their previous occupant's height (phantom gaps between bubbles). The
spring catch-up was the animated snapshot applies, which stay gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: silent self-sizing via cell subclass; glued-at-tail content growth

FeedCell lays out inside performWithoutAnimation — self-sizing stays
ENABLED (correct heights) but its estimated→real settle no longer
plays the spring catch-up. And a contentSize observer keeps the last
message flush when late sizing grows the content while the reader
rests at the tail (untouched) — the half-hidden-bubble-under-composer
case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: staged image previews float over the feed, never push it

The chips render as an overlay hanging above the composer cluster
(alignmentGuide hoists them clear) instead of participating in the
inset's layout — staging an image no longer shoves the chat up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: layout-level animation suppression + inset telemetry

The spring survives the cell subclass because it's the SIBLING rows
sliding during a self-size settle — the collection view now lays out
without implicit animations wholesale (offset-driven scrolling is
unaffected). And applyInsets logs its actual numbers to the hitch log
so the under-the-composer inset bug gets diagnosed from device truth
instead of another guess.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: staged previews hug the composer's top edge

The overlay rides the FEED region (applied before the bottom inset,
so its bottom edge is the composer's top) — the alignment-guide hoist
had parked the chip on top of the field itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: staged chip aligns with the + button's left edge

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: device-truth insets — the telemetry's verdict applied

The pulled inset log said it plainly: safe=0 on every line (SwiftUI's
proxy never reports the home-indicator inset → bubbles rested ~34pt
under the composer, unscrollably) and winH=-1 (the view wasn't
attached when values applied — and the keyboard handler GUARDED on
window, so every keyboard event was silently dropped: kb=0 forever).

Now: the device bottom inset comes from UIKit itself
(view.safeAreaInsets, re-derived on viewSafeAreaInsetsDidChange once
attachment lands), and keyboard overlap is computed in screen
coordinates against the screen — no window required.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: fix the layout-recursion crash; calm sticks — one animation, yours

The crash report (recursive _updateVisibleCellsNow → UIKit assertion)
named it: the content-size glue mutated contentOffset from INSIDE the
layout pass (self-sizing publishes contentSize mid-pass), recursing
the update. The glue defers to the next runloop, coalesced;
viewSafeAreaInsetsDidChange defers the same way; applyInsets guards a
not-yet-built collection view.

And the springy scrolling: every typing toggle and arrival near the
tail ran an ANIMATED scrollToItem. Now only your own send animates —
arrivals and typing changes flush silently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: stable scroll-back — flow layout with offset-compensated self-sizing

The "catch-up" was cells correcting from estimated to real height
during scroll-back, shifting everything on screen. The compositional
list exposes no compensation hook, so the feed moves to a
UICollectionViewFlowLayout subclass: when a self-size correction lands
ABOVE the viewport, its delta folds into contentOffset (the classic
chat-app trick) — the rows being read never move. Cells force
full-width self-sizing (flow would let hosted SwiftUI pick its width),
and estimates track the real width so first guesses are close.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: pre-measured row heights — the feed scrolls as one piece

The offset-compensation experiment fought the pan gesture (bubbles
freezing while new ones "scrolled in from the top" — chopped-sausage
scrolling). Reverted, and the estimated-height dance is now simply
gone: every row's height is measured up front by a hosting controller
(sizeThatFits, cached per message+width) and handed to a plain flow
layout as its EXACT size. No self-sizing anywhere — frames are final
before a cell ever appears, so the whole view scrolls rigidly.

Async growth handled: link-preview cards render synchronously from the
unfurl cache (MessageBubble gains peekUnfurl; the card seeds its state
from a resolved entry — off-screen measurement never runs .task), and
new linked messages prefetch their unfurl then re-measure that one row.
Content edits/reactions invalidate their cached height; width changes
flush the cache.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: no layout before real width — the top-of-screen bunching

Data applies deferred until the collection has a non-zero width (the
zero-width first pass sized items into garbage frames that lingered as
collapsed/overlapping cells at the top), and the first real width —
or any later change — flushes every cached height and re-lays the feed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core,ios: the main-thread stalls, named and killed

Paul's read was right — something WAS blocking the main thread:

- SharedThreadKeys.save ran a Keychain write PER THREAD on EVERY
  ThreadStore.refresh, on main (5-50ms each) — the intermittent
  can't-scroll stall, fired by any instance activity. Unchanged
  payloads now skip entirely (fingerprint check) and real writes run
  on a utility queue.
- Flow layout sizes every item up front, so a 200-message chat meant
  200 synchronous SwiftUI measurements at open — the delay-before-
  scroll. A render window shows the newest 60 initially; scroll-back
  widens it over already-loaded messages (instant, prepend-anchored)
  before the model ever fetches.
- The DEBUG hitch logger's own synchronous file writes were adding
  stalls to the storms it measured — buffered to a utility queue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: throttle window expansion — the top-of-feed bunching

didScroll fires per frame near the top, and the unguarded expansion
prepended +60 rows per frame: stacked measurements and offset shifts
mid-pan collapsed the top of the feed into overlapping cells. One
expansion per 300ms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: A/B the feed engines — the SwiftUI implementation returns as a toggle

Paul's hypothesis deserves a real test: the main-thread stalls (the
Keychain-per-refresh writes above all) predated the UIKit container
and were fixed in CORE, so the pre-UIKit SwiftUI feed gets those fixes
for free — and it never had the container's teething bugs. Resurrected
from git (LegacyNativeChatScreen + its scroll introspection, types
renamed to coexist) behind a "SwiftUI feed engine" toggle under the
native-chat one, so the two engines can be compared on the same build
with the same fixes. The loser gets deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: the SwiftUI feed engine wins — UIKit container removed

Paul's A/B verdict was immediate. With the real main-thread stalls
fixed in core (the per-refresh Keychain writes above all), the SwiftUI
implementation is smoother AND carries none of the collection view's
teething bugs — so ChatFeedCollection, the UIKit NativeChatScreen and
the A/B toggle are gone; the resurrected SwiftUI screen IS
NativeChatScreen again. The HitchLogger goes too: it found the
Keychain stall, the layout-recursion crash and the inset lies — job
done. Worth keeping from the detour: FeedWebCache-style parked models,
off-main folds, memoised parsing, unobserved store, and the diagnosis
method itself (on-device telemetry files pulled via devicectl).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core,ios: off-main thread-key unwraps; keyboard-riding scroll; logger back

- ThreadStore.refresh ran a P-256 ECDH unwrap PER THREAD on the main
  actor, on every live-socket-triggered refresh — the same stall class
  as the Keychain writes, one layer deeper. The whole batch unwraps in
  a detached task now; the fold keeps only cheap AES/JSON work on main.
- The feed opts out of SwiftUI's keyboard safe area and tracks the
  keyboard as a scroll-content margin instead: interactive dismissal
  used to re-inset the scroll view mid-drag and fight the finger — the
  list now keeps scrolling under the departing keyboard.
- HitchLogger returns (it earns its keep) with a live-refresh duration
  breadcrumb, so the next "is something blocking main?" gets numbers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core: the local message store — decrypt once, in the background, forever

Paul's proposal, implemented: MessageStore, a SQLite actor (raw
sqlite3, WAL — the same engine the server runs on) holding every
message decrypted ONCE at ingest. All statements run off the main
actor by construction; the UI reads plaintext rows with indexed
queries; FTS5 rides along from day one as the search foundation.

E2E posture (deliberate, documented here like the unfurl exception):
the SERVER still only ever sees ciphertext — this DB lives on the
device, which already holds the keys. The file gets OS protection
(complete-until-first-auth, matching the keys' accessibility) and is
EXCLUDED FROM BACKUPS so plaintext never leaves the device in an
iCloud/iTunes backup.

Wiring: history pages decrypt detached and upsert in batches; opening
a previously-seen chat publishes from the store INSTANTLY, before the
network answers, then converges by upsert; live frames, edits,
deletions and reaction tallies mirror through. A live echo can't
clobber a stored reaction tally (merge rule tested — round-trip,
merge, delete and FTS covered in MessageStoreTests).

Also reverts the ignoresSafeArea(.keyboard) contentMargins experiment
(it coincided with Paul's bubble-rendering report — boring known-good
keyboard behaviour restored while the report is investigated).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: group management — View Members, Add People, Rename Group

Native sheets on the thread menu (rows, rail, chat header): the
members list with avatars/badges; add-people multi-select that wraps
the EXISTING thread key to each newcomer's public key (ECIES, exactly
the web's member sheet); rename re-encrypts {name} under the thread
key. LChatAPI gains addMember/setThreadName; ThreadStore gains
addMembers/renameGroup. Invite-someone (the invite-link secret flow)
deliberately deferred — it carries the whole join crypto and deserves
its own pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac,ios: native Settings — themes and display name, both platforms

Themes: the web's palettes (Classic, Lit Room, Lagoon, Sunny, Linen)
transcribed into ChatTheme presets; the native surfaces read the
chosen one live (bubbles, sidebar/panel, accents, typing bubble).

Profile: display name changes go out encrypted under the instance key
(the web's publishMyLabel, byte-compatible), refresh the roster, and
persist into the SavedInstance so outgoing payloads carry the new name.

Surfaces: the Mac Settings window (⌘,) gets a General tab — theme
radio group + per-server profile with an instance picker — beside the
existing Updates tab; iOS gets the cog on the chat list (beside
search) opening a per-server sheet: name, theme, notifications hint.
Profile photo upload is the noted follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac,ios: no Save-name button — Done becomes Save, fields commit themselves

iOS: the sheet's Done turns into Save the moment the name is edited
(saves then closes); Return also commits. Mac: Return or leaving the
field saves — no button. Saves self-gate on actual changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: opaque reply/edit banner — feed showed through it

The composer banner floats over the feed since the inset lost its .bar
slab, so bubbles and the typing indicator bled straight through
'Replying to…'. It's now an opaque panel card (theme surface, soft
shadow) above the composer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: search typeahead — conversations + full-text message hits, keyboard-driven

Messages.app-style dropdown under the sidebar search box: a
Conversations section (title matches, avatars, hidden chats included
so closed ones stay findable) and a Messages section served by the
local store's FTS — the first feature standing on the SQLite index
(sender + snippet per hit, newest first, 120ms debounce). Fully
keyboard-navigable: ↓/↑ move the highlight, Return opens, Esc clears;
hover tracks, click opens. Jump-to-message within the feed is the
noted follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac,ios: banner shadow blanked the Mac feed; iOS pins gated on need

Mac: the reply banner's .shadow lived inside safeAreaInset, and a
shadow there makes the hosted scroll content vanish entirely — the
feed went pure white the moment a reply started. The card keeps its
opaque fill and takes a hairline stroke instead.

iOS: proxy.scrollTo is a ~100ms whole-list layout pass, and both
remaining "little pauses" were pins firing when the feed was ALREADY
flush at the bottom — where defaultScrollAnchor holds growth for free.
A 1pt sentinel-visibility flag now gates the arrival pin and the
typing-bubble pin; they only run when genuinely away from the tail
(the keyboard-inset blind spot they exist for).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: notification tap lands the first unread at the top of the view

Opening a chat from a push with unreads now anchors the oldest unread
message just below the title pill (Messages-style: read on from where
you left off) instead of pinning the newest. The pre-tap read
watermark is captured at init — before markRead bumps it — and the
settle pass anchors to the first message past it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: ⌘K reaches everything — closed chats, people without a DM, unopened apps

The palette's fuzzy match was already there ("td" → To-do); what was
missing was the SOURCE: it only got open threads. The native surface
now publishes every thread (hidden included — picking one reopens it),
roster people and bots with no DM yet (picking creates one; the server
dedupes), and personal apps not yet started (picking creates the app
thread). New row kinds ride "user:"/"app:" ids consumed by the
takeover surface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* release: native is THE surface — toggles removed, changelog minted

The iOS "Native chat (beta)" drawer toggle and the Mac View ▸ Use Web
App escape hatch are gone: the native feed is the only chat surface on
both platforms (mini-app threads keep their webview interface, and
joining still runs through the web flow). Per the native-changelog
rule, this release-minting change carries the member-facing entry
covering the whole native build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell b57506b924a67c38be4e070b39676f95a0477f0c parent 7ac0b3d
87 files changed, +9636 −589
  • .gitignore +3 −0
  • changelog/2026-07-07-native-apps.md +8 −0
  • docs/native-chat.md +306 −0
  • ios/LChat/AvatarLoader.swift +1 −0
  • ios/LChat/FeedWebCache.swift +7 −6
  • ios/LChat/LChatApp.swift +7 −0
  • ios/LChat/Models.swift +0 −137
  • ios/LChat/PushManager.swift +1 −0
  • ios/LChat/Views/Avatar.swift +1 −0
  • ios/LChat/Views/ChatView.swift +62 −61
  • ios/LChat/Views/HitchLogger.swift +67 −0
  • ios/LChat/Views/InstanceSidebar.swift +2 −0
  • ios/LChat/Views/JoinView.swift +1 −14
  • ios/LChat/Views/MacTitlebar.swift +0 −29
  • ios/LChat/Views/NativeChatScreen.swift +1055 −0
  • ios/LChat/Views/NewChatView.swift +1 −0
  • ios/LChat/Views/ServerSettingsSheet.swift +83 −0
  • ios/LChat/Views/ThreadListCollection.swift +53 −7
  • ios/LChat/Views/ThreadListView.swift +68 −8
  • ios/LChat/Views/ThreadRow.swift +6 −15
  • ios/LChatCore/Package.swift +25 −0
  • ios/LChatCore/Sources/LChatCore/AgentBadge.swift +25 −0
  • ios/LChatCore/Sources/LChatCore/AttachmentDragItem.swift +52 −0
  • ios/LChatCore/Sources/LChatCore/AttachmentFormat.swift +120 −0
  • ios/LChatCore/Sources/LChatCore/BlobDataCache.swift +30 −0
  • ios/LChatCore/Sources/LChatCore/BlobImageLoader.swift +90 −0
  • ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift +780 −0
  • ios/LChatCore/Sources/LChatCore/ChatSocket.swift +137 −0
  • ios/LChatCore/Sources/LChatCore/ChatTheme.swift +105 −0
  • ios/LChatCore/Sources/LChatCore/ComposerTypeahead.swift +218 −0
  • ios/{LChat => LChatCore/Sources/LChatCore}/Crypto.swift +15 −15
  • ios/LChatCore/Sources/LChatCore/Format.swift +31 −0
  • ios/LChatCore/Sources/LChatCore/ImageProcessing.swift +39 −0
  • ios/{LChat => LChatCore/Sources/LChatCore}/InviteLink.swift +2 −2
  • ios/{LChat => LChatCore/Sources/LChatCore}/Keychain.swift +17 −16
  • ios/{LChat => LChatCore/Sources/LChatCore}/LChatAPI.swift +75 −14
  • ios/LChatCore/Sources/LChatCore/MemoryVideoAsset.swift +75 −0
  • ios/LChatCore/Sources/LChatCore/MessageBubble.swift +756 −0
  • ios/LChatCore/Sources/LChatCore/MessageRightClick.swift +149 −0
  • ios/LChatCore/Sources/LChatCore/MessageStore.swift +230 −0
  • ios/LChatCore/Sources/LChatCore/Models.swift +220 −0
  • ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift +54 −0
  • ios/LChatCore/Sources/LChatCore/PlatformImage.swift +25 −0
  • ios/LChatCore/Sources/LChatCore/RichText.swift +282 −0
  • ios/{LChat => LChatCore/Sources/LChatCore}/SharedThreadKeys.swift +28 −8
  • ios/{LChat => LChatCore/Sources/LChatCore}/ThreadStore.swift +169 −31
  • ios/LChatCore/Sources/LChatCore/TypeaheadStrip.swift +119 −0
  • ios/LChatCore/Sources/LChatCore/TypingBubble.swift +45 −0
  • ios/LChatCore/Sources/LChatCore/YouTubeCard.swift +179 −0
  • ios/LChatCore/Tests/LChatCoreTests/AttachmentBytesVectorTests.swift +31 −0
  • ios/LChatCore/Tests/LChatCoreTests/ComposerTypeaheadTests.swift +120 −0
  • ios/LChatCore/Tests/LChatCoreTests/ECIESVectorTests.swift +37 −0
  • ios/LChatCore/Tests/LChatCoreTests/Fixtures/attachment-bytes-vector.json +6 −0
  • ios/LChatCore/Tests/LChatCoreTests/Fixtures/message-payload-vector.json +36 −0
  • ios/LChatCore/Tests/LChatCoreTests/Fixtures/native-message-vector.json +11 −0
  • ios/{LChatTests => LChatCore/Tests/LChatCoreTests/Fixtures}/push-blob-vector.json +0 −0
  • ios/{LChatTests => LChatCore/Tests/LChatCoreTests/Fixtures}/thread-key-vector.json +0 −0
  • ios/LChatCore/Tests/LChatCoreTests/InviteLinkTests.swift +50 −0
  • ios/LChatCore/Tests/LChatCoreTests/MessagePayloadVectorTests.swift +63 −0
  • ios/LChatCore/Tests/LChatCoreTests/MessageStoreTests.swift +34 −0
  • ios/LChatCore/Tests/LChatCoreTests/NativeMessageVectorTests.swift +67 −0
  • ios/LChatCore/Tests/LChatCoreTests/PushBlobDecryptTests.swift +28 −0
  • ios/LChatCore/Tests/LChatCoreTests/RichTextTests.swift +150 −0
  • ios/LChatMac/AddChatView.swift +1 −0
  • ios/LChatMac/AppThreadWebView.swift +92 −0
  • ios/LChatMac/ChatFeedWebView.swift +37 −11
  • ios/LChatMac/GroupSheets.swift +148 −0
  • ios/LChatMac/LChatMacApp.swift +80 −1
  • ios/LChatMac/Lightbox.swift +278 −0
  • ios/LChatMac/MacInstance.swift +9 −6
  • ios/LChatMac/MacSettingsView.swift +85 −0
  • ios/LChatMac/NativeChatView.swift +923 −0
  • ios/LChatMac/NativeFeedView.swift +1074 −0
  • ios/LChatMac/NewChatPopover.swift +171 −0
  • ios/LChatMac/RootView.swift +67 −16
  • ios/LChatMac/Sidebar.swift +1 −0
  • ios/LChatNSE/NotificationService.swift +1 −0
  • ios/LChatTests/ECIESVectorTest.swift +0 −52
  • ios/LChatTests/InviteLinkTest.swift +0 −65
  • ios/LChatTests/PushBlobDecryptTest.swift +0 −52
  • ios/project.yml +14 −13
  • package.json +1 −1
  • test/blobbytes.test.mjs +73 −0
  • test/messageblob.test.mjs +80 −0
  • test/nativeblob.test.mjs +29 −0
  • test/threadkey.test.mjs +10 −9
  • web/static/shell.js +5 −0
.gitignore

Loading diff…

changelog/2026-07-07-native-apps.md

Loading diff…

docs/native-chat.md

Loading diff…

ios/LChat/AvatarLoader.swift

Loading diff…

ios/LChat/FeedWebCache.swift

Loading diff…

ios/LChat/LChatApp.swift

Loading diff…

ios/LChat/Models.swift

Loading diff…

ios/LChat/PushManager.swift

Loading diff…

ios/LChat/Views/Avatar.swift

Loading diff…

ios/LChat/Views/ChatView.swift

Loading diff…

ios/LChat/Views/HitchLogger.swift

Loading diff…

ios/LChat/Views/InstanceSidebar.swift

Loading diff…

ios/LChat/Views/JoinView.swift

Loading diff…

ios/LChat/Views/MacTitlebar.swift

Loading diff…

ios/LChat/Views/NativeChatScreen.swift

Loading diff…

ios/LChat/Views/NewChatView.swift

Loading diff…

ios/LChat/Views/ServerSettingsSheet.swift

Loading diff…

ios/LChat/Views/ThreadListCollection.swift

Loading diff…

ios/LChat/Views/ThreadListView.swift

Loading diff…

ios/LChat/Views/ThreadRow.swift

Loading diff…

ios/LChatCore/Package.swift

Loading diff…

ios/LChatCore/Sources/LChatCore/AgentBadge.swift

Loading diff…

ios/LChatCore/Sources/LChatCore/AttachmentDragItem.swift

Loading diff…

ios/LChatCore/Sources/LChatCore/AttachmentFormat.swift

Loading diff…

ios/LChatCore/Sources/LChatCore/BlobDataCache.swift

Loading diff…

ios/LChatCore/Sources/LChatCore/BlobImageLoader.swift

Loading diff…

ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift

Loading diff…

ios/LChatCore/Sources/LChatCore/ChatSocket.swift

Loading diff…

ios/LChatCore/Sources/LChatCore/ChatTheme.swift

Loading diff…

ios/LChatCore/Sources/LChatCore/ComposerTypeahead.swift

Loading diff…

ios/{LChat => LChatCore/Sources/LChatCore}/Crypto.swift

Loading diff…

ios/LChatCore/Sources/LChatCore/Format.swift

Loading diff…

ios/LChatCore/Sources/LChatCore/ImageProcessing.swift

Loading diff…

ios/{LChat => LChatCore/Sources/LChatCore}/InviteLink.swift

Loading diff…

ios/{LChat => LChatCore/Sources/LChatCore}/Keychain.swift

Loading diff…

ios/{LChat => LChatCore/Sources/LChatCore}/LChatAPI.swift

Loading diff…

ios/LChatCore/Sources/LChatCore/MemoryVideoAsset.swift

Loading diff…

ios/LChatCore/Sources/LChatCore/MessageBubble.swift

Loading diff…

ios/LChatCore/Sources/LChatCore/MessageRightClick.swift

Loading diff…

ios/LChatCore/Sources/LChatCore/MessageStore.swift

Loading diff…

ios/LChatCore/Sources/LChatCore/Models.swift

Loading diff…

ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift

Loading diff…

ios/LChatCore/Sources/LChatCore/PlatformImage.swift

Loading diff…

ios/LChatCore/Sources/LChatCore/RichText.swift

Loading diff…

ios/{LChat => LChatCore/Sources/LChatCore}/SharedThreadKeys.swift

Loading diff…

ios/{LChat => LChatCore/Sources/LChatCore}/ThreadStore.swift

Loading diff…

ios/LChatCore/Sources/LChatCore/TypeaheadStrip.swift

Loading diff…

ios/LChatCore/Sources/LChatCore/TypingBubble.swift

Loading diff…

ios/LChatCore/Sources/LChatCore/YouTubeCard.swift

Loading diff…

ios/LChatCore/Tests/LChatCoreTests/AttachmentBytesVectorTests.swift

Loading diff…

ios/LChatCore/Tests/LChatCoreTests/ComposerTypeaheadTests.swift

Loading diff…

ios/LChatCore/Tests/LChatCoreTests/ECIESVectorTests.swift

Loading diff…

ios/LChatCore/Tests/LChatCoreTests/Fixtures/attachment-bytes-vector.json

Loading diff…

ios/LChatCore/Tests/LChatCoreTests/Fixtures/message-payload-vector.json

Loading diff…

ios/LChatCore/Tests/LChatCoreTests/Fixtures/native-message-vector.json

Loading diff…

ios/{LChatTests => LChatCore/Tests/LChatCoreTests/Fixtures}/push-blob-vector.json

Loading diff…

ios/{LChatTests => LChatCore/Tests/LChatCoreTests/Fixtures}/thread-key-vector.json

Loading diff…

ios/LChatCore/Tests/LChatCoreTests/InviteLinkTests.swift

Loading diff…

ios/LChatCore/Tests/LChatCoreTests/MessagePayloadVectorTests.swift

Loading diff…

ios/LChatCore/Tests/LChatCoreTests/MessageStoreTests.swift

Loading diff…

ios/LChatCore/Tests/LChatCoreTests/NativeMessageVectorTests.swift

Loading diff…

ios/LChatCore/Tests/LChatCoreTests/PushBlobDecryptTests.swift

Loading diff…

ios/LChatCore/Tests/LChatCoreTests/RichTextTests.swift

Loading diff…

ios/LChatMac/AddChatView.swift

Loading diff…

ios/LChatMac/AppThreadWebView.swift

Loading diff…

ios/LChatMac/ChatFeedWebView.swift

Loading diff…

ios/LChatMac/GroupSheets.swift

Loading diff…

ios/LChatMac/LChatMacApp.swift

Loading diff…

ios/LChatMac/Lightbox.swift

Loading diff…

ios/LChatMac/MacInstance.swift

Loading diff…

ios/LChatMac/MacSettingsView.swift

Loading diff…

ios/LChatMac/NativeChatView.swift

Loading diff…

ios/LChatMac/NativeFeedView.swift

Loading diff…

ios/LChatMac/NewChatPopover.swift

Loading diff…

ios/LChatMac/RootView.swift

Loading diff…

ios/LChatMac/Sidebar.swift

Loading diff…

ios/LChatNSE/NotificationService.swift

Loading diff…

ios/LChatTests/ECIESVectorTest.swift

Loading diff…

ios/LChatTests/InviteLinkTest.swift

Loading diff…

ios/LChatTests/PushBlobDecryptTest.swift

Loading diff…

ios/project.yml

Loading diff…

package.json

Loading diff…

test/blobbytes.test.mjs

Loading diff…

test/messageblob.test.mjs

Loading diff…

test/nativeblob.test.mjs

Loading diff…

test/threadkey.test.mjs

Loading diff…

web/static/shell.js

Loading diff…