soapbox/app/soapbox/components/status-list.tsx

266 wiersze
8.1 KiB
TypeScript
Czysty Zwykły widok Historia

2023-02-06 18:01:03 +00:00
import clsx from 'clsx';
import { Map as ImmutableMap } from 'immutable';
import debounce from 'lodash/debounce';
2022-06-02 18:32:08 +00:00
import React, { useRef, useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import { v4 as uuidv4 } from 'uuid';
2022-06-02 18:32:08 +00:00
2022-11-15 16:00:49 +00:00
import LoadGap from 'soapbox/components/load-gap';
import ScrollableList from 'soapbox/components/scrollable-list';
2022-11-15 16:13:54 +00:00
import StatusContainer from 'soapbox/containers/status-container';
2022-08-02 03:43:28 +00:00
import Ad from 'soapbox/features/ads/components/ad';
2022-07-01 20:09:07 +00:00
import FeedSuggestions from 'soapbox/features/feed-suggestions/feed-suggestions';
2022-11-15 19:00:40 +00:00
import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder-status';
import { ALGORITHMS } from 'soapbox/features/timeline-insertion';
2022-11-16 13:32:32 +00:00
import PendingStatus from 'soapbox/features/ui/components/pending-status';
2022-08-02 03:43:28 +00:00
import { useSoapboxConfig } from 'soapbox/hooks';
import useAds from 'soapbox/queries/ads';
2022-06-02 18:32:08 +00:00
import type { OrderedSet as ImmutableOrderedSet } from 'immutable';
2022-06-02 18:32:08 +00:00
import type { VirtuosoHandle } from 'react-virtuoso';
2022-11-15 16:00:49 +00:00
import type { IScrollableList } from 'soapbox/components/scrollable-list';
2022-10-20 21:29:14 +00:00
import type { Ad as AdEntity } from 'soapbox/types/soapbox';
2022-06-02 18:32:08 +00:00
2022-06-02 19:00:35 +00:00
interface IStatusList extends Omit<IScrollableList, 'onLoadMore' | 'children'> {
/** Unique key to preserve the scroll position when navigating back. */
scrollKey: string
/** List of status IDs to display. */
statusIds: ImmutableOrderedSet<string>
/** Last _unfiltered_ status ID (maxId) for pagination. */
lastStatusId?: string
/** Pinned statuses to show at the top of the feed. */
featuredStatusIds?: ImmutableOrderedSet<string>
/** 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'
2022-08-02 03:43:28 +00:00
/** Whether to display ads. */
showAds?: boolean
/** Whether to show group information. */
showGroup?: boolean
2022-06-02 18:32:08 +00:00
}
/** Feed of statuses, built atop ScrollableList. */
2022-06-02 18:32:08 +00:00
const StatusList: React.FC<IStatusList> = ({
statusIds,
lastStatusId,
featuredStatusIds,
divideType = 'border',
onLoadMore,
timelineId,
isLoading,
isPartial,
2022-08-02 03:43:28 +00:00
showAds = false,
showGroup = true,
2022-06-02 18:32:08 +00:00
...other
}) => {
2022-08-02 03:43:28 +00:00
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<string, any>).toJS();
2022-06-02 18:32:08 +00:00
const node = useRef<VirtuosoHandle>(null);
const seed = useRef<string>(uuidv4());
2022-06-02 18:32:08 +00:00
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) {
2022-07-01 20:09:07 +00:00
onLoadMore(maxId.replace('末suggestions-', ''));
2022-06-02 18:32:08 +00:00
}
}, 300, { leading: true }), [onLoadMore, lastStatusId, statusIds.last()]);
2022-06-02 18:32:08 +00:00
const selectChild = (index: number) => {
node.current?.scrollIntoView({
index,
behavior: 'smooth',
done: () => {
const element = document.querySelector<HTMLDivElement>(`#status-list [data-index="${index}"] .focusable`);
2022-06-02 18:32:08 +00:00
element?.focus();
},
});
};
const renderLoadGap = (index: number) => {
const ids = statusIds.toList();
2022-06-02 19:00:35 +00:00
const nextId = ids.get(index + 1);
const prevId = ids.get(index - 1);
if (index < 1 || !nextId || !prevId || !onLoadMore) return null;
2022-06-02 18:32:08 +00:00
return (
<LoadGap
2022-06-02 19:00:35 +00:00
key={'gap:' + nextId}
2022-06-02 18:32:08 +00:00
disabled={isLoading}
2022-06-02 19:00:35 +00:00
maxId={prevId!}
2022-06-02 18:32:08 +00:00
onClick={onLoadMore}
/>
);
};
const renderStatus = (statusId: string) => {
return (
<StatusContainer
key={statusId}
id={statusId}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
contextType={timelineId}
showGroup={showGroup}
2022-06-02 18:32:08 +00:00
/>
);
};
const renderAd = (ad: AdEntity, index: number) => {
2022-08-02 03:43:28 +00:00
return (
2022-10-20 21:29:14 +00:00
<Ad key={`ad-${index}`} ad={ad} />
2022-08-02 03:43:28 +00:00
);
};
2022-06-02 18:32:08 +00:00
const renderPendingStatus = (statusId: string) => {
const idempotencyKey = statusId.replace(/^末pending-/, '');
return (
<PendingStatus
key={statusId}
idempotencyKey={idempotencyKey}
/>
);
};
2022-06-02 19:00:35 +00:00
const renderFeaturedStatuses = (): React.ReactNode[] => {
2022-06-02 18:32:08 +00:00
if (!featuredStatusIds) return [];
return featuredStatusIds.toArray().map(statusId => (
<StatusContainer
key={`f-${statusId}`}
id={statusId}
featured
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
contextType={timelineId}
showGroup={showGroup}
2022-06-02 18:32:08 +00:00
/>
));
};
2022-07-01 20:09:07 +00:00
const renderFeedSuggestions = (): React.ReactNode => {
return <FeedSuggestions key='suggestions' />;
};
2022-06-02 19:00:35 +00:00
const renderStatuses = (): React.ReactNode[] => {
2022-06-02 18:32:08 +00:00
if (isLoading || statusIds.size > 0) {
2022-08-02 03:43:28 +00:00
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));
}
}
2022-08-02 03:43:28 +00:00
2022-06-02 18:32:08 +00:00
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);
}
2022-07-01 20:09:07 +00:00
} else if (statusId.startsWith('末suggestions-')) {
2022-11-28 03:05:39 +00:00
if (soapboxConfig.feedInjection) {
acc.push(renderFeedSuggestions());
}
2022-06-02 18:32:08 +00:00
} else if (statusId.startsWith('末pending-')) {
2022-08-02 03:43:28 +00:00
acc.push(renderPendingStatus(statusId));
2022-06-02 18:32:08 +00:00
} else {
2022-08-02 03:43:28 +00:00
acc.push(renderStatus(statusId));
}
return acc;
}, [] as React.ReactNode[]);
2022-06-02 18:32:08 +00:00
} else {
return [];
}
};
const renderScrollableContent = () => {
const featuredStatuses = renderFeaturedStatuses();
const statuses = renderStatuses();
if (featuredStatuses && statuses) {
return featuredStatuses.concat(statuses);
} else {
return statuses;
}
};
if (isPartial) {
return (
<div className='regeneration-indicator'>
<div>
<div className='regeneration-indicator__label'>
<FormattedMessage id='regeneration_indicator.label' tagName='strong' defaultMessage='Loading&hellip;' />
<FormattedMessage id='regeneration_indicator.sublabel' defaultMessage='Your home feed is being prepared!' />
</div>
</div>
</div>
);
}
return (
<ScrollableList
id='status-list'
key='scrollable-list'
isLoading={isLoading}
showLoading={isLoading && statusIds.size === 0}
onLoadMore={handleLoadOlder}
placeholderComponent={PlaceholderStatus}
placeholderCount={20}
ref={node}
2023-02-06 18:01:03 +00:00
className={clsx('divide-y divide-solid divide-gray-200 dark:divide-gray-800', {
'divide-none': divideType !== 'border',
})}
2023-02-06 18:01:03 +00:00
itemClassName={clsx({
'pb-3': divideType !== 'border',
})}
{...other}
>
{renderScrollableContent()}
</ScrollableList>
2022-06-02 18:32:08 +00:00
);
};
export default StatusList;
2022-06-02 20:15:30 +00:00
export type { IStatusList };