| index c916b8b..ff04366 100644 |
| --- a/ios/LChat/Views/NativeChatScreen.swift |
| +++ b/ios/LChat/Views/NativeChatScreen.swift |
| @@ -484,14 +484,6 @@ struct NativeChatScreen: View { |
| .sheet(isPresented: $showRename) { RenameGroupSheet(store: store, thread: thread) } |
| .sheet(isPresented: $showGroupApps) { GroupAppsSheet(model: model) } |
| .onAppear { |
| - // The mount cost — the "opening a thread hangs a little" |
| - // (#1210). feed.openThread only covers first loads; re-opens |
| - // attach a parked model and their whole cost is THIS mount. |
| - // Ending on the next main-queue turn brackets the first |
| - // render pass — an approximation, but one that made the 1–3s |
| - // opens visible at all. |
| - let mount = Perf.begin("feed.mount", slowMs: 300) |
| - DispatchQueue.main.async { mount.end("\(model.messages.count) messages") } |
| model.start() |
| LastOpenedChat.note(origin: instance.origin, thread: thread.id, |
| title: thread.title) |
| @@ -614,8 +606,15 @@ struct NativeChatScreen: View { |
| // to the bottom, stranding the photo they just viewed off-screen. Set when |
| // we open the viewer, it makes that one return-appear skip the pin. |
| @State private var suppressSettleOnReturn = false |
| - |
| - private func settle(_ proxy: ScrollViewProxy) { |
| + // The open's "tap → usable feed" measurement (#1210): begun when the feed |
| + // first appears, ended one turn AFTER `settled` flips so the reveal |
| + // frame's layout is inside it — not the old next-runloop approximation, |
| + // which timed the first render pass and called 1–3s opens "fast". |
| + @State private var mountSpan: Perf.Span? |
| + |
| + // `hot` = the model was ready BEFORE the feed's first layout (a parked or |
| + // store-hydrated open — the overwhelmingly common case). |
| + private func settle(_ proxy: ScrollViewProxy, hot: Bool = false) { |
| guard !model.messages.isEmpty else { |
| settled = true // empty chat — nothing to pin |
| return |
| @@ -655,7 +654,21 @@ struct NativeChatScreen: View { |
| return |
| } |
| } |
| - // The immediate jump runs against ESTIMATED row heights — the |
| + // A hot open needs NO pin at all: defaultScrollAnchor(.bottom) already |
| + // landed the first layout at the bottom against REAL row heights (the |
| + // content was folded before layout ran), so there is nothing to |
| + // correct and nothing to hide behind an opacity hold. Reveal in the |
| + // first frame. The estimated-height scrollTo this skips was itself |
| + // the #1209 stranded-viewport source, and the held reveal — its |
| + // timers queued behind the open's layout churn — was the felt 1–3s |
| + // open (#1210). The composer-inset re-pin (onChange(composerHeight)) |
| + // still covers the one genuinely late measurement. |
| + if hot { |
| + settled = true |
| + return |
| + } |
| + // COLD loads only, from here down: content published after first |
| + // layout. 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 |
| @@ -678,13 +691,14 @@ struct NativeChatScreen: View { |
| 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. |
| + // Bounded fallback: the reveal must never deadlock. Pin once more |
| + // UNCONDITIONALLY — bottomVisible can be stale-true here (the |
| + // estimated pass flashed the sentinel through the viewport), and |
| + // skipping the pin on that lie revealed the feed stranded above the |
| + // fold (#1209's cold-open shape). |
| DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { |
| guard !settled else { return } |
| - if !bottomVisible { |
| - proxy.scrollTo(Self.bottomSentinel, anchor: .bottom) |
| - } |
| + proxy.scrollTo(Self.bottomSentinel, anchor: .bottom) |
| finishBottomPin() |
| } |
| } |
| @@ -722,11 +736,14 @@ struct NativeChatScreen: View { |
| insertion: .move(edge: .bottom).combined(with: .opacity), |
| removal: .opacity)) |
| .onAppear { |
| - // `settled` gate: the opening pin-jump |
| - // transiently materialises top rows, and each |
| - // spurious page prepend churned layout so hard |
| - // scrolling felt dead for seconds after open. |
| - guard settled, i < 8, model.ready, !model.exhausted else { return } |
| + // `landed` gate (the +0.4s opening beat): the |
| + // opening layout transiently materialises top |
| + // rows, and each spurious page prepend jumped |
| + // the feed up a screenful and churned layout so |
| + // hard scrolling felt dead for seconds. Hot |
| + // opens are `settled` from the first frame, so |
| + // that gate alone stopped protecting this. |
| + guard settled, landed, i < 8, model.ready, !model.exhausted else { return } |
| Task { await model.loadOlder() } |
| } |
| } |
| @@ -875,8 +892,19 @@ struct NativeChatScreen: View { |
| // 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) |
| + // Hot opens are `settled` from the first frame, but the inset |
| + // still resolves late — `landed` (the +0.4s opening beat) |
| + // keeps the hard, unanimated pin for that window. DEFERRED a |
| + // turn: this fires DURING the opening layout pass, when row |
| + // heights are still estimates, and a scrollTo computed |
| + // against those strands the viewport beyond the content — a |
| + // blank feed with the messages above the fold. One turn later |
| + // layout is real and the pin lands exactly (the same trick as |
| + // the sentinel's re-pin). |
| + if !settled || !landed { |
| + DispatchQueue.main.async { |
| + proxy.scrollTo(Self.bottomSentinel, anchor: .bottom) |
| + } |
| } else if bottomVisible { |
| withAnimation(.easeOut(duration: 0.15)) { |
| proxy.scrollTo(Self.bottomSentinel, anchor: .bottom) |
| @@ -884,19 +912,36 @@ struct NativeChatScreen: View { |
| } |
| } |
| .onAppear { |
| + // The mount clock starts at the OPENING appear only — modal |
| + // returns (Quick Look, the video viewer) re-fire onAppear on |
| + // an already-settled feed and must not restart it. |
| + if mountSpan == nil, !settled { |
| + mountSpan = Perf.begin("feed.mount", slowMs: 500) |
| + } |
| // A parked model is ready immediately — onChange(ready) never |
| - // fires on a revisit, so settle from here too. But skip it when |
| - // returning from Quick Look: re-pinning would strand the |
| - // just-viewed photo off-screen (see suppressSettleOnReturn). |
| + // fires on a revisit, so settle from here too, on the HOT |
| + // path: its content laid out before this appear, so the |
| + // bottom anchor already landed it. But skip it when returning |
| + // from Quick Look: re-pinning would strand the just-viewed |
| + // photo off-screen (see suppressSettleOnReturn). |
| if suppressSettleOnReturn { |
| suppressSettleOnReturn = false |
| - } else if model.ready { |
| - settle(proxy) |
| + } else if model.ready, !settled { |
| + settle(proxy, hot: true) |
| } |
| // Arm the arrival animation only after the opening render has |
| // settled (parked models are ready immediately). |
| DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { landed = true } |
| } |
| + // The mount span ends one turn after the reveal commits, so the |
| + // revealed frame's layout is measured — "feed.mount" now honestly |
| + // means tap → usable feed, cold fetch + pin phase included. |
| + .onChange(of: settled) { _, isSettled in |
| + guard isSettled, let span = mountSpan else { return } |
| + mountSpan = nil |
| + let count = model.messages.count |
| + DispatchQueue.main.async { span.end("\(count) messages") } |
| + } |
| // Foregrounding with this chat on screen: merge whatever the NSE |
| // filed while we were suspended straight from disk — the pushed |
| // message shows before the socket reconnects and re-pulls. |