this repo has no description
0
fork

Configure Feed

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

at e28f6d2f370b4e882ed6f23d08ca0f8d94dbac5f 1045 lines 32 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 {useLingui} from '@lingui/react/macro' 19import {useQueryClient} from '@tanstack/react-query' 20 21import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants' 22import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 23import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 24import {isNetworkError} from '#/lib/strings/errors' 25import {logger} from '#/logger' 26import {usePostAuthorShadowFilter} from '#/state/cache/profile-shadow' 27import {listenPostCreated} from '#/state/events' 28import {useFeedFeedbackContext} from '#/state/feed-feedback' 29import {useTrendingSettings} from '#/state/preferences/trending' 30import {STALE} from '#/state/queries' 31import { 32 type AuthorFilter, 33 type FeedDescriptor, 34 type FeedParams, 35 type FeedPostSlice, 36 type FeedPostSliceItem, 37 pollLatest, 38 RQKEY, 39 usePostFeedQuery, 40} from '#/state/queries/post-feed' 41import {useSession} from '#/state/session' 42import {useProgressGuide} from '#/state/shell/progress-guide' 43import {useSelectedFeed} from '#/state/shell/selected-feed' 44import {List, type ListRef} from '#/view/com/util/List' 45import {PostFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 46import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn' 47import {type VideoFeedSourceContext} from '#/screens/VideoFeed/types' 48import {useBreakpoints, useLayoutBreakpoints} from '#/alf' 49import { 50 AgeAssuranceDismissibleFeedBanner, 51 useInternalState as useAgeAssuranceBannerState, 52} from '#/components/ageAssurance/AgeAssuranceDismissibleFeedBanner' 53import {ProgressGuide, SuggestedFollows} from '#/components/FeedInterstitials' 54import { 55 PostFeedVideoGridRow, 56 PostFeedVideoGridRowPlaceholder, 57} from '#/components/feeds/PostFeedVideoGridRow' 58import {TrendingInterstitial} from '#/components/interstitials/Trending' 59import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos' 60import {useAnalytics} from '#/analytics' 61import {IS_IOS, IS_NATIVE, IS_WEB} from '#/env' 62import {DiscoverFeedLiveEventFeedsAndTrendingBanner} from '#/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner' 63import { 64 isStatusStillActive, 65 isStatusValidForViewers, 66 useLiveNowConfig, 67} from '#/features/liveNow' 68import {ComposerPrompt} from '../feeds/ComposerPrompt' 69import {DiscoverFallbackHeader} from './DiscoverFallbackHeader' 70import {FeedShutdownMsg} from './FeedShutdownMsg' 71import {PostFeedErrorMessage} from './PostFeedErrorMessage' 72import {PostFeedItem} from './PostFeedItem' 73import {ShowLessFollowup} from './ShowLessFollowup' 74import {ViewFullThread} from './ViewFullThread' 75 76type FeedRow = 77 | { 78 type: 'loading' 79 key: string 80 } 81 | { 82 type: 'empty' 83 key: string 84 } 85 | { 86 type: 'error' 87 key: string 88 } 89 | { 90 type: 'loadMoreError' 91 key: string 92 } 93 | { 94 type: 'feedShutdownMsg' 95 key: string 96 } 97 | { 98 type: 'fallbackMarker' 99 key: string 100 } 101 | { 102 type: 'sliceItem' 103 key: string 104 slice: FeedPostSlice 105 indexInSlice: number 106 showReplyTo: boolean 107 } 108 | { 109 type: 'videoGridRowPlaceholder' 110 key: string 111 } 112 | { 113 type: 'videoGridRow' 114 key: string 115 items: FeedPostSliceItem[] 116 sourceFeedUri: string 117 feedContexts: (string | undefined)[] 118 reqIds: (string | undefined)[] 119 } 120 | { 121 type: 'sliceViewFullThread' 122 key: string 123 uri: string 124 } 125 | { 126 type: 'interstitialFollows' 127 key: string 128 } 129 | { 130 type: 'interstitialProgressGuide' 131 key: string 132 } 133 | { 134 type: 'interstitialTrending' 135 key: string 136 } 137 | { 138 type: 'interstitialTrendingVideos' 139 key: string 140 } 141 | { 142 type: 'showLessFollowup' 143 key: string 144 } 145 | { 146 type: 'ageAssuranceBanner' 147 key: string 148 } 149 | { 150 type: 'composerPrompt' 151 key: string 152 } 153 | { 154 type: 'liveEventFeedsAndTrendingBanner' 155 key: string 156 } 157 158export function getItemsForFeedback(feedRow: FeedRow): { 159 item: FeedPostSliceItem 160 feedContext: string | undefined 161 reqId: string | undefined 162}[] { 163 if (feedRow.type === 'sliceItem') { 164 return feedRow.slice.items.map(item => ({ 165 item, 166 feedContext: feedRow.slice.feedContext, 167 reqId: feedRow.slice.reqId, 168 })) 169 } else if (feedRow.type === 'videoGridRow') { 170 return feedRow.items.map((item, i) => ({ 171 item, 172 feedContext: feedRow.feedContexts[i], 173 reqId: feedRow.reqIds[i], 174 })) 175 } else { 176 return [] 177 } 178} 179 180// DISABLED need to check if this is causing random feed refreshes -prf 181// const REFRESH_AFTER = STALE.HOURS.ONE 182const CHECK_LATEST_AFTER = STALE.SECONDS.THIRTY 183 184let PostFeed = ({ 185 feed, 186 feedParams, 187 ignoreFilterFor, 188 style, 189 enabled, 190 pollInterval, 191 disablePoll, 192 scrollElRef, 193 onScrolledDownChange, 194 onHasNew, 195 renderEmptyState, 196 renderEndOfFeed, 197 testID, 198 headerOffset = 0, 199 progressViewOffset, 200 desktopFixedHeightOffset, 201 ListHeaderComponent, 202 extraData, 203 savedFeedConfig, 204 initialNumToRender: initialNumToRenderOverride, 205 isVideoFeed = false, 206}: { 207 feed: FeedDescriptor 208 feedParams?: FeedParams 209 ignoreFilterFor?: string 210 style?: StyleProp<ViewStyle> 211 enabled?: boolean 212 pollInterval?: number 213 disablePoll?: boolean 214 scrollElRef?: ListRef 215 onHasNew?: (v: boolean) => void 216 onScrolledDownChange?: (isScrolledDown: boolean) => void 217 renderEmptyState: () => React.ReactElement 218 renderEndOfFeed?: () => React.ReactElement 219 testID?: string 220 headerOffset?: number 221 progressViewOffset?: number 222 desktopFixedHeightOffset?: number 223 ListHeaderComponent?: () => React.ReactElement 224 extraData?: any 225 savedFeedConfig?: AppBskyActorDefs.SavedFeed 226 initialNumToRender?: number 227 isVideoFeed?: boolean 228 lastFetchDate?: () => number 229}): React.ReactNode => { 230 const ax = useAnalytics() 231 const {t: l} = useLingui() 232 const queryClient = useQueryClient() 233 const {currentAccount, hasSession} = useSession() 234 const initialNumToRender = useInitialNumToRender() 235 const feedFeedback = useFeedFeedbackContext() 236 const [isPTRing, setIsPTRing] = useState(false) 237 // eslint-disable-next-line react-hooks/purity 238 const lastFetchRef = useRef<number>(Date.now()) 239 const [feedType, feedUriOrActorDid, feedTab] = feed.split('|') 240 const {gtMobile} = useBreakpoints() 241 const {rightNavVisible} = useLayoutBreakpoints() 242 const areVideoFeedsEnabled = IS_NATIVE 243 244 const [hasPressedShowLessUris, setHasPressedShowLessUris] = useState( 245 () => new Set<string>(), 246 ) 247 const onPressShowLess = useCallback( 248 (interaction: AppBskyFeedDefs.Interaction) => { 249 if (interaction.item) { 250 const uri = interaction.item 251 setHasPressedShowLessUris(prev => new Set([...prev, uri])) 252 LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 253 } 254 }, 255 [], 256 ) 257 258 const feedCacheKey = feedParams?.feedCacheKey 259 const opts = useMemo( 260 () => ({enabled, ignoreFilterFor}), 261 [enabled, ignoreFilterFor], 262 ) 263 const { 264 data, 265 isFetching, 266 isFetched, 267 isError, 268 error, 269 refetch, 270 hasNextPage, 271 isFetchingNextPage, 272 fetchNextPage, 273 } = usePostFeedQuery(feed, feedParams, opts) 274 const lastFetchedAt = data?.pages[0].fetchedAt 275 const isEmpty = useMemo( 276 () => !isFetching && !data?.pages?.some(page => page.slices.length), 277 [isFetching, data], 278 ) 279 280 useEffect(() => { 281 if (lastFetchedAt) { 282 lastFetchRef.current = lastFetchedAt 283 } 284 }, [lastFetchedAt]) 285 286 const checkForNew = useNonReactiveCallback(async () => { 287 if (!data?.pages[0] || isFetching || !onHasNew || !enabled || disablePoll) { 288 return 289 } 290 291 // Discover always has fresh content 292 if (feedUriOrActorDid === DISCOVER_FEED_URI) { 293 return onHasNew(true) 294 } 295 296 try { 297 if (await pollLatest(data.pages[0])) { 298 if (isEmpty) { 299 void refetch() 300 } else { 301 onHasNew(true) 302 } 303 } 304 } catch (e) { 305 if (!isNetworkError(e)) { 306 logger.error('Poll latest failed', {feed, message: String(e)}) 307 } 308 } 309 }) 310 311 const isScrolledDownRef = useRef(false) 312 const handleScrolledDownChange = (isScrolledDown: boolean) => { 313 isScrolledDownRef.current = isScrolledDown 314 onScrolledDownChange?.(isScrolledDown) 315 } 316 317 const myDid = currentAccount?.did || '' 318 const onPostCreated = useCallback(() => { 319 // NOTE 320 // only invalidate if at the top of the feed 321 // changing content when scrolled can trigger some UI freakouts on iOS and android 322 // -sfn 323 if ( 324 !isScrolledDownRef.current && 325 (feed === 'following' || 326 feed === `author|${myDid}|posts_and_author_threads`) 327 ) { 328 void queryClient.invalidateQueries({queryKey: RQKEY(feed)}) 329 } 330 }, [queryClient, feed, myDid]) 331 useEffect(() => { 332 return listenPostCreated(onPostCreated) 333 }, [onPostCreated]) 334 335 useEffect(() => { 336 if (enabled && !disablePoll) { 337 const timeSinceFirstLoad = Date.now() - lastFetchRef.current 338 if (isEmpty || timeSinceFirstLoad > CHECK_LATEST_AFTER) { 339 // check for new on enable (aka on focus) 340 void checkForNew() 341 } 342 } 343 }, [enabled, isEmpty, disablePoll, checkForNew]) 344 345 useEffect(() => { 346 let cleanup1: () => void | undefined, cleanup2: () => void | undefined 347 const subscription = AppState.addEventListener('change', nextAppState => { 348 // check for new on app foreground 349 if (nextAppState === 'active') { 350 void checkForNew() 351 } 352 }) 353 cleanup1 = () => subscription.remove() 354 if (pollInterval) { 355 // check for new on interval 356 const i = setInterval(() => { 357 void checkForNew() 358 }, pollInterval) 359 cleanup2 = () => clearInterval(i) 360 } 361 return () => { 362 cleanup1?.() 363 cleanup2?.() 364 } 365 }, [pollInterval, checkForNew]) 366 367 const followProgressGuide = useProgressGuide('follow-10') 368 const followAndLikeProgressGuide = useProgressGuide('like-10-and-follow-7') 369 370 const showProgressInterstitial = 371 (followProgressGuide || followAndLikeProgressGuide) && !rightNavVisible 372 373 const {trendingVideoDisabled} = useTrendingSettings() 374 375 const ageAssuranceBannerState = useAgeAssuranceBannerState() 376 const selectedFeed = useSelectedFeed() 377 /** 378 * Cached value of whether the current feed was selected at startup. We don't 379 * want this to update when user swipes. 380 */ 381 const [isCurrentFeedAtStartupSelected] = useState(selectedFeed === feed) 382 383 const blockedOrMutedAuthors = usePostAuthorShadowFilter( 384 // author feeds have their own handling 385 feed.startsWith('author|') ? undefined : data?.pages, 386 ) 387 388 const feedItems: FeedRow[] = useMemo(() => { 389 // wraps a slice item, and replaces it with a showLessFollowup item 390 // if the user has pressed show less on it 391 const sliceItem = (row: Extract<FeedRow, {type: 'sliceItem'}>) => { 392 if (hasPressedShowLessUris.has(row.slice.items[row.indexInSlice]?.uri)) { 393 return { 394 type: 'showLessFollowup', 395 key: row.key, 396 } as const 397 } else { 398 return row 399 } 400 } 401 402 let feedKind: 'following' | 'discover' | 'profile' | 'thevids' | undefined 403 if (feedType === 'following') { 404 feedKind = 'following' 405 } else if (feedUriOrActorDid === DISCOVER_FEED_URI) { 406 feedKind = 'discover' 407 } else if ( 408 feedType === 'author' && 409 (feedTab === 'posts_and_author_threads' || 410 feedTab === 'posts_with_replies') 411 ) { 412 feedKind = 'profile' 413 } 414 415 let arr: FeedRow[] = [] 416 if (KNOWN_SHUTDOWN_FEEDS.includes(feedUriOrActorDid)) { 417 arr.push({ 418 type: 'feedShutdownMsg', 419 key: 'feedShutdownMsg', 420 }) 421 } 422 if (isFetched) { 423 if (isError && isEmpty) { 424 arr.push({ 425 type: 'error', 426 key: 'error', 427 }) 428 } else if (isEmpty) { 429 arr.push({ 430 type: 'empty', 431 key: 'empty', 432 }) 433 } else if (data) { 434 let sliceIndex = -1 435 436 if (isVideoFeed) { 437 const videos: { 438 item: FeedPostSliceItem 439 feedContext: string | undefined 440 reqId: string | undefined 441 }[] = [] 442 for (const page of data.pages) { 443 for (const slice of page.slices) { 444 const item = slice.items.find( 445 item => item.uri === slice.feedPostUri, 446 ) 447 if ( 448 item && 449 AppBskyEmbedVideo.isView(item.post.embed) && 450 !blockedOrMutedAuthors.includes(item.post.author.did) 451 ) { 452 videos.push({ 453 item, 454 feedContext: slice.feedContext, 455 reqId: slice.reqId, 456 }) 457 } 458 } 459 } 460 461 const rows: { 462 item: FeedPostSliceItem 463 feedContext: string | undefined 464 reqId: string | undefined 465 }[][] = [] 466 for (let i = 0; i < videos.length; i++) { 467 const video = videos[i] 468 const item = video.item 469 const cols = gtMobile ? 3 : 2 470 const rowItem = { 471 item, 472 feedContext: video.feedContext, 473 reqId: video.reqId, 474 } 475 if (i % cols === 0) { 476 rows.push([rowItem]) 477 } else { 478 rows[rows.length - 1].push(rowItem) 479 } 480 } 481 482 for (const row of rows) { 483 sliceIndex++ 484 arr.push({ 485 type: 'videoGridRow', 486 key: row.map(r => r.item._reactKey).join('-'), 487 items: row.map(r => r.item), 488 sourceFeedUri: feedUriOrActorDid, 489 feedContexts: row.map(r => r.feedContext), 490 reqIds: row.map(r => r.reqId), 491 }) 492 } 493 } else { 494 for (const page of data?.pages) { 495 for (const slice of page.slices) { 496 sliceIndex++ 497 498 if (hasSession) { 499 if (feedKind === 'discover') { 500 if (sliceIndex === 0) { 501 if (showProgressInterstitial) { 502 arr.push({ 503 type: 'interstitialProgressGuide', 504 key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, 505 }) 506 } else { 507 /* 508 * Only insert if Discover was the last selected feed at 509 * startup, the progress guide isn't shown, and the 510 * banner is eligible to be shown. 511 */ 512 if ( 513 isCurrentFeedAtStartupSelected && 514 ageAssuranceBannerState.visible 515 ) { 516 arr.push({ 517 type: 'ageAssuranceBanner', 518 key: 'ageAssuranceBanner-' + sliceIndex, 519 }) 520 } 521 } 522 arr.push({ 523 type: 'liveEventFeedsAndTrendingBanner', 524 key: 'liveEventFeedsAndTrendingBanner-' + sliceIndex, 525 }) 526 // Show composer prompt for Discover and Following feeds 527 if ( 528 hasSession && 529 (feedUriOrActorDid === DISCOVER_FEED_URI || 530 feed === 'following') 531 ) { 532 arr.push({ 533 type: 'composerPrompt', 534 key: 'composerPrompt-' + sliceIndex, 535 }) 536 } 537 } else if (sliceIndex === 15) { 538 if (areVideoFeedsEnabled && !trendingVideoDisabled) { 539 arr.push({ 540 type: 'interstitialTrendingVideos', 541 key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, 542 }) 543 } 544 } else if (sliceIndex === 30) { 545 arr.push({ 546 type: 'interstitialFollows', 547 key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, 548 }) 549 } 550 } else if (feedKind === 'following') { 551 if (sliceIndex === 0) { 552 // Show composer prompt for Following feed 553 if (hasSession) { 554 arr.push({ 555 type: 'composerPrompt', 556 key: 'composerPrompt-' + sliceIndex, 557 }) 558 } 559 } 560 } else if (feedKind === 'profile') { 561 if (sliceIndex === 5) { 562 arr.push({ 563 type: 'interstitialFollows', 564 key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, 565 }) 566 } 567 } else { 568 /* 569 * Only insert if this feed was the last selected feed at 570 * startup and the banner is eligible to be shown. 571 */ 572 if (sliceIndex === 0 && isCurrentFeedAtStartupSelected) { 573 arr.push({ 574 type: 'ageAssuranceBanner', 575 key: 'ageAssuranceBanner-' + sliceIndex, 576 }) 577 } 578 } 579 } 580 581 if (slice.isFallbackMarker) { 582 arr.push({ 583 type: 'fallbackMarker', 584 key: 585 'sliceFallbackMarker-' + sliceIndex + '-' + lastFetchedAt, 586 }) 587 } else if ( 588 slice.items.some(item => 589 blockedOrMutedAuthors.includes(item.post.author.did), 590 ) 591 ) { 592 // skip 593 } else if (slice.isIncompleteThread && slice.items.length >= 3) { 594 const beforeLast = slice.items.length - 2 595 const last = slice.items.length - 1 596 arr.push( 597 sliceItem({ 598 type: 'sliceItem', 599 key: slice.items[0]._reactKey, 600 slice: slice, 601 indexInSlice: 0, 602 showReplyTo: false, 603 }), 604 ) 605 arr.push({ 606 type: 'sliceViewFullThread', 607 key: slice._reactKey + '-viewFullThread', 608 uri: slice.items[0].uri, 609 }) 610 arr.push( 611 sliceItem({ 612 type: 'sliceItem', 613 key: slice.items[beforeLast]._reactKey, 614 slice: slice, 615 indexInSlice: beforeLast, 616 showReplyTo: 617 slice.items[beforeLast].parentAuthor?.did !== 618 slice.items[beforeLast].post.author.did, 619 }), 620 ) 621 arr.push( 622 sliceItem({ 623 type: 'sliceItem', 624 key: slice.items[last]._reactKey, 625 slice: slice, 626 indexInSlice: last, 627 showReplyTo: false, 628 }), 629 ) 630 } else { 631 for (let i = 0; i < slice.items.length; i++) { 632 arr.push( 633 sliceItem({ 634 type: 'sliceItem', 635 key: slice.items[i]._reactKey, 636 slice: slice, 637 indexInSlice: i, 638 showReplyTo: i === 0, 639 }), 640 ) 641 } 642 } 643 } 644 } 645 } 646 } 647 if (isError && !isEmpty) { 648 arr.push({ 649 type: 'loadMoreError', 650 key: 'loadMoreError', 651 }) 652 } 653 } else { 654 if (isVideoFeed) { 655 arr.push({ 656 type: 'videoGridRowPlaceholder', 657 key: 'videoGridRowPlaceholder', 658 }) 659 } else { 660 arr.push({ 661 type: 'loading', 662 key: 'loading', 663 }) 664 } 665 } 666 667 return arr 668 }, [ 669 isFetched, 670 isError, 671 isEmpty, 672 lastFetchedAt, 673 data, 674 feed, 675 feedType, 676 feedUriOrActorDid, 677 feedTab, 678 hasSession, 679 showProgressInterstitial, 680 trendingVideoDisabled, 681 gtMobile, 682 isVideoFeed, 683 areVideoFeedsEnabled, 684 hasPressedShowLessUris, 685 ageAssuranceBannerState, 686 isCurrentFeedAtStartupSelected, 687 blockedOrMutedAuthors, 688 ]) 689 690 // events 691 // = 692 693 const onRefresh = useCallback(async () => { 694 if (!enabled) return 695 696 ax.metric('feed:refresh', { 697 feedType: feedType, 698 feedUrl: feed, 699 reason: 'pull-to-refresh', 700 }) 701 setIsPTRing(true) 702 try { 703 await refetch() 704 onHasNew?.(false) 705 } catch (err) { 706 logger.error('Failed to refresh posts feed', {message: err}) 707 } 708 setIsPTRing(false) 709 }, [ax, refetch, setIsPTRing, onHasNew, feed, feedType, enabled]) 710 711 const onEndReached = useCallback(async () => { 712 if (isFetching || !hasNextPage || isError) return 713 714 ax.metric('feed:endReached', { 715 feedType: feedType, 716 feedUrl: feed, 717 itemCount: feedItems.length, 718 }) 719 try { 720 await fetchNextPage() 721 } catch (err) { 722 logger.error('Failed to load more posts', {message: err}) 723 } 724 }, [ 725 ax, 726 isFetching, 727 hasNextPage, 728 isError, 729 fetchNextPage, 730 feed, 731 feedType, 732 feedItems.length, 733 ]) 734 735 const onPressTryAgain = useCallback(() => { 736 void refetch() 737 onHasNew?.(false) 738 }, [refetch, onHasNew]) 739 740 const onPressRetryLoadMore = useCallback(() => { 741 void fetchNextPage() 742 }, [fetchNextPage]) 743 744 // rendering 745 // = 746 747 const renderItem = useCallback( 748 ({item: row, index: rowIndex}: ListRenderItemInfo<FeedRow>) => { 749 if (row.type === 'empty') { 750 return renderEmptyState() 751 } else if (row.type === 'error') { 752 return ( 753 <PostFeedErrorMessage 754 feedDesc={feed} 755 error={error ?? undefined} 756 onPressTryAgain={onPressTryAgain} 757 savedFeedConfig={savedFeedConfig} 758 /> 759 ) 760 } else if (row.type === 'loadMoreError') { 761 return ( 762 <LoadMoreRetryBtn 763 label={l`There was an issue fetching posts. Tap here to try again.`} 764 onPress={onPressRetryLoadMore} 765 /> 766 ) 767 } else if (row.type === 'loading') { 768 return <PostFeedLoadingPlaceholder /> 769 } else if (row.type === 'feedShutdownMsg') { 770 return <FeedShutdownMsg feedUri={feedUriOrActorDid} /> 771 } else if (row.type === 'interstitialFollows') { 772 return <SuggestedFollows feed={feed} /> 773 } else if (row.type === 'interstitialProgressGuide') { 774 return <ProgressGuide /> 775 } else if (row.type === 'ageAssuranceBanner') { 776 return <AgeAssuranceDismissibleFeedBanner /> 777 } else if (row.type === 'interstitialTrending') { 778 return <TrendingInterstitial /> 779 } else if (row.type === 'liveEventFeedsAndTrendingBanner') { 780 return <DiscoverFeedLiveEventFeedsAndTrendingBanner /> 781 } else if (row.type === 'composerPrompt') { 782 return <ComposerPrompt /> 783 } else if (row.type === 'interstitialTrendingVideos') { 784 return <TrendingVideosInterstitial /> 785 } else if (row.type === 'fallbackMarker') { 786 // HACK 787 // tell the user we fell back to discover 788 // see home.ts (feed api) for more info 789 // -prf 790 return <DiscoverFallbackHeader /> 791 } else if (row.type === 'sliceItem') { 792 const slice = row.slice 793 const indexInSlice = row.indexInSlice 794 const item = slice.items[indexInSlice] 795 return ( 796 <PostFeedItem 797 post={item.post} 798 record={item.record} 799 reason={indexInSlice === 0 ? slice.reason : undefined} 800 feedContext={slice.feedContext} 801 reqId={slice.reqId} 802 moderation={item.moderation} 803 parentAuthor={item.parentAuthor} 804 showReplyTo={row.showReplyTo} 805 isThreadParent={isThreadParentAt(slice.items, indexInSlice)} 806 isThreadChild={isThreadChildAt(slice.items, indexInSlice)} 807 isThreadLastChild={ 808 isThreadChildAt(slice.items, indexInSlice) && 809 slice.items.length === indexInSlice + 1 810 } 811 isParentBlocked={item.isParentBlocked} 812 isParentNotFound={item.isParentNotFound} 813 hideTopBorder={rowIndex === 0 && indexInSlice === 0} 814 rootPost={slice.items[0].post} 815 onShowLess={onPressShowLess} 816 /> 817 ) 818 } else if (row.type === 'sliceViewFullThread') { 819 return <ViewFullThread uri={row.uri} /> 820 } else if (row.type === 'videoGridRowPlaceholder') { 821 return ( 822 <View> 823 <PostFeedVideoGridRowPlaceholder /> 824 <PostFeedVideoGridRowPlaceholder /> 825 <PostFeedVideoGridRowPlaceholder /> 826 </View> 827 ) 828 } else if (row.type === 'videoGridRow') { 829 let sourceContext: VideoFeedSourceContext 830 if (feedType === 'author') { 831 sourceContext = { 832 type: 'author', 833 did: feedUriOrActorDid, 834 filter: feedTab as AuthorFilter, 835 } 836 } else { 837 sourceContext = { 838 type: 'feedgen', 839 uri: row.sourceFeedUri, 840 sourceInterstitial: feedCacheKey ?? 'none', 841 } 842 } 843 844 return ( 845 <PostFeedVideoGridRow 846 items={row.items} 847 sourceContext={sourceContext} 848 /> 849 ) 850 } else if (row.type === 'showLessFollowup') { 851 return <ShowLessFollowup /> 852 } else { 853 return null 854 } 855 }, 856 [ 857 renderEmptyState, 858 feed, 859 error, 860 onPressTryAgain, 861 savedFeedConfig, 862 l, 863 onPressRetryLoadMore, 864 feedType, 865 feedUriOrActorDid, 866 feedTab, 867 feedCacheKey, 868 onPressShowLess, 869 ], 870 ) 871 872 const shouldRenderEndOfFeed = 873 !hasNextPage && !isEmpty && !isFetching && !isError && !!renderEndOfFeed 874 const FeedFooter = useCallback(() => { 875 /** 876 * A bit of padding at the bottom of the feed as you scroll and when you 877 * reach the end, so that content isn't cut off by the bottom of the 878 * screen. 879 */ 880 const offset = Math.max(headerOffset, 32) * (IS_WEB ? 1 : 2) 881 882 return isFetchingNextPage ? ( 883 <View style={[styles.feedFooter]}> 884 <ActivityIndicator /> 885 <View style={{height: offset}} /> 886 </View> 887 ) : shouldRenderEndOfFeed ? ( 888 <View style={{minHeight: offset}}>{renderEndOfFeed()}</View> 889 ) : ( 890 <View style={{height: offset}} /> 891 ) 892 }, [isFetchingNextPage, shouldRenderEndOfFeed, renderEndOfFeed, headerOffset]) 893 894 const liveNowConfig = useLiveNowConfig() 895 896 const seenActorWithStatusRef = useRef<Set<string>>(new Set()) 897 const seenPostUrisRef = useRef<Set<string>>(new Set()) 898 899 // Helper to calculate position in feed (count only root posts, not interstitials or thread replies) 900 const getPostPosition = useNonReactiveCallback( 901 (type: FeedRow['type'], key: string) => { 902 // Calculate position: find the row index in feedItems, then calculate position 903 const rowIndex = feedItems.findIndex( 904 row => row.type === 'sliceItem' && row.key === key, 905 ) 906 907 if (rowIndex >= 0) { 908 let position = 0 909 for (let i = 0; i < rowIndex && i < feedItems.length; i++) { 910 const row = feedItems[i] 911 if (row.type === 'sliceItem') { 912 // Only count root posts (indexInSlice === 0), not thread replies 913 if (row.indexInSlice === 0) { 914 position++ 915 } 916 } else if (row.type === 'videoGridRow') { 917 // Count each video in the grid row 918 position += row.items.length 919 } 920 } 921 return position 922 } 923 }, 924 ) 925 926 const onItemSeen = useCallback( 927 (item: FeedRow) => { 928 feedFeedback.onItemSeen(item) 929 930 // Track post:view events 931 if (item.type === 'sliceItem') { 932 const slice = item.slice 933 const indexInSlice = item.indexInSlice 934 const postItem = slice.items[indexInSlice] 935 const post = postItem.post 936 937 // Only track the root post of each slice (index 0) to avoid double-counting thread items 938 if (indexInSlice === 0 && !seenPostUrisRef.current.has(post.uri)) { 939 seenPostUrisRef.current.add(post.uri) 940 941 const position = getPostPosition('sliceItem', item.key) 942 943 ax.metric('post:view', { 944 uri: post.uri, 945 authorDid: post.author.did, 946 logContext: 'FeedItem', 947 feedDescriptor: feedFeedback.feedDescriptor || feed, 948 position, 949 }) 950 } 951 952 // Live status tracking (existing code) 953 const actor = post.author 954 if ( 955 actor.status && 956 isStatusValidForViewers(actor.status, liveNowConfig) && 957 isStatusStillActive(actor.status.expiresAt) 958 ) { 959 if (!seenActorWithStatusRef.current.has(actor.did)) { 960 seenActorWithStatusRef.current.add(actor.did) 961 ax.metric('live:view:post', { 962 subject: actor.did, 963 feed, 964 }) 965 } 966 } 967 } else if (item.type === 'videoGridRow') { 968 // Track each video in the grid row 969 for (let i = 0; i < item.items.length; i++) { 970 const postItem = item.items[i] 971 const post = postItem.post 972 973 if (!seenPostUrisRef.current.has(post.uri)) { 974 seenPostUrisRef.current.add(post.uri) 975 976 const position = getPostPosition('videoGridRow', item.key) 977 978 ax.metric('post:view', { 979 uri: post.uri, 980 authorDid: post.author.did, 981 logContext: 'FeedItem', 982 feedDescriptor: feedFeedback.feedDescriptor || feed, 983 position, 984 }) 985 } 986 } 987 } 988 }, 989 [feedFeedback, feed, liveNowConfig, getPostPosition, ax], 990 ) 991 992 return ( 993 <View testID={testID} style={style}> 994 <List 995 testID={testID ? `${testID}-flatlist` : undefined} 996 ref={scrollElRef} 997 data={feedItems} 998 keyExtractor={(item: FeedRow) => item.key} 999 renderItem={renderItem} 1000 ListFooterComponent={FeedFooter} 1001 ListHeaderComponent={ListHeaderComponent} 1002 refreshing={isPTRing} 1003 onRefresh={() => void onRefresh()} 1004 headerOffset={headerOffset} 1005 progressViewOffset={progressViewOffset} 1006 contentContainerStyle={{ 1007 minHeight: Dimensions.get('window').height * 1.5, 1008 }} 1009 onScrolledDownChange={handleScrolledDownChange} 1010 onEndReached={() => void onEndReached()} 1011 onEndReachedThreshold={2} // number of posts left to trigger load more 1012 removeClippedSubviews={true} 1013 extraData={extraData} 1014 desktopFixedHeight={ 1015 desktopFixedHeightOffset ? desktopFixedHeightOffset : true 1016 } 1017 initialNumToRender={initialNumToRenderOverride ?? initialNumToRender} 1018 windowSize={9} 1019 maxToRenderPerBatch={IS_IOS ? 5 : 1} 1020 updateCellsBatchingPeriod={40} 1021 onItemSeen={onItemSeen} 1022 /> 1023 </View> 1024 ) 1025} 1026PostFeed = memo(PostFeed) 1027export {PostFeed} 1028 1029const styles = StyleSheet.create({ 1030 feedFooter: {paddingTop: 20}, 1031}) 1032 1033export function isThreadParentAt<T>(arr: Array<T>, i: number) { 1034 if (arr.length === 1) { 1035 return false 1036 } 1037 return i < arr.length - 1 1038} 1039 1040export function isThreadChildAt<T>(arr: Array<T>, i: number) { 1041 if (arr.length === 1) { 1042 return false 1043 } 1044 return i > 0 1045}