eleven / messenger Public

ios+mac: resurrect the #465 main-thread audit — socket decode, staging reads, publish guards (#1257)

* mac: feed.mount span — the click-to-usable-feed measurement, iOS twin

The Mac feed had zero spans of its own, so its open cost was invisible
the way iOS's was before feed.mount: begun at the opening appear (one
appear = one open — the view rebuilds per thread switch), ended one turn
after `settled` commits so the reveal frame's layout is measured. Same
span name and slow floor as iOS, so the ledger report digests both
platforms through one seam. Its numbers decide how soon the Mac needs
its twin of the #1246 container rebuild.

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

* ios+mac: resurrect the #465 main-thread audit — socket decode, staging reads, publish guards

The 2026-07-22 Mac alt ledger (238 hangs, 235s total) says the audit PR
onto the rebuilt world, keeping only what survives:

- ChatSocket decodes frames on a background serial delegate queue —
  every socket on the instance (open chat, up to 8 parked models, the
  list) receives EVERY thread's frames, so a busy group decoded the same
  burst on main once per socket. Only the generation check + delivery
  hop to main; order preserved.
- LastOpenedChat.note keychain write (5-50ms, on the exact chat-open
  frame) is fingerprint-skipped and moved to a utility queue.
- ThreadStore publishes only real changes: spinner only before first
  rows, instanceName/roster/threads/gates/blockedPeople on change only,
  presence beats and markRead skip no-op flips. Every unchanged publish
  cost a full list diff + reconfigure and a chat-screen body pass.
