All files / apps/chatbot/src/hooks useChatScroll.ts

100% Statements 24/24
100% Branches 7/7
100% Functions 9/9
100% Lines 18/18

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81                          1x 13x 13x   13x   13x 6x     13x                   5x                             13x 13x           2x 2x               16x       13x 13x         3x 3x     13x    
import { useCallback, useEffect, useRef } from "react";
import { useKeyboardHandler } from "react-native-keyboard-controller";
import { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
 
import { FlashListRef } from "@shopify/flash-list";
 
import { ChatMessage } from "@/types/chat";
 
/**
 * Keeps the newest message in view. The keyboard height is tracked as a shared
 * value so the list can pad itself on the UI thread rather than re-rendering
 * on every frame of the keyboard transition.
 */
export const useChatScroll = (messages: ChatMessage[]) => {
  const listRef = useRef<FlashListRef<ChatMessage>>(null);
  const keyboardHeight = useSharedValue(0);
  /** Which way the keyboard is travelling — only an opening one pulls the list. */
  const isOpening = useSharedValue(false);
 
  const scrollToBottom = useCallback((animated = true) => {
    listRef.current?.scrollToEnd({ animated });
  }, []);
 
  useKeyboardHandler({
    /**
     * `onStart` carries the DESTINATION height, which is the only place the
     * direction is known: a live frame reads the same either way, since a
     * closing keyboard is still hundreds of points tall for most of its
     * travel. Reading direction off the frame height is what made dismissing
     * the keyboard drag the reader back down to the newest message.
     */
    onStart: (e) => {
      "worklet";
      isOpening.value = e.height > 0;
    },
    /**
     * The padding sits on the view *around* the list, so an opening keyboard
     * shrinks the viewport while the content stays put — the end of the list
     * drops below the fold by exactly the keyboard's height.
     *
     * So the scroll has to follow the shrink frame by frame rather than fire
     * once. Scrolling before it (on start) measures a viewport that is about
     * to change and lands short; scrolling only after it (on end) is correct
     * but cannot begin until the keyboard has finished, which is the delay.
     * Unanimated, because the frame cadence is already the animation.
     */
    onMove: (e) => {
      "worklet";
      keyboardHeight.value = e.height;
      if (isOpening.value) runOnJS(scrollToBottom)(false);
    },
    // Final correction against the settled layout. Normally a no-op — onMove
    // has already arrived — but it costs one call and guarantees the landing.
    onEnd: (e) => {
      "worklet";
      keyboardHeight.value = e.height;
      if (isOpening.value) runOnJS(scrollToBottom)(false);
    },
  });
 
  // Assigned straight through: `onMove` already delivers the keyboard's own
  // interpolation frame by frame, so re-animating it with `withTiming` only
  // made the padding chase a value that was itself still moving — which is
  // what left the list trailing behind the keyboard.
  const animatedChatViewStyle = useAnimatedStyle(() => ({
    paddingBottom: keyboardHeight.value,
  }));
 
  useEffect(() => {
    if (messages.length === 0) return;
 
    // One frame, not a fixed delay: the new message has to be laid out before
    // the list knows where its end is, but waiting a flat 50ms for that is
    // both longer than it needs to be and no guarantee on a busy JS thread.
    const frame = requestAnimationFrame(() => scrollToBottom());
    return () => cancelAnimationFrame(frame);
  }, [messages, scrollToBottom]);
 
  return { listRef, keyboardHeight, animatedChatViewStyle };
};