Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 41 additions & 10 deletions packages/app-expo/src/components/chat/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -60,10 +61,31 @@ export function MessageList({
const s = makeStyles(colors);
const flatListRef = useRef<FlatList>(null);
const isAtBottomRef = useRef(true);
const latestBottomDistanceRef = useRef(0);
const [showScrollDown, setShowScrollDown] = useState(false);
const scrollToBottomControllerRef = useRef<
ReturnType<typeof createScrollToBottomController> | 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) {
Expand Down Expand Up @@ -114,19 +136,27 @@ export function MessageList({
};
}, [messages.length]);

const handleScroll = useCallback((e: NativeSyntheticEvent<NativeScrollEvent>) => {
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<NativeScrollEvent>) => {
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<string | null>(null);
const handleBubbleLongPress = useCallback((text: string) => {
Expand Down Expand Up @@ -169,6 +199,7 @@ export function MessageList({
renderItem={renderMessage}
contentContainerStyle={s.listContent}
onScroll={handleScroll}
onContentSizeChange={handleContentSizeChange}
onScrollBeginDrag={Keyboard.dismiss}
scrollEventThrottle={16}
showsVerticalScrollIndicator={false}
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
115 changes: 115 additions & 0 deletions packages/app-expo/src/components/chat/scroll-to-bottom-controller.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setInterval>));
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;
},
};
}