Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
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 useEffect(() => {
801 if (enabled === false) {
802 setIsPTRing(false)
803 }
804 }, [enabled])
805
806 // events
807 // =
808
809 const onRefresh = useCallback(async () => {
810 ax.metric('feed:refresh', {
811 feedType: feedType,
812 feedUrl: feed,
813 reason: 'pull-to-refresh',
814 })
815 setIsPTRing(true)
816 try {
817 await refetch()
818 onHasNew?.(false)
819 } catch (err) {
820 logger.error('Failed to refresh posts feed', {message: err})
821 }
822 setIsPTRing(false)
823 }, [ax, refetch, setIsPTRing, onHasNew, feed, feedType])
824
825 const onEndReached = useCallback(async () => {
826 if (isFetching || !hasNextPage || isError) return
827
828 ax.metric('feed:endReached', {
829 feedType: feedType,
830 feedUrl: feed,
831 itemCount: feedItems.length,
832 })
833 try {
834 await fetchNextPage()
835 } catch (err) {
836 logger.error('Failed to load more posts', {message: err})
837 }
838 }, [
839 ax,
840 isFetching,
841 hasNextPage,
842 isError,
843 fetchNextPage,
844 feed,
845 feedType,
846 feedItems.length,
847 ])
848
849 const onPressTryAgain = useCallback(() => {
850 refetch()
851 onHasNew?.(false)
852 }, [refetch, onHasNew])
853
854 const onPressRetryLoadMore = useCallback(() => {
855 fetchNextPage()
856 }, [fetchNextPage])
857
858 // rendering
859 // =
860
861 const renderItem = useCallback(
862 ({item: row, index: rowIndex}: ListRenderItemInfo<FeedRow>) => {
863 if (row.type === 'empty') {
864 return renderEmptyState()
865 } else if (row.type === 'error') {
866 return (
867 <PostFeedErrorMessage
868 feedDesc={feed}
869 error={error ?? undefined}
870 onPressTryAgain={onPressTryAgain}
871 savedFeedConfig={savedFeedConfig}
872 />
873 )
874 } else if (row.type === 'loadMoreError') {
875 return (
876 <LoadMoreRetryBtn
877 label={_(
878 msg`There was an issue fetching posts. Tap here to try again.`,
879 )}
880 onPress={onPressRetryLoadMore}
881 />
882 )
883 } else if (row.type === 'loading') {
884 return <PostFeedLoadingPlaceholder />
885 } else if (row.type === 'feedShutdownMsg') {
886 return <FeedShutdownMsg feedUri={feedUriOrActorDid} />
887 } else if (row.type === 'interstitialFollows') {
888 return <SuggestedFollows feed={feed} />
889 } else if (row.type === 'interstitialProgressGuide') {
890 return <ProgressGuide />
891 } else if (row.type === 'ageAssuranceBanner') {
892 return <AgeAssuranceDismissibleFeedBanner />
893 } else if (row.type === 'interstitialTrending') {
894 return <TrendingInterstitial />
895 } else if (row.type === 'liveEventFeedsAndTrendingBanner') {
896 return <DiscoverFeedLiveEventFeedsAndTrendingBanner />
897 } else if (row.type === 'composerPrompt') {
898 return <ComposerPrompt />
899 } else if (row.type === 'interstitialTrendingVideos') {
900 return <TrendingVideosInterstitial />
901 } else if (row.type === 'fallbackMarker') {
902 // HACK
903 // tell the user we fell back to discover
904 // see home.ts (feed api) for more info
905 // -prf
906 return <DiscoverFallbackHeader />
907 } else if (row.type === 'sliceItem') {
908 const slice = row.slice
909 const indexInSlice = row.indexInSlice
910 const item = slice.items[indexInSlice]
911 return (
912 <PostFeedItem
913 post={item.post}
914 record={item.record}
915 reason={indexInSlice === 0 ? slice.reason : undefined}
916 feedContext={slice.feedContext}
917 reqId={slice.reqId}
918 moderation={item.moderation}
919 parentAuthor={item.parentAuthor}
920 showReplyTo={row.showReplyTo}
921 isThreadParent={isThreadParentAt(slice.items, indexInSlice)}
922 isThreadChild={isThreadChildAt(slice.items, indexInSlice)}
923 isThreadLastChild={
924 isThreadChildAt(slice.items, indexInSlice) &&
925 slice.items.length === indexInSlice + 1
926 }
927 isParentBlocked={item.isParentBlocked}
928 isParentNotFound={item.isParentNotFound}
929 hideTopBorder={rowIndex === 0 && indexInSlice === 0}
930 rootPost={slice.items[0].post}
931 onShowLess={onPressShowLess}
932 />
933 )
934 } else if (row.type === 'reposts') {
935 return <PostFeedItemCarousel items={row.items} />
936 } else if (row.type === 'sliceViewFullThread') {
937 return <ViewFullThread uri={row.uri} />
938 } else if (row.type === 'videoGridRowPlaceholder') {
939 return (
940 <View>
941 <PostFeedVideoGridRowPlaceholder />
942 <PostFeedVideoGridRowPlaceholder />
943 <PostFeedVideoGridRowPlaceholder />
944 </View>
945 )
946 } else if (row.type === 'videoGridRow') {
947 let sourceContext: VideoFeedSourceContext
948 if (feedType === 'author') {
949 sourceContext = {
950 type: 'author',
951 did: feedUriOrActorDid,
952 filter: feedTab as AuthorFilter,
953 }
954 } else {
955 sourceContext = {
956 type: 'feedgen',
957 uri: row.sourceFeedUri,
958 sourceInterstitial: feedCacheKey ?? 'none',
959 }
960 }
961
962 return (
963 <PostFeedVideoGridRow
964 items={row.items}
965 sourceContext={sourceContext}
966 />
967 )
968 } else if (row.type === 'showLessFollowup') {
969 return <ShowLessFollowup />
970 } else {
971 return null
972 }
973 },
974 [
975 renderEmptyState,
976 feed,
977 error,
978 onPressTryAgain,
979 savedFeedConfig,
980 _,
981 onPressRetryLoadMore,
982 feedType,
983 feedUriOrActorDid,
984 feedTab,
985 feedCacheKey,
986 onPressShowLess,
987 ],
988 )
989
990 const shouldRenderEndOfFeed =
991 !hasNextPage && !isEmpty && !isFetching && !isError && !!renderEndOfFeed
992 const FeedFooter = useCallback(() => {
993 /**
994 * A bit of padding at the bottom of the feed as you scroll and when you
995 * reach the end, so that content isn't cut off by the bottom of the
996 * screen.
997 */
998 const offset = Math.max(headerOffset, 32) * (IS_WEB ? 1 : 2)
999
1000 return isFetchingNextPage ? (
1001 <View style={[styles.feedFooter]}>
1002 <ActivityIndicator color={t.palette.primary_500} />
1003 <View style={{height: offset}} />
1004 </View>
1005 ) : shouldRenderEndOfFeed ? (
1006 <View style={{minHeight: offset}}>{renderEndOfFeed()}</View>
1007 ) : (
1008 <View style={{height: offset}} />
1009 )
1010 }, [isFetchingNextPage, shouldRenderEndOfFeed, renderEndOfFeed, headerOffset])
1011
1012 const liveNowConfig = useLiveNowConfig()
1013
1014 const seenActorWithStatusRef = useRef<Set<string>>(new Set())
1015 const seenPostUrisRef = useRef<Set<string>>(new Set())
1016
1017 // Helper to calculate position in feed (count only root posts, not interstitials or thread replies)
1018 const getPostPosition = useNonReactiveCallback(
1019 (type: FeedRow['type'], key: string) => {
1020 // Calculate position: find the row index in feedItems, then calculate position
1021 const rowIndex = feedItems.findIndex(
1022 row => row.type === 'sliceItem' && row.key === key,
1023 )
1024
1025 if (rowIndex >= 0) {
1026 let position = 0
1027 for (let i = 0; i < rowIndex && i < feedItems.length; i++) {
1028 const row = feedItems[i]
1029 if (row.type === 'sliceItem') {
1030 // Only count root posts (indexInSlice === 0), not thread replies
1031 if (row.indexInSlice === 0) {
1032 position++
1033 }
1034 } else if (row.type === 'videoGridRow') {
1035 // Count each video in the grid row
1036 position += row.items.length
1037 }
1038 }
1039 return position
1040 }
1041 },
1042 )
1043
1044 const onItemSeen = useCallback(
1045 (item: FeedRow) => {
1046 feedFeedback.onItemSeen(item)
1047
1048 // Track post:view events
1049 if (item.type === 'sliceItem') {
1050 const slice = item.slice
1051 const indexInSlice = item.indexInSlice
1052 const postItem = slice.items[indexInSlice]
1053 const post = postItem.post
1054
1055 // Only track the root post of each slice (index 0) to avoid double-counting thread items
1056 if (indexInSlice === 0 && !seenPostUrisRef.current.has(post.uri)) {
1057 seenPostUrisRef.current.add(post.uri)
1058
1059 const position = getPostPosition('sliceItem', item.key)
1060
1061 ax.metric('post:view', {
1062 uri: post.uri,
1063 authorDid: post.author.did,
1064 logContext: 'FeedItem',
1065 feedDescriptor: feedFeedback.feedDescriptor || feed,
1066 position,
1067 })
1068 }
1069
1070 // Live status tracking (existing code)
1071 const actor = post.author
1072 if (
1073 actor.status &&
1074 isStatusValidForViewers(actor.status, liveNowConfig) &&
1075 isStatusStillActive(actor.status.expiresAt)
1076 ) {
1077 if (!seenActorWithStatusRef.current.has(actor.did)) {
1078 seenActorWithStatusRef.current.add(actor.did)
1079 ax.metric('live:view:post', {
1080 subject: actor.did,
1081 feed,
1082 })
1083 }
1084 }
1085 } else if (item.type === 'videoGridRow') {
1086 // Track each video in the grid row
1087 for (let i = 0; i < item.items.length; i++) {
1088 const postItem = item.items[i]
1089 const post = postItem.post
1090
1091 if (!seenPostUrisRef.current.has(post.uri)) {
1092 seenPostUrisRef.current.add(post.uri)
1093
1094 const position = getPostPosition('videoGridRow', item.key)
1095
1096 ax.metric('post:view', {
1097 uri: post.uri,
1098 authorDid: post.author.did,
1099 logContext: 'FeedItem',
1100 feedDescriptor: feedFeedback.feedDescriptor || feed,
1101 position,
1102 })
1103 }
1104 }
1105 }
1106 },
1107 [feedFeedback, feed, liveNowConfig, getPostPosition, ax],
1108 )
1109
1110 return (
1111 <View testID={testID} style={style}>
1112 <List
1113 testID={testID ? `${testID}-flatlist` : undefined}
1114 ref={scrollElRef}
1115 data={feedItems}
1116 keyExtractor={item => item.key}
1117 renderItem={renderItem}
1118 ListFooterComponent={FeedFooter}
1119 ListHeaderComponent={ListHeaderComponent}
1120 refreshing={isPTRing}
1121 onRefresh={onRefresh}
1122 headerOffset={headerOffset}
1123 progressViewOffset={progressViewOffset}
1124 contentContainerStyle={{
1125 minHeight: Dimensions.get('window').height * 1.5,
1126 }}
1127 onScrolledDownChange={handleScrolledDownChange}
1128 onEndReached={onEndReached}
1129 onEndReachedThreshold={2} // number of posts left to trigger load more
1130 removeClippedSubviews={true}
1131 extraData={extraData}
1132 desktopFixedHeight={
1133 desktopFixedHeightOffset ? desktopFixedHeightOffset : true
1134 }
1135 initialNumToRender={initialNumToRenderOverride ?? initialNumToRender}
1136 windowSize={9}
1137 maxToRenderPerBatch={IS_IOS ? 5 : 1}
1138 updateCellsBatchingPeriod={40}
1139 onItemSeen={onItemSeen}
1140 />
1141 </View>
1142 )
1143}
1144PostFeed = memo(PostFeed)
1145export {PostFeed}
1146
1147const styles = StyleSheet.create({
1148 feedFooter: {paddingTop: 20},
1149})
1150
1151export function isThreadParentAt<T>(arr: Array<T>, i: number) {
1152 if (arr.length === 1) {
1153 return false
1154 }
1155 return i < arr.length - 1
1156}
1157
1158export function isThreadChildAt<T>(arr: Array<T>, i: number) {
1159 if (arr.length === 1) {
1160 return false
1161 }
1162 return i > 0
1163}