import debounce from 'lodash/debounce'; import React, { useEffect, useRef, useMemo, useCallback } from 'react'; import { useHistory } from 'react-router-dom'; import { Virtuoso, Components, VirtuosoProps, VirtuosoHandle, ListRange, IndexLocationWithAlign } from 'react-virtuoso'; import { useSettings } from 'soapbox/hooks'; import LoadMore from './load-more'; import { Card, Spinner } from './ui'; /** Custom Viruoso component context. */ type Context = { itemClassName?: string listClassName?: string } /** Scroll position saved in sessionStorage. */ type SavedScrollPosition = { index: number offset: number } /** Custom Virtuoso Item component representing a single scrollable item. */ // NOTE: It's crucial to space lists with **padding** instead of margin! // Pass an `itemClassName` like `pb-3`, NOT a `space-y-3` className // https://virtuoso.dev/troubleshooting#list-does-not-scroll-to-the-bottom--items-jump-around const Item: Components['Item'] = ({ context, ...rest }) => (
); /** Custom Virtuoso List component for the outer container. */ // Ensure the className winds up here const List: Components['List'] = React.forwardRef((props, ref) => { const { context, ...rest } = props; return
; }); interface IScrollableList extends VirtuosoProps { /** Unique key to preserve the scroll position when navigating back. */ scrollKey?: string /** Pagination callback when the end of the list is reached. */ onLoadMore?: () => void /** Whether the data is currently being fetched. */ isLoading?: boolean /** Whether to actually display the loading state. */ showLoading?: boolean /** Whether we expect an additional page of data. */ hasMore?: boolean /** Additional element to display at the top of the list. */ prepend?: React.ReactNode /** Whether to display the prepended element. */ alwaysPrepend?: boolean /** Message to display when the list is loaded but empty. */ emptyMessage?: React.ReactNode /** Scrollable content. */ children: Iterable /** Callback when the list is scrolled to the top. */ onScrollToTop?: () => void /** Callback when the list is scrolled. */ onScroll?: () => void /** Placeholder component to render while loading. */ placeholderComponent?: React.ComponentType | React.NamedExoticComponent /** Number of placeholders to render while loading. */ placeholderCount?: number /** * Pull to refresh callback. * @deprecated Put a PTR around the component instead. */ onRefresh?: () => Promise /** Extra class names on the Virtuoso element. */ className?: string /** Class names on each item container. */ itemClassName?: string /** `id` attribute on the Virtuoso element. */ id?: string /** CSS styles on the Virtuoso element. */ style?: React.CSSProperties /** Whether to use the window to scroll the content instead of Virtuoso's container. */ useWindowScroll?: boolean } /** Legacy ScrollableList with Virtuoso for backwards-compatibility. */ const ScrollableList = React.forwardRef(({ scrollKey, prepend = null, alwaysPrepend, children, isLoading, emptyMessage, showLoading, onRefresh, onScroll, onScrollToTop, onLoadMore, className, itemClassName, id, hasMore, placeholderComponent: Placeholder, placeholderCount = 0, initialTopMostItemIndex = 0, style = {}, useWindowScroll = true, }, ref) => { const history = useHistory(); const settings = useSettings(); const autoloadMore = settings.get('autoloadMore'); // Preserve scroll position const scrollDataKey = `soapbox:scrollData:${scrollKey}`; const scrollData: SavedScrollPosition | null = useMemo(() => JSON.parse(sessionStorage.getItem(scrollDataKey)!), [scrollDataKey]); const topIndex = useRef(scrollData ? scrollData.index : 0); const topOffset = useRef(scrollData ? scrollData.offset : 0); /** Normalized children. */ const elements = Array.from(children || []); const showPlaceholder = showLoading && Placeholder && placeholderCount > 0; // NOTE: We are doing some trickery to load a feed of placeholders // Virtuoso's `EmptyPlaceholder` unfortunately doesn't work for our use-case const data = showPlaceholder ? Array(placeholderCount).fill('') : elements; // Add a placeholder at the bottom for loading // (Don't use Virtuoso's `Footer` component because it doesn't preserve its height) if (hasMore && (autoloadMore || isLoading) && Placeholder) { data.push(); } else if (hasMore && (autoloadMore || isLoading)) { data.push(); } const handleScroll = useCallback(debounce(() => { // HACK: Virtuoso has no better way to get this... const node = document.querySelector(`[data-virtuoso-scroller] [data-item-index="${topIndex.current}"]`); if (node) { topOffset.current = node.getBoundingClientRect().top * -1; } else { topOffset.current = 0; } }, 150, { trailing: true }), []); useEffect(() => { document.addEventListener('scroll', handleScroll); sessionStorage.removeItem(scrollDataKey); return () => { if (scrollKey) { const data: SavedScrollPosition = { index: topIndex.current, offset: topOffset.current }; sessionStorage.setItem(scrollDataKey, JSON.stringify(data)); } document.removeEventListener('scroll', handleScroll); }; }, []); /* Render an empty state instead of the scrollable list. */ const renderEmpty = (): JSX.Element => { return (
{alwaysPrepend && prepend} {isLoading ? ( ) : ( emptyMessage )}
); }; /** Render a single item. */ const renderItem = (_i: number, element: JSX.Element): JSX.Element => { if (showPlaceholder) { return ; } else { return element; } }; const handleEndReached = () => { if (autoloadMore && hasMore && onLoadMore) { onLoadMore(); } }; const loadMore = () => { if (autoloadMore || !hasMore || !onLoadMore) { return null; } else { return ; } }; const handleRangeChange = (range: ListRange) => { // HACK: using the first index can be buggy. // Track the second item instead, unless the endIndex comes before it (eg one 1 item in view). topIndex.current = Math.min(range.startIndex + 1, range.endIndex); handleScroll(); }; /** Figure out the initial index to scroll to. */ const initialIndex = useMemo(() => { if (showLoading) return 0; if (initialTopMostItemIndex) return initialTopMostItemIndex; if (scrollData && history.action === 'POP') { return { align: 'start', index: scrollData.index, offset: scrollData.offset, }; } return 0; }, [showLoading, initialTopMostItemIndex]); return ( isScrolling && onScroll && onScroll()} itemContent={renderItem} initialTopMostItemIndex={initialIndex} rangeChanged={handleRangeChange} style={style} context={{ listClassName: className, itemClassName, }} components={{ Header: () => <>{prepend}, ScrollSeekPlaceholder: Placeholder as any, EmptyPlaceholder: () => renderEmpty(), List, Item, Footer: loadMore, }} /> ); }); export default ScrollableList; export type { IScrollableList };