mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React, {memo} from 'react'
2import {
3 ActivityIndicator,
4 AppState,
5 Dimensions,
6 ListRenderItemInfo,
7 StyleProp,
8 StyleSheet,
9 View,
10 ViewStyle,
11} from 'react-native'
12import {AppBskyActorDefs} from '@atproto/api'
13import {msg} from '@lingui/macro'
14import {useLingui} from '@lingui/react'
15import {useQueryClient} from '@tanstack/react-query'
16
17import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants'
18import {logEvent, useGate} from '#/lib/statsig/statsig'
19import {logger} from '#/logger'
20import {isWeb} from '#/platform/detection'
21import {listenPostCreated} from '#/state/events'
22import {useFeedFeedbackContext} from '#/state/feed-feedback'
23import {STALE} from '#/state/queries'
24import {
25 FeedDescriptor,
26 FeedParams,
27 FeedPostSlice,
28 pollLatest,
29 RQKEY,
30 usePostFeedQuery,
31} from '#/state/queries/post-feed'
32import {useSession} from '#/state/session'
33import {useAnalytics} from 'lib/analytics/analytics'
34import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
35import {useTheme} from 'lib/ThemeContext'
36import {
37 ProgressGuide,
38 SuggestedFeeds,
39 SuggestedFollows,
40} from '#/components/FeedInterstitials'
41import {List, ListRef} from '../util/List'
42import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
43import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
44import {DiscoverFallbackHeader} from './DiscoverFallbackHeader'
45import {FeedErrorMessage} from './FeedErrorMessage'
46import {FeedShutdownMsg} from './FeedShutdownMsg'
47import {FeedSlice} from './FeedSlice'
48
49type FeedItem =
50 | {
51 type: 'loading'
52 key: string
53 }
54 | {
55 type: 'empty'
56 key: string
57 }
58 | {
59 type: 'error'
60 key: string
61 }
62 | {
63 type: 'loadMoreError'
64 key: string
65 }
66 | {
67 type: 'feedShutdownMsg'
68 key: string
69 }
70 | {
71 type: 'slice'
72 key: string
73 slice: FeedPostSlice
74 }
75 | {
76 type: 'interstitialFeeds'
77 key: string
78 params: {
79 variant: 'default' | string
80 }
81 slot: number
82 }
83 | {
84 type: 'interstitialFollows'
85 key: string
86 params: {
87 variant: 'default' | string
88 }
89 slot: number
90 }
91 | {
92 type: 'interstitialProgressGuide'
93 key: string
94 params: {
95 variant: 'default' | string
96 }
97 slot: number
98 }
99
100const feedInterstitialType = 'interstitialFeeds'
101const followInterstitialType = 'interstitialFollows'
102const progressGuideInterstitialType = 'interstitialProgressGuide'
103const interstials: Record<
104 'following' | 'discover' | 'profile',
105 (FeedItem & {
106 type:
107 | 'interstitialFeeds'
108 | 'interstitialFollows'
109 | 'interstitialProgressGuide'
110 })[]
111> = {
112 following: [],
113 discover: [
114 {
115 type: progressGuideInterstitialType,
116 params: {
117 variant: 'default',
118 },
119 key: progressGuideInterstitialType,
120 slot: 0,
121 },
122 {
123 type: followInterstitialType,
124 params: {
125 variant: 'default',
126 },
127 key: followInterstitialType,
128 slot: 20,
129 },
130 ],
131 profile: [
132 {
133 type: followInterstitialType,
134 params: {
135 variant: 'default',
136 },
137 key: followInterstitialType,
138 slot: 5,
139 },
140 ],
141}
142
143export function getFeedPostSlice(feedItem: FeedItem): FeedPostSlice | null {
144 if (feedItem.type === 'slice') {
145 return feedItem.slice
146 } else {
147 return null
148 }
149}
150
151// DISABLED need to check if this is causing random feed refreshes -prf
152// const REFRESH_AFTER = STALE.HOURS.ONE
153const CHECK_LATEST_AFTER = STALE.SECONDS.THIRTY
154
155let Feed = ({
156 feed,
157 feedParams,
158 ignoreFilterFor,
159 style,
160 enabled,
161 pollInterval,
162 disablePoll,
163 scrollElRef,
164 onScrolledDownChange,
165 onHasNew,
166 renderEmptyState,
167 renderEndOfFeed,
168 testID,
169 headerOffset = 0,
170 desktopFixedHeightOffset,
171 ListHeaderComponent,
172 extraData,
173 savedFeedConfig,
174 initialNumToRender: initialNumToRenderOverride,
175}: {
176 feed: FeedDescriptor
177 feedParams?: FeedParams
178 ignoreFilterFor?: string
179 style?: StyleProp<ViewStyle>
180 enabled?: boolean
181 pollInterval?: number
182 disablePoll?: boolean
183 scrollElRef?: ListRef
184 onHasNew?: (v: boolean) => void
185 onScrolledDownChange?: (isScrolledDown: boolean) => void
186 renderEmptyState: () => JSX.Element
187 renderEndOfFeed?: () => JSX.Element
188 testID?: string
189 headerOffset?: number
190 desktopFixedHeightOffset?: number
191 ListHeaderComponent?: () => JSX.Element
192 extraData?: any
193 savedFeedConfig?: AppBskyActorDefs.SavedFeed
194 initialNumToRender?: number
195}): React.ReactNode => {
196 const theme = useTheme()
197 const {track} = useAnalytics()
198 const {_} = useLingui()
199 const queryClient = useQueryClient()
200 const {currentAccount, hasSession} = useSession()
201 const initialNumToRender = useInitialNumToRender()
202 const feedFeedback = useFeedFeedbackContext()
203 const [isPTRing, setIsPTRing] = React.useState(false)
204 const checkForNewRef = React.useRef<(() => void) | null>(null)
205 const lastFetchRef = React.useRef<number>(Date.now())
206 const [feedType, feedUri, feedTab] = feed.split('|')
207 const gate = useGate()
208
209 const opts = React.useMemo(
210 () => ({enabled, ignoreFilterFor}),
211 [enabled, ignoreFilterFor],
212 )
213 const {
214 data,
215 isFetching,
216 isFetched,
217 isError,
218 error,
219 refetch,
220 hasNextPage,
221 isFetchingNextPage,
222 fetchNextPage,
223 } = usePostFeedQuery(feed, feedParams, opts)
224 const lastFetchedAt = data?.pages[0].fetchedAt
225 if (lastFetchedAt) {
226 lastFetchRef.current = lastFetchedAt
227 }
228 const isEmpty = React.useMemo(
229 () => !isFetching && !data?.pages?.some(page => page.slices.length),
230 [isFetching, data],
231 )
232
233 const checkForNew = React.useCallback(async () => {
234 if (!data?.pages[0] || isFetching || !onHasNew || !enabled || disablePoll) {
235 return
236 }
237 try {
238 if (await pollLatest(data.pages[0])) {
239 onHasNew(true)
240 }
241 } catch (e) {
242 logger.error('Poll latest failed', {feed, message: String(e)})
243 }
244 }, [feed, data, isFetching, onHasNew, enabled, disablePoll])
245
246 const myDid = currentAccount?.did || ''
247 const onPostCreated = React.useCallback(() => {
248 // NOTE
249 // only invalidate if there's 1 page
250 // more than 1 page can trigger some UI freakouts on iOS and android
251 // -prf
252 if (
253 data?.pages.length === 1 &&
254 (feed === 'following' ||
255 feed === `author|${myDid}|posts_and_author_threads`)
256 ) {
257 queryClient.invalidateQueries({queryKey: RQKEY(feed)})
258 }
259 }, [queryClient, feed, data, myDid])
260 React.useEffect(() => {
261 return listenPostCreated(onPostCreated)
262 }, [onPostCreated])
263
264 React.useEffect(() => {
265 // we store the interval handler in a ref to avoid needless
266 // reassignments in other effects
267 checkForNewRef.current = checkForNew
268 }, [checkForNew])
269 React.useEffect(() => {
270 if (enabled) {
271 const timeSinceFirstLoad = Date.now() - lastFetchRef.current
272 // DISABLED need to check if this is causing random feed refreshes -prf
273 /*if (timeSinceFirstLoad > REFRESH_AFTER) {
274 // do a full refresh
275 scrollElRef?.current?.scrollToOffset({offset: 0, animated: false})
276 queryClient.resetQueries({queryKey: RQKEY(feed)})
277 } else*/ if (
278 timeSinceFirstLoad > CHECK_LATEST_AFTER &&
279 checkForNewRef.current
280 ) {
281 // check for new on enable (aka on focus)
282 checkForNewRef.current()
283 }
284 }
285 }, [enabled, feed, queryClient, scrollElRef])
286 React.useEffect(() => {
287 let cleanup1: () => void | undefined, cleanup2: () => void | undefined
288 const subscription = AppState.addEventListener('change', nextAppState => {
289 // check for new on app foreground
290 if (nextAppState === 'active') {
291 checkForNewRef.current?.()
292 }
293 })
294 cleanup1 = () => subscription.remove()
295 if (pollInterval) {
296 // check for new on interval
297 const i = setInterval(() => checkForNewRef.current?.(), pollInterval)
298 cleanup2 = () => clearInterval(i)
299 }
300 return () => {
301 cleanup1?.()
302 cleanup2?.()
303 }
304 }, [pollInterval])
305
306 const feedItems: FeedItem[] = React.useMemo(() => {
307 let arr: FeedItem[] = []
308 if (KNOWN_SHUTDOWN_FEEDS.includes(feedUri)) {
309 arr.push({
310 type: 'feedShutdownMsg',
311 key: 'feedShutdownMsg',
312 })
313 }
314 if (isFetched) {
315 if (isError && isEmpty) {
316 arr.push({
317 type: 'error',
318 key: 'error',
319 })
320 } else if (isEmpty) {
321 arr.push({
322 type: 'empty',
323 key: 'empty',
324 })
325 } else if (data) {
326 for (const page of data?.pages) {
327 arr = arr.concat(
328 page.slices.map(s => ({
329 type: 'slice',
330 slice: s,
331 key: s._reactKey,
332 })),
333 )
334 }
335 }
336 if (isError && !isEmpty) {
337 arr.push({
338 type: 'loadMoreError',
339 key: 'loadMoreError',
340 })
341 }
342 } else {
343 arr.push({
344 type: 'loading',
345 key: 'loading',
346 })
347 }
348
349 if (hasSession) {
350 let feedKind: 'following' | 'discover' | 'profile' | undefined
351 if (feedType === 'following') {
352 feedKind = 'following'
353 } else if (feedUri === DISCOVER_FEED_URI) {
354 feedKind = 'discover'
355 } else if (
356 feedType === 'author' &&
357 (feedTab === 'posts_and_author_threads' ||
358 feedTab === 'posts_with_replies')
359 ) {
360 feedKind = 'profile'
361 }
362
363 if (feedKind) {
364 for (const interstitial of interstials[feedKind]) {
365 const shouldShow =
366 (interstitial.type === feedInterstitialType &&
367 gate('suggested_feeds_interstitial')) ||
368 interstitial.type === followInterstitialType ||
369 interstitial.type === progressGuideInterstitialType
370
371 if (shouldShow) {
372 const variant = 'default' // replace with experiment variant
373 const int = {
374 ...interstitial,
375 params: {variant},
376 // overwrite key with unique value
377 key: [interstitial.type, variant, lastFetchedAt].join(':'),
378 }
379
380 if (arr.length > interstitial.slot) {
381 arr.splice(interstitial.slot, 0, int)
382 }
383 }
384 }
385 }
386 }
387
388 return arr
389 }, [
390 isFetched,
391 isError,
392 isEmpty,
393 lastFetchedAt,
394 data,
395 feedType,
396 feedUri,
397 feedTab,
398 gate,
399 hasSession,
400 ])
401
402 // events
403 // =
404
405 const onRefresh = React.useCallback(async () => {
406 track('Feed:onRefresh')
407 logEvent('feed:refresh:sampled', {
408 feedType: feedType,
409 feedUrl: feed,
410 reason: 'pull-to-refresh',
411 })
412 setIsPTRing(true)
413 try {
414 await refetch()
415 onHasNew?.(false)
416 } catch (err) {
417 logger.error('Failed to refresh posts feed', {message: err})
418 }
419 setIsPTRing(false)
420 }, [refetch, track, setIsPTRing, onHasNew, feed, feedType])
421
422 const onEndReached = React.useCallback(async () => {
423 if (isFetching || !hasNextPage || isError) return
424
425 logEvent('feed:endReached:sampled', {
426 feedType: feedType,
427 feedUrl: feed,
428 itemCount: feedItems.length,
429 })
430 track('Feed:onEndReached')
431 try {
432 await fetchNextPage()
433 } catch (err) {
434 logger.error('Failed to load more posts', {message: err})
435 }
436 }, [
437 isFetching,
438 hasNextPage,
439 isError,
440 fetchNextPage,
441 track,
442 feed,
443 feedType,
444 feedItems.length,
445 ])
446
447 const onPressTryAgain = React.useCallback(() => {
448 refetch()
449 onHasNew?.(false)
450 }, [refetch, onHasNew])
451
452 const onPressRetryLoadMore = React.useCallback(() => {
453 fetchNextPage()
454 }, [fetchNextPage])
455
456 // rendering
457 // =
458
459 const renderItem = React.useCallback(
460 ({item, index}: ListRenderItemInfo<FeedItem>) => {
461 if (item.type === 'empty') {
462 return renderEmptyState()
463 } else if (item.type === 'error') {
464 return (
465 <FeedErrorMessage
466 feedDesc={feed}
467 error={error ?? undefined}
468 onPressTryAgain={onPressTryAgain}
469 savedFeedConfig={savedFeedConfig}
470 />
471 )
472 } else if (item.type === 'loadMoreError') {
473 return (
474 <LoadMoreRetryBtn
475 label={_(
476 msg`There was an issue fetching posts. Tap here to try again.`,
477 )}
478 onPress={onPressRetryLoadMore}
479 />
480 )
481 } else if (item.type === 'loading') {
482 return <PostFeedLoadingPlaceholder />
483 } else if (item.type === 'feedShutdownMsg') {
484 return <FeedShutdownMsg feedUri={feedUri} />
485 } else if (item.type === feedInterstitialType) {
486 return <SuggestedFeeds />
487 } else if (item.type === followInterstitialType) {
488 return <SuggestedFollows feed={feed} />
489 } else if (item.type === progressGuideInterstitialType) {
490 return <ProgressGuide />
491 } else if (item.type === 'slice') {
492 if (item.slice.isFallbackMarker) {
493 // HACK
494 // tell the user we fell back to discover
495 // see home.ts (feed api) for more info
496 // -prf
497 return <DiscoverFallbackHeader />
498 }
499 return <FeedSlice slice={item.slice} hideTopBorder={index === 0} />
500 } else {
501 return null
502 }
503 },
504 [
505 renderEmptyState,
506 feed,
507 error,
508 onPressTryAgain,
509 savedFeedConfig,
510 _,
511 onPressRetryLoadMore,
512 feedUri,
513 ],
514 )
515
516 const shouldRenderEndOfFeed =
517 !hasNextPage && !isEmpty && !isFetching && !isError && !!renderEndOfFeed
518 const FeedFooter = React.useCallback(() => {
519 /**
520 * A bit of padding at the bottom of the feed as you scroll and when you
521 * reach the end, so that content isn't cut off by the bottom of the
522 * screen.
523 */
524 const offset = Math.max(headerOffset, 32) * (isWeb ? 1 : 2)
525
526 return isFetchingNextPage ? (
527 <View style={[styles.feedFooter]}>
528 <ActivityIndicator />
529 <View style={{height: offset}} />
530 </View>
531 ) : shouldRenderEndOfFeed ? (
532 <View style={{minHeight: offset}}>{renderEndOfFeed()}</View>
533 ) : (
534 <View style={{height: offset}} />
535 )
536 }, [isFetchingNextPage, shouldRenderEndOfFeed, renderEndOfFeed, headerOffset])
537
538 return (
539 <View testID={testID} style={style}>
540 <List
541 testID={testID ? `${testID}-flatlist` : undefined}
542 ref={scrollElRef}
543 data={feedItems}
544 keyExtractor={item => item.key}
545 renderItem={renderItem}
546 ListFooterComponent={FeedFooter}
547 ListHeaderComponent={ListHeaderComponent}
548 refreshing={isPTRing}
549 onRefresh={onRefresh}
550 headerOffset={headerOffset}
551 contentContainerStyle={{
552 minHeight: Dimensions.get('window').height * 1.5,
553 }}
554 onScrolledDownChange={onScrolledDownChange}
555 indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'}
556 onEndReached={onEndReached}
557 onEndReachedThreshold={2} // number of posts left to trigger load more
558 removeClippedSubviews={true}
559 extraData={extraData}
560 // @ts-ignore our .web version only -prf
561 desktopFixedHeight={
562 desktopFixedHeightOffset ? desktopFixedHeightOffset : true
563 }
564 initialNumToRender={initialNumToRenderOverride ?? initialNumToRender}
565 windowSize={9}
566 maxToRenderPerBatch={5}
567 updateCellsBatchingPeriod={40}
568 onItemSeen={feedFeedback.onItemSeen}
569 />
570 </View>
571 )
572}
573Feed = memo(Feed)
574export {Feed}
575
576const styles = StyleSheet.create({
577 feedFooter: {paddingTop: 20},
578})