// Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { mergeRefs } from '@react-aria/utils'; import classNames from 'classnames'; import { maxBy } from 'lodash'; import type { CSSProperties, ReactNode, Ref } from 'react'; import React, { createContext, forwardRef, useCallback, useContext, useEffect, useRef, useState, } from 'react'; import { isScrollAtBottom, isScrollAtTop, isScrollOverflowVertical, useScrollObserver, } from '../../../hooks/useSizeObserver'; import { strictAssert } from '../../../util/assert'; export type FunScrollerProps = Readonly<{ sectionGap: number; onScrollSectionChange?: (id: string) => void; children: ReactNode; }>; type ScrollerSectionUnobserve = () => void; type ScrollerSectionObserve = (element: Element) => ScrollerSectionUnobserve; const ScrollerSectionObserveContext = createContext(null); export const FunScroller = forwardRef(function FunScroller( props: FunScrollerProps, ref: Ref ): JSX.Element { const scrollerRef = useRef(null); const scrollerInnerRef = useRef(null); const [scrollAtTop, setScrollAtTop] = useState(false); const [scrollAtBottom, setScrollAtBottom] = useState(false); const [scrollVerticalOverflow, setScrollOverflowVertical] = useState(false); useScrollObserver(scrollerRef, scrollerInnerRef, scroll => { setScrollAtTop(isScrollAtTop(scroll)); setScrollAtBottom(isScrollAtBottom(scroll)); setScrollOverflowVertical(isScrollOverflowVertical(scroll)); }); const showTopScrollHint = scrollVerticalOverflow && !scrollAtTop; const showBottomScrollHint = scrollVerticalOverflow && !scrollAtBottom; const observerRef = useRef(null); const onScrollChangeRef = useRef(props.onScrollSectionChange); useEffect(() => { onScrollChangeRef.current = props.onScrollSectionChange; }, [props.onScrollSectionChange]); useEffect(() => { const scrollerElement = scrollerRef.current; strictAssert(scrollerElement, 'Expected scrollerRef.current to be defined'); const options: IntersectionObserverInit = { threshold: 0, // 1px is visible (within margin) rootMargin: `-${props.sectionGap}px 0px -${props.sectionGap}px 0px`, root: scrollerElement, }; type HistoryItem = { id: string; time: number }; const history = new Map(); let lastId: string | null = null; const observer = new IntersectionObserver(entries => { for (const entry of entries) { const { id } = entry.target; strictAssert(id, 'Observed element must have an id'); if (entry.isIntersecting) { history.set(id, { id, time: entry.time }); } else { history.delete(id); } } const stack = Array.from(history.values()); const needle = maxBy(stack, x => x.time); if (needle != null && needle.id !== lastId) { lastId = needle.id; onScrollChangeRef.current?.(needle.id); } }, options); observerRef.current = observer; return () => { observer.disconnect(); }; }, [props.sectionGap]); const observe: ScrollerSectionObserve = useCallback((element: Element) => { const observer = observerRef.current; strictAssert(observer, 'Expected observerRef.current to be defined'); observer.observe(element); return () => { observer.unobserve(element); }; }, []); return (
{props.children}
); }); export type FunScrollerSectionProps = Readonly<{ id: string; className?: string; style?: CSSProperties; children: ReactNode; }>; export function FunScrollerSection( props: FunScrollerSectionProps ): JSX.Element { const ref = useRef(null); const observe = useContext(ScrollerSectionObserveContext); strictAssert(observe, 'Expected observe to be defined'); useEffect(() => { const element = ref.current; strictAssert(element, 'Expected ref.current to be defined'); return observe(element); }, [observe]); return (
{props.children}
); }