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