mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at samuel/exp-cli 866 lines 26 kB view raw
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}