| index 59b7261..57abb60 100644 |
| --- a/ios/LChatMac/NativeFeedView.swift |
| +++ b/ios/LChatMac/NativeFeedView.swift |
| @@ -107,6 +107,22 @@ struct NativeFeedView: View { |
| // saved at all"). Capturing every ~150ms mid-scroll keeps the anchor |
| // never more than a few frames stale, whatever happens next. |
| var lastCaptureAt: TimeInterval = 0 |
| + // The reader's own hands on the feed: when they last moved it (a |
| + // scroll, or a mouse drag â selecting text autoscrolls with no scroll |
| + // events at all), and whether a drag is still down. The self-correcting |
| + // scroll loops read these and hold off: moving the offset under a live |
| + // drag turns a text selection into a scroll, and a quiet read into a |
| + // jump. |
| + var lastInputAt: TimeInterval = 0 |
| + var dragging = false |
| + // The centre-row reading taken at that moment â the prepend |
| + // re-anchor's reference. Kept CURRENT as the reader moves the feed |
| + // rather than read once when a page fetch starts: the fetch is a real |
| + // network round-trip plus a decrypt, and a reading from before it |
| + // would put the reader back where they were when it began (a |
| + // scrolled-away-by-itself feed). Stale only while nothing is moving â |
| + // when it is stale, it is also still correct. |
| + var centreRef: NativeFeedModelCache.FeedScrollAnchor? |
| } |
| @StateObject private var frames = FrameStore() |
| |
| @@ -293,6 +309,10 @@ struct NativeFeedView: View { |
| // with a per-frame `onChange(of: CGRect)` (window-resize churn â #899). |
| @State private var paneFrame = LiveFrame() |
| @State private var swipeMonitor: Any? |
| + // The drag monitor's twin handle: mouse input over the feed is how a text |
| + // selection moves the offset (AppKit autoscrolls the drag), and it emits no |
| + // scroll events, so the swipe monitor alone can't see it. |
| + @State private var dragMonitor: Any? |
| // Identifies this feed's registration on the shared drop router, so a |
| // stale unregister (a thread switch's old feed disappearing after the new |
| // one registered the same pane key) can't evict the rightful receiver. |
| @@ -375,6 +395,7 @@ struct NativeFeedView: View { |
| model.start() // idempotent â a parked model is already live |
| frames.isActive = isActive |
| installSwipeMonitor() |
| + installDragMonitor() |
| registerForDrops() |
| installProjector() |
| } |
| @@ -395,6 +416,9 @@ struct NativeFeedView: View { |
| // (the FeedWebCache rule); the cache's eviction stops them. |
| if let swipeMonitor { NSEvent.removeMonitor(swipeMonitor) } |
| swipeMonitor = nil |
| + if let dragMonitor { NSEvent.removeMonitor(dragMonitor) } |
| + dragMonitor = nil |
| + frames.dragging = false |
| // One last capture at teardown, belt to the throttle's braces â |
| // captureAnchor no-ops if the row frames have already been |
| // cleared by the children's own teardown (order isn't |
| @@ -504,18 +528,23 @@ struct NativeFeedView: View { |
| // centre instead â solidly on screen, valid |
| // UnitPoint â and put IT back exactly where it |
| // was, after the prepend has laid out. |
| - let mid = frames.viewport.height / 2 |
| - let ref = frames.rows |
| - .filter { $0.value.minY >= 0 && $0.value.maxY <= frames.viewport.height } |
| - .min { abs($0.value.midY - mid) < abs($1.value.midY - mid) } |
| + refreshCentreRef() |
| Task { |
| await model.loadOlder() |
| guard model.feedMessages.first?.id != anchorID else { return } |
| + // The reading is taken at the LAST moment the |
| + // reader moved the feed (frames.centreRef), |
| + // not at the moment this fetch started: a page |
| + // is a network round-trip plus a decrypt, and |
| + // whatever they scrolled or dragged through it |
| + // would otherwise be undone â the feed |
| + // scrolling itself back a screenful for no |
| + // reason the reader can see. |
| DispatchQueue.main.async { |
| - if let ref, frames.viewport.height > ref.value.height { |
| - let f = max(0, min(1, ref.value.minY / |
| - (frames.viewport.height - ref.value.height))) |
| - proxy.scrollTo(ref.key, anchor: UnitPoint(x: 0, y: f)) |
| + if let ref = frames.centreRef, |
| + model.feedMessages.contains(where: { $0.id == ref.rowID }) { |
| + proxy.scrollTo(ref.rowID, |
| + anchor: UnitPoint(x: 0, y: ref.fraction)) |
| } else { |
| proxy.scrollTo(anchorID, anchor: .top) |
| } |
| @@ -624,7 +653,10 @@ struct NativeFeedView: View { |
| // The typing bubble appearing at the bottom should scroll into |
| // view when the reader is already there. |
| .onChange(of: model.typing.isEmpty) { _, empty in |
| - if !empty, let last = model.feedMessages.last?.id, |
| + // Never while the reader's hands are on the feed: someone else |
| + // starting to type is the weakest reason there is to move the |
| + // offset, and it lands under live drags all day in a busy group. |
| + if !empty, !readerIsDriving(), let last = model.feedMessages.last?.id, |
| let f = frames.rows[last], f.minY < frames.viewport.height + 150 { |
| proxy.scrollTo(Self.bottomSentinel, anchor: .bottom) |
| } |
| @@ -896,28 +928,17 @@ struct NativeFeedView: View { |
| // "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.feedMessages.last?.id, let f = frames.rows[last], |
| - f.maxY <= frames.viewport.height + 150 { |
| + if FeedGeometry.isNearBottom(lastRow: model.feedMessages.last.flatMap { frames.rows[$0.id] }, |
| + viewportHeight: frames.viewport.height) { |
| 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 |
| + // A reading of nothing (every row off-screen mid-relayout) leaves the |
| + // last good anchor alone rather than clearing it. |
| + if let ref = FeedGeometry.centreRow(rows: frames.rows, |
| + viewportHeight: frames.viewport.height) { |
| + storeAnchor(ref) |
| } |
| - 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 â |
| @@ -929,6 +950,11 @@ struct NativeFeedView: View { |
| _ anchor: NativeFeedModelCache.FeedScrollAnchor, |
| attempts: Int = 12) { |
| guard model.ready else { return } |
| + // The reader got there first (they scrolled, or grabbed a selection, |
| + // while the verify rounds were still landing) â where they are now IS |
| + // the position, so stop correcting toward the old one. Driving implies |
| + // settled, so the feed is already revealed at their spot. |
| + if readerIsDriving() { return } |
| guard model.feedMessages.contains(where: { $0.id == anchor.rowID }) else { |
| storeAnchor(nil) |
| pinUntilSettled(proxy) |
| @@ -1010,8 +1036,20 @@ struct NativeFeedView: View { |
| // give up while genuinely wedged: park and retry on a slower cadence |
| // (bounded) instead of leaving a blank pane until the next arrival. |
| private func pinUntilSettled(_ proxy: ScrollViewProxy, attempts: Int = 12, |
| - rescues: Int = 4) { |
| + rescues: Int = 4, waits: Int = 12) { |
| guard model.ready else { return } // the ready flip re-enters |
| + // Never pull the offset out from under a live scroll or selection |
| + // drag: the pin lands mid-gesture, the content jumps, and a drag that |
| + // was selecting text ends up scrolling instead. Wait for the reader's |
| + // hands to come off (bounded â a stuck `dragging` must not wedge the |
| + // feed blank forever) and pin then. A WEDGED feed is exempt: there is |
| + // nothing on screen to be reading. |
| + if readerIsDriving(), !feedIsWedged(), waits > 0 { |
| + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { |
| + pinUntilSettled(proxy, attempts: attempts, rescues: rescues, waits: waits - 1) |
| + } |
| + return |
| + } |
| guard let id = model.feedMessages.last?.id else { |
| settled = true // nothing to pin (empty chat) |
| return |
| @@ -1028,7 +1066,7 @@ struct NativeFeedView: View { |
| proxy.scrollTo(first, anchor: .top) |
| } |
| DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { |
| - pinUntilSettled(proxy, attempts: 6, rescues: rescues - 1) |
| + pinUntilSettled(proxy, attempts: 6, rescues: rescues - 1, waits: waits) |
| } |
| } else { |
| settled = true // flush, or out of rescues â reveal rather than hide forever |
| @@ -1055,7 +1093,8 @@ struct NativeFeedView: View { |
| // OR stranded above), keep pinning. |
| DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { |
| guard model.feedMessages.last?.id == id, !lastRowFlush() else { return } |
| - pinUntilSettled(proxy, attempts: max(attempts - 1, 2), rescues: rescues) |
| + pinUntilSettled(proxy, attempts: max(attempts - 1, 2), rescues: rescues, |
| + waits: waits) |
| } |
| return |
| } |
| @@ -1073,10 +1112,10 @@ struct NativeFeedView: View { |
| if let first = model.feedMessages.first?.id { |
| proxy.scrollTo(first, anchor: .top) |
| DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { |
| - pinUntilSettled(proxy, attempts: attempts - 1, rescues: rescues) |
| + pinUntilSettled(proxy, attempts: attempts - 1, rescues: rescues, waits: waits) |
| } |
| } else { |
| - pinUntilSettled(proxy, attempts: attempts - 1, rescues: rescues) |
| + pinUntilSettled(proxy, attempts: attempts - 1, rescues: rescues, waits: waits) |
| } |
| } |
| } |
| @@ -1104,6 +1143,7 @@ struct NativeFeedView: View { |
| // near-current anchor â the trailing capture alone lost that |
| // race), plus a trailing debounce for the exact resting spot. |
| let now = ProcessInfo.processInfo.systemUptime |
| + noteReaderInput(at: now) |
| if now - frames.lastCaptureAt > 0.15 { |
| frames.lastCaptureAt = now |
| captureAnchor() |
| @@ -1139,6 +1179,71 @@ struct NativeFeedView: View { |
| } |
| } |
| |
| + // Selecting text is the other way the reader moves this feed: a drag near |
| + // the pane's edge autoscrolls it, and AppKit does that WITHOUT scroll |
| + // events, so the swipe monitor never sees it. This monitor only watches |
| + // (every event passes through untouched) â it exists so the scroll loops |
| + // know a drag is live and hold off, and so the centre reading stays |
| + // current through an autoscroll. |
| + private func installDragMonitor() { |
| + let frames = frames |
| + dragMonitor = NSEvent.addLocalMonitorForEvents( |
| + matching: [.leftMouseDown, .leftMouseDragged, .leftMouseUp] |
| + ) { event in |
| + // The mouse-up ends the drag wherever it happens â a selection |
| + // routinely finishes outside the pane it started in â so it is |
| + // cleared before the pane test, never after it. |
| + if event.type == .leftMouseUp { frames.dragging = false; return event } |
| + guard frames.isActive, |
| + let window = event.window, |
| + let content = window.contentView else { return event } |
| + let p = event.locationInWindow |
| + let point = CGPoint(x: p.x, y: content.bounds.height - p.y) |
| + guard paneFrame.rect.contains(point) else { return event } |
| + if event.type == .leftMouseDown { frames.dragging = true } |
| + noteReaderInput(at: ProcessInfo.processInfo.systemUptime) |
| + return event |
| + } |
| + } |
| + |
| + // One stamp + one fresh reading, from whichever monitor saw the input. |
| + private func noteReaderInput(at now: TimeInterval) { |
| + frames.lastInputAt = now |
| + refreshCentreRef() |
| + } |
| + |
| + // Re-read where the reader is looking. A reading of nothing (mid-relayout, |
| + // no row fully on screen) keeps the last good one rather than blanking it. |
| + private func refreshCentreRef() { |
| + if let ref = FeedGeometry.centreRow(rows: frames.rows, |
| + viewportHeight: frames.viewport.height) { |
| + frames.centreRef = ref |
| + } |
| + } |
| + |
| + // True while the reader is working the feed â a scroll or a drag in the |
| + // last beat. Every loop below that moves the offset defers to it. |
| + // Only once the feed is SETTLED (on screen, and the reader's): while it's |
| + // still hidden behind the opening pin, nothing they do is aimed at it. |
| + private func readerIsDriving() -> Bool { |
| + guard settled else { return false } |
| + if frames.dragging { |
| + // Trust the hardware over our own bookkeeping: a mouse-up can be |
| + // swallowed (a menu, a drag ending in another app), and a stuck |
| + // "dragging" would hold the feed's own corrections off forever. |
| + if NSEvent.pressedMouseButtons & 1 != 0 { return true } |
| + frames.dragging = false |
| + } |
| + return ProcessInfo.processInfo.systemUptime - frames.lastInputAt < 0.25 |
| + } |
| + |
| + // A blank pane â the offset outlived a content shrink, no row materialised |
| + // â always wins the offset back: there is nothing on screen for the reader |
| + // to be reading, so the rescue runs whatever their hands are doing. |
| + private func feedIsWedged() -> Bool { |
| + frames.rows.isEmpty && !model.feedMessages.isEmpty |
| + } |
| + |
| // Bubble row with run-grouping, mirroring the web feed: the sender name |
| // shows once per run (groups only), and bubbles inside a run sit tighter |
| // than bubbles that start one. |