Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

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

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