diff --git a/packages/app-expo/src/components/chat/MessageList.tsx b/packages/app-expo/src/components/chat/MessageList.tsx index 985f6b9fc..3e6f6e6e8 100644 --- a/packages/app-expo/src/components/chat/MessageList.tsx +++ b/packages/app-expo/src/components/chat/MessageList.tsx @@ -25,6 +25,7 @@ import { } from "react-native"; import { PartRenderer } from "./PartRenderer"; import { StreamingIndicator } from "./StreamingIndicator"; +import { createScrollToBottomController } from "./scroll-to-bottom-controller"; interface MessageListProps { messages: MessageV2[]; @@ -60,10 +61,31 @@ export function MessageList({ const s = makeStyles(colors); const flatListRef = useRef(null); const isAtBottomRef = useRef(true); + const latestBottomDistanceRef = useRef(0); const [showScrollDown, setShowScrollDown] = useState(false); + const scrollToBottomControllerRef = useRef< + ReturnType | undefined + >(undefined); + if (!scrollToBottomControllerRef.current) { + scrollToBottomControllerRef.current = createScrollToBottomController({ + scrollToEnd: () => flatListRef.current?.scrollToEnd({ animated: false }), + bottomThreshold: BOTTOM_THRESHOLD, + maxAttempts: 60, + onExhausted: () => { + const nearBottom = latestBottomDistanceRef.current < BOTTOM_THRESHOLD; + isAtBottomRef.current = nearBottom; + setShowScrollDown(!nearBottom); + }, + }); + } + const scrollToBottomController = scrollToBottomControllerRef.current; const lastMsg = messages[messages.length - 1]; + useEffect(() => { + return () => scrollToBottomController.cancel(); + }, [scrollToBottomController]); + // Auto-scroll when new messages arrive or parts update useEffect(() => { if (isAtBottomRef.current && flatListRef.current && messages.length > 0) { @@ -114,19 +136,27 @@ export function MessageList({ }; }, [messages.length]); - const handleScroll = useCallback((e: NativeSyntheticEvent) => { - const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent; - const nearBottom = - contentSize.height - contentOffset.y - layoutMeasurement.height < BOTTOM_THRESHOLD; - isAtBottomRef.current = nearBottom; - setShowScrollDown(!nearBottom); - }, []); + const handleScroll = useCallback( + (e: NativeSyntheticEvent) => { + const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent; + const distance = contentSize.height - contentOffset.y - layoutMeasurement.height; + const nearBottom = distance < BOTTOM_THRESHOLD; + latestBottomDistanceRef.current = distance; + scrollToBottomController.observeDistance(distance); + isAtBottomRef.current = nearBottom; + setShowScrollDown(!nearBottom && !scrollToBottomController.isPending()); + }, + [scrollToBottomController], + ); const handleScrollToBottom = useCallback(() => { - isAtBottomRef.current = true; setShowScrollDown(false); - flatListRef.current?.scrollToEnd({ animated: true }); - }, []); + scrollToBottomController.request(); + }, [scrollToBottomController]); + + const handleContentSizeChange = useCallback(() => { + scrollToBottomController.contentSizeChanged(); + }, [scrollToBottomController]); const [selectModalText, setSelectModalText] = useState(null); const handleBubbleLongPress = useCallback((text: string) => { @@ -169,6 +199,7 @@ export function MessageList({ renderItem={renderMessage} contentContainerStyle={s.listContent} onScroll={handleScroll} + onContentSizeChange={handleContentSizeChange} onScrollBeginDrag={Keyboard.dismiss} scrollEventThrottle={16} showsVerticalScrollIndicator={false} diff --git a/packages/app-expo/src/components/chat/scroll-to-bottom-controller.test.ts b/packages/app-expo/src/components/chat/scroll-to-bottom-controller.test.ts new file mode 100644 index 000000000..7b90d7d3c --- /dev/null +++ b/packages/app-expo/src/components/chat/scroll-to-bottom-controller.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from "vitest"; +import { createScrollToBottomController } from "./scroll-to-bottom-controller"; + +describe("scroll-to-bottom controller", () => { + it("keeps converging when content grows after an estimated bottom", () => { + const ticks: Array<() => void> = []; + const scrollToEnd = vi.fn(); + const cancelSchedule = vi.fn(); + const controller = createScrollToBottomController({ + scrollToEnd, + schedule: (callback) => { + ticks.push(callback); + return 7; + }, + cancelSchedule, + bottomThreshold: 80, + maxAttempts: 10, + }); + + controller.request(); + expect(scrollToEnd).toHaveBeenCalledTimes(1); + expect(controller.isPending()).toBe(true); + + controller.observeDistance(79); + expect(controller.isPending()).toBe(true); + + controller.contentSizeChanged(); + expect(scrollToEnd).toHaveBeenCalledTimes(2); + controller.observeDistance(81); + ticks[0](); + expect(scrollToEnd).toHaveBeenCalledTimes(3); + controller.observeDistance(79); + + ticks[0](); + expect(controller.isPending()).toBe(true); + ticks[0](); + expect(controller.isPending()).toBe(false); + expect(cancelSchedule).toHaveBeenCalledWith(7); + }); + + it("does not spend the timer attempt budget on content-size events", () => { + let tick: (() => void) | undefined; + const scrollToEnd = vi.fn(); + const onExhausted = vi.fn(); + const controller = createScrollToBottomController({ + scrollToEnd, + schedule: (callback) => { + tick = callback; + return "timer"; + }, + cancelSchedule: vi.fn(), + maxAttempts: 2, + onExhausted, + }); + + controller.request(); + controller.contentSizeChanged(); + controller.contentSizeChanged(); + controller.contentSizeChanged(); + expect(scrollToEnd).toHaveBeenCalledTimes(4); + + tick?.(); + expect(scrollToEnd).toHaveBeenCalledTimes(5); + expect(onExhausted).not.toHaveBeenCalled(); + tick?.(); + expect(onExhausted).toHaveBeenCalledTimes(1); + }); + + it("stops at the attempt bound and reports exhaustion once", () => { + let tick: (() => void) | undefined; + const scrollToEnd = vi.fn(); + const onExhausted = vi.fn(); + const controller = createScrollToBottomController({ + scrollToEnd, + schedule: (callback) => { + tick = callback; + return "timer"; + }, + cancelSchedule: vi.fn(), + bottomThreshold: 80, + maxAttempts: 2, + onExhausted, + }); + + controller.request(); + tick?.(); + tick?.(); + tick?.(); + + expect(scrollToEnd).toHaveBeenCalledTimes(2); + expect(controller.isPending()).toBe(false); + expect(onExhausted).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/app-expo/src/components/chat/scroll-to-bottom-controller.ts b/packages/app-expo/src/components/chat/scroll-to-bottom-controller.ts new file mode 100644 index 000000000..7b1f42596 --- /dev/null +++ b/packages/app-expo/src/components/chat/scroll-to-bottom-controller.ts @@ -0,0 +1,115 @@ +type ScheduleHandle = unknown; + +interface ScrollToBottomControllerOptions { + scrollToEnd: () => void; + schedule?: (callback: () => void) => ScheduleHandle; + cancelSchedule?: (handle: ScheduleHandle) => void; + bottomThreshold?: number; + maxAttempts?: number; + onExhausted?: () => void; +} + +export interface ScrollToBottomController { + request: () => void; + observeDistance: (distance: number) => void; + contentSizeChanged: () => void; + cancel: () => void; + isPending: () => boolean; +} + +export function createScrollToBottomController( + options: ScrollToBottomControllerOptions, +): ScrollToBottomController { + const schedule = options.schedule ?? ((callback: () => void) => setInterval(callback, 50)); + const cancelSchedule = + options.cancelSchedule ?? + ((handle: ScheduleHandle) => clearInterval(handle as ReturnType)); + const bottomThreshold = options.bottomThreshold ?? 80; + const maxAttempts = Math.max(1, Math.trunc(options.maxAttempts ?? 20)); + + let pending = false; + let attempts = 0; + let scheduleHandle: ScheduleHandle; + let hasSchedule = false; + let exhaustionReported = false; + let bottomObserved = false; + let stableBottomTicks = 0; + + const stop = () => { + pending = false; + if (hasSchedule) { + cancelSchedule(scheduleHandle); + hasSchedule = false; + } + }; + + const exhaust = () => { + stop(); + if (!exhaustionReported) { + exhaustionReported = true; + options.onExhausted?.(); + } + }; + + const attempt = () => { + if (!pending) return; + + if (bottomObserved) { + stableBottomTicks += 1; + if (stableBottomTicks >= 2) { + stop(); + return; + } + options.scrollToEnd(); + return; + } + + stableBottomTicks = 0; + if (attempts >= maxAttempts) { + exhaust(); + return; + } + + attempts += 1; + options.scrollToEnd(); + }; + + const cancel = () => { + stop(); + }; + + return { + request() { + cancel(); + pending = true; + attempts = 0; + exhaustionReported = false; + bottomObserved = false; + stableBottomTicks = 0; + attempt(); + + const handle = schedule(attempt); + if (pending) { + scheduleHandle = handle; + hasSchedule = true; + } else { + cancelSchedule(handle); + } + }, + observeDistance(distance) { + if (!pending) return; + bottomObserved = Number.isFinite(distance) && distance < bottomThreshold; + if (!bottomObserved) stableBottomTicks = 0; + }, + contentSizeChanged() { + if (!pending) return; + bottomObserved = false; + stableBottomTicks = 0; + options.scrollToEnd(); + }, + cancel, + isPending() { + return pending; + }, + }; +}