fix(app): keep chat scrollable during streaming

Continuous Android stream updates kept bottom-anchor verification pending, so genuine finger drags were mistaken for programmatic corrections. Preserve explicit drag ownership so user scrolling can detach immediately.
This commit is contained in:
Mohamed Boudra
2026-07-23 00:35:08 +02:00
parent d1f19a5cdd
commit c81c7bb7c0
3 changed files with 50 additions and 5 deletions

View File

@@ -774,4 +774,18 @@ describe("controller helper predicates", () => {
}),
).toBe(true);
});
it("lets an active user drag override pending sticky verification", () => {
expect(
__private__.shouldDetachFromScrollAway({
mode: "sticky-bottom",
nextIsNearBottom: false,
scrollDelta: 48,
hasPendingRequest: false,
hasPendingVerification: true,
hasUnverifiedStickyMeasurementChange: true,
isUserScroll: true,
}),
).toBe(true);
});
});

View File

@@ -84,6 +84,7 @@ interface BottomAnchorControllerDriver {
handleScrollNearBottomChange: (params: {
nextIsNearBottom: boolean;
scrollDelta: number;
isUserScroll?: boolean;
}) => void;
notifyAuthoritativeHistoryMaybeChanged: () => void;
reevaluate: (animated?: boolean) => void;
@@ -570,7 +571,7 @@ function createBottomAnchorControllerDriver(
evaluate(false, "content_size_change");
},
handleScrollNearBottomChange(params) {
const { nextIsNearBottom, scrollDelta } = params;
const { nextIsNearBottom, scrollDelta, isUserScroll = false } = params;
if (
nextIsNearBottom &&
mode === "sticky-bottom" &&
@@ -588,6 +589,7 @@ function createBottomAnchorControllerDriver(
hasPendingRequest: pendingRequest !== null,
hasPendingVerification: pendingVerification !== null,
hasUnverifiedStickyMeasurementChange,
isUserScroll,
})
) {
this.detachByUser();
@@ -657,14 +659,16 @@ export const __private__ = {
hasPendingRequest: boolean;
hasPendingVerification: boolean;
hasUnverifiedStickyMeasurementChange: boolean;
isUserScroll?: boolean;
}): boolean {
const scrolledAwayIntentionally = Math.abs(input.scrollDelta) >= USER_SCROLL_AWAY_DELTA_PX;
return (
input.mode === "sticky-bottom" &&
!input.nextIsNearBottom &&
!input.hasPendingRequest &&
!input.hasPendingVerification &&
(!input.hasUnverifiedStickyMeasurementChange || scrolledAwayIntentionally)
(input.isUserScroll ||
(!input.hasPendingRequest &&
!input.hasPendingVerification &&
(!input.hasUnverifiedStickyMeasurementChange || scrolledAwayIntentionally)))
);
},
};
@@ -758,7 +762,11 @@ export function useBottomAnchorController(input: {
prepareForStickyContentChange() {
driverRef.current?.prepareForStickyContentChange();
},
handleScrollNearBottomChange(params: { nextIsNearBottom: boolean; scrollDelta: number }) {
handleScrollNearBottomChange(params: {
nextIsNearBottom: boolean;
scrollDelta: number;
isUserScroll?: boolean;
}) {
driverRef.current?.handleScrollNearBottomChange(params);
},
reevaluate(animated = false) {

View File

@@ -89,6 +89,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
contentMeasuredForKey: null as string | null,
});
const scrollOffsetYRef = useRef(0);
const isUserScrollActiveRef = useRef(false);
const programmaticScrollEventBudgetRef = useRef(0);
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
@@ -208,6 +209,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
contentMeasuredForKey: null,
};
scrollOffsetYRef.current = 0;
isUserScrollActiveRef.current = false;
clearNativeViewportSettling();
setIsNativeViewportSettling(false);
historyStartReadyRef.current = false;
@@ -308,10 +310,27 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
bottomAnchorController.handleScrollNearBottomChange({
nextIsNearBottom: nearBottom,
scrollDelta: contentOffset.y - previousOffsetY,
isUserScroll: isUserScrollActiveRef.current,
});
}
});
const handleScrollBeginDrag = useStableEvent(() => {
isUserScrollActiveRef.current = true;
});
const handleScrollEndDrag = useStableEvent(() => {
isUserScrollActiveRef.current = false;
});
const handleMomentumScrollBegin = useStableEvent(() => {
isUserScrollActiveRef.current = true;
});
const handleMomentumScrollEnd = useStableEvent(() => {
isUserScrollActiveRef.current = false;
});
const handleListLayout = useStableEvent((event: LayoutChangeEvent) => {
const previousViewportWidth = streamViewportMetricsRef.current.viewportWidth;
const previousViewportHeight = streamViewportMetricsRef.current.viewportHeight;
@@ -419,6 +438,10 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
style={listStyle}
onLayout={handleListLayout}
onScroll={handleScroll}
onScrollBeginDrag={handleScrollBeginDrag}
onScrollEndDrag={handleScrollEndDrag}
onMomentumScrollBegin={handleMomentumScrollBegin}
onMomentumScrollEnd={handleMomentumScrollEnd}
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
maintainVisibleContentPosition={maintainVisibleContentPosition}