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