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

Configure Feed

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

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