mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at verify-code 578 lines 16 kB view raw
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})