fix(chat): restore the 40px end-follow threshold
The half-viewport band made return-to-end detection too broad. Restore the fixed 40px threshold without changing gesture handling or follow transitions. Testing: 14 focused scroll tests pass; UI type-check passes; UI lint has one unrelated existing warning; dead-code report inspected. Live chat behavior not validated.
This commit is contained in:
@@ -94,11 +94,11 @@ describe('resolveRealContentEndOffset', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('resolveTimelineIsAtEnd', () => {
|
describe('resolveTimelineIsAtEnd', () => {
|
||||||
test('counts half a viewport from the full content length as the end', () => {
|
test('uses a 40px band regardless of viewport height', () => {
|
||||||
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1400, scrollLength: 600 })).toBe(true);
|
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1400, scrollLength: 600 })).toBe(true);
|
||||||
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1100, scrollLength: 600 })).toBe(true);
|
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1360, scrollLength: 600 })).toBe(true);
|
||||||
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1099, scrollLength: 600 })).toBe(false);
|
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1359, scrollLength: 600 })).toBe(false);
|
||||||
// Tiny viewports keep a 40px floor.
|
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1100, scrollLength: 600 })).toBe(false);
|
||||||
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1900, scrollLength: 60 })).toBe(true);
|
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1900, scrollLength: 60 })).toBe(true);
|
||||||
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1899, scrollLength: 60 })).toBe(false);
|
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1899, scrollLength: 60 })).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -61,17 +61,8 @@ export const resolveRealContentEndOffset = ({
|
|||||||
return Math.max(0, lastBottom + Math.max(0, footerSize) - visibleLength);
|
return Math.max(0, lastBottom + Math.max(0, footerSize) - visibleLength);
|
||||||
};
|
};
|
||||||
|
|
||||||
// "At the end" for follow purposes is half a viewport. Leaving the end is
|
// Keep return-to-end detection in a tight band, rather than half a viewport.
|
||||||
// only ever decided by a real gesture, so this band never yanks a reader who
|
export const TIMELINE_FOLLOW_REARM_THRESHOLD_PX = 40;
|
||||||
// is still on the end; what it decides is how close to the live edge a reader
|
|
||||||
// who scrolled away must come back before follow re-arms and the pill hides.
|
|
||||||
// Half a screen reads as "I am back at the bottom" without having to land on
|
|
||||||
// the last pixel, and stray row growth or late measurements cannot push a
|
|
||||||
// pinned reader out of it. Distance is measured against the full content
|
|
||||||
// length.
|
|
||||||
const FOLLOW_REARM_MIN_THRESHOLD_PX = 40;
|
|
||||||
export const resolveFollowRearmThresholdPx = (scrollLength: number): number =>
|
|
||||||
Math.max(FOLLOW_REARM_MIN_THRESHOLD_PX, scrollLength / 2);
|
|
||||||
|
|
||||||
export const resolveTimelineIsAtEnd = (
|
export const resolveTimelineIsAtEnd = (
|
||||||
state: {
|
state: {
|
||||||
@@ -90,7 +81,7 @@ export const resolveTimelineIsAtEnd = (
|
|||||||
&& typeof scrollLength === 'number'
|
&& typeof scrollLength === 'number'
|
||||||
&& Number.isFinite(contentLength)
|
&& Number.isFinite(contentLength)
|
||||||
) {
|
) {
|
||||||
return contentLength - (scroll + scrollLength) <= resolveFollowRearmThresholdPx(scrollLength);
|
return contentLength - (scroll + scrollLength) <= TIMELINE_FOLLOW_REARM_THRESHOLD_PX;
|
||||||
}
|
}
|
||||||
return state.isNearEnd ?? state.isAtEnd;
|
return state.isNearEnd ?? state.isAtEnd;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
getRowBottom,
|
getRowBottom,
|
||||||
resolveRealContentEndOffset,
|
resolveRealContentEndOffset,
|
||||||
resolveTimelineIsAtEnd,
|
resolveTimelineIsAtEnd,
|
||||||
resolveFollowRearmThresholdPx,
|
TIMELINE_FOLLOW_REARM_THRESHOLD_PX,
|
||||||
type TimelineListMeasurementState,
|
type TimelineListMeasurementState,
|
||||||
type TimelineScrollMode,
|
type TimelineScrollMode,
|
||||||
} from '@/components/chat/lib/scroll/timelineScrollAnchoring';
|
} from '@/components/chat/lib/scroll/timelineScrollAnchoring';
|
||||||
@@ -496,7 +496,7 @@ export const useChatTimelineScroll = ({
|
|||||||
const lastBottom = lastIndex >= 0 ? getRowBottom(state, lastIndex) : null;
|
const lastBottom = lastIndex >= 0 ? getRowBottom(state, lastIndex) : null;
|
||||||
if (lastBottom !== null) {
|
if (lastBottom !== null) {
|
||||||
const visibleBottom = state.scroll + state.scrollLength - composerOverlayHeightRef.current;
|
const visibleBottom = state.scroll + state.scrollLength - composerOverlayHeightRef.current;
|
||||||
if (lastBottom - visibleBottom > resolveFollowRearmThresholdPx(state.scrollLength)) {
|
if (lastBottom - visibleBottom > TIMELINE_FOLLOW_REARM_THRESHOLD_PX) {
|
||||||
isAtEndRef.current = false;
|
isAtEndRef.current = false;
|
||||||
setIsPinned(false);
|
setIsPinned(false);
|
||||||
scheduleShowScrollButton();
|
scheduleShowScrollButton();
|
||||||
|
|||||||
Reference in New Issue
Block a user