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