import React, {isValidElement, memo, startTransition, useRef} from 'react' import {FlatListProps, StyleSheet, View, ViewProps} from 'react-native' import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/reanimated2/hook/commonTypes' import {batchedUpdates} from '#/lib/batchedUpdates' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useScrollHandlers} from '#/lib/ScrollContext' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {addStyle} from 'lib/styles' export type ListMethods = any // TODO: Better types. export type ListProps = Omit< FlatListProps, | 'onScroll' // Use ScrollContext instead. | 'refreshControl' // Pass refreshing and/or onRefresh instead. | 'contentOffset' // Pass headerOffset instead. > & { onScrolledDownChange?: (isScrolledDown: boolean) => void headerOffset?: number refreshing?: boolean onRefresh?: () => void desktopFixedHeight: any // TODO: Better types. containWeb?: boolean } export type ListRef = React.MutableRefObject // TODO: Better types. function ListImpl( { ListHeaderComponent, ListFooterComponent, containWeb, contentContainerStyle, data, desktopFixedHeight, headerOffset, keyExtractor, refreshing: _unsupportedRefreshing, onStartReached, onStartReachedThreshold = 0, onEndReached, onEndReachedThreshold = 0, onRefresh: _unsupportedOnRefresh, onScrolledDownChange, onContentSizeChange, renderItem, extraData, style, ...props }: ListProps, ref: React.Ref, ) { const contextScrollHandlers = useScrollHandlers() const pal = usePalette('default') const {isMobile} = useWebMediaQueries() if (!isMobile) { contentContainerStyle = addStyle( contentContainerStyle, styles.containerScroll, ) } let header: JSX.Element | null = null if (ListHeaderComponent != null) { if (isValidElement(ListHeaderComponent)) { header = ListHeaderComponent } else { // @ts-ignore Nah it's fine. header = } } let footer: JSX.Element | null = null if (ListFooterComponent != null) { if (isValidElement(ListFooterComponent)) { footer = ListFooterComponent } else { // @ts-ignore Nah it's fine. footer = } } if (headerOffset != null) { style = addStyle(style, { paddingTop: headerOffset, }) } const getScrollableNode = React.useCallback(() => { if (containWeb) { const element = nativeRef.current as HTMLDivElement | null if (!element) return return { scrollWidth: element.scrollWidth, scrollHeight: element.scrollHeight, clientWidth: element.clientWidth, clientHeight: element.clientHeight, scrollY: element.scrollTop, scrollX: element.scrollLeft, scrollTo(options?: ScrollToOptions) { element.scrollTo(options) }, scrollBy(options: ScrollToOptions) { element.scrollBy(options) }, addEventListener(event: string, handler: any) { element.addEventListener(event, handler) }, removeEventListener(event: string, handler: any) { element.removeEventListener(event, handler) }, } } else { return { scrollWidth: document.documentElement.scrollWidth, scrollHeight: document.documentElement.scrollHeight, clientWidth: window.innerWidth, clientHeight: window.innerHeight, scrollY: window.scrollY, scrollX: window.scrollX, scrollTo(options: ScrollToOptions) { window.scrollTo(options) }, scrollBy(options: ScrollToOptions) { window.scrollBy(options) }, addEventListener(event: string, handler: any) { window.addEventListener(event, handler) }, removeEventListener(event: string, handler: any) { window.removeEventListener(event, handler) }, } } }, [containWeb]) const nativeRef = React.useRef(null) React.useImperativeHandle( ref, () => ({ scrollToTop() { getScrollableNode()?.scrollTo({top: 0}) }, scrollToOffset({ animated, offset, }: { animated: boolean offset: number }) { getScrollableNode()?.scrollTo({ left: 0, top: offset, behavior: animated ? 'smooth' : 'instant', }) }, scrollToEnd({animated = true}: {animated?: boolean}) { const element = getScrollableNode() element?.scrollTo({ left: 0, top: element.scrollHeight, behavior: animated ? 'smooth' : 'instant', }) }, } as any), // TODO: Better types. [getScrollableNode], ) // --- onContentSizeChange, maintainVisibleContentPosition --- const containerRef = useRef(null) useResizeObserver(containerRef, onContentSizeChange) // --- onScroll --- const [isInsideVisibleTree, setIsInsideVisibleTree] = React.useState(false) const handleScroll = useNonReactiveCallback(() => { if (!isInsideVisibleTree) return const element = getScrollableNode() contextScrollHandlers.onScroll?.( { contentOffset: { x: Math.max(0, element?.scrollX ?? 0), y: Math.max(0, element?.scrollY ?? 0), }, layoutMeasurement: { width: element?.clientWidth, height: element?.clientHeight, }, contentSize: { width: element?.scrollWidth, height: element?.scrollHeight, }, } as Exclude< ReanimatedScrollEvent, | 'velocity' | 'eventName' | 'zoomScale' | 'targetContentOffset' | 'contentInset' >, null as any, ) }) React.useEffect(() => { if (!isInsideVisibleTree) { // Prevents hidden tabs from firing scroll events. // Only one list is expected to be firing these at a time. return } const element = getScrollableNode() element?.addEventListener('scroll', handleScroll) return () => { element?.removeEventListener('scroll', handleScroll) } }, [isInsideVisibleTree, handleScroll, containWeb, getScrollableNode]) // --- onScrolledDownChange --- const isScrolledDown = useRef(false) function handleAboveTheFoldVisibleChange(isAboveTheFold: boolean) { const didScrollDown = !isAboveTheFold if (isScrolledDown.current !== didScrollDown) { isScrolledDown.current = didScrollDown startTransition(() => { onScrolledDownChange?.(didScrollDown) }) } } // --- onStartReached --- const onHeadVisibilityChange = useNonReactiveCallback( (isHeadVisible: boolean) => { if (isHeadVisible) { onStartReached?.({ distanceFromStart: onStartReachedThreshold || 0, }) } }, ) // --- onEndReached --- const onTailVisibilityChange = useNonReactiveCallback( (isTailVisible: boolean) => { if (isTailVisible) { onEndReached?.({ distanceFromEnd: onEndReachedThreshold || 0, }) } }, ) return ( ) } function useResizeObserver( ref: React.RefObject, onResize: undefined | ((w: number, h: number) => void), ) { const handleResize = useNonReactiveCallback(onResize ?? (() => {})) const isActive = !!onResize React.useEffect(() => { if (!isActive) { return } const resizeObserver = new ResizeObserver(entries => { batchedUpdates(() => { for (let entry of entries) { const rect = entry.contentRect handleResize(rect.width, rect.height) } }) }) const node = ref.current! resizeObserver.observe(node) return () => { resizeObserver.unobserve(node) } }, [handleResize, isActive, ref]) } let Row = function RowImpl({ item, index, renderItem, extraData: _unused, }: { item: ItemT index: number renderItem: | null | undefined | ((data: {index: number; item: any; separators: any}) => React.ReactNode) extraData: any }): React.ReactNode { if (!renderItem) { return null } return ( {renderItem({item, index, separators: null as any})} ) } Row = React.memo(Row) let Visibility = ({ root = null, topMargin = '0px', bottomMargin = '0px', onVisibleChange, style, }: { root?: Element | null topMargin?: string bottomMargin?: string onVisibleChange: (isVisible: boolean) => void style?: ViewProps['style'] }): React.ReactNode => { const tailRef = React.useRef(null) const isIntersecting = React.useRef(false) const handleIntersection = useNonReactiveCallback( (entries: IntersectionObserverEntry[]) => { batchedUpdates(() => { entries.forEach(entry => { if (entry.isIntersecting !== isIntersecting.current) { isIntersecting.current = entry.isIntersecting onVisibleChange(entry.isIntersecting) } }) }) }, ) React.useEffect(() => { const observer = new IntersectionObserver(handleIntersection, { root, rootMargin: `${topMargin} 0px ${bottomMargin} 0px`, }) const tail: Element | null = tailRef.current! observer.observe(tail) return () => { observer.unobserve(tail) } }, [bottomMargin, handleIntersection, topMargin, root]) return ( ) } Visibility = React.memo(Visibility) export const List = memo(React.forwardRef(ListImpl)) as ( props: ListProps & {ref?: React.Ref}, ) => React.ReactElement // https://stackoverflow.com/questions/7944460/detect-safari-browser const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent) const styles = StyleSheet.create({ sideBorders: { borderLeftWidth: 1, borderRightWidth: 1, }, containerScroll: { width: '100%', maxWidth: 600, marginLeft: 'auto', marginRight: 'auto', }, row: { // @ts-ignore web only contentVisibility: isSafari ? '' : 'auto', // Safari support for this is buggy. }, minHeightViewport: { // @ts-ignore web only minHeight: '100vh', }, parentTreeVisibilityDetector: { // @ts-ignore web only position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, }, aboveTheFoldDetector: { position: 'absolute', top: 0, left: 0, right: 0, // Bottom is dynamic. }, visibilityDetector: { pointerEvents: 'none', zIndex: -1, }, })