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