A React Native app for the ultimate thinking partner.
1import { useRef, useEffect, useCallback, useState } from 'react';
2import { FlatList, NativeScrollEvent, NativeSyntheticEvent } from 'react-native';
3
4interface UseScrollToBottomOptions {
5 /**
6 * Whether to scroll to bottom on initial mount/load
7 * @default true
8 */
9 scrollOnMount?: boolean;
10
11 /**
12 * Delay before scrolling (ms) - useful for rendering completion
13 * @default 100
14 */
15 delay?: number;
16
17 /**
18 * Distance from bottom (px) to consider "at bottom"
19 * @default 100
20 */
21 threshold?: number;
22}
23
24/**
25 * Hook for managing scroll-to-bottom behavior in chat interfaces
26 *
27 * ULTRATHINK SIMPLE SOLUTION:
28 * - Tracks if user is at bottom
29 * - Only auto-scrolls if user is at bottom
30 * - Prevents scroll jumps on content replace
31 * - Allows manual scroll up without interference
32 *
33 * @example
34 * const { scrollViewRef, scrollToBottom, onContentSizeChange, onScroll } = useScrollToBottom();
35 *
36 * <FlatList
37 * ref={scrollViewRef}
38 * onContentSizeChange={onContentSizeChange}
39 * onScroll={onScroll}
40 * />
41 */
42export function useScrollToBottom(options: UseScrollToBottomOptions = {}) {
43 const {
44 scrollOnMount = true,
45 delay = 100,
46 threshold = 100,
47 } = options;
48
49 const scrollViewRef = useRef<FlatList<any>>(null);
50 const hasMountedRef = useRef(false);
51 const isNearBottomRef = useRef(true); // Assume at bottom initially
52 const contentSizeRef = useRef({ width: 0, height: 0 });
53 const layoutHeightRef = useRef(0);
54
55 // Track scroll position to determine if user is at bottom
56 const onScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
57 const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
58
59 // Calculate distance from bottom
60 const distanceFromBottom = contentSize.height - (contentOffset.y + layoutMeasurement.height);
61
62 // Update near-bottom state
63 isNearBottomRef.current = distanceFromBottom <= threshold;
64
65 // Store layout height for later use
66 layoutHeightRef.current = layoutMeasurement.height;
67 }, [threshold]);
68
69 // Scroll to bottom - but only if user is already near bottom
70 const scrollToBottom = useCallback((force: boolean = false) => {
71 if (force || isNearBottomRef.current) {
72 setTimeout(() => {
73 scrollViewRef.current?.scrollToEnd({ animated: false });
74 }, delay);
75 }
76 }, [delay]);
77
78 // Handle content size change - scroll if user is at bottom
79 const onContentSizeChange = useCallback((width: number, height: number) => {
80 const prevHeight = contentSizeRef.current.height;
81 contentSizeRef.current = { width, height };
82
83 // Initial mount - always scroll to bottom
84 if (!hasMountedRef.current && height > 0) {
85 hasMountedRef.current = true;
86 if (scrollOnMount) {
87 setTimeout(() => {
88 scrollViewRef.current?.scrollToEnd({ animated: false });
89 }, delay);
90 }
91 return;
92 }
93
94 // Content grew (new messages) - only scroll if user was at bottom
95 if (height > prevHeight && isNearBottomRef.current) {
96 setTimeout(() => {
97 scrollViewRef.current?.scrollToEnd({ animated: false });
98 }, delay);
99 }
100 }, [scrollOnMount, delay]);
101
102 return {
103 scrollViewRef,
104 scrollToBottom,
105 onContentSizeChange,
106 onScroll,
107 };
108}