import { type ReactElement, type ReactNode, useEffect, useRef, useState } from 'react';
import type { AppBskyActorDefs, AppBskyFeedDefs } from '@atcute/bluesky';
import { useQueryClient } from '@tanstack/react-query';
import { onVisibilityChange } from '#/lib/browser/visibility';
import { DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS } from '#/lib/constants/feeds';
import { cleanError, isNetworkError } from '#/lib/errors';
import { useNonReactiveCallback } from '#/lib/hooks/use-non-reactive-callback';
import type { Richtext } from '#/lib/rich-text';
import { usePostAuthorShadowFilter } from '#/state/cache/profile-shadow';
import { postCreated } from '#/state/events';
import { useFeedFeedbackContext } from '#/state/feed-feedback';
import { STALE } from '#/state/queries';
import type { FeedDescriptor } from '#/state/queries/feed-descriptor';
import {
type FeedPostSlice,
type FeedPostSliceItem,
pollLatest,
RQKEY,
usePostFeedQuery,
} from '#/state/queries/post-feed';
import { useSession } from '#/state/session';
import { SuggestedFollows } from '#/components/FeedInterstitials';
import { List, type ListRef, type ListRenderItemInfo } from '#/components/List/List';
import * as ListTail from '#/components/List/ListTail';
import { PostFeedLoadingPlaceholder } from '#/components/PostFeed/PostFeedLoadingPlaceholder';
import { RichText } from '#/components/RichText';
import { TrendingInterstitial, useShowTrendingInterstitial } from '#/components/TrendingInterstitial';
import { m } from '#/paraglide/messages';
import { useFocusEffect, useIsFocused } from '#/router';
import { FeedShutdownMsg } from './FeedShutdownMsg';
import * as css from './PostFeed.css';
import { PostFeedErrorMessage } from './PostFeedErrorMessage';
import { PostFeedItem } from './PostFeedItem';
import { ShowLessFollowup } from './ShowLessFollowup';
import { ViewFullThread } from './ViewFullThread';
export type FeedRow =
| {
type: 'loading';
key: string;
}
| {
type: 'empty';
key: string;
}
| {
type: 'error';
key: string;
}
| {
type: 'feedShutdownMsg';
key: string;
feedUri: string;
}
| {
type: 'sliceItem';
key: string;
slice: FeedPostSlice;
indexInSlice: number;
showReplyTo: boolean;
}
| {
type: 'sliceViewFullThread';
key: string;
uri: string;
}
| {
type: 'interstitialFollows';
key: string;
}
| {
type: 'interstitialTrending';
key: string;
}
| {
type: 'showLessFollowup';
key: string;
}
| {
type: 'description';
key: string;
value: Richtext | string;
};
export function getItemsForFeedback(feedRow: FeedRow): {
item: FeedPostSliceItem;
feedContext: string | undefined;
reqId: string | undefined;
}[] {
if (feedRow.type === 'sliceItem') {
// Report only the post this row actually rendered, not its unshown slice siblings.
const item = feedRow.slice.items[feedRow.indexInSlice];
if (!item) {
return [];
}
return [{ feedContext: feedRow.slice.feedContext, item, reqId: feedRow.slice.reqId }];
} else {
return [];
}
}
const CHECK_LATEST_AFTER = STALE.SECONDS.THIRTY;
const FEED_ITEM_HEIGHT_ESTIMATE = 300;
export function PostFeed({
feed,
description,
ignoreFilterFor,
pollInterval,
disablePoll,
scrollElRef,
onScrolledDownChange,
onHasNew,
renderEmptyState,
savedFeedConfig,
}: {
feed: FeedDescriptor;
/** shown as the first row, above the posts. for feeds whose description is part of the surface. */
description?: Richtext | string;
ignoreFilterFor?: string;
pollInterval?: number;
disablePoll?: boolean;
scrollElRef?: ListRef;
onHasNew?: (v: boolean) => void;
onScrolledDownChange?: (isScrolledDown: boolean) => void;
renderEmptyState: () => ReactElement;
savedFeedConfig?: AppBskyActorDefs.SavedFeed;
}): ReactNode {
const feedgenUri = feed.type === 'feedgen' ? feed.uri : undefined;
const isDiscover = feedgenUri === DISCOVER_FEED_URI;
const queryClient = useQueryClient();
const { currentAccount, hasSession } = useSession();
const feedFeedback = useFeedFeedbackContext();
const isFocused = useIsFocused();
const lastFetchRef = useRef<number | null>(null);
if (lastFetchRef.current === null) {
// oxlint-disable-next-line react/purity -- initialize once
lastFetchRef.current = Date.now();
}
const showTrendingInterstitial = useShowTrendingInterstitial({
enabled: hasSession && isDiscover,
});
const [hasPressedShowLessUris, setHasPressedShowLessUris] = useState(() => new Set<string>());
const onPressShowLess = (interaction: AppBskyFeedDefs.Interaction) => {
if (interaction.item) {
const uri = interaction.item;
setHasPressedShowLessUris((prev) => new Set([...prev, uri]));
}
};
const opts = { enabled: isFocused, ignoreFilterFor };
const {
data,
error,
fetchNextPage,
hasNextPage,
isError,
isFetched,
isFetching,
isFetchingNextPage,
refetch,
} = usePostFeedQuery(feed, opts);
const lastFetchedAt = data?.pages[0]?.fetchedAt;
const isEmpty = !isFetching && !data?.pages?.some((page) => page.slices.length);
useEffect(() => {
if (lastFetchedAt) {
lastFetchRef.current = lastFetchedAt;
}
}, [lastFetchedAt]);
const checkForNew = useNonReactiveCallback(async () => {
if (!data?.pages[0] || isFetching || !onHasNew || !isFocused || disablePoll) {
return;
}
// Discover always has fresh content.
if (isDiscover) {
return onHasNew(true);
}
try {
if (await pollLatest(data.pages[0])) {
if (isEmpty) {
void refetch();
} else {
onHasNew(true);
}
}
} catch (e) {
// this runs on a poll, so a dropped connection is expected and would only be noise
if (!isNetworkError(e)) {
console.error('Poll latest failed', feed, e);
}
}
});
const isScrolledDownRef = useRef(false);
const handleScrolledDownChange = (isScrolledDown: boolean) => {
isScrolledDownRef.current = isScrolledDown;
onScrolledDownChange?.(isScrolledDown);
};
const myDid = currentAccount?.did || '';
const showsOwnPosts = feed.type === 'following' || (feed.type === 'author' && feed.did === myDid);
useEffect(() => {
if (!showsOwnPosts) {
return;
}
return postCreated.subscribe(() => {
if (!isScrolledDownRef.current) {
void queryClient.invalidateQueries({ queryKey: RQKEY(feed) });
}
});
}, [feed, queryClient, showsOwnPosts]);
useFocusEffect(() => {
if (!disablePoll) {
const timeSinceFirstLoad = Date.now() - lastFetchRef.current!;
if (isEmpty || timeSinceFirstLoad > CHECK_LATEST_AFTER) {
void checkForNew();
}
}
});
useFocusEffect(() => {
const unsubscribe = onVisibilityChange((visible) => {
// check for new when the document becomes visible
if (visible) {
void checkForNew();
}
});
let stopPolling: (() => void) | undefined;
if (pollInterval) {
// check for new on interval
const i = setInterval(() => {
void checkForNew();
}, pollInterval);
stopPolling = () => clearInterval(i);
}
return () => {
unsubscribe();
stopPolling?.();
};
});
const blockedOrMutedAuthors = usePostAuthorShadowFilter(
// author feeds have their own handling
feed.type === 'author' ? undefined : data?.pages,
);
const feedItems: FeedRow[] = ((): FeedRow[] => {
// wraps a slice item, and replaces it with a showLessFollowup item
// if the user has pressed show less on it
const sliceItem = (row: Extract<FeedRow, { type: 'sliceItem' }>) => {
const uri = row.slice.items[row.indexInSlice]?.uri;
if (uri && hasPressedShowLessUris.has(uri)) {
return {
type: 'showLessFollowup',
key: row.key,
} as const;
} else {
return row;
}
};
let feedKind: 'discover' | 'profile' | undefined;
if (isDiscover) {
feedKind = 'discover';
} else if (feed.type === 'author' && feed.view === 'posts') {
feedKind = 'profile';
}
const arr: FeedRow[] = [];
if (feedgenUri !== undefined && KNOWN_SHUTDOWN_FEEDS.includes(feedgenUri)) {
arr.push({
type: 'feedShutdownMsg',
key: 'feedShutdownMsg',
feedUri: feedgenUri,
});
}
if (isFetched) {
if (isError && isEmpty) {
arr.push({
type: 'error',
key: 'error',
});
} else if (isEmpty) {
arr.push({
type: 'empty',
key: 'empty',
});
} else if (data) {
let sliceIndex = -1;
for (const page of data.pages) {
for (const slice of page.slices) {
sliceIndex++;
if (hasSession) {
switch (feedKind) {
case 'discover': {
// only reserve a row (and its border) for trending when it will actually render
if (sliceIndex === 0 && showTrendingInterstitial) {
arr.push({
type: 'interstitialTrending',
key: 'interstitialTrending-' + sliceIndex,
});
} else if (sliceIndex === 30) {
arr.push({
type: 'interstitialFollows',
key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
});
}
break;
}
case 'profile': {
if (sliceIndex === 5) {
arr.push({
type: 'interstitialFollows',
key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
});
}
break;
}
}
}
if (slice.items.some((item) => blockedOrMutedAuthors.includes(item.post.author.did))) {
// skip
} else if (slice.isIncompleteThread && slice.items.length >= 3) {
const beforeLast = slice.items.length - 2;
const last = slice.items.length - 1;
arr.push(
sliceItem({
type: 'sliceItem',
key: slice.items[0]!._reactKey,
slice: slice,
indexInSlice: 0,
showReplyTo: false,
}),
);
arr.push({
type: 'sliceViewFullThread',
key: slice._reactKey + '-viewFullThread',
uri: slice.items[0]!.uri,
});
arr.push(
sliceItem({
type: 'sliceItem',
key: slice.items[beforeLast]!._reactKey,
slice: slice,
indexInSlice: beforeLast,
showReplyTo:
slice.items[beforeLast]!.parentAuthor?.did !== slice.items[beforeLast]!.post.author.did,
}),
);
arr.push(
sliceItem({
type: 'sliceItem',
key: slice.items[last]!._reactKey,
slice: slice,
indexInSlice: last,
showReplyTo: false,
}),
);
} else {
for (let i = 0; i < slice.items.length; i++) {
const item = slice.items[i]!;
arr.push(
sliceItem({
type: 'sliceItem',
key: item._reactKey,
slice: slice,
indexInSlice: i,
showReplyTo: i === 0,
}),
);
}
}
}
}
}
} else {
if (isFocused) {
arr.push({
type: 'loading',
key: 'loading',
});
}
}
if (description) {
arr.unshift({
type: 'description',
key: 'description',
value: description,
});
}
return arr;
})();
const onPressTryAgain = () => {
void refetch();
onHasNew?.(false);
};
const renderItem = ({ item: row, index: rowIndex }: ListRenderItemInfo<FeedRow>) => {
switch (row.type) {
case 'empty': {
return renderEmptyState();
}
case 'error': {
return (
<PostFeedErrorMessage
feedDesc={feed}
error={error ?? undefined}
onPressTryAgain={onPressTryAgain}
savedFeedConfig={savedFeedConfig}
topBorder={rowIndex !== 0}
/>
);
}
case 'loading': {
return <PostFeedLoadingPlaceholder topBorder={rowIndex !== 0} />;
}
case 'feedShutdownMsg': {
return <FeedShutdownMsg feedUri={row.feedUri} topBorder={rowIndex !== 0} />;
}
case 'interstitialFollows': {
return <SuggestedFollows feed={feed} />;
}
case 'interstitialTrending': {
return <TrendingInterstitial />;
}
case 'description': {
return (
<div className={css.description}>
<RichText color="textContrastHigh" size="md" value={row.value} />
</div>
);
}
case 'sliceItem': {
const slice = row.slice;
const indexInSlice = row.indexInSlice;
const item = slice.items[indexInSlice]!;
return (
<PostFeedItem
post={item.post}
record={item.record}
postNumbering={item.postNumbering}
reason={indexInSlice === 0 ? slice.reason : undefined}
feedContext={slice.feedContext}
reqId={slice.reqId}
moderation={item.moderation}
parentAuthor={item.parentAuthor}
showReplyTo={row.showReplyTo}
isThreadParent={isThreadParentAt(slice.items, indexInSlice)}
isThreadChild={isThreadChildAt(slice.items, indexInSlice)}
isParentBlocked={item.isParentBlocked}
isParentNotFound={item.isParentNotFound}
hideTopBorder={rowIndex === 0 && indexInSlice === 0}
rootPost={slice.items[0]!.post}
onShowLess={onPressShowLess}
/>
);
}
case 'sliceViewFullThread': {
return <ViewFullThread uri={row.uri} />;
}
case 'showLessFollowup': {
return <ShowLessFollowup topBorder={rowIndex !== 0} />;
}
}
};
const onItemSeen = (item: FeedRow) => {
feedFeedback.onItemSeen(item);
};
return (
<List
ref={scrollElRef}
data={feedItems}
keyExtractor={(item: FeedRow) => item.key}
estimateHeight={FEED_ITEM_HEIGHT_ESTIMATE}
renderItem={renderItem}
ListFooterComponent={
<ListTail.Frame border={!isEmpty}>
{isFetchingNextPage ? (
<ListTail.Pending />
) : isError && !isEmpty ? (
<ListTail.Error message={cleanError(error)} onRetry={() => void fetchNextPage()} />
) : !isEmpty && !isFetching && !hasNextPage ? (
<ListTail.End>{m['common.list.endOfList']()}</ListTail.End>
) : null}
</ListTail.Frame>
}
onScrolledDownChange={handleScrolledDownChange}
onEndReached={() => {
if (isError) {
return;
}
void fetchNextPage();
}}
onEndReachedThreshold={2}
onItemSeen={onItemSeen}
/>
);
}
export function isThreadParentAt(arr: Array<unknown>, i: number) {
if (arr.length === 1) {
return false;
}
return i < arr.length - 1;
}
export function isThreadChildAt(arr: Array<unknown>, i: number) {
if (arr.length === 1) {
return false;
}
return i > 0;
}