| 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 { |