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