- CoalescedRunner: mid-pass callers now AWAIT the trailing rerun instead
  of returning early — createChat reads `threads` right after refresh()
  and relied on a pass that started after its call (the contract #465's
  task chain documented and #1208's early-return quietly broke). Still
  one rerun for N callers; tests re-pin the new contract.
- NativeFeedModelCache throttles the parked-model store catch-up to one
  per 2s — the chat screen re-inits (and lands there) on every store
  publish, each catch-up a full store page read.
- ThreadListCollection reconfigures only rows whose ChatThread actually
  changed, not every survivor, per publish.
- Mac staging: dropped/picked file reads and the chip-thumbnail decode
  move off main (the chip upgrades from ring-only when the thumbnail
  lands, like the video poster already did).

Skipped as superseded: the task-chain serialisation (#1208's runner),
ChatFeedWebView (file gone), the iOS staging reads (already detached),
and the branch's later hitch-hunt commits (#1204/#1213/#1246 solved
those properly). Closes #465.

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

* mac: chat switches cut content-to-content — and ⌘[/⌘] clamps at the list's ends

The ⌘[/⌘] flash: switching chats swapped the feed by identity, so the
outgoing view unmounted instantly while the incoming one sat hidden
through its settle beat — a blank pane between every pair of chats. The
pane's ZStack now keeps BOTH feeds mounted, keyed by thread id so a feed
keeps its identity (state, scroll, settled pixels) as it moves between
the current and outgoing roles: the old chat covers the beat, and the
incoming feed's onSettled drops the cover in the same commit its own
reveal lands — old content straight to new content, no blank frame. A
0.4s cap keeps a cold load's spinner from staying covered, and rapid
cycling holds the last VISIBLE chat as cover rather than promoting a
never-settled blank one. App/companion switches keep today's behaviour.

⌘[ on the top row (and ⌘] on the last) now does nothing — wrapping to
the opposite end read as a jump to nowhere.

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

* mac: sidebar header shows the space name; both headers drag the window edge-to-edge

The sidebar's generic 'Chats' becomes the space's name (the one place
that says where you are; 'Chats' only until it loads). The window-drag
zones now track each header's WHOLE visible band — tracking only the
inner 60pt row left the chat header's lower reaches inert — and each
zone extends to the window's top edge, so the title-bar sliver above a
band drags and double-click-zooms like the real thing.

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

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 92166d440e15864b95ec5a0dea6a3920c7c83183 parent e34f337
9 files changed, +330 −77
  • ios/LChat/Views/ThreadListCollection.swift +11 −4
  • ios/LChatCore/Sources/LChatCore/ChatSocket.swift +34 −15
  • ios/LChatCore/Sources/LChatCore/CoalescedRunner.swift +25 −12
  • ios/LChatCore/Sources/LChatCore/Keychain.swift +14 −3
  • ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift +12 −3
  • ios/LChatCore/Sources/LChatCore/ThreadStore.swift +32 −11
  • ios/LChatCore/Tests/LChatCoreTests/CoalescedRunnerTests.swift +30 −7
  • ios/LChatMac/NativeChatView.swift +119 −13
  • ios/LChatMac/NativeFeedView.swift +53 −9
diff --git a/ios/LChat/Views/ThreadListCollection.swift b/ios/LChat/Views/ThreadListCollection.swift
index 7ef2ef3..fd4c3fb 100644
--- a/ios/LChat/Views/ThreadListCollection.swift
+++ b/ios/LChat/Views/ThreadListCollection.swift
@@ -162,16 +162,23 @@ final class ThreadListController: UICollectionViewController {
func apply(_ threads: [ChatThread]) {
latestThreads = threads
guard isViewLoaded, dataSource != nil else { return } // deferred to viewDidLoad
- let existing = Set(threadsByID.keys)
+ let previous = threadsByID
threadsByID = Dictionary(threads.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
let ids = threads.map(\.id)
itemCount = ids.count
var snap = NSDiffableDataSourceSnapshot<Section, String>()
snap.appendSections([.main])
snap.appendItems(ids, toSection: .main)
- // Rows that survive may have a fresh preview / time / unread dot — re-render them.
- let survivors = ids.filter { existing.contains($0) }
- if !survivors.isEmpty { snap.reconfigureItems(survivors) }
+ // Surviving rows re-render (fresh preview / time / unread dot) — but
+ // only the ones that actually changed: each reconfigure rebuilds a
+ // UIHostingConfiguration on the main thread, and this runs on every
+ // store publish, so one flipped unread dot must not re-host every
+ // row (#465).
+ let changed = ids.filter { id in
+ guard let old = previous[id] else { return false } // new row: the snapshot inserts it
+ return old != threadsByID[id]
+ }
+ if !changed.isEmpty { snap.reconfigureItems(changed) }
dataSource.apply(snap, animatingDifferences: populated)
populated = true
}
diff --git a/ios/LChatCore/Sources/LChatCore/ChatSocket.swift b/ios/LChatCore/Sources/LChatCore/ChatSocket.swift
index 766afb7..be61ebc 100644
--- a/ios/LChatCore/Sources/LChatCore/ChatSocket.swift
+++ b/ios/LChatCore/Sources/LChatCore/ChatSocket.swift
@@ -50,7 +50,8 @@ public final class ChatSocket: NSObject, URLSessionWebSocketDelegate {
// task (its error, its reconnect timer) see a stale value and stand down.
private var generation = 0
// The connect→handshake signpost, open between connect() and didOpen.
- // Main-thread state, like everything else here (delegateQueue: .main).
+ // Main-thread state, like everything else here — the delegate queue is
+ // background (frame decode), so delegate callbacks hop to main first.
private var connectSpan: Perf.Span?
// baseURL is the instance origin (https://host[:port]); the socket rides
@@ -107,15 +108,29 @@ public final class ChatSocket: NSObject, URLSessionWebSocketDelegate {
deinit { stop() }
// "connected" arrives via the delegate when the handshake actually
- // completes — resuming the task only means we've started trying.
+ // completes — resuming the task only means we've started trying. The
+ // delegate queue is a background queue now, so hop to main before touching
+ // main-owned state (`task`, the span) or the consumer's callback.
public func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask,
didOpenWithProtocol protocol: String?) {
- guard webSocketTask === task else { return }
- connectSpan?.end()
- connectSpan = nil
- onStatus?("connected")
+ DispatchQueue.main.async { [weak self] in
+ guard let self, webSocketTask === self.task else { return }
+ self.connectSpan?.end()
+ self.connectSpan = nil
+ self.onStatus?("connected")
+ }
}
+ // Frames land (and their JSON decodes) here, off main — every socket on the
+ // instance receives EVERY thread's frames, so a busy group otherwise decodes
+ // the same burst on the main thread once per open/parked model (#465).
+ private let receiveQueue: OperationQueue = {
+ let q = OperationQueue()
+ q.maxConcurrentOperationCount = 1
+ q.name = "chat-socket-receive"
+ return q
+ }()
+
private func connect(gen: Int) {
guard gen == generation else { return }
// A dangling span from a failed attempt closes unrecorded — only a
@@ -125,7 +140,7 @@ public final class ChatSocket: NSObject, URLSessionWebSocketDelegate {
var req = URLRequest(url: url)
req.setValue(origin, forHTTPHeaderField: "Origin")
if session == nil {
- session = URLSession(configuration: .default, delegate: self, delegateQueue: .main)
+ session = URLSession(configuration: .default, delegate: self, delegateQueue: receiveQueue)
}
let task = session!.webSocketTask(with: req)
self.task = task
@@ -147,16 +162,17 @@ public final class ChatSocket: NSObject, URLSessionWebSocketDelegate {
private func receive(_ task: URLSessionWebSocketTask, gen: Int) {
task.receive { [weak self] result in
+ // Decode on the delegate queue (background, serial — order kept);
+ // only the generation check + delivery hop to main.
+ var frame: Frame?
+ if case .success(.string(let text)) = result {
+ frame = try? JSONDecoder().decode(Frame.self, from: Data(text.utf8))
+ }
+ let failed: Bool
+ if case .failure = result { failed = true } else { failed = false }
DispatchQueue.main.async {
guard let self, gen == self.generation else { return }
- switch result {
- case .success(let message):
- if case .string(let text) = message,
- let frame = try? JSONDecoder().decode(Frame.self, from: Data(text.utf8)) {
- self.onFrame?(frame)
- }
- self.receive(task, gen: gen)
- case .failure:
+ if failed {
// Dropped (sleep, network blip, server restart) — reconnect and
// let the consumer refetch what it missed.
Perf.event("socket.reconnect")
@@ -164,7 +180,10 @@ public final class ChatSocket: NSObject, URLSessionWebSocketDelegate {
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.connect(gen: gen)
}
+ return
}
+ if let frame { self.onFrame?(frame) }
+ self.receive(task, gen: gen)
}
}
}
diff --git a/ios/LChatCore/Sources/LChatCore/CoalescedRunner.swift b/ios/LChatCore/Sources/LChatCore/CoalescedRunner.swift
index a2dff4c..9d8df8c 100644
--- a/ios/LChatCore/Sources/LChatCore/CoalescedRunner.swift
+++ b/ios/LChatCore/Sources/LChatCore/CoalescedRunner.swift
@@ -8,26 +8,39 @@ import Foundation
// second-plus on a big space: uncoalesced, overlapping passes raced
// duplicate /api/threads fetches and stacked their folds' publishes into
// the 120–270ms frame hitches the 2026-07-20 device trace pinned on
-// NativeChatScreen's re-render. Callers that arrive mid-pass return
-// immediately — the trailing rerun guarantees eventual freshness, which is
-// exactly the repaint semantic every caller wants.
+// NativeChatScreen's re-render.
+//
+// A caller that arrives mid-pass AWAITS the trailing rerun rather than
+// returning early: callers like createChat read the published state right
+// after run() and rely on a pass that STARTED after their call (the new
+// thread must be in `threads` when run() returns). The early-return first
+// cut broke that quietly — repaint callers didn't care, createChat did.
@MainActor
public final class CoalescedRunner {
- private var running = false
+ private var current: Task<Void, Never>?
private var queued = false
public init() {}
- public func run(_ pass: @MainActor () async -> Void) async {
- if running {
+ public func run(_ pass: @escaping @MainActor () async -> Void) async {
+ if let current {
+ // Mid-pass: the trailing rerun starts after this call, so
+ // awaiting the whole task is the freshness guarantee.
queued = true
+ await current.value
return
}
- running = true
- defer { running = false }
- repeat {
- queued = false
- await pass()
- } while queued
+ let task = Task { @MainActor [weak self] in
+ repeat {
+ self?.queued = false
+ await pass()
+ } while self?.queued == true
+ // Cleared as the task's LAST act, with no suspension after the
+ // loop — so no caller can ever observe a finished-but-current
+ // task and return without its covering pass.
+ self?.current = nil
+ }
+ current = task
+ await task.value
}
}
diff --git a/ios/LChatCore/Sources/LChatCore/Keychain.swift b/ios/LChatCore/Sources/LChatCore/Keychain.swift
index 6b4ac47..268eb26 100644
--- a/ios/LChatCore/Sources/LChatCore/Keychain.swift
+++ b/ios/LChatCore/Sources/LChatCore/Keychain.swift
@@ -156,15 +156,26 @@ public enum ThreadListSnapshot {
// because the decrypted title is user content: same posture as
// SharedThreadKeys, which already parks decrypted titles for the NSE.
public enum LastOpenedChat {
- public struct Entry: Codable {
+ public struct Entry: Codable, Equatable {
public let origin: String
public let thread: String
public let title: String
}
+ // A Keychain write costs 5-50ms and note() runs at the exact moment a chat
+ // opens — the one frame that must not stall. Skip re-noting the same chat
+ // and do the real write off the main thread (Keychain is thread-safe; the
+ // same treatment SharedThreadKeys got). #465.
+ private static let writeQueue = DispatchQueue(label: "last-opened-chat", qos: .utility)
+ private static var lastNoted: Entry? // touched only on writeQueue
+
public static func note(origin: String, thread: String, title: String) {
- Keychain.save(Entry(origin: origin, thread: thread, title: title),
- account: "last-opened-chat")
+ let entry = Entry(origin: origin, thread: thread, title: title)
+ writeQueue.async {
+ guard entry != lastNoted else { return }
+ lastNoted = entry
+ Keychain.save(entry, account: "last-opened-chat")
+ }
}
public static func load() -> Entry? {
diff --git a/ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift b/ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift
index d26400d..0809b8e 100644
--- a/ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift
+++ b/ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift
@@ -16,6 +16,7 @@ public final class NativeFeedModelCache {
let model: ChatFeedModel
let seedKey: String
var lastUsed = Date()
+ var lastCatchUp = Date.distantPast
}
private var entries: [String: Entry] = [:] // "instanceID|threadID"
@@ -144,13 +145,21 @@ public final class NativeFeedModelCache {
let key = instance.id + "|" + thread.id
if var entry = entries[key], entry.seedKey == thread.threadKeyB64 {
entry.lastUsed = Date()
- entries[key] = entry
// A parked model missed whatever the NSE / background refresh
// filed while the app was suspended — merge it from disk now, so
- // a notification tap lands on an already-caught-up chat.
+ // a notification tap lands on an already-caught-up chat. Throttled
+ // (#465): the open chat screen re-inits (and lands back here) on
+ // EVERY ThreadStore publish, and each catch-up is a full store
+ // page read; while the app is live the model's own socket keeps
+ // it current anyway (the NSE only files while we're suspended,
+ // and foregrounding runs its own catchUpFromStore).
let model = entry.model
model.integrity = integrityMonitor(for: instance)
- Task { await model.catchUpFromStore() }
+ if Date().timeIntervalSince(entry.lastCatchUp) > 2 {
+ entry.lastCatchUp = Date()
+ Task { await model.catchUpFromStore() }
+ }
+ entries[key] = entry
// Same staleness for an ended DM (#578): the row the caller holds
// is fresher than a park that slept through the dm_closed frame.
model.noteDMClosed(thread.dmClosed)
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
index eb4a095..b327296 100644
--- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
+++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
@@ -138,12 +138,18 @@ public final class ThreadStore: ObservableObject {
private func refreshPass() async {
guard let api else { loadError = "This instance's address is invalid."; return }
- loading = true
+ // The published spinner only matters before the first rows exist —
+ // steady-state refreshes (every message anywhere lands here) must not
+ // toggle a @Published var twice per pass: each objectWillChange
+ // re-renders the whole chat list AND re-evaluates the pushed chat
+ // screen (#465).
+ let showSpinner = threads.isEmpty
+ if showSpinner { loading = true }
// Refires on every socket frame, so the budget is generous — only a
// genuinely wedged refresh (slow roster + key unwraps) should record.
let span = Perf.begin("threads.refresh", slowMs: 2500)
defer {
- loading = false
+ if showSpinner { loading = false }
span.end("\(threads.count) threads")
}
@@ -169,9 +175,9 @@ public final class ThreadStore: ObservableObject {
// fetched "" must never clobber a name we already resolved.
let fetchedName = await api.groupName()
if !fetchedName.isEmpty {
- instanceName = fetchedName
+ if instanceName != fetchedName { instanceName = fetchedName }
} else if let sealed = sealedInstanceName {
- instanceName = sealed
+ if instanceName != sealed { instanceName = sealed }
} else if !sealedNameAttempted {
sealedNameAttempted = true
if let opened = await SealedBrandingResolver.open(api: api, userPrivPkcs8B64: instance.userPriv),
@@ -186,8 +192,8 @@ public final class ThreadStore: ObservableObject {
let nickFetch = await nickFetchAsync
let prefsFetch = await prefsFetchAsync
let gates = await gatesAsync
- botsEnabled = gates.botsEnabled
- appsEnabled = gates.appsEnabled
+ if botsEnabled != gates.botsEnabled { botsEnabled = gates.botsEnabled }
+ if appsEnabled != gates.appsEnabled { appsEnabled = gates.appsEnabled }
// Route unfurls through the instance's preferred fetch service. nil =
// the gates read failed — keep the routing we hold (a blip must not
// reroute); "" is a real answer (the instance says unfurl locally).
@@ -201,7 +207,9 @@ public final class ThreadStore: ObservableObject {
}
// nil = fetch failed — keep the held set rather than "unblocking"
// everyone over a network blip.
- if let blocked = await blocksAsync { blockedPeople = Set(blocked) }
+ if let blocked = await blocksAsync, blockedPeople != Set(blocked) {
+ blockedPeople = Set(blocked)
+ }
// ALL crypto in one detached pass — the instance-key unwrap, roster
// labels, per-thread ECDH unwraps, previews, titles, app ids. refresh()
@@ -240,7 +248,11 @@ public final class ThreadStore: ObservableObject {
let id = row.id
Task { await api.nudgeTier(id) }
}
- roster = folded.people
+ // Publish only real changes: most activity-driven refreshes fold to
+ // the exact rows already showing (e.g. a message in the open chat its
+ // own feed already rendered), and an unchanged publish still costs a
+ // full list diff + reconfigure and a chat-screen body pass on main.
+ if roster != folded.people { roster = folded.people }
verifyInviteBindings() // KT: check any invitees we hold bind keys for (#573)
rosterLabels = folded.rosterLabels
nicknames = folded.nicknames
@@ -280,7 +292,7 @@ public final class ThreadStore: ObservableObject {
// we have and let the next refresh reconcile, rather than blanking a
// list the member is looking at (and its snapshot with it).
if !folded.rows.isEmpty || threads.isEmpty {
- threads = folded.rows
+ if threads != folded.rows { threads = folded.rows }
}
// Park the rows for next time: the hydrate() above is what makes
// re-entering a space instant across launches. Off-main, dedup-skipped
@@ -963,10 +975,15 @@ public final class ThreadStore: ObservableObject {
/// roster and any DM/self row showing that member — the between-refresh
/// counterpart of the stamp refresh() reads off /api/roster.
public func applyPresence(userID: String, state: String) {
- if let i = roster.firstIndex(where: { $0.user_id == userID }) {
+ // Presence beats repeat the current state constantly — only a real
+ // transition is worth an objectWillChange (each publish re-renders
+ // the whole chat list and re-evaluates the pushed chat screen, #465).
+ if let i = roster.firstIndex(where: { $0.user_id == userID }),
+ roster[i].presence != state {
roster[i].presence = state
}
for i in threads.indices where !threads[i].isApp
+ && threads[i].presence != state
&& presenceUserID(kind: threads[i].kind, members: threads[i].members) == userID {
threads[i].presence = state
}
@@ -1351,7 +1368,11 @@ public final class ThreadStore: ObservableObject {
public func markRead(_ thread: ChatThread) {
ReadState.setManualUnread(instance: instance.id, thread: thread.id, at: 0)
ReadState.setLastSeen(instance: instance.id, thread: thread.id, at: thread.lastAt)
- if let i = threads.firstIndex(where: { $0.id == thread.id }) { threads[i].unread = false }
+ // Publish only a real flip — markRead re-fires on every arrival while
+ // the chat is open and on every open of an already-read chat (#465).
+ if let i = threads.firstIndex(where: { $0.id == thread.id }), threads[i].unread {
+ threads[i].unread = false
+ }
Self.onMarkRead?(thread.id)
if thread.lastAt > (serverReadMarks[thread.id] ?? 0) {
serverReadMarks[thread.id] = thread.lastAt
diff --git a/ios/LChatCore/Tests/LChatCoreTests/CoalescedRunnerTests.swift b/ios/LChatCore/Tests/LChatCoreTests/CoalescedRunnerTests.swift
index afa20ed..cdf00d4 100644
--- a/ios/LChatCore/Tests/LChatCoreTests/CoalescedRunnerTests.swift
+++ b/ios/LChatCore/Tests/LChatCoreTests/CoalescedRunnerTests.swift
@@ -3,8 +3,9 @@ import XCTest
// 🤖 The refresh gate's contract: overlapping calls collapse into exactly
// one trailing rerun (not zero — a frame that arrives mid-pass must still
-// produce a fresh pass; not N — the stampede was the bug), and sequential
-// calls each run.
+// produce a fresh pass; not N — the stampede was the bug); sequential
+// calls each run; and a mid-pass caller resumes only after a pass that
+// STARTED after its call (createChat reads `threads` right after run()).
@MainActor
final class CoalescedRunnerTests: XCTestCase {
func testSequentialCallsEachRun() async {
@@ -15,7 +16,7 @@ final class CoalescedRunnerTests: XCTestCase {
XCTAssertEqual(passes, 2)
}
- func testOverlappingCallsCollapseToOneTrailingRerun() async {
+ func testOverlappingCallsCollapseToOneTrailingRerunAndAwaitIt() async {
let runner = CoalescedRunner()
var passes = 0
var blocked = false
@@ -33,13 +34,35 @@ final class CoalescedRunnerTests: XCTestCase {
}
}
while release == nil { await Task.yield() }
- // Three calls while the pass is running — all coalesce into ONE rerun.
- for _ in 0..<3 {
- await runner.run { passes += 1 }
+ // Three callers while the pass is running: none may run a pass of
+ // their own, and none may RESUME until the trailing rerun has
+ // covered them.
+ var resumed = 0
+ let waiters = (0..<3).map { _ in
+ Task { @MainActor in
+ await runner.run { passes += 1 }
+ resumed += 1
+ }
}
- XCTAssertEqual(passes, 1, "mid-pass calls must return immediately, not run")
+ for _ in 0..<20 { await Task.yield() } // let the waiters park in run()
+ XCTAssertEqual(passes, 1, "mid-pass calls must not run a pass themselves")
+ XCTAssertEqual(resumed, 0, "mid-pass callers wait for their covering pass")
release?.resume()
_ = await first.value
+ for w in waiters { _ = await w.value }
XCTAssertEqual(passes, 2, "exactly one trailing rerun after the pass ends")
+ XCTAssertEqual(resumed, 3, "every waiter resumed once the rerun landed")
+ }
+
+ func testCallAfterCompletionRunsFresh() async {
+ // The current-task handle must be cleared by the time any awaiter
+ // resumes — a later call must start a fresh pass, never attach to a
+ // finished task and return passless.
+ let runner = CoalescedRunner()
+ var passes = 0
+ await runner.run { passes += 1 }
+ await runner.run { passes += 1 }
+ await runner.run { passes += 1 }
+ XCTAssertEqual(passes, 3)
}
}
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift
index 9278a2c..361bd0f 100644
--- a/ios/LChatMac/NativeChatView.swift
+++ b/ios/LChatMac/NativeChatView.swift
@@ -19,6 +19,17 @@ struct NativeChatView: View {
// transaction as the click held the row highlight back until the pane
// finished building, which made every chat switch read as lag.
@State private var paneID: String?
+ // The just-left chat, held MOUNTED (identity preserved in the pane's
+ // ForEach) on top of the incoming feed while that feed runs its settle
+ // beat — so a ⌘[/⌘] switch cuts old content → new content with no blank
+ // frame between (the flash). Dropped the moment the incoming feed
+ // reports settled, or at a short cap so a cold load's spinner is never
+ // covered. lastSettledFeedID tracks the last feed that actually became
+ // visible: rapid cycling through never-settled chats keeps the last
+ // VISIBLE one as cover instead of promoting a blank pane.
+ @State private var outgoingFeedThread: ChatThread?
+ @State private var lastSettledFeedID: String?
+ @State private var outgoingGen = 0
@State private var showDiscover = false
// One instance-wide socket keeps the SIDEBAR live: the hub fans every
// thread's frames to us anyway, so any stored-message activity just
@@ -78,7 +89,14 @@ struct NativeChatView: View {
case .leftMouseDown:
let p = event.locationInWindow
let point = CGPoint(x: p.x, y: content.bounds.height - p.y)
- guard zones.feed.rect.contains(point) || zones.list.rect.contains(point) else { return event }
+ // Each zone reaches the window's TOP edge: the sliver between
+ // a band and the frame edge is title-bar-shaped space, and
+ // treating it as part of the zone also gives it the
+ // double-click zoom a real title bar has.
+ let grabs = [zones.feed.rect, zones.list.rect].map { r in
+ r.isEmpty ? r : CGRect(x: r.minX, y: 0, width: r.width, height: r.maxY)
+ }
+ guard grabs.contains(where: { $0.contains(point) }) else { return event }
if event.clickCount == 2 {
window.zoom(nil)
return nil
@@ -169,6 +187,27 @@ struct NativeChatView: View {
.onChange(of: selectedID) { _, id in
DispatchQueue.main.async { paneID = id }
}
+ // Feed→feed switches hold the outgoing chat over the settle beat
+ // (see outgoingFeedThread). Switches involving app/companion panes
+ // keep today's behaviour — those surfaces paint their own way.
+ .onChange(of: paneID) { old, new in
+ outgoingGen += 1
+ let gen = outgoingGen
+ let newThread = store.threads.first { $0.id == new }
+ guard old != new, let newThread, isFeedSurface(newThread) else {
+ outgoingFeedThread = nil
+ return
+ }
+ if let oldThread = store.threads.first(where: { $0.id == old }),
+ isFeedSurface(oldThread), old == lastSettledFeedID {
+ outgoingFeedThread = oldThread
+ }
+ // else: the old pane never became visible (mid-cycle) — keep the
+ // current cover, which is still the last content on screen.
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
+ if outgoingGen == gen { outgoingFeedThread = nil }
+ }
+ }
.onChange(of: isActive) { _, active in
dragZones.isActive = active
if active {
@@ -224,15 +263,19 @@ struct NativeChatView: View {
.sheet(isPresented: $showInvite) {
InviteSomeoneSheet(store: store)
}
- // ⌘[ / ⌘] step through the chat list, wrapping — the change's SIGN is
- // the direction (LChatMacApp's menu items move the tick ±1).
+ // ⌘[ / ⌘] step through the chat list, CLAMPED at both ends — ⌘[ on
+ // the top row (or ⌘] on the last) does nothing; wrapping to the
+ // opposite end read as a jump to nowhere. The change's SIGN is the
+ // direction (LChatMacApp's menu items move the tick ±1).
.onChange(of: app.cycleChatTick) { old, new in
guard isActive else { return }
let list = visibleThreads
guard !list.isEmpty else { return }
let dir = new > old ? 1 : -1
let at = list.firstIndex { $0.id == selectedID } ?? (dir == 1 ? -1 : 0)
- selectedID = list[(at + dir + list.count) % list.count].id
+ let next = at + dir
+ guard list.indices.contains(next) else { return }
+ selectedID = list[next].id
}
.onChange(of: app.nativeOpenThread) { _, id in
guard isActive, let id else { return }
@@ -295,6 +338,37 @@ struct NativeChatView: View {
store.threads.first { $0.id == paneID }
}
+ // A surface the feed pane's ZStack renders itself (plain chat or group
+ // conversation) — the ones that take part in the outgoing-cover switch.
+ private func isFeedSurface(_ t: ChatThread) -> Bool {
+ !t.isApp && !isCompanion(t)
+ }
+
+ // A feed's position verifiably landed: record it as the last visible
+ // content, and if it's the CURRENT pane, drop the outgoing cover — the
+ // uncover and the incoming's reveal share one commit, so the switch cuts
+ // content-to-content.
+ private func feedDidSettle(_ threadID: String) {
+ lastSettledFeedID = threadID
+ if threadID == paneID, outgoingFeedThread != nil {
+ outgoingGen += 1
+ outgoingFeedThread = nil
+ }
+ }
+
+ // The pane's mounted feeds: the selected chat, plus the outgoing one
+ // being held over the settle beat. Keyed by thread id in the ForEach, so
+ // role swaps never change a feed's identity.
+ private var mountedFeedThreads: [ChatThread] {
+ var list: [ChatThread] = []
+ if let t = selectedThread { list.append(t) }
+ if let out = outgoingFeedThread, out.id != selectedThread?.id,
+ isFeedSurface(out) {
+ list.append(out)
+ }
+ return list
+ }
+
// ---- the resize handle + collapsed rail ---------------------------------
// A slim grab area over the divider; dragging resizes the list, and going
@@ -360,7 +434,24 @@ struct NativeChatView: View {
// Plain chats: the header rides OVER the feed as a safe-area inset,
// frosted with a within-window blur, so messages visibly scroll
// beneath it — Messages.app's header treatment (see FrostedBand).
- feed
+ // The ZStack also carries the OUTGOING chat during a switch: keyed
+ // by thread id, so a feed keeps its identity (state, scroll,
+ // settled pixels) as it moves between the current and outgoing
+ // roles — the cover renders instantly, and dropping it uncovers
+ // the incoming feed in the same commit. No blank frame.
+ ZStack {
+ ForEach(mountedFeedThreads) { t in
+ Group {
+ if t.kind == "group" {
+ groupSurface(t)
+ } else {
+ nativeFeed(t)
+ }
+ }
+ .zIndex(t.id == outgoingFeedThread?.id ? 1 : 0)
+ .allowsHitTesting(t.id == thread.id)
+ }
+ }
.safeAreaInset(edge: .top, spacing: 0) {
VStack(spacing: 0) {
feedHeader(thread)
@@ -370,6 +461,9 @@ struct NativeChatView: View {
RenameBanner(store: store, thread: thread)
}
.background(FrostedBand())
+ // The whole visible band doubles as title bar — any
+ // non-interactive point in it drags the window.
+ .trackingFrame(into: dragZones.feed)
}
} else {
// App/companion surfaces host webviews that fill their frame (an
@@ -377,9 +471,13 @@ struct NativeChatView: View {
// stacked band above the pane.
VStack(spacing: 0) {
if let thread = selectedThread {
- feedHeader(thread)
- Divider()
- RenameBanner(store: store, thread: thread)
+ VStack(spacing: 0) {
+ feedHeader(thread)
+ Divider()
+ RenameBanner(store: store, thread: thread)
+ }
+ // Same title-bar duty as the plain-chat band above.
+ .trackingFrame(into: dragZones.feed)
}
feed
}
@@ -564,7 +662,11 @@ struct NativeChatView: View {
reportTarget = ReportTarget(
thread: thread, uid: uid,
evidence: ReportEvidence(message: m))
- })
+ },
+ // The switch cover's drop signal: this feed is
+ // now visibly landed, so the outgoing chat held
+ // over its settle beat can go.
+ onSettled: { feedDidSettle(thread.id) })
.id(thread.id + "|" + thread.threadKeyB64 + "|"
+ String(UInt(bitPattern: ObjectIdentifier(model).hashValue)))
// The Text Size pref (View menu ⌘+/⌘−, Settings slider) drives the
@@ -648,10 +750,10 @@ struct NativeChatView: View {
}
.padding(.horizontal, 14)
// A hair shorter than the side-head, so its border sits 1pt above the
- // search box's top edge.
+ // search box's top edge. (The window-drag zone tracks the whole
+ // header band at the call sites, not this inner row — tracking only
+ // the row left the band's lower reaches unable to drag the window.)
.frame(height: Self.headerHeight - 1)
- // The header doubles as title bar: drag here moves the window.
- .trackingFrame(into: dragZones.feed)
}
@State private var membersSheetThread: ChatThread?
@@ -853,7 +955,11 @@ struct NativeChatView: View {
HStack(spacing: 10) {
ThreadAvatar(title: myLabel, source: myAvatarSource, size: 34,
onTap: openAvatarLightbox(named: myLabel))
- Text("Chats")
+ // The SPACE'S name, not a generic "Chats" — the sidebar
+ // header is the one place that says where you are (the web's
+ // side-head shows the group name the same way). "Chats" only
+ // while the name hasn't loaded.
+ Text(store.instanceName.isEmpty ? "Chats" : store.instanceName)
.font(.system(size: 19, weight: .bold))
.lineLimit(1)
Spacer(minLength: 4)
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index 1b56701..7120617 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -123,7 +123,8 @@ struct NativeFeedView: View {
mentionAvatar: @escaping (String) -> AvatarSource?,
onReopenDM: (() async -> LChatAPI.ReopenDM)? = nil,
onMutePerson: ((String, Bool) -> Void)? = nil,
- onReport: ((ChatFeedModel.Message) -> Void)? = nil) {
+ onReport: ((ChatFeedModel.Message) -> Void)? = nil,
+ onSettled: (() -> Void)? = nil) {
// The model arrives PARKED (NativeFeedModelCache) — folded messages and
// a live socket survive leaving the chat, so re-opening never flashes.
_model = StateObject(wrappedValue: model)
@@ -160,6 +161,7 @@ struct NativeFeedView: View {
self.onReopenDM = onReopenDM
self.onMutePerson = onMutePerson
self.onReport = onReport
+ self.onSettled = onSettled
}
private let defaultCommand: AppDefaultCommand.Spec?
@@ -180,6 +182,10 @@ struct NativeFeedView: View {
// sheet (it holds the ThreadStore with the roster + keys to seal to);
// nil hides the menu item.
private let onReport: ((ChatFeedModel.Message) -> Void)?
+ // Fired every time the feed's position verifiably lands (settled flips
+ // true) — the chat switcher uses it to drop the outgoing feed it holds
+ // over the settle beat.
+ private let onSettled: (() -> Void)?
// The companion app's start-of-history intro (chatIntro on the web),
// shown above the earliest message once all history is loaded.
@@ -534,6 +540,11 @@ struct NativeFeedView: View {
}
}
.onAppear {
+ // The mount clock starts at the opening appear only (the view
+ // is rebuilt per thread switch, so one appear = one open).
+ if mountSpan == nil, !settled {
+ mountSpan = Perf.begin("feed.mount", slowMs: 500)
+ }
// Switching BACK to a thread the reader had scrolled away from
// restores their spot instead of pinning to the bottom — the
// anchor lives in the model cache (the view and its scroll
@@ -550,6 +561,18 @@ struct NativeFeedView: View {
}
consumeJump(proxy)
}
+ // Ends the mount span one turn after the reveal commits, so the
+ // revealed frame's layout is measured — cold fetch, pin/restore
+ // verify rounds and all. Also the switcher's signal to drop the
+ // outgoing feed it held over this beat.
+ .onChange(of: settled) { _, isSettled in
+ guard isSettled else { return }
+ onSettled?()
+ guard let span = mountSpan else { return }
+ mountSpan = nil
+ let count = model.messages.count
+ DispatchQueue.main.async { span.end("\(count) messages") }
+ }
// Anchor to the bottom BEFORE first layout — a parked/prewarmed
// model arrives with messages already folded, and the onAppear pin
// races layout and loses (the land-at-top bug, same as iOS).
@@ -788,6 +811,11 @@ struct NativeFeedView: View {
// should find the chat already at the bottom, never watch it snap there.
// A parked one starts settled (see init) so switching chats is instant.
@State private var settled: Bool
+ // The open's "click → usable feed" measurement, the iOS feed.mount twin:
+ // begun at the opening appear, ended one turn after `settled` commits so
+ // the reveal frame's layout is inside it. Same span name — the ledger
+ // report digests both platforms through one seam.
+ @State private var mountSpan: Perf.Span?
// Whether the newest row is genuinely resting at the viewport bottom (a
// tall chat pinned) or, for a chat too short to fill the viewport, fully
@@ -2053,10 +2081,16 @@ struct NativeFeedView: View {
}()
private func stage(_ url: URL) {
- guard let data = try? Data(contentsOf: url) else { return }
- let name = url.lastPathComponent
- let type = UTType(filenameExtension: url.pathExtension)
- stage(data: data, name: name, type: type)
+ // A dropped/picked file can be tens of MB — the read happens off main
+ // (Data(contentsOf:) is a synchronous disk read on the staging frame,
+ // #465's Mac twin).
+ Task {
+ guard let data = await Task.detached(priority: .userInitiated,
+ operation: { try? Data(contentsOf: url) }).value
+ else { return }
+ stage(data: data, name: url.lastPathComponent,
+ type: UTType(filenameExtension: url.pathExtension))
+ }
}
private func stage(data: Data, name: String, type: UTType?) {
@@ -2068,11 +2102,21 @@ struct NativeFeedView: View {
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { busyText = nil }
return
}
- var entry = Staged(name: name, isImage: isImage)
- if isImage, let thumb = processImage(data, maxDim: 288) {
- entry.preview = PlatformImage(data: thumb.bytes)
- }
+ let entry = Staged(name: name, isImage: isImage)
staged.append(entry)
+ if isImage {
+ // The chip's thumbnail is a full image decode + downscale — off
+ // main like the upload's own processing; the chip upgrades from
+ // ring-only the moment it lands (same shape as the video poster).
+ let entryID = entry.id
+ Task {
+ if let thumb = await Task.detached(priority: .userInitiated,
+ operation: { processImage(data, maxDim: 288) }).value,
+ let i = staged.firstIndex(where: { $0.id == entryID }) {
+ staged[i].preview = PlatformImage(data: thumb.bytes)
+ }
+ }
+ }
if isVideo {
// Poster thumbnails arrive async — the chip upgrades from ring-only
// to a real frame the moment extraction lands.