mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at rm-broken-strings 598 lines 17 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 {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}