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