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;
|
&& timelineController.historySignals.canLoadEarlier;
|
||||||
const timelineLoadEarlier = timelineController.loadEarlier;
|
const timelineLoadEarlier = timelineController.loadEarlier;
|
||||||
const handleLoadOlderClick = React.useCallback(() => {
|
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 });
|
void timelineLoadEarlier({ userInitiated: true });
|
||||||
}, [timelineLoadEarlier]);
|
}, [onManualNavigation, timelineLoadEarlier]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
activeTurnChangeRef.current = timelineController.handleActiveTurnChange;
|
activeTurnChangeRef.current = timelineController.handleActiveTurnChange;
|
||||||
|
|||||||
@@ -629,10 +629,26 @@ export const useChatTimelineScroll = ({
|
|||||||
// Scrolling toward the end is not opting out of follow.
|
// Scrolling toward the end is not opting out of follow.
|
||||||
if (event.deltaY < 0 && canScrollUp()) gesture();
|
if (event.deltaY < 0 && canScrollUp()) gesture();
|
||||||
};
|
};
|
||||||
const handleTouchMove = () => {
|
// Touch mirrors wheel by finger direction, not by having already left
|
||||||
// Touch is continuous: the first move may still read as at-end,
|
// the end: while a stream keeps re-pinning the viewport, waiting for
|
||||||
// but the next one lands after the viewport left it.
|
// an at-end transition means the drag never registers — the user
|
||||||
if (!isAtEndRef.current && canScrollUp()) gesture();
|
// 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) => {
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
// The scrollbar track is the scroll node itself; a tap on a row
|
// 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('wheel', handleWheel, { passive: true });
|
||||||
|
scrollNode.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||||
scrollNode.addEventListener('touchmove', handleTouchMove, { 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('pointerdown', handlePointerDown, { passive: true });
|
||||||
scrollNode.addEventListener('keydown', handleKeyDown);
|
scrollNode.addEventListener('keydown', handleKeyDown);
|
||||||
scrollNode.addEventListener('scroll', handleScroll, { passive: true });
|
scrollNode.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
scrollNode.removeEventListener('wheel', handleWheel);
|
scrollNode.removeEventListener('wheel', handleWheel);
|
||||||
|
scrollNode.removeEventListener('touchstart', handleTouchStart);
|
||||||
scrollNode.removeEventListener('touchmove', handleTouchMove);
|
scrollNode.removeEventListener('touchmove', handleTouchMove);
|
||||||
|
scrollNode.removeEventListener('touchend', handleTouchEnd);
|
||||||
|
scrollNode.removeEventListener('touchcancel', handleTouchEnd);
|
||||||
scrollNode.removeEventListener('pointerdown', handlePointerDown);
|
scrollNode.removeEventListener('pointerdown', handlePointerDown);
|
||||||
scrollNode.removeEventListener('keydown', handleKeyDown);
|
scrollNode.removeEventListener('keydown', handleKeyDown);
|
||||||
scrollNode.removeEventListener('scroll', handleScroll);
|
scrollNode.removeEventListener('scroll', handleScroll);
|
||||||
|
|||||||
Reference in New Issue
Block a user