import clsx from 'clsx'; import { Map as ImmutableMap } from 'immutable'; import debounce from 'lodash/debounce'; import React, { useRef, useCallback } from 'react'; import { FormattedMessage } from 'react-intl'; import { v4 as uuidv4 } from 'uuid'; import LoadGap from 'soapbox/components/load-gap'; import ScrollableList from 'soapbox/components/scrollable-list'; import StatusContainer from 'soapbox/containers/status-container'; import Ad from 'soapbox/features/ads/components/ad'; import FeedSuggestions from 'soapbox/features/feed-suggestions/feed-suggestions'; import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder-status'; import { ALGORITHMS } from 'soapbox/features/timeline-insertion'; import PendingStatus from 'soapbox/features/ui/components/pending-status'; import { useSoapboxConfig } from 'soapbox/hooks'; import useAds from 'soapbox/queries/ads'; import type { OrderedSet as ImmutableOrderedSet } from 'immutable'; import type { VirtuosoHandle } from 'react-virtuoso'; import type { IScrollableList } from 'soapbox/components/scrollable-list'; import type { Ad as AdEntity } from 'soapbox/types/soapbox'; 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 { data: ads } = useAds(); const soapboxConfig = useSoapboxConfig(); const adsAlgorithm = String(soapboxConfig.extensions.getIn(['ads', 'algorithm', 0])); const adsOpts = (soapboxConfig.extensions.getIn(['ads', 'algorithm', 1], ImmutableMap()) as ImmutableMap).toJS(); const node = useRef(null); const seed = useRef(uuidv4()); 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 renderAd = (ad: AdEntity, index: number) => { 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 = (): React.ReactNode => { return ; }; const renderStatuses = (): React.ReactNode[] => { if (isLoading || statusIds.size > 0) { return statusIds.toList().reduce((acc, statusId, index) => { if (showAds && ads) { const ad = ALGORITHMS[adsAlgorithm]?.(ads, index, { ...adsOpts, seed: seed.current }); if (ad) { acc.push(renderAd(ad, 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()); } } 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 ( {renderScrollableContent()} ); }; export default StatusList; export type { IStatusList };