eleven / messenger Public

ios: chat opens pinned to the bottom, without the multi-second hang (#557)

* ios: chat opens pinned to the bottom, without the multi-second hang

Two user reports on the native iOS feed, both in the opening seconds:

1. The UI hung / scrolling felt dead for a few seconds after opening a
   chat. Three main-thread render-churn causes, each fixed:
   - The socket's "connected" callback re-ran the full loadHistory
     moments after the initial page had just folded, doubling every
     open's fetch->fold->publish->layout work. The re-pull now skips the
     FIRST connect while the open's own pull is in flight or landed
     within the last 3s; a real RE-connect (which can have missed
     frames) still always re-pulls. (ChatFeedModel - shared with the
     Mac, where the same first-connect double applied.)
   - settle() pinned the bottom on a multi-pass cadence; each
     proxy.scrollTo is a ~100ms layout stall that cancels an in-progress
     user scroll. Now one pin plus at most one deferred correction,
     skipped when the sentinel is already on screen, and the feed
     reveals 70ms sooner.
   - The ForEach materialised Array(messages.enumerated()) - a full
     copy of every heavy Message struct - on every body evaluation
     (each publish, each keystroke). It now iterates the published
     array directly; a row finds its neighbour by binary search
     (messages are sorted by id), which lazy rows only run for what's
     on screen. Row ids, transitions and the pagination onAppear
     trigger are unchanged.

2. Chats opened with the newest message hidden under the composer. The
   composer is a bottom safeAreaInset whose height (GrowingTextView
   intrinsic size + glass) resolves a frame or two AFTER the opening
   pin, and defaultScrollAnchor(.bottom) does not re-pin through
   bottom-inset growth. The inset's height is now measured, and growth
   re-pins the feed - mirroring the keyboardWillShow handler - but only
   before the reveal or while the reader is at the bottom, so it never
   yanks someone who has scrolled up into history.

No changelog entry: native changes hold theirs until a release is
minted.

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

* ios: reveal the feed only once the bottom sentinel really lands

Regression from the single-pass settle(): the opening pin computes its
offset against ESTIMATED LazyVStack row heights (nothing is materialised
yet), and attachment-heavy threads (320pt image rows vs ~40pt text rows)
estimate so far off that the viewport can strand BEYOND the real content
— a blank feed, header + composer only, until a gesture forces a layout
pass and the messages pop in from the top, unpinned. The 0.05s
correction was gated on !bottomVisible, but that flag can be stale-true
from the transient estimated pass, so the one pass that would have
recovered it was skipped and the reveal fired anyway. (Image boxes
themselves are metadata-sized up front — the reflow is the lazy stack's
estimation, not thumbnail decode. main survived by accident: its
correction was unconditional, and the since-removed first-connect
re-pull + per-publish enumerated ForEach re-eval forced extra recovery
layout passes.)

The pin is now a phase, not a timed beat: it ends when the sentinel
GENUINELY materialises — its onAppear re-pins once against real layout
and reveals — with the 0.05s correction kept (unconditional again, it
runs pre-reveal where a scrollTo stalls nobody) for the stranded case,
and a 0.35s bounded fallback so the reveal can never deadlock. Text
threads still reveal on the first sentinel appearance (as fast as
before, usually faster), still with no redundant post-settle pins; the
jump/unread-cutoff opens keep their own paths (pinningToBottom is never
armed for them).

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

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 9c9d25f317f9ebf19984f0c2a3c7647607c22f3a parent a6d68c1
2 files changed, +128 −10
  • ios/LChat/Views/NativeChatScreen.swift +105 −8
  • ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift +23 −2
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift
index 730b5ce..0329a3a 100644
--- a/ios/LChat/Views/NativeChatScreen.swift
+++ b/ios/LChat/Views/NativeChatScreen.swift
@@ -147,6 +147,10 @@ struct NativeChatScreen: View {
@State private var avatarPop = false
// Whether the bottom sentinel is on screen — gates the scroll pins.
@State private var bottomVisible = false
+ // The composer inset's measured height — its growth re-pins the feed (see
+ // the onChange in `feed`): the inset resolves a frame or two AFTER the
+ // opening pin, which left the newest message hiding under the composer.
+ @State private var composerHeight: CGFloat = 0
@State private var showMembers = false
@State private var showRename = false
@@ -262,7 +266,16 @@ struct NativeChatScreen: View {
.safeAreaInset(edge: .bottom) {
// An app surface owns the whole pane, inputs included — the
// chat composer steps aside (ChatView's interface-view rule).
- if activeGroupAppID == nil { composerCluster }
+ if activeGroupAppID == nil {
+ composerCluster
+ .background(GeometryReader { g in
+ // Measured for the feed's inset-growth re-pin —
+ // a background probe, so it never shapes layout.
+ Color.clear
+ .onAppear { composerHeight = g.size.height }
+ .onChange(of: g.size.height) { _, h in composerHeight = h }
+ })
+ }
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
@@ -384,9 +397,13 @@ struct NativeChatScreen: View {
@State private var landed = false
// The feed stays invisible until the pin sequence has run.
@State private var settled = false
+ // True while the OPENING bottom pin is in flight (never for the jump /
+ // unread-cutoff opens). The sentinel's onAppear uses it to tell "the
+ // opening pin needs me" from an ordinary later scroll to the bottom.
+ @State private var pinningToBottom = false
private func settle(_ proxy: ScrollViewProxy) {
- guard let id = model.messages.last?.id else {
+ guard !model.messages.isEmpty else {
settled = true // empty chat — nothing to pin
return
}
@@ -425,15 +442,43 @@ struct NativeChatScreen: View {
return
}
}
+ // The immediate jump runs against ESTIMATED row heights — the
+ // LazyVStack hasn't materialised anything yet, and attachment-heavy
+ // threads (320pt image rows vs ~40pt text rows) estimate so far off
+ // that the computed offset can strand the viewport BEYOND the real
+ // content: a blank feed with nothing on screen to re-anchor it. So the
+ // pin isn't a timed beat, it's a phase that ends when the bottom
+ // sentinel GENUINELY materialises (its onAppear re-pins against real
+ // layout and reveals — see the sentinel below). Everything here is
+ // pre-reveal, where a scrollTo stalls nobody; the settled feed still
+ // gets no redundant pins.
+ pinningToBottom = true
proxy.scrollTo(Self.bottomSentinel, anchor: .bottom)
- // Tight: every pin cancels an in-progress user scroll, so this window
- // is exactly the "scrolling feels dead" period — keep it minimal.
+ // One correction after first layout, for when the jump landed in
+ // empty space and the sentinel never appeared to finish the pin. The
+ // pin is deliberately NOT skipped on bottomVisible: the flag can be
+ // stale-true from the transient estimated pass (the sentinel flashed
+ // through the viewport, then the reflow stranded it with no layout
+ // pass left to deliver onDisappear) — trust the pin, not the flag.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
- if model.messages.last?.id == id {
+ guard !settled else { return }
+ proxy.scrollTo(Self.bottomSentinel, anchor: .bottom)
+ if bottomVisible { finishBottomPin() }
+ }
+ // Bounded fallback: the reveal must never deadlock. If the sentinel
+ // still hasn't reported in, pin once more and show the feed anyway.
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
+ guard !settled else { return }
+ if !bottomVisible {
proxy.scrollTo(Self.bottomSentinel, anchor: .bottom)
}
+ finishBottomPin()
}
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { settled = true }
+ }
+
+ private func finishBottomPin() {
+ pinningToBottom = false
+ settled = true
}
private var feed: some View {
@@ -441,7 +486,13 @@ struct NativeChatScreen: View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 0) {
- ForEach(Array(model.messages.enumerated()), id: \.element.id) { i, m in
+ // Iterate the published array directly: the old
+ // Array(enumerated()) copied every heavy Message struct on
+ // EVERY body evaluation (each publish, each keystroke) just
+ // to know a row's position — with lazy rows, a per-row
+ // binary search only runs for what's actually on screen.
+ ForEach(model.messages) { m in
+ let i = messageIndex(of: m.id) ?? 0
row(m, previous: i > 0 ? model.messages[i - 1] : nil, proxy: proxy)
.id(m.id)
// Removal is a plain fade, NOT a scale-implosion:
@@ -484,7 +535,23 @@ struct NativeChatScreen: View {
// ~100ms layout stall, and while the sentinel is visible
// the bottom anchor keeps growth pinned for free.
Color.clear.frame(height: 1).id(Self.bottomSentinel)
- .onAppear { bottomVisible = true }
+ .onAppear {
+ bottomVisible = true
+ // The opening pin computed its offset from
+ // ESTIMATED row heights; the sentinel materialising
+ // means layout is now real — one unanimated re-pin
+ // (deferred out of this layout pass) lands the
+ // bottom exactly, and the feed reveals actually
+ // pinned. Later visits to the bottom skip this:
+ // pinningToBottom is only up during the open.
+ if pinningToBottom {
+ DispatchQueue.main.async {
+ guard pinningToBottom else { return }
+ proxy.scrollTo(Self.bottomSentinel, anchor: .bottom)
+ finishBottomPin()
+ }
+ }
+ }
.onDisappear { bottomVisible = false }
}
.padding(.vertical, 6)
@@ -564,6 +631,23 @@ struct NativeChatScreen: View {
}
}
}
+ // The composer inset resolves its height a frame or two AFTER the
+ // opening pin (GrowingTextView's intrinsic size + the glass), and
+ // defaultScrollAnchor(.bottom) does NOT hold through bottom-inset
+ // growth — so chats opened with the last message under the
+ // composer. Mirror the keyboard handler: growth re-pins, but only
+ // before the reveal or while the reader is at the bottom — never
+ // yanking someone who has scrolled up into history.
+ .onChange(of: composerHeight) { old, new in
+ guard new > old, model.ready else { return }
+ if !settled {
+ proxy.scrollTo(Self.bottomSentinel, anchor: .bottom)
+ } else if bottomVisible {
+ withAnimation(.easeOut(duration: 0.15)) {
+ proxy.scrollTo(Self.bottomSentinel, anchor: .bottom)
+ }
+ }
+ }
.onAppear {
// A parked model is ready immediately — onChange(ready) never
// fires on a revisit, so settle from here too.
@@ -593,6 +677,19 @@ struct NativeChatScreen: View {
}
}
+ // A row's position in the feed — binary search, safe because every fold
+ // path in ChatFeedModel sorts by id before publishing.
+ private func messageIndex(of id: Int64) -> Int? {
+ let list = model.messages
+ var lo = 0, hi = list.count - 1
+ while lo <= hi {
+ let mid = (lo + hi) / 2
+ if list[mid].id == id { return mid }
+ if list[mid].id < id { lo = mid + 1 } else { hi = mid - 1 }
+ }
+ return nil
+ }
+
@ViewBuilder
private func row(_ m: ChatFeedModel.Message, previous: ChatFeedModel.Message?,
proxy: ScrollViewProxy) -> some View {
diff --git a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
index d7b381a..7db18ed 100644
--- a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
+++ b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
@@ -80,6 +80,12 @@ public final class ChatFeedModel: ObservableObject {
@Published public private(set) var ready = false
private var loadingOlder = false
private var lastOlderLoad = Date.distantPast
+ // The connect-time re-pull's debounce inputs: only a RE-connect can have
+ // missed frames, and a pull in flight (or one that just landed) already
+ // covers the socket's first connect — see onStatus in start().
+ private var hasConnected = false
+ private var loadingHistory = false
+ private var lastHistoryLoad = Date.distantPast
private let instance: SavedInstance
/// The sender's screen name — the composer needs it to build a /me line.
@@ -367,9 +373,19 @@ public final class ChatFeedModel: ObservableObject {
socket?.onStatus = { [weak self] s in
guard let self else { return }
self.status = s
+ guard s == "connected" else { return }
// A reconnect may have missed frames; re-pull history (upsert by id,
- // so this is idempotent) to converge.
- if s == "connected", !self.messages.isEmpty { Task { await self.loadHistory(api) } }
+ // so this is idempotent) to converge. The socket's FIRST connect is
+ // exempt while the open's own pull is in flight or just landed —
+ // re-pulling then re-ran the whole fetch→fold→publish moments after
+ // the initial page, doubling the open's layout work for no new data.
+ let reconnect = self.hasConnected
+ self.hasConnected = true
+ if !reconnect,
+ self.loadingHistory || Date().timeIntervalSince(self.lastHistoryLoad) < 3 {
+ return
+ }
+ if !self.messages.isEmpty { Task { await self.loadHistory(api) } }
}
self.socket = socket
socket?.start()
@@ -723,6 +739,11 @@ public final class ChatFeedModel: ObservableObject {
}
private func loadHistory(_ api: LChatAPI) async {
+ loadingHistory = true
+ defer {
+ loadingHistory = false
+ lastHistoryLoad = Date()
+ }
// INSTANT open: the local store (decrypted once, at ingest) renders
// before the network answers; the fetch below then converges by
// upsert. Cold threads just wait for the fetch as before.