import clsx from 'clsx'; import debounce from 'lodash/debounce'; import React, { useRef, useCallback } from 'react'; import { FormattedMessage } from 'react-intl'; import LoadGap from 'soapbox/components/load-gap'; import ScrollableList from 'soapbox/components/scrollable-list'; import StatusContainer from 'soapbox/containers/status-container'; import FeedSuggestions from 'soapbox/features/feed-suggestions/feed-suggestions'; import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder-status'; import PendingStatus from 'soapbox/features/ui/components/pending-status'; import { useSoapboxConfig } from 'soapbox/hooks'; import type { OrderedSet as ImmutableOrderedSet } from 'immutable'; import type { VirtuosoHandle } from 'react-virtuoso'; import type { IScrollableList } from 'soapbox/components/scrollable-list'; interface IStatusList extends Omit { /** Unique key to preserve the scroll position when navigating back. */ scrollKey: string; /** List of status IDs to display. */ statusIds: ImmutableOrderedSet; /** Last _unfiltered_ status ID (maxId) for pagination. */ lastStatusId?: string; /** Pinned statuses to show at the top of the feed. */ featuredStatusIds?: ImmutableOrderedSet; /** Pagination callback when the end of the list is reached. */ onLoadMore?: (lastStatusId: string) => void; /** Whether the data is currently being fetched. */ isLoading: boolean; /** Whether the server did not return a complete page. */ isPartial?: boolean; /** Whether we expect an additional page of data. */ hasMore: boolean; /** Message to display when the list is loaded but empty. */ emptyMessage: React.ReactNode; /** ID of the timeline in Redux. */ timelineId?: string; /** Whether to display a gap or border between statuses in the list. */ divideType?: 'space' | 'border'; /** Whether to display ads. */ showAds?: boolean; /** Whether to show group information. */ showGroup?: boolean; } /** Feed of statuses, built atop ScrollableList. */ const StatusList: React.FC = ({ statusIds, lastStatusId, featuredStatusIds, divideType = 'border', onLoadMore, timelineId, isLoading, isPartial, showAds = false, showGroup = true, ...other }) => { const soapboxConfig = useSoapboxConfig(); const node = useRef(null); const getFeaturedStatusCount = () => { return featuredStatusIds?.size || 0; }; const getCurrentStatusIndex = (id: string, featured: boolean): number => { if (featured) { return featuredStatusIds?.keySeq().findIndex(key => key === id) || 0; } else { return statusIds.keySeq().findIndex(key => key === id) + getFeaturedStatusCount(); } }; const handleMoveUp = (id: string, featured: boolean = false) => { const elementIndex = getCurrentStatusIndex(id, featured) - 1; selectChild(elementIndex); }; const handleMoveDown = (id: string, featured: boolean = false) => { const elementIndex = getCurrentStatusIndex(id, featured) + 1; selectChild(elementIndex); }; const handleLoadOlder = useCallback(debounce(() => { const maxId = lastStatusId || statusIds.last(); if (onLoadMore && maxId) { onLoadMore(maxId.replace('末suggestions-', '')); } }, 300, { leading: true }), [onLoadMore, lastStatusId, statusIds.last()]); const selectChild = (index: number) => { node.current?.scrollIntoView({ index, behavior: 'smooth', done: () => { const element = document.querySelector(`#status-list [data-index="${index}"] .focusable`); element?.focus(); }, }); }; const renderLoadGap = (index: number) => { const ids = statusIds.toList(); const nextId = ids.get(index + 1); const prevId = ids.get(index - 1); if (index < 1 || !nextId || !prevId || !onLoadMore) return null; return ( ); }; const renderStatus = (statusId: string) => { return ( ); }; const renderPendingStatus = (statusId: string) => { const idempotencyKey = statusId.replace(/^末pending-/, ''); return ( ); }; const renderFeaturedStatuses = (): React.ReactNode[] => { if (!featuredStatusIds) return []; return featuredStatusIds.toArray().map(statusId => ( )); }; const renderFeedSuggestions = (statusId: string): React.ReactNode => { return ( ); }; const renderStatuses = (): React.ReactNode[] => { if (isLoading || statusIds.size > 0) { return statusIds.toList().reduce((acc, statusId, index) => { if (statusId === null) { const gap = renderLoadGap(index); // one does not simply push a null item to Virtuoso: https://github.com/petyosi/react-virtuoso/issues/206#issuecomment-747363793 if (gap) { acc.push(gap); } } else if (statusId.startsWith('末suggestions-')) { if (soapboxConfig.feedInjection) { acc.push(renderFeedSuggestions(statusId)); } } else if (statusId.startsWith('末pending-')) { acc.push(renderPendingStatus(statusId)); } else { acc.push(renderStatus(statusId)); } return acc; }, [] as React.ReactNode[]); } else { return []; } }; const renderScrollableContent = () => { const featuredStatuses = renderFeaturedStatuses(); const statuses = renderStatuses(); if (featuredStatuses && statuses) { return featuredStatuses.concat(statuses); } else { return statuses; } }; if (isPartial) { return (
); } return ( } placeholderCount={20} ref={node} className={clsx('divide-y divide-solid divide-gray-200 dark:divide-gray-800', { 'divide-none': divideType !== 'border', })} itemClassName={clsx({ 'pb-3': divideType !== 'border', })} {...other} > {renderScrollableContent()} ); }; export default StatusList; export type { IStatusList };