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