All files / src/hooks useAutoScroll.ts

0% Statements 0/54
0% Branches 0/1
0% Functions 0/1
0% Lines 0/54

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                                                                                                                                 
import { useCallback, useEffect, useRef, useState } from "react";
 
const SCROLL_THRESHOLD = 80;
 
export function useAutoScroll(
  changeKey: unknown,
  options?: {
    followOnChange?: boolean;
  },
) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [showNewMessages, setShowNewMessages] = useState(false);
  const userScrolledRef = useRef(false);
  const rafRef = useRef<number>(0);
  const followOnChange = options?.followOnChange ?? true;
 
  const handleScroll = useCallback(() => {
    const el = containerRef.current;
    if (!el) return;
    const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_THRESHOLD;
    userScrolledRef.current = !atBottom;
    if (atBottom) {
      setShowNewMessages(false);
    }
  }, []);
 
  useEffect(() => {
    const el = containerRef.current;
    if (!el) return;
    el.addEventListener("scroll", handleScroll, { passive: true });
    return () => el.removeEventListener("scroll", handleScroll);
  }, [handleScroll]);
 
  useEffect(() => {
    const el = containerRef.current;
    if (!el) return;
    if (!followOnChange) return;
    if (!userScrolledRef.current) {
      cancelAnimationFrame(rafRef.current);
      rafRef.current = requestAnimationFrame(() => {
        el.scrollTo({ top: el.scrollHeight, behavior: "instant" });
      });
    } else {
      // eslint-disable-next-line react-hooks/set-state-in-effect -- badge visibility should change with the message batch that triggered this effect, not one frame later.
      setShowNewMessages(true);
    }
  }, [changeKey, followOnChange]);
 
  useEffect(() => {
    return () => {
      cancelAnimationFrame(rafRef.current);
    };
  }, []);
 
  const scrollToBottom = useCallback(() => {
    const el = containerRef.current;
    if (!el) return;
    el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
    userScrolledRef.current = false;
    setShowNewMessages(false);
  }, []);
 
  return { containerRef, showNewMessages, setShowNewMessages, scrollToBottom, userScrolledRef };
}