import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react' import { ActivityIndicator, AppState, Dimensions, LayoutAnimation, type ListRenderItemInfo, type StyleProp, StyleSheet, View, type ViewStyle, } from 'react-native' import { type AppBskyActorDefs, AppBskyEmbedVideo, AppBskyFeedDefs, } from '@atproto/api' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {isNetworkError} from '#/lib/strings/errors' import {logger} from '#/logger' import {usePostAuthorShadowFilter} from '#/state/cache/profile-shadow' import {listenPostCreated} from '#/state/events' import {useFeedFeedbackContext} from '#/state/feed-feedback' import {useDisableComposerPrompt} from '#/state/preferences/disable-composer-prompt' import {useHideUnreplyablePosts} from '#/state/preferences/hide-unreplyable-posts' import {useRepostCarouselEnabled} from '#/state/preferences/repost-carousel-enabled' import {useTrendingSettings} from '#/state/preferences/trending' import {STALE} from '#/state/queries' import { type AuthorFilter, type FeedDescriptor, type FeedParams, type FeedPostSlice, type FeedPostSliceItem, pollLatest, RQKEY, usePostFeedQuery, } from '#/state/queries/post-feed' import {useSession} from '#/state/session' import {useProgressGuide} from '#/state/shell/progress-guide' import {useSelectedFeed} from '#/state/shell/selected-feed' import {List, type ListRef} from '#/view/com/util/List' import {PostFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn' import {type VideoFeedSourceContext} from '#/screens/VideoFeed/types' import {useBreakpoints, useLayoutBreakpoints, useTheme} from '#/alf' import { AgeAssuranceDismissibleFeedBanner, useInternalState as useAgeAssuranceBannerState, } from '#/components/ageAssurance/AgeAssuranceDismissibleFeedBanner' import {ProgressGuide, SuggestedFollows} from '#/components/FeedInterstitials' import { PostFeedVideoGridRow, PostFeedVideoGridRowPlaceholder, } from '#/components/feeds/PostFeedVideoGridRow' import {TrendingInterstitial} from '#/components/interstitials/Trending' import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos' import {useAnalytics} from '#/analytics' import {IS_IOS, IS_NATIVE, IS_WEB} from '#/env' import {DiscoverFeedLiveEventFeedsAndTrendingBanner} from '#/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner' import { isStatusStillActive, isStatusValidForViewers, useLiveNowConfig, } from '#/features/liveNow' import {ComposerPrompt} from '../feeds/ComposerPrompt' import {DiscoverFallbackHeader} from './DiscoverFallbackHeader' import {FeedShutdownMsg} from './FeedShutdownMsg' import {PostFeedErrorMessage} from './PostFeedErrorMessage' import {PostFeedItem} from './PostFeedItem' import {PostFeedItemCarousel} from './PostFeedItemCarousel' import {ShowLessFollowup} from './ShowLessFollowup' import {ViewFullThread} from './ViewFullThread' type FeedRow = | { type: 'loading' key: string } | { type: 'empty' key: string } | { type: 'error' key: string } | { type: 'loadMoreError' key: string } | { type: 'feedShutdownMsg' key: string } | { type: 'fallbackMarker' key: string } | { type: 'sliceItem' key: string slice: FeedPostSlice indexInSlice: number showReplyTo: boolean } | { type: 'reposts' key: string items: FeedPostSlice[] } | { type: 'videoGridRowPlaceholder' key: string } | { type: 'videoGridRow' key: string items: FeedPostSliceItem[] sourceFeedUri: string feedContexts: (string | undefined)[] reqIds: (string | undefined)[] } | { type: 'sliceViewFullThread' key: string uri: string } | { type: 'interstitialFollows' key: string } | { type: 'interstitialProgressGuide' key: string } | { type: 'interstitialTrending' key: string } | { type: 'interstitialTrendingVideos' key: string } | { type: 'showLessFollowup' key: string } | { type: 'ageAssuranceBanner' key: string } | { type: 'composerPrompt' key: string } | { type: 'liveEventFeedsAndTrendingBanner' key: string } type FeedPostSliceOrGroup = | (FeedPostSlice & { isRepostSlice?: false }) | { isRepostSlice: true slices: FeedPostSlice[] } export function getItemsForFeedback(feedRow: FeedRow): { item: FeedPostSliceItem feedContext: string | undefined reqId: string | undefined }[] { if (feedRow.type === 'sliceItem') { return feedRow.slice.items.map(item => ({ item, feedContext: feedRow.slice.feedContext, reqId: feedRow.slice.reqId, })) } else if (feedRow.type === 'reposts') { return feedRow.items.map((item, i) => ({ item: item.items[0], feedContext: feedRow.items[i].feedContext, reqId: feedRow.items[i].reqId, })) } else if (feedRow.type === 'videoGridRow') { return feedRow.items.map((item, i) => ({ item, feedContext: feedRow.feedContexts[i], reqId: feedRow.reqIds[i], })) } else { return [] } } // logic from https://github.com/cheeaun/phanpy/blob/d608ee0a7594e3c83cdb087e81002f176d0d7008/src/utils/timeline-utils.js#L9 function groupReposts(values: FeedPostSlice[]) { let newValues: FeedPostSliceOrGroup[] = [] const reposts: FeedPostSlice[] = [] // serial reposts lain let serialReposts = 0 for (const row of values) { if (AppBskyFeedDefs.isReasonRepost(row.reason)) { reposts.push(row) serialReposts++ continue } newValues.push(row) if (serialReposts < 3) { serialReposts = 0 } } // TODO: handle counts for multi-item slices if ( values.length > 10 && (reposts.length > values.length / 4 || serialReposts >= 3) ) { // if boostStash is more than 3 quarter of values if (reposts.length > (values.length * 3) / 4) { // insert boost array at the end of specialHome list newValues = [...newValues, {isRepostSlice: true, slices: reposts}] } else { // insert boosts array in the middle of specialHome list const half = Math.floor(newValues.length / 2) newValues = [ ...newValues.slice(0, half), {isRepostSlice: true, slices: reposts}, ...newValues.slice(half), ] } return newValues } return values as FeedPostSliceOrGroup[] } // DISABLED need to check if this is causing random feed refreshes -prf // const REFRESH_AFTER = STALE.HOURS.ONE const CHECK_LATEST_AFTER = STALE.SECONDS.THIRTY let PostFeed = ({ feed, feedParams, ignoreFilterFor, style, enabled, pollInterval, disablePoll, scrollElRef, onScrolledDownChange, onHasNew, renderEmptyState, renderEndOfFeed, testID, headerOffset = 0, progressViewOffset, desktopFixedHeightOffset, ListHeaderComponent, extraData, savedFeedConfig, initialNumToRender: initialNumToRenderOverride, isVideoFeed = false, useRepostCarousel = false, }: { feed: FeedDescriptor feedParams?: FeedParams ignoreFilterFor?: string style?: StyleProp enabled?: boolean pollInterval?: number disablePoll?: boolean scrollElRef?: ListRef onHasNew?: (v: boolean) => void onScrolledDownChange?: (isScrolledDown: boolean) => void renderEmptyState: () => React.ReactElement renderEndOfFeed?: () => React.ReactElement testID?: string headerOffset?: number progressViewOffset?: number desktopFixedHeightOffset?: number ListHeaderComponent?: () => React.ReactElement extraData?: any savedFeedConfig?: AppBskyActorDefs.SavedFeed initialNumToRender?: number isVideoFeed?: boolean useRepostCarousel?: boolean }): React.ReactNode => { const t = useTheme() const ax = useAnalytics() const {_} = useLingui() const queryClient = useQueryClient() const {currentAccount, hasSession} = useSession() const initialNumToRender = useInitialNumToRender() const feedFeedback = useFeedFeedbackContext() const [isPTRing, setIsPTRing] = useState(false) const lastFetchRef = useRef(Date.now()) const [feedType, feedUriOrActorDid, feedTab] = feed.split('|') const {gtMobile} = useBreakpoints() const {rightNavVisible} = useLayoutBreakpoints() const areVideoFeedsEnabled = IS_NATIVE const [hasPressedShowLessUris, setHasPressedShowLessUris] = useState( () => new Set(), ) const onPressShowLess = useCallback( (interaction: AppBskyFeedDefs.Interaction) => { if (interaction.item) { const uri = interaction.item setHasPressedShowLessUris(prev => new Set([...prev, uri])) LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) } }, [], ) const feedCacheKey = feedParams?.feedCacheKey const opts = useMemo( () => ({enabled, ignoreFilterFor}), [enabled, ignoreFilterFor], ) const { data, isFetching, isFetched, isError, error, refetch, hasNextPage, isFetchingNextPage, fetchNextPage, } = usePostFeedQuery(feed, feedParams, opts) const lastFetchedAt = data?.pages[0].fetchedAt if (lastFetchedAt) { lastFetchRef.current = lastFetchedAt } const isEmpty = useMemo( () => !isFetching && !data?.pages?.some(page => page.slices.length), [isFetching, data], ) const checkForNew = useNonReactiveCallback(async () => { if (!data?.pages[0] || isFetching || !onHasNew || !enabled || disablePoll) { return } // Discover always has fresh content if (feedUriOrActorDid === DISCOVER_FEED_URI) { return onHasNew(true) } try { if (await pollLatest(data.pages[0])) { if (isEmpty) { refetch() } else { onHasNew(true) } } } catch (e) { if (!isNetworkError(e)) { logger.error('Poll latest failed', {feed, message: String(e)}) } } }) const isScrolledDownRef = useRef(false) const handleScrolledDownChange = (isScrolledDown: boolean) => { isScrolledDownRef.current = isScrolledDown onScrolledDownChange?.(isScrolledDown) } const myDid = currentAccount?.did || '' const onPostCreated = useCallback(() => { // NOTE // only invalidate if at the top of the feed // changing content when scrolled can trigger some UI freakouts on iOS and android // -sfn if ( !isScrolledDownRef.current && (feed === 'following' || feed === `author|${myDid}|posts_and_author_threads`) ) { void queryClient.invalidateQueries({queryKey: RQKEY(feed)}) } }, [queryClient, feed, myDid]) useEffect(() => { return listenPostCreated(onPostCreated) }, [onPostCreated]) useEffect(() => { if (enabled && !disablePoll) { const timeSinceFirstLoad = Date.now() - lastFetchRef.current if (isEmpty || timeSinceFirstLoad > CHECK_LATEST_AFTER) { // check for new on enable (aka on focus) checkForNew() } } }, [enabled, isEmpty, disablePoll, checkForNew]) useEffect(() => { let cleanup1: () => void | undefined, cleanup2: () => void | undefined const subscription = AppState.addEventListener('change', nextAppState => { // check for new on app foreground if (nextAppState === 'active') { checkForNew() } }) cleanup1 = () => subscription.remove() if (pollInterval) { // check for new on interval const i = setInterval(() => { checkForNew() }, pollInterval) cleanup2 = () => clearInterval(i) } return () => { cleanup1?.() cleanup2?.() } }, [pollInterval, checkForNew]) const followProgressGuide = useProgressGuide('follow-10') const followAndLikeProgressGuide = useProgressGuide('like-10-and-follow-7') const showProgressIntersitial = (followProgressGuide || followAndLikeProgressGuide) && !rightNavVisible const {trendingVideoDisabled} = useTrendingSettings() const repostCarouselEnabled = useRepostCarouselEnabled() const hideUnreplyablePosts = useHideUnreplyablePosts() const disableComposerPrompt = useDisableComposerPrompt() if (feedType === 'following') { useRepostCarousel = repostCarouselEnabled } const ageAssuranceBannerState = useAgeAssuranceBannerState() const selectedFeed = useSelectedFeed() /** * Cached value of whether the current feed was selected at startup. We don't * want this to update when user swipes. */ const [isCurrentFeedAtStartupSelected] = useState(selectedFeed === feed) const blockedOrMutedAuthors = usePostAuthorShadowFilter( // author feeds have their own handling feed.startsWith('author|') ? undefined : data?.pages, ) const feedItems: FeedRow[] = useMemo(() => { // wraps a slice item, and replaces it with a showLessFollowup item // if the user has pressed show less on it const sliceItem = (row: Extract) => { if (hasPressedShowLessUris.has(row.slice.items[row.indexInSlice]?.uri)) { return { type: 'showLessFollowup', key: row.key, } as const } else { return row } } let feedKind: 'following' | 'discover' | 'profile' | 'thevids' | undefined if (feedType === 'following') { feedKind = 'following' } else if (feedUriOrActorDid === DISCOVER_FEED_URI) { feedKind = 'discover' } else if ( feedType === 'author' && (feedTab === 'posts_and_author_threads' || feedTab === 'posts_with_replies') ) { feedKind = 'profile' } let arr: FeedRow[] = [] if (KNOWN_SHUTDOWN_FEEDS.includes(feedUriOrActorDid)) { arr.push({ type: 'feedShutdownMsg', key: 'feedShutdownMsg', }) } 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 if (isVideoFeed) { const videos: { item: FeedPostSliceItem feedContext: string | undefined reqId: string | undefined }[] = [] for (const page of data.pages) { for (const slice of page.slices) { const item = slice.items.find( item => item.uri === slice.feedPostUri, ) if ( item && AppBskyEmbedVideo.isView(item.post.embed) && !blockedOrMutedAuthors.includes(item.post.author.did) ) { videos.push({ item, feedContext: slice.feedContext, reqId: slice.reqId, }) } } } const rows: { item: FeedPostSliceItem feedContext: string | undefined reqId: string | undefined }[][] = [] for (let i = 0; i < videos.length; i++) { const video = videos[i] const item = video.item const cols = gtMobile ? 3 : 2 const rowItem = { item, feedContext: video.feedContext, reqId: video.reqId, } if (i % cols === 0) { rows.push([rowItem]) } else { rows[rows.length - 1].push(rowItem) } } for (const row of rows) { sliceIndex++ arr.push({ type: 'videoGridRow', key: row.map(r => r.item._reactKey).join('-'), items: row.map(r => r.item), sourceFeedUri: feedUriOrActorDid, feedContexts: row.map(r => r.feedContext), reqIds: row.map(r => r.reqId), }) } } else { for (const page of data?.pages) { let slices = useRepostCarousel ? groupReposts(page.slices) : (page.slices as FeedPostSliceOrGroup[]) // Filter out posts that cannot be replied to if the setting is enabled if (hideUnreplyablePosts) { slices = slices.filter(slice => { if (slice.isRepostSlice) { // For repost slices, filter the inner slices slice.slices = slice.slices.filter(innerSlice => { // Check if any item in the slice has replyDisabled return !innerSlice.items.some( item => item.post.viewer?.replyDisabled === true, ) }) return slice.slices.length > 0 } else { // For regular slices, check if any item has replyDisabled return !slice.items.some( item => item.post.viewer?.replyDisabled === true, ) } }) } for (const slice of slices) { sliceIndex++ if (hasSession) { if (feedKind === 'discover') { if (sliceIndex === 0) { if (showProgressIntersitial) { arr.push({ type: 'interstitialProgressGuide', key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, }) } else { /* * Only insert if Discover was the last selected feed at * startup, the progress guide isn't shown, and the * banner is eligible to be shown. */ if ( isCurrentFeedAtStartupSelected && ageAssuranceBannerState.visible ) { arr.push({ type: 'ageAssuranceBanner', key: 'ageAssuranceBanner-' + sliceIndex, }) } } arr.push({ type: 'liveEventFeedsAndTrendingBanner', key: 'liveEventFeedsAndTrendingBanner-' + sliceIndex, }) // Show composer prompt for Discover and Following feeds if ( hasSession && !disableComposerPrompt && (feedUriOrActorDid === DISCOVER_FEED_URI || feed === 'following') ) { arr.push({ type: 'composerPrompt', key: 'composerPrompt-' + sliceIndex, }) } } else if (sliceIndex === 15) { if (areVideoFeedsEnabled && !trendingVideoDisabled) { arr.push({ type: 'interstitialTrendingVideos', key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, }) } } else if (sliceIndex === 30) { arr.push({ type: 'interstitialFollows', key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, }) } } else if (feedKind === 'following') { if (sliceIndex === 0) { // Show composer prompt for Following feed if (hasSession && !disableComposerPrompt) { arr.push({ type: 'composerPrompt', key: 'composerPrompt-' + sliceIndex, }) } } } else if (feedKind === 'profile') { if (sliceIndex === 5) { arr.push({ type: 'interstitialFollows', key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, }) } } else { /* * Only insert if this feed was the last selected feed at * startup and the banner is eligible to be shown. */ if (sliceIndex === 0 && isCurrentFeedAtStartupSelected) { arr.push({ type: 'ageAssuranceBanner', key: 'ageAssuranceBanner-' + sliceIndex, }) } } } if (slice.isRepostSlice) { arr.push({ type: 'reposts', key: slice.slices[0]._reactKey, items: slice.slices, }) } else if (slice.isFallbackMarker) { arr.push({ type: 'fallbackMarker', key: 'sliceFallbackMarker-' + sliceIndex + '-' + lastFetchedAt, }) } else 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++) { arr.push( sliceItem({ type: 'sliceItem', key: slice.items[i]._reactKey, slice: slice, indexInSlice: i, showReplyTo: i === 0, }), ) } } } } } } if (isError && !isEmpty) { arr.push({ type: 'loadMoreError', key: 'loadMoreError', }) } } else { if (isVideoFeed) { arr.push({ type: 'videoGridRowPlaceholder', key: 'videoGridRowPlaceholder', }) } else { arr.push({ type: 'loading', key: 'loading', }) } } return arr }, [ isFetched, isError, isEmpty, lastFetchedAt, data, feed, feedType, feedUriOrActorDid, feedTab, hasSession, showProgressIntersitial, trendingVideoDisabled, gtMobile, isVideoFeed, areVideoFeedsEnabled, useRepostCarousel, hasPressedShowLessUris, ageAssuranceBannerState, isCurrentFeedAtStartupSelected, blockedOrMutedAuthors, hideUnreplyablePosts, ]) useEffect(() => { if (enabled === false) { setIsPTRing(false) } }, [enabled]) // events // = const onRefresh = useCallback(async () => { ax.metric('feed:refresh', { feedType: feedType, feedUrl: feed, reason: 'pull-to-refresh', }) setIsPTRing(true) try { await refetch() onHasNew?.(false) } catch (err) { logger.error('Failed to refresh posts feed', {message: err}) } setIsPTRing(false) }, [ax, refetch, setIsPTRing, onHasNew, feed, feedType]) const onEndReached = useCallback(async () => { if (isFetching || !hasNextPage || isError) return ax.metric('feed:endReached', { feedType: feedType, feedUrl: feed, itemCount: feedItems.length, }) try { await fetchNextPage() } catch (err) { logger.error('Failed to load more posts', {message: err}) } }, [ ax, isFetching, hasNextPage, isError, fetchNextPage, feed, feedType, feedItems.length, ]) const onPressTryAgain = useCallback(() => { refetch() onHasNew?.(false) }, [refetch, onHasNew]) const onPressRetryLoadMore = useCallback(() => { fetchNextPage() }, [fetchNextPage]) // rendering // = const renderItem = useCallback( ({item: row, index: rowIndex}: ListRenderItemInfo) => { if (row.type === 'empty') { return renderEmptyState() } else if (row.type === 'error') { return ( ) } else if (row.type === 'loadMoreError') { return ( ) } else if (row.type === 'loading') { return } else if (row.type === 'feedShutdownMsg') { return } else if (row.type === 'interstitialFollows') { return } else if (row.type === 'interstitialProgressGuide') { return } else if (row.type === 'ageAssuranceBanner') { return } else if (row.type === 'interstitialTrending') { return } else if (row.type === 'liveEventFeedsAndTrendingBanner') { return } else if (row.type === 'composerPrompt') { return } else if (row.type === 'interstitialTrendingVideos') { return } else if (row.type === 'fallbackMarker') { // HACK // tell the user we fell back to discover // see home.ts (feed api) for more info // -prf return } else if (row.type === 'sliceItem') { const slice = row.slice const indexInSlice = row.indexInSlice const item = slice.items[indexInSlice] return ( ) } else if (row.type === 'reposts') { return } else if (row.type === 'sliceViewFullThread') { return } else if (row.type === 'videoGridRowPlaceholder') { return ( ) } else if (row.type === 'videoGridRow') { let sourceContext: VideoFeedSourceContext if (feedType === 'author') { sourceContext = { type: 'author', did: feedUriOrActorDid, filter: feedTab as AuthorFilter, } } else { sourceContext = { type: 'feedgen', uri: row.sourceFeedUri, sourceInterstitial: feedCacheKey ?? 'none', } } return ( ) } else if (row.type === 'showLessFollowup') { return } else { return null } }, [ renderEmptyState, feed, error, onPressTryAgain, savedFeedConfig, _, onPressRetryLoadMore, feedType, feedUriOrActorDid, feedTab, feedCacheKey, onPressShowLess, ], ) const shouldRenderEndOfFeed = !hasNextPage && !isEmpty && !isFetching && !isError && !!renderEndOfFeed const FeedFooter = useCallback(() => { /** * A bit of padding at the bottom of the feed as you scroll and when you * reach the end, so that content isn't cut off by the bottom of the * screen. */ const offset = Math.max(headerOffset, 32) * (IS_WEB ? 1 : 2) return isFetchingNextPage ? ( ) : shouldRenderEndOfFeed ? ( {renderEndOfFeed()} ) : ( ) }, [isFetchingNextPage, shouldRenderEndOfFeed, renderEndOfFeed, headerOffset]) const liveNowConfig = useLiveNowConfig() const seenActorWithStatusRef = useRef>(new Set()) const seenPostUrisRef = useRef>(new Set()) // Helper to calculate position in feed (count only root posts, not interstitials or thread replies) const getPostPosition = useNonReactiveCallback( (type: FeedRow['type'], key: string) => { // Calculate position: find the row index in feedItems, then calculate position const rowIndex = feedItems.findIndex( row => row.type === 'sliceItem' && row.key === key, ) if (rowIndex >= 0) { let position = 0 for (let i = 0; i < rowIndex && i < feedItems.length; i++) { const row = feedItems[i] if (row.type === 'sliceItem') { // Only count root posts (indexInSlice === 0), not thread replies if (row.indexInSlice === 0) { position++ } } else if (row.type === 'videoGridRow') { // Count each video in the grid row position += row.items.length } } return position } }, ) const onItemSeen = useCallback( (item: FeedRow) => { feedFeedback.onItemSeen(item) // Track post:view events if (item.type === 'sliceItem') { const slice = item.slice const indexInSlice = item.indexInSlice const postItem = slice.items[indexInSlice] const post = postItem.post // Only track the root post of each slice (index 0) to avoid double-counting thread items if (indexInSlice === 0 && !seenPostUrisRef.current.has(post.uri)) { seenPostUrisRef.current.add(post.uri) const position = getPostPosition('sliceItem', item.key) ax.metric('post:view', { uri: post.uri, authorDid: post.author.did, logContext: 'FeedItem', feedDescriptor: feedFeedback.feedDescriptor || feed, position, }) } // Live status tracking (existing code) const actor = post.author if ( actor.status && isStatusValidForViewers(actor.status, liveNowConfig) && isStatusStillActive(actor.status.expiresAt) ) { if (!seenActorWithStatusRef.current.has(actor.did)) { seenActorWithStatusRef.current.add(actor.did) ax.metric('live:view:post', { subject: actor.did, feed, }) } } } else if (item.type === 'videoGridRow') { // Track each video in the grid row for (let i = 0; i < item.items.length; i++) { const postItem = item.items[i] const post = postItem.post if (!seenPostUrisRef.current.has(post.uri)) { seenPostUrisRef.current.add(post.uri) const position = getPostPosition('videoGridRow', item.key) ax.metric('post:view', { uri: post.uri, authorDid: post.author.did, logContext: 'FeedItem', feedDescriptor: feedFeedback.feedDescriptor || feed, position, }) } } } }, [feedFeedback, feed, liveNowConfig, getPostPosition, ax], ) return ( item.key} renderItem={renderItem} ListFooterComponent={FeedFooter} ListHeaderComponent={ListHeaderComponent} refreshing={isPTRing} onRefresh={onRefresh} headerOffset={headerOffset} progressViewOffset={progressViewOffset} contentContainerStyle={{ minHeight: Dimensions.get('window').height * 1.5, }} onScrolledDownChange={handleScrolledDownChange} onEndReached={onEndReached} onEndReachedThreshold={2} // number of posts left to trigger load more removeClippedSubviews={true} extraData={extraData} desktopFixedHeight={ desktopFixedHeightOffset ? desktopFixedHeightOffset : true } initialNumToRender={initialNumToRenderOverride ?? initialNumToRender} windowSize={9} maxToRenderPerBatch={IS_IOS ? 5 : 1} updateCellsBatchingPeriod={40} onItemSeen={onItemSeen} /> ) } PostFeed = memo(PostFeed) export {PostFeed} const styles = StyleSheet.create({ feedFooter: {paddingTop: 20}, }) export function isThreadParentAt(arr: Array, i: number) { if (arr.length === 1) { return false } return i < arr.length - 1 } export function isThreadChildAt(arr: Array, i: number) { if (arr.length === 1) { return false } return i > 0 }