fix(chat): register touch opt-out by finger direction
On touch surfaces the gesture opt-out waited for the viewport to have already left the end, but streaming corrections re-pin the viewport every chunk, so the drag never registered: the user could not scroll away mid-stream, the scroll-to-bottom pill (which waits for a gesture) never appeared, and live-follow stayed armed — which also made the mobile load-older button throw the viewport to the bottom, since the prepend's content growth ran the end correction. Track the finger's Y between touch events and treat a downward drag (the touch mirror of wheel-up) as an immediate opt-out. Loading older history also releases live follow explicitly before the prepend, covering the case where the button is reachable without any prior scroll.
This commit is contained in:
@@ -1009,8 +1009,12 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
&& timelineController.historySignals.canLoadEarlier;
|
||||
const timelineLoadEarlier = timelineController.loadEarlier;
|
||||
const handleLoadOlderClick = React.useCallback(() => {
|
||||
// Loading older history is an explicit move INTO the past: release
|
||||
// live follow first, or the prepend's content growth would trigger an
|
||||
// end correction and throw the viewport to the bottom.
|
||||
onManualNavigation();
|
||||
void timelineLoadEarlier({ userInitiated: true });
|
||||
}, [timelineLoadEarlier]);
|
||||
}, [onManualNavigation, timelineLoadEarlier]);
|
||||
|
||||
React.useEffect(() => {
|
||||
activeTurnChangeRef.current = timelineController.handleActiveTurnChange;
|
||||
|
||||
@@ -629,10 +629,26 @@ export const useChatTimelineScroll = ({
|
||||
// Scrolling toward the end is not opting out of follow.
|
||||
if (event.deltaY < 0 && canScrollUp()) gesture();
|
||||
};
|
||||
const handleTouchMove = () => {
|
||||
// Touch is continuous: the first move may still read as at-end,
|
||||
// but the next one lands after the viewport left it.
|
||||
if (!isAtEndRef.current && canScrollUp()) gesture();
|
||||
// Touch mirrors wheel by finger direction, not by having already left
|
||||
// the end: while a stream keeps re-pinning the viewport, waiting for
|
||||
// an at-end transition means the drag never registers — the user
|
||||
// cannot scroll, the pill never appears, and live-follow stays armed
|
||||
// under a viewport they are fighting for.
|
||||
let touchLastY: number | null = null;
|
||||
const handleTouchStart = (event: TouchEvent) => {
|
||||
touchLastY = event.touches[0]?.clientY ?? null;
|
||||
};
|
||||
const handleTouchMove = (event: TouchEvent) => {
|
||||
const y = event.touches[0]?.clientY ?? null;
|
||||
const lastY = touchLastY;
|
||||
touchLastY = y;
|
||||
if (y === null) return;
|
||||
// A downward finger drags the content up — the touch wheel-up.
|
||||
const draggedUp = lastY !== null && y > lastY;
|
||||
if ((draggedUp || !isAtEndRef.current) && canScrollUp()) gesture();
|
||||
};
|
||||
const handleTouchEnd = () => {
|
||||
touchLastY = null;
|
||||
};
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
// The scrollbar track is the scroll node itself; a tap on a row
|
||||
@@ -649,14 +665,20 @@ export const useChatTimelineScroll = ({
|
||||
};
|
||||
|
||||
scrollNode.addEventListener('wheel', handleWheel, { passive: true });
|
||||
scrollNode.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||
scrollNode.addEventListener('touchmove', handleTouchMove, { passive: true });
|
||||
scrollNode.addEventListener('touchend', handleTouchEnd, { passive: true });
|
||||
scrollNode.addEventListener('touchcancel', handleTouchEnd, { passive: true });
|
||||
scrollNode.addEventListener('pointerdown', handlePointerDown, { passive: true });
|
||||
scrollNode.addEventListener('keydown', handleKeyDown);
|
||||
scrollNode.addEventListener('scroll', handleScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
scrollNode.removeEventListener('wheel', handleWheel);
|
||||
scrollNode.removeEventListener('touchstart', handleTouchStart);
|
||||
scrollNode.removeEventListener('touchmove', handleTouchMove);
|
||||
scrollNode.removeEventListener('touchend', handleTouchEnd);
|
||||
scrollNode.removeEventListener('touchcancel', handleTouchEnd);
|
||||
scrollNode.removeEventListener('pointerdown', handlePointerDown);
|
||||
scrollNode.removeEventListener('keydown', handleKeyDown);
|
||||
scrollNode.removeEventListener('scroll', handleScroll);
|
||||
|
||||
Reference in New Issue
Block a user