Bluesky app fork with some witchin' additions 馃挮
at main 1157 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 {msg} from '@lingui/core/macro' 19import {useLingui} from '@lingui/react' 20import {useQueryClient} from '@tanstack/react-query' 21 22import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants' 23import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 24import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 25import {isNetworkError} from '#/lib/strings/errors' 26import {logger} from '#/logger' 27import {usePostAuthorShadowFilter} from '#/state/cache/profile-shadow' 28import {listenPostCreated} from '#/state/events' 29import {useFeedFeedbackContext} from '#/state/feed-feedback' 30import {useDisableComposerPrompt} from '#/state/preferences/disable-composer-prompt' 31import {useHideUnreplyablePosts} from '#/state/preferences/hide-unreplyable-posts' 32import {useRepostCarouselEnabled} from '#/state/preferences/repost-carousel-enabled' 33import {useTrendingSettings} from '#/state/preferences/trending' 34import {STALE} from '#/state/queries' 35import { 36 type AuthorFilter, 37 type FeedDescriptor, 38 type FeedParams, 39 type FeedPostSlice, 40 type FeedPostSliceItem, 41 pollLatest, 42 RQKEY, 43 usePostFeedQuery, 44} from '#/state/queries/post-feed' 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 useRepostCarousel?: boolean 301}): React.ReactNode => { 302 const t = useTheme() 303 const ax = useAnalytics() 304 const {_} = useLingui() 305 const queryClient = useQueryClient() 306 const {currentAccount, hasSession} = useSession() 307 const initialNumToRender = useInitialNumToRender() 308 const feedFeedback = useFeedFeedbackContext() 309 const [isPTRing, setIsPTRing] = useState(false) 310 const lastFetchRef = useRef<number>(Date.now()) 311 const [feedType, feedUriOrActorDid, feedTab] = feed.split('|') 312 const {gtMobile} = useBreakpoints() 313 const {rightNavVisible} = useLayoutBreakpoints() 314 const areVideoFeedsEnabled = IS_NATIVE 315 316 const [hasPressedShowLessUris, setHasPressedShowLessUris] = useState( 317 () => new Set<string>(), 318 ) 319 const onPressShowLess = useCallback( 320 (interaction: AppBskyFeedDefs.Interaction) => { 321 if (interaction.item) { 322 const uri = interaction.item 323 setHasPressedShowLessUris(prev => new Set([...prev, uri])) 324 LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 325 } 326 }, 327 [], 328 ) 329 330 const feedCacheKey = feedParams?.feedCacheKey 331 const opts = useMemo( 332 () => ({enabled, ignoreFilterFor}), 333 [enabled, ignoreFilterFor], 334 ) 335 const { 336 data, 337 isFetching, 338 isFetched, 339 isError, 340 error, 341 refetch, 342 hasNextPage, 343 isFetchingNextPage, 344 fetchNextPage, 345 } = usePostFeedQuery(feed, feedParams, opts) 346 const lastFetchedAt = data?.pages[0].fetchedAt 347 if (lastFetchedAt) { 348 lastFetchRef.current = lastFetchedAt 349 } 350 const isEmpty = useMemo( 351 () => !isFetching && !data?.pages?.some(page => page.slices.length), 352 [isFetching, data], 353 ) 354 355 const checkForNew = useNonReactiveCallback(async () => { 356 if (!data?.pages[0] || isFetching || !onHasNew || !enabled || disablePoll) { 357 return 358 } 359 360 // Discover always has fresh content 361 if (feedUriOrActorDid === DISCOVER_FEED_URI) { 362 return onHasNew(true) 363 } 364 365 try { 366 if (await pollLatest(data.pages[0])) { 367 if (isEmpty) { 368 refetch() 369 } else { 370 onHasNew(true) 371 } 372 } 373 } catch (e) { 374 if (!isNetworkError(e)) { 375 logger.error('Poll latest failed', {feed, message: String(e)}) 376 } 377 } 378 }) 379 380 const isScrolledDownRef = useRef(false) 381 const handleScrolledDownChange = (isScrolledDown: boolean) => { 382 isScrolledDownRef.current = isScrolledDown 383 onScrolledDownChange?.(isScrolledDown) 384 } 385 386 const myDid = currentAccount?.did || '' 387 const onPostCreated = useCallback(() => { 388 // NOTE 389 // only invalidate if at the top of the feed 390 // changing content when scrolled can trigger some UI freakouts on iOS and android 391 // -sfn 392 if ( 393 !isScrolledDownRef.current && 394 (feed === 'following' || 395 feed === `author|${myDid}|posts_and_author_threads`) 396 ) { 397 void queryClient.invalidateQueries({queryKey: RQKEY(feed)}) 398 } 399 }, [queryClient, feed, myDid]) 400 useEffect(() => { 401 return listenPostCreated(onPostCreated) 402 }, [onPostCreated]) 403 404 useEffect(() => { 405 if (enabled && !disablePoll) { 406 const timeSinceFirstLoad = Date.now() - lastFetchRef.current 407 if (isEmpty || timeSinceFirstLoad > CHECK_LATEST_AFTER) { 408 // check for new on enable (aka on focus) 409 checkForNew() 410 } 411 } 412 }, [enabled, isEmpty, disablePoll, checkForNew]) 413 414 useEffect(() => { 415 let cleanup1: () => void | undefined, cleanup2: () => void | undefined 416 const subscription = AppState.addEventListener('change', nextAppState => { 417 // check for new on app foreground 418 if (nextAppState === 'active') { 419 checkForNew() 420 } 421 }) 422 cleanup1 = () => subscription.remove() 423 if (pollInterval) { 424 // check for new on interval 425 const i = setInterval(() => { 426 checkForNew() 427 }, pollInterval) 428 cleanup2 = () => clearInterval(i) 429 } 430 return () => { 431 cleanup1?.() 432 cleanup2?.() 433 } 434 }, [pollInterval, checkForNew]) 435 436 const followProgressGuide = useProgressGuide('follow-10') 437 const followAndLikeProgressGuide = useProgressGuide('like-10-and-follow-7') 438 439 const showProgressIntersitial = 440 (followProgressGuide || followAndLikeProgressGuide) && !rightNavVisible 441 442 const {trendingVideoDisabled} = useTrendingSettings() 443 444 const repostCarouselEnabled = useRepostCarouselEnabled() 445 const hideUnreplyablePosts = useHideUnreplyablePosts() 446 const disableComposerPrompt = useDisableComposerPrompt() 447 448 if (feedType === 'following') { 449 useRepostCarousel = repostCarouselEnabled 450 } 451 const ageAssuranceBannerState = useAgeAssuranceBannerState() 452 const selectedFeed = useSelectedFeed() 453 /** 454 * Cached value of whether the current feed was selected at startup. We don't 455 * want this to update when user swipes. 456 */ 457 const [isCurrentFeedAtStartupSelected] = useState(selectedFeed === feed) 458 459 const blockedOrMutedAuthors = usePostAuthorShadowFilter( 460 // author feeds have their own handling 461 feed.startsWith('author|') ? undefined : data?.pages, 462 ) 463 464 const feedItems: FeedRow[] = useMemo(() => { 465 // wraps a slice item, and replaces it with a showLessFollowup item 466 // if the user has pressed show less on it 467 const sliceItem = (row: Extract<FeedRow, {type: 'sliceItem'}>) => { 468 if (hasPressedShowLessUris.has(row.slice.items[row.indexInSlice]?.uri)) { 469 return { 470 type: 'showLessFollowup', 471 key: row.key, 472 } as const 473 } else { 474 return row 475 } 476 } 477 478 let feedKind: 'following' | 'discover' | 'profile' | 'thevids' | undefined 479 if (feedType === 'following') { 480 feedKind = 'following' 481 } else if (feedUriOrActorDid === DISCOVER_FEED_URI) { 482 feedKind = 'discover' 483 } else if ( 484 feedType === 'author' && 485 (feedTab === 'posts_and_author_threads' || 486 feedTab === 'posts_with_replies') 487 ) { 488 feedKind = 'profile' 489 } 490 491 let arr: FeedRow[] = [] 492 if (KNOWN_SHUTDOWN_FEEDS.includes(feedUriOrActorDid)) { 493 arr.push({ 494 type: 'feedShutdownMsg', 495 key: 'feedShutdownMsg', 496 }) 497 } 498 if (isFetched) { 499 if (isError && isEmpty) { 500 arr.push({ 501 type: 'error', 502 key: 'error', 503 }) 504 } else if (isEmpty) { 505 arr.push({ 506 type: 'empty', 507 key: 'empty', 508 }) 509 } else if (data) { 510 let sliceIndex = -1 511 512 if (isVideoFeed) { 513 const videos: { 514 item: FeedPostSliceItem 515 feedContext: string | undefined 516 reqId: string | undefined 517 }[] = [] 518 for (const page of data.pages) { 519 for (const slice of page.slices) { 520 const item = slice.items.find( 521 item => item.uri === slice.feedPostUri, 522 ) 523 if ( 524 item && 525 AppBskyEmbedVideo.isView(item.post.embed) && 526 !blockedOrMutedAuthors.includes(item.post.author.did) 527 ) { 528 videos.push({ 529 item, 530 feedContext: slice.feedContext, 531 reqId: slice.reqId, 532 }) 533 } 534 } 535 } 536 537 const rows: { 538 item: FeedPostSliceItem 539 feedContext: string | undefined 540 reqId: string | undefined 541 }[][] = [] 542 for (let i = 0; i < videos.length; i++) { 543 const video = videos[i] 544 const item = video.item 545 const cols = gtMobile ? 3 : 2 546 const rowItem = { 547 item, 548 feedContext: video.feedContext, 549 reqId: video.reqId, 550 } 551 if (i % cols === 0) { 552 rows.push([rowItem]) 553 } else { 554 rows[rows.length - 1].push(rowItem) 555 } 556 } 557 558 for (const row of rows) { 559 sliceIndex++ 560 arr.push({ 561 type: 'videoGridRow', 562 key: row.map(r => r.item._reactKey).join('-'), 563 items: row.map(r => r.item), 564 sourceFeedUri: feedUriOrActorDid, 565 feedContexts: row.map(r => r.feedContext), 566 reqIds: row.map(r => r.reqId), 567 }) 568 } 569 } else { 570 for (const page of data?.pages) { 571 let slices = useRepostCarousel 572 ? groupReposts(page.slices) 573 : (page.slices as FeedPostSliceOrGroup[]) 574 575 // Filter out posts that cannot be replied to if the setting is enabled 576 if (hideUnreplyablePosts) { 577 slices = slices.filter(slice => { 578 if (slice.isRepostSlice) { 579 // For repost slices, filter the inner slices 580 slice.slices = slice.slices.filter(innerSlice => { 581 // Check if any item in the slice has replyDisabled 582 return !innerSlice.items.some( 583 item => item.post.viewer?.replyDisabled === true, 584 ) 585 }) 586 return slice.slices.length > 0 587 } else { 588 // For regular slices, check if any item has replyDisabled 589 return !slice.items.some( 590 item => item.post.viewer?.replyDisabled === true, 591 ) 592 } 593 }) 594 } 595 596 for (const slice of slices) { 597 sliceIndex++ 598 599 if (hasSession) { 600 if (feedKind === 'discover') { 601 if (sliceIndex === 0) { 602 if (showProgressIntersitial) { 603 arr.push({ 604 type: 'interstitialProgressGuide', 605 key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, 606 }) 607 } else { 608 /* 609 * Only insert if Discover was the last selected feed at 610 * startup, the progress guide isn't shown, and the 611 * banner is eligible to be shown. 612 */ 613 if ( 614 isCurrentFeedAtStartupSelected && 615 ageAssuranceBannerState.visible 616 ) { 617 arr.push({ 618 type: 'ageAssuranceBanner', 619 key: 'ageAssuranceBanner-' + sliceIndex, 620 }) 621 } 622 } 623 arr.push({ 624 type: 'liveEventFeedsAndTrendingBanner', 625 key: 'liveEventFeedsAndTrendingBanner-' + sliceIndex, 626 }) 627 // Show composer prompt for Discover and Following feeds 628 if ( 629 hasSession && 630 !disableComposerPrompt && 631 (feedUriOrActorDid === DISCOVER_FEED_URI || 632 feed === 'following') 633 ) { 634 arr.push({ 635 type: 'composerPrompt', 636 key: 'composerPrompt-' + sliceIndex, 637 }) 638 } 639 } else if (sliceIndex === 15) { 640 if (areVideoFeedsEnabled && !trendingVideoDisabled) { 641 arr.push({ 642 type: 'interstitialTrendingVideos', 643 key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, 644 }) 645 } 646 } else if (sliceIndex === 30) { 647 arr.push({ 648 type: 'interstitialFollows', 649 key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, 650 }) 651 } 652 } else if (feedKind === 'following') { 653 if (sliceIndex === 0) { 654 // Show composer prompt for Following feed 655 if (hasSession && !disableComposerPrompt) { 656 arr.push({ 657 type: 'composerPrompt', 658 key: 'composerPrompt-' + sliceIndex, 659 }) 660 } 661 } 662 } else if (feedKind === 'profile') { 663 if (sliceIndex === 5) { 664 arr.push({ 665 type: 'interstitialFollows', 666 key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, 667 }) 668 } 669 } else { 670 /* 671 * Only insert if this feed was the last selected feed at 672 * startup and the banner is eligible to be shown. 673 */ 674 if (sliceIndex === 0 && isCurrentFeedAtStartupSelected) { 675 arr.push({ 676 type: 'ageAssuranceBanner', 677 key: 'ageAssuranceBanner-' + sliceIndex, 678 }) 679 } 680 } 681 } 682 683 if (slice.isRepostSlice) { 684 arr.push({ 685 type: 'reposts', 686 key: slice.slices[0]._reactKey, 687 items: slice.slices, 688 }) 689 } else if (slice.isFallbackMarker) { 690 arr.push({ 691 type: 'fallbackMarker', 692 key: 693 'sliceFallbackMarker-' + sliceIndex + '-' + lastFetchedAt, 694 }) 695 } else if ( 696 slice.items.some(item => 697 blockedOrMutedAuthors.includes(item.post.author.did), 698 ) 699 ) { 700 // skip 701 } else if (slice.isIncompleteThread && slice.items.length >= 3) { 702 const beforeLast = slice.items.length - 2 703 const last = slice.items.length - 1 704 arr.push( 705 sliceItem({ 706 type: 'sliceItem', 707 key: slice.items[0]._reactKey, 708 slice: slice, 709 indexInSlice: 0, 710 showReplyTo: false, 711 }), 712 ) 713 arr.push({ 714 type: 'sliceViewFullThread', 715 key: slice._reactKey + '-viewFullThread', 716 uri: slice.items[0].uri, 717 }) 718 arr.push( 719 sliceItem({ 720 type: 'sliceItem', 721 key: slice.items[beforeLast]._reactKey, 722 slice: slice, 723 indexInSlice: beforeLast, 724 showReplyTo: 725 slice.items[beforeLast].parentAuthor?.did !== 726 slice.items[beforeLast].post.author.did, 727 }), 728 ) 729 arr.push( 730 sliceItem({ 731 type: 'sliceItem', 732 key: slice.items[last]._reactKey, 733 slice: slice, 734 indexInSlice: last, 735 showReplyTo: false, 736 }), 737 ) 738 } else { 739 for (let i = 0; i < slice.items.length; i++) { 740 arr.push( 741 sliceItem({ 742 type: 'sliceItem', 743 key: slice.items[i]._reactKey, 744 slice: slice, 745 indexInSlice: i, 746 showReplyTo: i === 0, 747 }), 748 ) 749 } 750 } 751 } 752 } 753 } 754 } 755 if (isError && !isEmpty) { 756 arr.push({ 757 type: 'loadMoreError', 758 key: 'loadMoreError', 759 }) 760 } 761 } else { 762 if (isVideoFeed) { 763 arr.push({ 764 type: 'videoGridRowPlaceholder', 765 key: 'videoGridRowPlaceholder', 766 }) 767 } else { 768 arr.push({ 769 type: 'loading', 770 key: 'loading', 771 }) 772 } 773 } 774 775 return arr 776 }, [ 777 isFetched, 778 isError, 779 isEmpty, 780 lastFetchedAt, 781 data, 782 feed, 783 feedType, 784 feedUriOrActorDid, 785 feedTab, 786 hasSession, 787 showProgressIntersitial, 788 trendingVideoDisabled, 789 gtMobile, 790 isVideoFeed, 791 areVideoFeedsEnabled, 792 useRepostCarousel, 793 hasPressedShowLessUris, 794 ageAssuranceBannerState, 795 isCurrentFeedAtStartupSelected, 796 blockedOrMutedAuthors, 797 hideUnreplyablePosts, 798 ]) 799 800 // events 801 // = 802 803 const onRefresh = useCallback(async () => { 804 ax.metric('feed:refresh', { 805 feedType: feedType, 806 feedUrl: feed, 807 reason: 'pull-to-refresh', 808 }) 809 setIsPTRing(true) 810 try { 811 await refetch() 812 onHasNew?.(false) 813 } catch (err) { 814 logger.error('Failed to refresh posts feed', {message: err}) 815 } 816 setIsPTRing(false) 817 }, [ax, refetch, setIsPTRing, onHasNew, feed, feedType]) 818 819 const onEndReached = useCallback(async () => { 820 if (isFetching || !hasNextPage || isError) return 821 822 ax.metric('feed:endReached', { 823 feedType: feedType, 824 feedUrl: feed, 825 itemCount: feedItems.length, 826 }) 827 try { 828 await fetchNextPage() 829 } catch (err) { 830 logger.error('Failed to load more posts', {message: err}) 831 } 832 }, [ 833 ax, 834 isFetching, 835 hasNextPage, 836 isError, 837 fetchNextPage, 838 feed, 839 feedType, 840 feedItems.length, 841 ]) 842 843 const onPressTryAgain = useCallback(() => { 844 refetch() 845 onHasNew?.(false) 846 }, [refetch, onHasNew]) 847 848 const onPressRetryLoadMore = useCallback(() => { 849 fetchNextPage() 850 }, [fetchNextPage]) 851 852 // rendering 853 // = 854 855 const renderItem = useCallback( 856 ({item: row, index: rowIndex}: ListRenderItemInfo<FeedRow>) => { 857 if (row.type === 'empty') { 858 return renderEmptyState() 859 } else if (row.type === 'error') { 860 return ( 861 <PostFeedErrorMessage 862 feedDesc={feed} 863 error={error ?? undefined} 864 onPressTryAgain={onPressTryAgain} 865 savedFeedConfig={savedFeedConfig} 866 /> 867 ) 868 } else if (row.type === 'loadMoreError') { 869 return ( 870 <LoadMoreRetryBtn 871 label={_( 872 msg`There was an issue fetching posts. Tap here to try again.`, 873 )} 874 onPress={onPressRetryLoadMore} 875 /> 876 ) 877 } else if (row.type === 'loading') { 878 return <PostFeedLoadingPlaceholder /> 879 } else if (row.type === 'feedShutdownMsg') { 880 return <FeedShutdownMsg feedUri={feedUriOrActorDid} /> 881 } else if (row.type === 'interstitialFollows') { 882 return <SuggestedFollows feed={feed} /> 883 } else if (row.type === 'interstitialProgressGuide') { 884 return <ProgressGuide /> 885 } else if (row.type === 'ageAssuranceBanner') { 886 return <AgeAssuranceDismissibleFeedBanner /> 887 } else if (row.type === 'interstitialTrending') { 888 return <TrendingInterstitial /> 889 } else if (row.type === 'liveEventFeedsAndTrendingBanner') { 890 return <DiscoverFeedLiveEventFeedsAndTrendingBanner /> 891 } else if (row.type === 'composerPrompt') { 892 return <ComposerPrompt /> 893 } else if (row.type === 'interstitialTrendingVideos') { 894 return <TrendingVideosInterstitial /> 895 } else if (row.type === 'fallbackMarker') { 896 // HACK 897 // tell the user we fell back to discover 898 // see home.ts (feed api) for more info 899 // -prf 900 return <DiscoverFallbackHeader /> 901 } else if (row.type === 'sliceItem') { 902 const slice = row.slice 903 const indexInSlice = row.indexInSlice 904 const item = slice.items[indexInSlice] 905 return ( 906 <PostFeedItem 907 post={item.post} 908 record={item.record} 909 reason={indexInSlice === 0 ? slice.reason : undefined} 910 feedContext={slice.feedContext} 911 reqId={slice.reqId} 912 moderation={item.moderation} 913 parentAuthor={item.parentAuthor} 914 showReplyTo={row.showReplyTo} 915 isThreadParent={isThreadParentAt(slice.items, indexInSlice)} 916 isThreadChild={isThreadChildAt(slice.items, indexInSlice)} 917 isThreadLastChild={ 918 isThreadChildAt(slice.items, indexInSlice) && 919 slice.items.length === indexInSlice + 1 920 } 921 isParentBlocked={item.isParentBlocked} 922 isParentNotFound={item.isParentNotFound} 923 hideTopBorder={rowIndex === 0 && indexInSlice === 0} 924 rootPost={slice.items[0].post} 925 onShowLess={onPressShowLess} 926 /> 927 ) 928 } else if (row.type === 'reposts') { 929 return <PostFeedItemCarousel items={row.items} /> 930 } else if (row.type === 'sliceViewFullThread') { 931 return <ViewFullThread uri={row.uri} /> 932 } else if (row.type === 'videoGridRowPlaceholder') { 933 return ( 934 <View> 935 <PostFeedVideoGridRowPlaceholder /> 936 <PostFeedVideoGridRowPlaceholder /> 937 <PostFeedVideoGridRowPlaceholder /> 938 </View> 939 ) 940 } else if (row.type === 'videoGridRow') { 941 let sourceContext: VideoFeedSourceContext 942 if (feedType === 'author') { 943 sourceContext = { 944 type: 'author', 945 did: feedUriOrActorDid, 946 filter: feedTab as AuthorFilter, 947 } 948 } else { 949 sourceContext = { 950 type: 'feedgen', 951 uri: row.sourceFeedUri, 952 sourceInterstitial: feedCacheKey ?? 'none', 953 } 954 } 955 956 return ( 957 <PostFeedVideoGridRow 958 items={row.items} 959 sourceContext={sourceContext} 960 /> 961 ) 962 } else if (row.type === 'showLessFollowup') { 963 return <ShowLessFollowup /> 964 } else { 965 return null 966 } 967 }, 968 [ 969 renderEmptyState, 970 feed, 971 error, 972 onPressTryAgain, 973 savedFeedConfig, 974 _, 975 onPressRetryLoadMore, 976 feedType, 977 feedUriOrActorDid, 978 feedTab, 979 feedCacheKey, 980 onPressShowLess, 981 ], 982 ) 983 984 const shouldRenderEndOfFeed = 985 !hasNextPage && !isEmpty && !isFetching && !isError && !!renderEndOfFeed 986 const FeedFooter = useCallback(() => { 987 /** 988 * A bit of padding at the bottom of the feed as you scroll and when you 989 * reach the end, so that content isn't cut off by the bottom of the 990 * screen. 991 */ 992 const offset = Math.max(headerOffset, 32) * (IS_WEB ? 1 : 2) 993 994 return isFetchingNextPage ? ( 995 <View style={[styles.feedFooter]}> 996 <ActivityIndicator color={t.palette.primary_500} /> 997 <View style={{height: offset}} /> 998 </View> 999 ) : shouldRenderEndOfFeed ? ( 1000 <View style={{minHeight: offset}}>{renderEndOfFeed()}</View> 1001 ) : ( 1002 <View style={{height: offset}} /> 1003 ) 1004 }, [isFetchingNextPage, shouldRenderEndOfFeed, renderEndOfFeed, headerOffset]) 1005 1006 const liveNowConfig = useLiveNowConfig() 1007 1008 const seenActorWithStatusRef = useRef<Set<string>>(new Set()) 1009 const seenPostUrisRef = useRef<Set<string>>(new Set()) 1010 1011 // Helper to calculate position in feed (count only root posts, not interstitials or thread replies) 1012 const getPostPosition = useNonReactiveCallback( 1013 (type: FeedRow['type'], key: string) => { 1014 // Calculate position: find the row index in feedItems, then calculate position 1015 const rowIndex = feedItems.findIndex( 1016 row => row.type === 'sliceItem' && row.key === key, 1017 ) 1018 1019 if (rowIndex >= 0) { 1020 let position = 0 1021 for (let i = 0; i < rowIndex && i < feedItems.length; i++) { 1022 const row = feedItems[i] 1023 if (row.type === 'sliceItem') { 1024 // Only count root posts (indexInSlice === 0), not thread replies 1025 if (row.indexInSlice === 0) { 1026 position++ 1027 } 1028 } else if (row.type === 'videoGridRow') { 1029 // Count each video in the grid row 1030 position += row.items.length 1031 } 1032 } 1033 return position 1034 } 1035 }, 1036 ) 1037 1038 const onItemSeen = useCallback( 1039 (item: FeedRow) => { 1040 feedFeedback.onItemSeen(item) 1041 1042 // Track post:view events 1043 if (item.type === 'sliceItem') { 1044 const slice = item.slice 1045 const indexInSlice = item.indexInSlice 1046 const postItem = slice.items[indexInSlice] 1047 const post = postItem.post 1048 1049 // Only track the root post of each slice (index 0) to avoid double-counting thread items 1050 if (indexInSlice === 0 && !seenPostUrisRef.current.has(post.uri)) { 1051 seenPostUrisRef.current.add(post.uri) 1052 1053 const position = getPostPosition('sliceItem', item.key) 1054 1055 ax.metric('post:view', { 1056 uri: post.uri, 1057 authorDid: post.author.did, 1058 logContext: 'FeedItem', 1059 feedDescriptor: feedFeedback.feedDescriptor || feed, 1060 position, 1061 }) 1062 } 1063 1064 // Live status tracking (existing code) 1065 const actor = post.author 1066 if ( 1067 actor.status && 1068 isStatusValidForViewers(actor.status, liveNowConfig) && 1069 isStatusStillActive(actor.status.expiresAt) 1070 ) { 1071 if (!seenActorWithStatusRef.current.has(actor.did)) { 1072 seenActorWithStatusRef.current.add(actor.did) 1073 ax.metric('live:view:post', { 1074 subject: actor.did, 1075 feed, 1076 }) 1077 } 1078 } 1079 } else if (item.type === 'videoGridRow') { 1080 // Track each video in the grid row 1081 for (let i = 0; i < item.items.length; i++) { 1082 const postItem = item.items[i] 1083 const post = postItem.post 1084 1085 if (!seenPostUrisRef.current.has(post.uri)) { 1086 seenPostUrisRef.current.add(post.uri) 1087 1088 const position = getPostPosition('videoGridRow', item.key) 1089 1090 ax.metric('post:view', { 1091 uri: post.uri, 1092 authorDid: post.author.did, 1093 logContext: 'FeedItem', 1094 feedDescriptor: feedFeedback.feedDescriptor || feed, 1095 position, 1096 }) 1097 } 1098 } 1099 } 1100 }, 1101 [feedFeedback, feed, liveNowConfig, getPostPosition, ax], 1102 ) 1103 1104 return ( 1105 <View testID={testID} style={style}> 1106 <List 1107 testID={testID ? `${testID}-flatlist` : undefined} 1108 ref={scrollElRef} 1109 data={feedItems} 1110 keyExtractor={item => item.key} 1111 renderItem={renderItem} 1112 ListFooterComponent={FeedFooter} 1113 ListHeaderComponent={ListHeaderComponent} 1114 refreshing={isPTRing} 1115 onRefresh={onRefresh} 1116 headerOffset={headerOffset} 1117 progressViewOffset={progressViewOffset} 1118 contentContainerStyle={{ 1119 minHeight: Dimensions.get('window').height * 1.5, 1120 }} 1121 onScrolledDownChange={handleScrolledDownChange} 1122 onEndReached={onEndReached} 1123 onEndReachedThreshold={2} // number of posts left to trigger load more 1124 removeClippedSubviews={true} 1125 extraData={extraData} 1126 desktopFixedHeight={ 1127 desktopFixedHeightOffset ? desktopFixedHeightOffset : true 1128 } 1129 initialNumToRender={initialNumToRenderOverride ?? initialNumToRender} 1130 windowSize={9} 1131 maxToRenderPerBatch={IS_IOS ? 5 : 1} 1132 updateCellsBatchingPeriod={40} 1133 onItemSeen={onItemSeen} 1134 /> 1135 </View> 1136 ) 1137} 1138PostFeed = memo(PostFeed) 1139export {PostFeed} 1140 1141const styles = StyleSheet.create({ 1142 feedFooter: {paddingTop: 20}, 1143}) 1144 1145export function isThreadParentAt<T>(arr: Array<T>, i: number) { 1146 if (arr.length === 1) { 1147 return false 1148 } 1149 return i < arr.length - 1 1150} 1151 1152export function isThreadChildAt<T>(arr: Array<T>, i: number) { 1153 if (arr.length === 1) { 1154 return false 1155 } 1156 return i > 0 1157}