mac: every near-bottom gate reads the row's bottom, not its top (#1510)
#1488's re-pin gate was fixed in #1502 to go through the shared, tested FeedGeometry.isNearBottom; three neighbouring triggers still carried the old `minY < viewport.height + 150` shape, which is wrong in the same way. A newest message TALLER than the viewport (a long code block) is the case that separates the two readings: the reader can be a screenful into scroll-back with that row's top still above the fold, so the old test called them "at the bottom" and yanked them down to the newest message. Swept: the typing-bubble follow, the link-card (unfurl) re-pin, and the text-size change. No behaviour change when the newest row is short — which is why this went unnoticed. Two judgement calls kept, not swept flat: - The unfurl re-pin now takes settleBottomGently instead of pinUntilSettled. A card growing one bubble is incremental content on a feed that is already revealed; the heavy loop's jolt to the top of loaded history is far too big an instrument for it, and it fires while the reader is reading. - The text-size change KEEPS the heavy loop: every row re-lays out at a new height, which is exactly what its verify rounds and rescue exist for. The arrival handler's remaining minY test is deliberately untouched — it reads the PREVIOUS last row to ask "was the reader following the conversation before this message landed", which is a different question. FeedGeometryTests gains the tall-row case, asserting both readings so the old shape can't creep back: a 900pt newest row in an 800pt viewport reads as at the bottom when pinned and NOT at the bottom after 300pt of scroll-back, where `minY < height + 150` would still have said yes.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2 files changed,
+42
−6
ios/LChatCore/Tests/LChatCoreTests/FeedGeometryTests.swift+22 −0ios/LChatMac/NativeFeedView.swift+20 −6
diff --git a/ios/LChatCore/Tests/LChatCoreTests/FeedGeometryTests.swift b/ios/LChatCore/Tests/LChatCoreTests/FeedGeometryTests.swift| index a8879b5..8cfb76e 100644 |
| --- a/ios/LChatCore/Tests/LChatCoreTests/FeedGeometryTests.swift |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/FeedGeometryTests.swift |
| @@ -133,4 +133,26 @@ final class FeedGeometryTests: XCTestCase { |
| lastRow: CGRect(x: 0, y: 1200, width: 400, height: 100), viewportHeight: 800)) |
| XCTAssertFalse(FeedGeometry.isNearBottom(lastRow: nil, viewportHeight: 800)) |
| } |
| + |
| + // Why every near-bottom gate in NativeFeedView reads the row's BOTTOM and |
| + // not its top. A newest message TALLER than the viewport (a long code |
| + // block) is the case that separates them: the reader can be a screenful |
| + // into scroll-back and still have that row's top above the fold, so a |
| + // `minY < height + slack` test called them "at the bottom" and every |
| + // trigger keyed on it — a composer resize, a link card landing, the typing |
| + // bubble, a text-size change — yanked them down to the newest message. |
| + func testTallNewestRowDoesNotReadAsAtTheBottom() { |
| + let viewport: CGFloat = 800 |
| + // One 900pt message, pinned: its bottom rests at the viewport bottom, |
| + // its top is far above the fold (minY = -100). |
| + let pinned = CGRect(x: 0, y: -100, width: 400, height: 900) |
| + XCTAssertTrue(FeedGeometry.isNearBottom(lastRow: pinned, viewportHeight: viewport)) |
| + |
| + // The same row after scrolling 300pt back INTO it: still overlapping the |
| + // viewport, top now on screen — and emphatically not at the bottom. |
| + let scrolledBack = CGRect(x: 0, y: 200, width: 400, height: 900) |
| + XCTAssertFalse(FeedGeometry.isNearBottom(lastRow: scrolledBack, viewportHeight: viewport)) |
| + // The reading the gates used to use would have said yes to that one. |
| + XCTAssertLessThan(scrolledBack.minY, viewport + 150) |
| + } |
| } |
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift| index e456eba..78b13bf 100644 |
| --- a/ios/LChatMac/NativeFeedView.swift |
| +++ b/ios/LChatMac/NativeFeedView.swift |
| @@ -770,8 +770,13 @@ struct NativeFeedView: View { |
| // 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. |
| + // The shared reading (the row's BOTTOM within slack) — the |
| + // row's TOP still counted a reader hundreds of points into |
| + // scroll-back as "at the bottom" whenever the newest message |
| + // was tall. Same correction as the viewport-resize gate above. |
| if !empty, !readerIsDriving(), let last = model.feedMessages.last?.id, |
| - let f = frames.rows[last], f.minY < frames.viewport.height + 150 { |
| + FeedGeometry.isNearBottom(lastRow: frames.rows[last], |
| + viewportHeight: frames.viewport.height) { |
| proxy.scrollTo(Self.bottomSentinel, anchor: .bottom) |
| } |
| } |
| @@ -894,8 +899,12 @@ struct NativeFeedView: View { |
| // beat and rescue only if the viewport actually came up empty, so |
| // a scrolled-back reader isn't yanked to the newest message. |
| .onChange(of: chatFontSize) { _, _ in |
| - let nearBottom = model.feedMessages.last.flatMap { frames.rows[$0.id]?.minY } |
| - .map { $0 < frames.viewport.height + 150 } ?? false |
| + // The heavy loop is right here — every row re-lays out at a |
| + // new height, which is what its verify rounds exist for — but |
| + // the gate is the shared one, tall-newest-row included. |
| + let nearBottom = FeedGeometry.isNearBottom( |
| + lastRow: model.feedMessages.last.flatMap { frames.rows[$0.id] }, |
| + viewportHeight: frames.viewport.height) |
| if nearBottom { |
| pinUntilSettled(proxy) |
| return |
| @@ -1688,9 +1697,14 @@ struct NativeFeedView: View { |
| // bottom, re-pin so the posted link stays |
| // visible (verified, like every other pin). |
| if card != nil, m.id == model.feedMessages.last?.id, |
| - let f = frames.rows[m.id], |
| - f.minY < frames.viewport.height + 150 { |
| - DispatchQueue.main.async { pinUntilSettled(proxy) } |
| + FeedGeometry.isNearBottom(lastRow: frames.rows[m.id], |
| + viewportHeight: frames.viewport.height) { |
| + // Gentle, not the cold-open loop: a card |
| + // growing one bubble is incremental content |
| + // on a feed that is already revealed, and the |
| + // heavy loop's jolt to the top of history is |
| + // far too big an instrument for it (#1502). |
| + DispatchQueue.main.async { settleBottomGently(proxy) } |
| } |
| return card |
| }, |