| index c9cd0e6..6113823 100644 |
| --- a/ios/LChatMac/NativeFeedView.swift |
| +++ b/ios/LChatMac/NativeFeedView.swift |
| @@ -91,6 +91,10 @@ struct NativeFeedView: View { |
| // only act for the pane actually on screen. A box (not view state) so |
| // the monitor closure reads the live value. |
| var isActive = true |
| + // Debounces the scroll-position capture (the per-thread anchor): the |
| + // wheel monitor fires per tick, and reading the row frames mid-fling |
| + // is churn for nothing — capture once the scroll goes quiet. |
| + var anchorDebounce: DispatchWorkItem? |
| } |
| @StateObject private var frames = FrameStore() |
| |
| @@ -418,7 +422,11 @@ struct NativeFeedView: View { |
| // row is actually flush with the viewport bottom. |
| .onChange(of: model.ready) { _, ready in |
| guard ready else { return } |
| - pinUntilSettled(proxy) |
| + if let anchor = savedAnchor() { |
| + restoreUntilSettled(proxy, anchor) |
| + } else { |
| + pinUntilSettled(proxy) |
| + } |
| consumeJump(proxy) |
| } |
| // Search hits land on their message: page history until the |
| @@ -444,7 +452,20 @@ struct NativeFeedView: View { |
| } |
| } |
| .onAppear { |
| - pinUntilSettled(proxy) |
| + // 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 |
| + // offset are rebuilt per thread; the cache survives). Hidden |
| + // while the restore lands (one or two verify rounds) so the |
| + // switch never shows the position being corrected — the jump |
| + // Paul reported. No anchor = the reader left at the bottom = |
| + // today's pin, still visible from the first frame. |
| + if let anchor = savedAnchor() { |
| + if settled { settled = false } |
| + restoreUntilSettled(proxy, anchor) |
| + } else { |
| + pinUntilSettled(proxy) |
| + } |
| consumeJump(proxy) |
| } |
| // Anchor to the bottom BEFORE first layout — a parked/prewarmed |
| @@ -536,6 +557,11 @@ struct NativeFeedView: View { |
| // (the FeedWebCache rule); the cache's eviction stops them. |
| if let swipeMonitor { NSEvent.removeMonitor(swipeMonitor) } |
| swipeMonitor = nil |
| + // A capture racing the teardown would read half-cleared row |
| + // frames; the anchor is already current (captured on the scroll |
| + // that put the reader wherever they are). |
| + frames.anchorDebounce?.cancel() |
| + frames.anchorDebounce = nil |
| if let key = instance?.origin { |
| PaneDropRouter.shared.unregister(key, token: dropToken) |
| } |
| @@ -570,6 +596,85 @@ struct NativeFeedView: View { |
| |
| static let bottomSentinel = "bottom-sentinel" |
| |
| + // ---- per-thread scroll memory ------------------------------------------- |
| + // The anchor is the row nearest the viewport's centre plus its fractional |
| + // travel — the same reference the prepend re-anchor uses — stored in the |
| + // model cache so it outlives this view's per-thread teardown. Captured on |
| + // a debounce after wheel scrolling goes quiet; cleared whenever a bottom |
| + // pin verifiably lands (being at the bottom IS the no-anchor state). |
| + |
| + private func savedAnchor() -> NativeFeedModelCache.FeedScrollAnchor? { |
| + guard let iid = instance?.id else { return nil } |
| + return NativeFeedModelCache.shared.scrollAnchor(instance: iid, thread: threadID) |
| + } |
| + |
| + private func storeAnchor(_ anchor: NativeFeedModelCache.FeedScrollAnchor?) { |
| + guard let iid = instance?.id else { return } |
| + NativeFeedModelCache.shared.setScrollAnchor(anchor, instance: iid, thread: threadID) |
| + } |
| + |
| + // Read the current position out of the frame store. Near the bottom |
| + // (the newest row materialised by the viewport) counts as AT the bottom — |
| + // clearing the anchor — so a reader hovering a few points up isn't |
| + // "restored" away from arriving messages next time. |
| + private func captureAnchor() { |
| + guard instance != nil, frames.viewport.height > 0, !frames.rows.isEmpty else { return } |
| + if let last = model.messages.last?.id, let f = frames.rows[last], |
| + f.maxY <= frames.viewport.height + 150 { |
| + storeAnchor(nil) |
| + return |
| + } |
| + let mid = frames.viewport.height / 2 |
| + guard let ref = frames.rows |
| + .filter({ $0.value.minY >= 0 && $0.value.maxY <= frames.viewport.height }) |
| + .min(by: { abs($0.value.midY - mid) < abs($1.value.midY - mid) }) |
| + else { |
| + // No fully-visible row — one taller-than-the-viewport bubble (a |
| + // long code block) fills the screen. Anchor its top; scrollTo |
| + // can't express "row top minus 300pt", so this is the closest |
| + // honest restore. |
| + if let giant = frames.rows.first(where: { $0.value.minY < mid && $0.value.maxY > mid }) { |
| + storeAnchor(.init(rowID: giant.key, fraction: 0)) |
| + } |
| + return |
| + } |
| + let denom = frames.viewport.height - ref.value.height |
| + let fraction = denom > 0 ? max(0, min(1, ref.value.minY / denom)) : 0 |
| + storeAnchor(.init(rowID: ref.key, fraction: fraction)) |
| + } |
| + |
| + // Put the saved anchor's row back where it was and VERIFY it landed — |
| + // pinUntilSettled's shape, aimed at a row instead of the bottom. Rows |
| + // materialise with real heights after the first jump, so one round is |
| + // rarely enough. A vanished anchor row (evicted history, a deleted |
| + // message) falls back to the ordinary bottom pin, anchor cleared. |
| + private func restoreUntilSettled(_ proxy: ScrollViewProxy, |
| + _ anchor: NativeFeedModelCache.FeedScrollAnchor, |
| + attempts: Int = 12) { |
| + guard model.ready else { return } |
| + guard model.messages.contains(where: { $0.id == anchor.rowID }) else { |
| + storeAnchor(nil) |
| + pinUntilSettled(proxy) |
| + return |
| + } |
| + proxy.scrollTo(anchor.rowID, anchor: UnitPoint(x: 0, y: anchor.fraction)) |
| + guard attempts > 0 else { |
| + settled = true // close enough — reveal rather than hide forever |
| + return |
| + } |
| + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { |
| + if let f = frames.rows[anchor.rowID], frames.viewport.height > 0 { |
| + let denom = frames.viewport.height - f.height |
| + let want = denom > 0 ? anchor.fraction * denom : 0 |
| + if abs(f.minY - want) <= 2 { |
| + settled = true |
| + return |
| + } |
| + } |
| + restoreUntilSettled(proxy, anchor, attempts: attempts - 1) |
| + } |
| + } |
| + |
| // A COLD feed stays invisible until the pin is verified flush — the reader |
| // 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. |
| @@ -617,6 +722,11 @@ struct NativeFeedView: View { |
| } |
| if let f = frames.rows[id], f.maxY <= frames.viewport.height + 2 { |
| settled = true // flush — reveal |
| + // A verified bottom IS the no-anchor state: an own-send (or |
| + // arrival) pin that yanked a scrolled-back reader down makes |
| + // any saved anchor stale — the next open should land here, |
| + // at the newest, not back at the old spot. |
| + storeAnchor(nil) |
| // Flush NOW isn't flush in a beat: a send clears the staged |
| // strip a moment after this verify passes, the composer inset |
| // shrinks, and the content shifts under the satisfied pin — |
| @@ -656,6 +766,16 @@ struct NativeFeedView: View { |
| let point = CGPoint(x: p.x, y: content.bounds.height - p.y) |
| guard paneFrame.rect.contains(point) else { return event } |
| |
| + // Any scroll over this pane re-arms the position capture: once |
| + // the wheel goes quiet for a beat, remember where the reader is |
| + // (or that they're back at the bottom) for the next thread |
| + // switch. Debounced — reading row frames per tick mid-fling is |
| + // churn for nothing. |
| + frames.anchorDebounce?.cancel() |
| + let capture = DispatchWorkItem { captureAnchor() } |
| + frames.anchorDebounce = capture |
| + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: capture) |
| + |
| // The release event arrives with ZERO deltas, so it must be |
| // handled before any horizontal-dominance check — that guard |
| // silently ate it and the gutter froze wherever it was left. |