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