this repo has no description
1import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'
2import {
3 ActivityIndicator,
4 AppState,
5 Dimensions,
6 LayoutAnimation,
7 type ListRenderItemInfo,
8 type StyleProp,
9 StyleSheet,
10 View,
11 type ViewStyle,
12} from 'react-native'
13import {
14 type AppBskyActorDefs,
15 AppBskyEmbedVideo,
16 type AppBskyFeedDefs,
17} from '@atproto/api'
18import {useLingui} from '@lingui/react/macro'
19import {useQueryClient} from '@tanstack/react-query'
20
21import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants'
22import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
23import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
24import {isNetworkError} from '#/lib/strings/errors'
25import {logger} from '#/logger'
26import {usePostAuthorShadowFilter} from '#/state/cache/profile-shadow'
27import {listenPostCreated} from '#/state/events'
28import {useFeedFeedbackContext} from '#/state/feed-feedback'
29import {useTrendingSettings} from '#/state/preferences/trending'
30import {STALE} from '#/state/queries'
31import {
32 type AuthorFilter,
33 type FeedDescriptor,
34 type FeedParams,
35 type FeedPostSlice,
36 type FeedPostSliceItem,
37 pollLatest,
38 RQKEY,
39 usePostFeedQuery,
40} from '#/state/queries/post-feed'
41import {useSession} from '#/state/session'
42import {useProgressGuide} from '#/state/shell/progress-guide'
43import {useSelectedFeed} from '#/state/shell/selected-feed'
44import {List, type ListRef} from '#/view/com/util/List'
45import {PostFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
46import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn'
47import {type VideoFeedSourceContext} from '#/screens/VideoFeed/types'
48import {useBreakpoints, useLayoutBreakpoints} from '#/alf'
49import {
50 AgeAssuranceDismissibleFeedBanner,
51 useInternalState as useAgeAssuranceBannerState,
52} from '#/components/ageAssurance/AgeAssuranceDismissibleFeedBanner'
53import {ProgressGuide, SuggestedFollows} from '#/components/FeedInterstitials'
54import {
55 PostFeedVideoGridRow,
56 PostFeedVideoGridRowPlaceholder,
57} from '#/components/feeds/PostFeedVideoGridRow'
58import {TrendingInterstitial} from '#/components/interstitials/Trending'
59import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos'
60import {useAnalytics} from '#/analytics'
61import {IS_IOS, IS_NATIVE, IS_WEB} from '#/env'
62import {DiscoverFeedLiveEventFeedsAndTrendingBanner} from '#/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner'
63import {
64 isStatusStillActive,
65 isStatusValidForViewers,
66 useLiveNowConfig,
67} from '#/features/liveNow'
68import {ComposerPrompt} from '../feeds/ComposerPrompt'
69import {DiscoverFallbackHeader} from './DiscoverFallbackHeader'
70import {FeedShutdownMsg} from './FeedShutdownMsg'
71import {PostFeedErrorMessage} from './PostFeedErrorMessage'
72import {PostFeedItem} from './PostFeedItem'
73import {ShowLessFollowup} from './ShowLessFollowup'
74import {ViewFullThread} from './ViewFullThread'
75
76type FeedRow =
77 | {
78 type: 'loading'
79 key: string
80 }
81 | {
82 type: 'empty'
83 key: string
84 }
85 | {
86 type: 'error'
87 key: string
88 }
89 | {
90 type: 'loadMoreError'
91 key: string
92 }
93 | {
94 type: 'feedShutdownMsg'
95 key: string
96 }
97 | {
98 type: 'fallbackMarker'
99 key: string
100 }
101 | {
102 type: 'sliceItem'
103 key: string
104 slice: FeedPostSlice
105 indexInSlice: number
106 showReplyTo: boolean
107 }
108 | {
109 type: 'videoGridRowPlaceholder'
110 key: string
111 }
112 | {
113 type: 'videoGridRow'
114 key: string
115 items: FeedPostSliceItem[]
116 sourceFeedUri: string
117 feedContexts: (string | undefined)[]
118 reqIds: (string | undefined)[]
119 }
120 | {
121 type: 'sliceViewFullThread'
122 key: string
123 uri: string
124 }
125 | {
126 type: 'interstitialFollows'
127 key: string
128 }
129 | {
130 type: 'interstitialProgressGuide'
131 key: string
132 }
133 | {
134 type: 'interstitialTrending'
135 key: string
136 }
137 | {
138 type: 'interstitialTrendingVideos'
139 key: string
140 }
141 | {
142 type: 'showLessFollowup'
143 key: string
144 }
145 | {
146 type: 'ageAssuranceBanner'
147 key: string
148 }
149 | {
150 type: 'composerPrompt'
151 key: string
152 }
153 | {
154 type: 'liveEventFeedsAndTrendingBanner'
155 key: string
156 }
157
158export function getItemsForFeedback(feedRow: FeedRow): {
159 item: FeedPostSliceItem
160 feedContext: string | undefined
161 reqId: string | undefined
162}[] {
163 if (feedRow.type === 'sliceItem') {
164 return feedRow.slice.items.map(item => ({
165 item,
166 feedContext: feedRow.slice.feedContext,
167 reqId: feedRow.slice.reqId,
168 }))
169 } else if (feedRow.type === 'videoGridRow') {
170 return feedRow.items.map((item, i) => ({
171 item,
172 feedContext: feedRow.feedContexts[i],
173 reqId: feedRow.reqIds[i],
174 }))
175 } else {
176 return []
177 }
178}
179
180// DISABLED need to check if this is causing random feed refreshes -prf
181// const REFRESH_AFTER = STALE.HOURS.ONE
182const CHECK_LATEST_AFTER = STALE.SECONDS.THIRTY
183
184let PostFeed = ({
185 feed,
186 feedParams,
187 ignoreFilterFor,
188 style,
189 enabled,
190 pollInterval,
191 disablePoll,
192 scrollElRef,
193 onScrolledDownChange,
194 onHasNew,
195 renderEmptyState,
196 renderEndOfFeed,
197 testID,
198 headerOffset = 0,
199 progressViewOffset,
200 desktopFixedHeightOffset,
201 ListHeaderComponent,
202 extraData,
203 savedFeedConfig,
204 initialNumToRender: initialNumToRenderOverride,
205 isVideoFeed = false,
206}: {
207 feed: FeedDescriptor
208 feedParams?: FeedParams
209 ignoreFilterFor?: string
210 style?: StyleProp<ViewStyle>
211 enabled?: boolean
212 pollInterval?: number
213 disablePoll?: boolean
214 scrollElRef?: ListRef
215 onHasNew?: (v: boolean) => void
216 onScrolledDownChange?: (isScrolledDown: boolean) => void
217 renderEmptyState: () => React.ReactElement
218 renderEndOfFeed?: () => React.ReactElement
219 testID?: string
220 headerOffset?: number
221 progressViewOffset?: number
222 desktopFixedHeightOffset?: number
223 ListHeaderComponent?: () => React.ReactElement
224 extraData?: any
225 savedFeedConfig?: AppBskyActorDefs.SavedFeed
226 initialNumToRender?: number
227 isVideoFeed?: boolean
228 lastFetchDate?: () => number
229}): React.ReactNode => {
230 const ax = useAnalytics()
231 const {t: l} = useLingui()
232 const queryClient = useQueryClient()
233 const {currentAccount, hasSession} = useSession()
234 const initialNumToRender = useInitialNumToRender()
235 const feedFeedback = useFeedFeedbackContext()
236 const [isPTRing, setIsPTRing] = useState(false)
237 // eslint-disable-next-line react-hooks/purity
238 const lastFetchRef = useRef<number>(Date.now())
239 const [feedType, feedUriOrActorDid, feedTab] = feed.split('|')
240 const {gtMobile} = useBreakpoints()
241 const {rightNavVisible} = useLayoutBreakpoints()
242 const areVideoFeedsEnabled = IS_NATIVE
243
244 const [hasPressedShowLessUris, setHasPressedShowLessUris] = useState(
245 () => new Set<string>(),
246 )
247 const onPressShowLess = useCallback(
248 (interaction: AppBskyFeedDefs.Interaction) => {
249 if (interaction.item) {
250 const uri = interaction.item
251 setHasPressedShowLessUris(prev => new Set([...prev, uri]))
252 LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
253 }
254 },
255 [],
256 )
257
258 const feedCacheKey = feedParams?.feedCacheKey
259 const opts = useMemo(
260 () => ({enabled, ignoreFilterFor}),
261 [enabled, ignoreFilterFor],
262 )
263 const {
264 data,
265 isFetching,
266 isFetched,
267 isError,
268 error,
269 refetch,
270 hasNextPage,
271 isFetchingNextPage,
272 fetchNextPage,
273 } = usePostFeedQuery(feed, feedParams, opts)
274 const lastFetchedAt = data?.pages[0].fetchedAt
275 const isEmpty = useMemo(
276 () => !isFetching && !data?.pages?.some(page => page.slices.length),
277 [isFetching, data],
278 )
279
280 useEffect(() => {
281 if (lastFetchedAt) {
282 lastFetchRef.current = lastFetchedAt
283 }
284 }, [lastFetchedAt])
285
286 const checkForNew = useNonReactiveCallback(async () => {
287 if (!data?.pages[0] || isFetching || !onHasNew || !enabled || disablePoll) {
288 return
289 }
290
291 // Discover always has fresh content
292 if (feedUriOrActorDid === DISCOVER_FEED_URI) {
293 return onHasNew(true)
294 }
295
296 try {
297 if (await pollLatest(data.pages[0])) {
298 if (isEmpty) {
299 void refetch()
300 } else {
301 onHasNew(true)
302 }
303 }
304 } catch (e) {
305 if (!isNetworkError(e)) {
306 logger.error('Poll latest failed', {feed, message: String(e)})
307 }
308 }
309 })
310
311 const isScrolledDownRef = useRef(false)
312 const handleScrolledDownChange = (isScrolledDown: boolean) => {
313 isScrolledDownRef.current = isScrolledDown
314 onScrolledDownChange?.(isScrolledDown)
315 }
316
317 const myDid = currentAccount?.did || ''
318 const onPostCreated = useCallback(() => {
319 // NOTE
320 // only invalidate if at the top of the feed
321 // changing content when scrolled can trigger some UI freakouts on iOS and android
322 // -sfn
323 if (
324 !isScrolledDownRef.current &&
325 (feed === 'following' ||
326 feed === `author|${myDid}|posts_and_author_threads`)
327 ) {
328 void queryClient.invalidateQueries({queryKey: RQKEY(feed)})
329 }
330 }, [queryClient, feed, myDid])
331 useEffect(() => {
332 return listenPostCreated(onPostCreated)
333 }, [onPostCreated])
334
335 useEffect(() => {
336 if (enabled && !disablePoll) {
337 const timeSinceFirstLoad = Date.now() - lastFetchRef.current
338 if (isEmpty || timeSinceFirstLoad > CHECK_LATEST_AFTER) {
339 // check for new on enable (aka on focus)
340 void checkForNew()
341 }
342 }
343 }, [enabled, isEmpty, disablePoll, checkForNew])
344
345 useEffect(() => {
346 let cleanup1: () => void | undefined, cleanup2: () => void | undefined
347 const subscription = AppState.addEventListener('change', nextAppState => {
348 // check for new on app foreground
349 if (nextAppState === 'active') {
350 void checkForNew()
351 }
352 })
353 cleanup1 = () => subscription.remove()
354 if (pollInterval) {
355 // check for new on interval
356 const i = setInterval(() => {
357 void checkForNew()
358 }, pollInterval)
359 cleanup2 = () => clearInterval(i)
360 }
361 return () => {
362 cleanup1?.()
363 cleanup2?.()
364 }
365 }, [pollInterval, checkForNew])
366
367 const followProgressGuide = useProgressGuide('follow-10')
368 const followAndLikeProgressGuide = useProgressGuide('like-10-and-follow-7')
369
370 const showProgressInterstitial =
371 (followProgressGuide || followAndLikeProgressGuide) && !rightNavVisible
372
373 const {trendingVideoDisabled} = useTrendingSettings()
374
375 const ageAssuranceBannerState = useAgeAssuranceBannerState()
376 const selectedFeed = useSelectedFeed()
377 /**
378 * Cached value of whether the current feed was selected at startup. We don't
379 * want this to update when user swipes.
380 */
381 const [isCurrentFeedAtStartupSelected] = useState(selectedFeed === feed)
382
383 const blockedOrMutedAuthors = usePostAuthorShadowFilter(
384 // author feeds have their own handling
385 feed.startsWith('author|') ? undefined : data?.pages,
386 )
387
388 const feedItems: FeedRow[] = useMemo(() => {
389 // wraps a slice item, and replaces it with a showLessFollowup item
390 // if the user has pressed show less on it
391 const sliceItem = (row: Extract<FeedRow, {type: 'sliceItem'}>) => {
392 if (hasPressedShowLessUris.has(row.slice.items[row.indexInSlice]?.uri)) {
393 return {
394 type: 'showLessFollowup',
395 key: row.key,
396 } as const
397 } else {
398 return row
399 }
400 }
401
402 let feedKind: 'following' | 'discover' | 'profile' | 'thevids' | undefined
403 if (feedType === 'following') {
404 feedKind = 'following'
405 } else if (feedUriOrActorDid === DISCOVER_FEED_URI) {
406 feedKind = 'discover'
407 } else if (
408 feedType === 'author' &&
409 (feedTab === 'posts_and_author_threads' ||
410 feedTab === 'posts_with_replies')
411 ) {
412 feedKind = 'profile'
413 }
414
415 let arr: FeedRow[] = []
416 if (KNOWN_SHUTDOWN_FEEDS.includes(feedUriOrActorDid)) {
417 arr.push({
418 type: 'feedShutdownMsg',
419 key: 'feedShutdownMsg',
420 })
421 }
422 if (isFetched) {
423 if (isError && isEmpty) {
424 arr.push({
425 type: 'error',
426 key: 'error',
427 })
428 } else if (isEmpty) {
429 arr.push({
430 type: 'empty',
431 key: 'empty',
432 })
433 } else if (data) {
434 let sliceIndex = -1
435
436 if (isVideoFeed) {
437 const videos: {
438 item: FeedPostSliceItem
439 feedContext: string | undefined
440 reqId: string | undefined
441 }[] = []
442 for (const page of data.pages) {
443 for (const slice of page.slices) {
444 const item = slice.items.find(
445 item => item.uri === slice.feedPostUri,
446 )
447 if (
448 item &&
449 AppBskyEmbedVideo.isView(item.post.embed) &&
450 !blockedOrMutedAuthors.includes(item.post.author.did)
451 ) {
452 videos.push({
453 item,
454 feedContext: slice.feedContext,
455 reqId: slice.reqId,
456 })
457 }
458 }
459 }
460
461 const rows: {
462 item: FeedPostSliceItem
463 feedContext: string | undefined
464 reqId: string | undefined
465 }[][] = []
466 for (let i = 0; i < videos.length; i++) {
467 const video = videos[i]
468 const item = video.item
469 const cols = gtMobile ? 3 : 2
470 const rowItem = {
471 item,
472 feedContext: video.feedContext,
473 reqId: video.reqId,
474 }
475 if (i % cols === 0) {
476 rows.push([rowItem])
477 } else {
478 rows[rows.length - 1].push(rowItem)
479 }
480 }
481
482 for (const row of rows) {
483 sliceIndex++
484 arr.push({
485 type: 'videoGridRow',
486 key: row.map(r => r.item._reactKey).join('-'),
487 items: row.map(r => r.item),
488 sourceFeedUri: feedUriOrActorDid,
489 feedContexts: row.map(r => r.feedContext),
490 reqIds: row.map(r => r.reqId),
491 })
492 }
493 } else {
494 for (const page of data?.pages) {
495 for (const slice of page.slices) {
496 sliceIndex++
497
498 if (hasSession) {
499 if (feedKind === 'discover') {
500 if (sliceIndex === 0) {
501 if (showProgressInterstitial) {
502 arr.push({
503 type: 'interstitialProgressGuide',
504 key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
505 })
506 } else {
507 /*
508 * Only insert if Discover was the last selected feed at
509 * startup, the progress guide isn't shown, and the
510 * banner is eligible to be shown.
511 */
512 if (
513 isCurrentFeedAtStartupSelected &&
514 ageAssuranceBannerState.visible
515 ) {
516 arr.push({
517 type: 'ageAssuranceBanner',
518 key: 'ageAssuranceBanner-' + sliceIndex,
519 })
520 }
521 }
522 arr.push({
523 type: 'liveEventFeedsAndTrendingBanner',
524 key: 'liveEventFeedsAndTrendingBanner-' + sliceIndex,
525 })
526 // Show composer prompt for Discover and Following feeds
527 if (
528 hasSession &&
529 (feedUriOrActorDid === DISCOVER_FEED_URI ||
530 feed === 'following')
531 ) {
532 arr.push({
533 type: 'composerPrompt',
534 key: 'composerPrompt-' + sliceIndex,
535 })
536 }
537 } else if (sliceIndex === 15) {
538 if (areVideoFeedsEnabled && !trendingVideoDisabled) {
539 arr.push({
540 type: 'interstitialTrendingVideos',
541 key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
542 })
543 }
544 } else if (sliceIndex === 30) {
545 arr.push({
546 type: 'interstitialFollows',
547 key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
548 })
549 }
550 } else if (feedKind === 'following') {
551 if (sliceIndex === 0) {
552 // Show composer prompt for Following feed
553 if (hasSession) {
554 arr.push({
555 type: 'composerPrompt',
556 key: 'composerPrompt-' + sliceIndex,
557 })
558 }
559 }
560 } else if (feedKind === 'profile') {
561 if (sliceIndex === 5) {
562 arr.push({
563 type: 'interstitialFollows',
564 key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
565 })
566 }
567 } else {
568 /*
569 * Only insert if this feed was the last selected feed at
570 * startup and the banner is eligible to be shown.
571 */
572 if (sliceIndex === 0 && isCurrentFeedAtStartupSelected) {
573 arr.push({
574 type: 'ageAssuranceBanner',
575 key: 'ageAssuranceBanner-' + sliceIndex,
576 })
577 }
578 }
579 }
580
581 if (slice.isFallbackMarker) {
582 arr.push({
583 type: 'fallbackMarker',
584 key:
585 'sliceFallbackMarker-' + sliceIndex + '-' + lastFetchedAt,
586 })
587 } else if (
588 slice.items.some(item =>
589 blockedOrMutedAuthors.includes(item.post.author.did),
590 )
591 ) {
592 // skip
593 } else if (slice.isIncompleteThread && slice.items.length >= 3) {
594 const beforeLast = slice.items.length - 2
595 const last = slice.items.length - 1
596 arr.push(
597 sliceItem({
598 type: 'sliceItem',
599 key: slice.items[0]._reactKey,
600 slice: slice,
601 indexInSlice: 0,
602 showReplyTo: false,
603 }),
604 )
605 arr.push({
606 type: 'sliceViewFullThread',
607 key: slice._reactKey + '-viewFullThread',
608 uri: slice.items[0].uri,
609 })
610 arr.push(
611 sliceItem({
612 type: 'sliceItem',
613 key: slice.items[beforeLast]._reactKey,
614 slice: slice,
615 indexInSlice: beforeLast,
616 showReplyTo:
617 slice.items[beforeLast].parentAuthor?.did !==
618 slice.items[beforeLast].post.author.did,
619 }),
620 )
621 arr.push(
622 sliceItem({
623 type: 'sliceItem',
624 key: slice.items[last]._reactKey,
625 slice: slice,
626 indexInSlice: last,
627 showReplyTo: false,
628 }),
629 )
630 } else {
631 for (let i = 0; i < slice.items.length; i++) {
632 arr.push(
633 sliceItem({
634 type: 'sliceItem',
635 key: slice.items[i]._reactKey,
636 slice: slice,
637 indexInSlice: i,
638 showReplyTo: i === 0,
639 }),
640 )
641 }
642 }
643 }
644 }
645 }
646 }
647 if (isError && !isEmpty) {
648 arr.push({
649 type: 'loadMoreError',
650 key: 'loadMoreError',
651 })
652 }
653 } else {
654 if (isVideoFeed) {
655 arr.push({
656 type: 'videoGridRowPlaceholder',
657 key: 'videoGridRowPlaceholder',
658 })
659 } else {
660 arr.push({
661 type: 'loading',
662 key: 'loading',
663 })
664 }
665 }
666
667 return arr
668 }, [
669 isFetched,
670 isError,
671 isEmpty,
672 lastFetchedAt,
673 data,
674 feed,
675 feedType,
676 feedUriOrActorDid,
677 feedTab,
678 hasSession,
679 showProgressInterstitial,
680 trendingVideoDisabled,
681 gtMobile,
682 isVideoFeed,
683 areVideoFeedsEnabled,
684 hasPressedShowLessUris,
685 ageAssuranceBannerState,
686 isCurrentFeedAtStartupSelected,
687 blockedOrMutedAuthors,
688 ])
689
690 // events
691 // =
692
693 const onRefresh = useCallback(async () => {
694 if (!enabled) return
695
696 ax.metric('feed:refresh', {
697 feedType: feedType,
698 feedUrl: feed,
699 reason: 'pull-to-refresh',
700 })
701 setIsPTRing(true)
702 try {
703 await refetch()
704 onHasNew?.(false)
705 } catch (err) {
706 logger.error('Failed to refresh posts feed', {message: err})
707 }
708 setIsPTRing(false)
709 }, [ax, refetch, setIsPTRing, onHasNew, feed, feedType, enabled])
710
711 const onEndReached = useCallback(async () => {
712 if (isFetching || !hasNextPage || isError) return
713
714 ax.metric('feed:endReached', {
715 feedType: feedType,
716 feedUrl: feed,
717 itemCount: feedItems.length,
718 })
719 try {
720 await fetchNextPage()
721 } catch (err) {
722 logger.error('Failed to load more posts', {message: err})
723 }
724 }, [
725 ax,
726 isFetching,
727 hasNextPage,
728 isError,
729 fetchNextPage,
730 feed,
731 feedType,
732 feedItems.length,
733 ])
734
735 const onPressTryAgain = useCallback(() => {
736 void refetch()
737 onHasNew?.(false)
738 }, [refetch, onHasNew])
739
740 const onPressRetryLoadMore = useCallback(() => {
741 void fetchNextPage()
742 }, [fetchNextPage])
743
744 // rendering
745 // =
746
747 const renderItem = useCallback(
748 ({item: row, index: rowIndex}: ListRenderItemInfo<FeedRow>) => {
749 if (row.type === 'empty') {
750 return renderEmptyState()
751 } else if (row.type === 'error') {
752 return (
753 <PostFeedErrorMessage
754 feedDesc={feed}
755 error={error ?? undefined}
756 onPressTryAgain={onPressTryAgain}
757 savedFeedConfig={savedFeedConfig}
758 />
759 )
760 } else if (row.type === 'loadMoreError') {
761 return (
762 <LoadMoreRetryBtn
763 label={l`There was an issue fetching posts. Tap here to try again.`}
764 onPress={onPressRetryLoadMore}
765 />
766 )
767 } else if (row.type === 'loading') {
768 return <PostFeedLoadingPlaceholder />
769 } else if (row.type === 'feedShutdownMsg') {
770 return <FeedShutdownMsg feedUri={feedUriOrActorDid} />
771 } else if (row.type === 'interstitialFollows') {
772 return <SuggestedFollows feed={feed} />
773 } else if (row.type === 'interstitialProgressGuide') {
774 return <ProgressGuide />
775 } else if (row.type === 'ageAssuranceBanner') {
776 return <AgeAssuranceDismissibleFeedBanner />
777 } else if (row.type === 'interstitialTrending') {
778 return <TrendingInterstitial />
779 } else if (row.type === 'liveEventFeedsAndTrendingBanner') {
780 return <DiscoverFeedLiveEventFeedsAndTrendingBanner />
781 } else if (row.type === 'composerPrompt') {
782 return <ComposerPrompt />
783 } else if (row.type === 'interstitialTrendingVideos') {
784 return <TrendingVideosInterstitial />
785 } else if (row.type === 'fallbackMarker') {
786 // HACK
787 // tell the user we fell back to discover
788 // see home.ts (feed api) for more info
789 // -prf
790 return <DiscoverFallbackHeader />
791 } else if (row.type === 'sliceItem') {
792 const slice = row.slice
793 const indexInSlice = row.indexInSlice
794 const item = slice.items[indexInSlice]
795 return (
796 <PostFeedItem
797 post={item.post}
798 record={item.record}
799 reason={indexInSlice === 0 ? slice.reason : undefined}
800 feedContext={slice.feedContext}
801 reqId={slice.reqId}
802 moderation={item.moderation}
803 parentAuthor={item.parentAuthor}
804 showReplyTo={row.showReplyTo}
805 isThreadParent={isThreadParentAt(slice.items, indexInSlice)}
806 isThreadChild={isThreadChildAt(slice.items, indexInSlice)}
807 isThreadLastChild={
808 isThreadChildAt(slice.items, indexInSlice) &&
809 slice.items.length === indexInSlice + 1
810 }
811 isParentBlocked={item.isParentBlocked}
812 isParentNotFound={item.isParentNotFound}
813 hideTopBorder={rowIndex === 0 && indexInSlice === 0}
814 rootPost={slice.items[0].post}
815 onShowLess={onPressShowLess}
816 />
817 )
818 } else if (row.type === 'sliceViewFullThread') {
819 return <ViewFullThread uri={row.uri} />
820 } else if (row.type === 'videoGridRowPlaceholder') {
821 return (
822 <View>
823 <PostFeedVideoGridRowPlaceholder />
824 <PostFeedVideoGridRowPlaceholder />
825 <PostFeedVideoGridRowPlaceholder />
826 </View>
827 )
828 } else if (row.type === 'videoGridRow') {
829 let sourceContext: VideoFeedSourceContext
830 if (feedType === 'author') {
831 sourceContext = {
832 type: 'author',
833 did: feedUriOrActorDid,
834 filter: feedTab as AuthorFilter,
835 }
836 } else {
837 sourceContext = {
838 type: 'feedgen',
839 uri: row.sourceFeedUri,
840 sourceInterstitial: feedCacheKey ?? 'none',
841 }
842 }
843
844 return (
845 <PostFeedVideoGridRow
846 items={row.items}
847 sourceContext={sourceContext}
848 />
849 )
850 } else if (row.type === 'showLessFollowup') {
851 return <ShowLessFollowup />
852 } else {
853 return null
854 }
855 },
856 [
857 renderEmptyState,
858 feed,
859 error,
860 onPressTryAgain,
861 savedFeedConfig,
862 l,
863 onPressRetryLoadMore,
864 feedType,
865 feedUriOrActorDid,
866 feedTab,
867 feedCacheKey,
868 onPressShowLess,
869 ],
870 )
871
872 const shouldRenderEndOfFeed =
873 !hasNextPage && !isEmpty && !isFetching && !isError && !!renderEndOfFeed
874 const FeedFooter = useCallback(() => {
875 /**
876 * A bit of padding at the bottom of the feed as you scroll and when you
877 * reach the end, so that content isn't cut off by the bottom of the
878 * screen.
879 */
880 const offset = Math.max(headerOffset, 32) * (IS_WEB ? 1 : 2)
881
882 return isFetchingNextPage ? (
883 <View style={[styles.feedFooter]}>
884 <ActivityIndicator />
885 <View style={{height: offset}} />
886 </View>
887 ) : shouldRenderEndOfFeed ? (
888 <View style={{minHeight: offset}}>{renderEndOfFeed()}</View>
889 ) : (
890 <View style={{height: offset}} />
891 )
892 }, [isFetchingNextPage, shouldRenderEndOfFeed, renderEndOfFeed, headerOffset])
893
894 const liveNowConfig = useLiveNowConfig()
895
896 const seenActorWithStatusRef = useRef<Set<string>>(new Set())
897 const seenPostUrisRef = useRef<Set<string>>(new Set())
898
899 // Helper to calculate position in feed (count only root posts, not interstitials or thread replies)
900 const getPostPosition = useNonReactiveCallback(
901 (type: FeedRow['type'], key: string) => {
902 // Calculate position: find the row index in feedItems, then calculate position
903 const rowIndex = feedItems.findIndex(
904 row => row.type === 'sliceItem' && row.key === key,
905 )
906
907 if (rowIndex >= 0) {
908 let position = 0
909 for (let i = 0; i < rowIndex && i < feedItems.length; i++) {
910 const row = feedItems[i]
911 if (row.type === 'sliceItem') {
912 // Only count root posts (indexInSlice === 0), not thread replies
913 if (row.indexInSlice === 0) {
914 position++
915 }
916 } else if (row.type === 'videoGridRow') {
917 // Count each video in the grid row
918 position += row.items.length
919 }
920 }
921 return position
922 }
923 },
924 )
925
926 const onItemSeen = useCallback(
927 (item: FeedRow) => {
928 feedFeedback.onItemSeen(item)
929
930 // Track post:view events
931 if (item.type === 'sliceItem') {
932 const slice = item.slice
933 const indexInSlice = item.indexInSlice
934 const postItem = slice.items[indexInSlice]
935 const post = postItem.post
936
937 // Only track the root post of each slice (index 0) to avoid double-counting thread items
938 if (indexInSlice === 0 && !seenPostUrisRef.current.has(post.uri)) {
939 seenPostUrisRef.current.add(post.uri)
940
941 const position = getPostPosition('sliceItem', item.key)
942
943 ax.metric('post:view', {
944 uri: post.uri,
945 authorDid: post.author.did,
946 logContext: 'FeedItem',
947 feedDescriptor: feedFeedback.feedDescriptor || feed,
948 position,
949 })
950 }
951
952 // Live status tracking (existing code)
953 const actor = post.author
954 if (
955 actor.status &&
956 isStatusValidForViewers(actor.status, liveNowConfig) &&
957 isStatusStillActive(actor.status.expiresAt)
958 ) {
959 if (!seenActorWithStatusRef.current.has(actor.did)) {
960 seenActorWithStatusRef.current.add(actor.did)
961 ax.metric('live:view:post', {
962 subject: actor.did,
963 feed,
964 })
965 }
966 }
967 } else if (item.type === 'videoGridRow') {
968 // Track each video in the grid row
969 for (let i = 0; i < item.items.length; i++) {
970 const postItem = item.items[i]
971 const post = postItem.post
972
973 if (!seenPostUrisRef.current.has(post.uri)) {
974 seenPostUrisRef.current.add(post.uri)
975
976 const position = getPostPosition('videoGridRow', item.key)
977
978 ax.metric('post:view', {
979 uri: post.uri,
980 authorDid: post.author.did,
981 logContext: 'FeedItem',
982 feedDescriptor: feedFeedback.feedDescriptor || feed,
983 position,
984 })
985 }
986 }
987 }
988 },
989 [feedFeedback, feed, liveNowConfig, getPostPosition, ax],
990 )
991
992 return (
993 <View testID={testID} style={style}>
994 <List
995 testID={testID ? `${testID}-flatlist` : undefined}
996 ref={scrollElRef}
997 data={feedItems}
998 keyExtractor={(item: FeedRow) => item.key}
999 renderItem={renderItem}
1000 ListFooterComponent={FeedFooter}
1001 ListHeaderComponent={ListHeaderComponent}
1002 refreshing={isPTRing}
1003 onRefresh={() => void onRefresh()}
1004 headerOffset={headerOffset}
1005 progressViewOffset={progressViewOffset}
1006 contentContainerStyle={{
1007 minHeight: Dimensions.get('window').height * 1.5,
1008 }}
1009 onScrolledDownChange={handleScrolledDownChange}
1010 onEndReached={() => void onEndReached()}
1011 onEndReachedThreshold={2} // number of posts left to trigger load more
1012 removeClippedSubviews={true}
1013 extraData={extraData}
1014 desktopFixedHeight={
1015 desktopFixedHeightOffset ? desktopFixedHeightOffset : true
1016 }
1017 initialNumToRender={initialNumToRenderOverride ?? initialNumToRender}
1018 windowSize={9}
1019 maxToRenderPerBatch={IS_IOS ? 5 : 1}
1020 updateCellsBatchingPeriod={40}
1021 onItemSeen={onItemSeen}
1022 />
1023 </View>
1024 )
1025}
1026PostFeed = memo(PostFeed)
1027export {PostFeed}
1028
1029const styles = StyleSheet.create({
1030 feedFooter: {paddingTop: 20},
1031})
1032
1033export function isThreadParentAt<T>(arr: Array<T>, i: number) {
1034 if (arr.length === 1) {
1035 return false
1036 }
1037 return i < arr.length - 1
1038}
1039
1040export function isThreadChildAt<T>(arr: Array<T>, i: number) {
1041 if (arr.length === 1) {
1042 return false
1043 }
1044 return i > 0
1045}