Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
at main 1163 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 useEffect(() => { 801 if (enabled === false) { 802 setIsPTRing(false) 803 } 804 }, [enabled]) 805 806 // events 807 // = 808 809 const onRefresh = useCallback(async () => { 810 ax.metric('feed:refresh', { 811 feedType: feedType, 812 feedUrl: feed, 813 reason: 'pull-to-refresh', 814 }) 815 setIsPTRing(true) 816 try { 817 await refetch() 818 onHasNew?.(false) 819 } catch (err) { 820 logger.error('Failed to refresh posts feed', {message: err}) 821 } 822 setIsPTRing(false) 823 }, [ax, refetch, setIsPTRing, onHasNew, feed, feedType]) 824 825 const onEndReached = useCallback(async () => { 826 if (isFetching || !hasNextPage || isError) return 827 828 ax.metric('feed:endReached', { 829 feedType: feedType, 830 feedUrl: feed, 831 itemCount: feedItems.length, 832 }) 833 try { 834 await fetchNextPage() 835 } catch (err) { 836 logger.error('Failed to load more posts', {message: err}) 837 } 838 }, [ 839 ax, 840 isFetching, 841 hasNextPage, 842 isError, 843 fetchNextPage, 844 feed, 845 feedType, 846 feedItems.length, 847 ]) 848 849 const onPressTryAgain = useCallback(() => { 850 refetch() 851 onHasNew?.(false) 852 }, [refetch, onHasNew]) 853 854 const onPressRetryLoadMore = useCallback(() => { 855 fetchNextPage() 856 }, [fetchNextPage]) 857 858 // rendering 859 // = 860 861 const renderItem = useCallback( 862 ({item: row, index: rowIndex}: ListRenderItemInfo<FeedRow>) => { 863 if (row.type === 'empty') { 864 return renderEmptyState() 865 } else if (row.type === 'error') { 866 return ( 867 <PostFeedErrorMessage 868 feedDesc={feed} 869 error={error ?? undefined} 870 onPressTryAgain={onPressTryAgain} 871 savedFeedConfig={savedFeedConfig} 872 /> 873 ) 874 } else if (row.type === 'loadMoreError') { 875 return ( 876 <LoadMoreRetryBtn 877 label={_( 878 msg`There was an issue fetching posts. Tap here to try again.`, 879 )} 880 onPress={onPressRetryLoadMore} 881 /> 882 ) 883 } else if (row.type === 'loading') { 884 return <PostFeedLoadingPlaceholder /> 885 } else if (row.type === 'feedShutdownMsg') { 886 return <FeedShutdownMsg feedUri={feedUriOrActorDid} /> 887 } else if (row.type === 'interstitialFollows') { 888 return <SuggestedFollows feed={feed} /> 889 } else if (row.type === 'interstitialProgressGuide') { 890 return <ProgressGuide /> 891 } else if (row.type === 'ageAssuranceBanner') { 892 return <AgeAssuranceDismissibleFeedBanner /> 893 } else if (row.type === 'interstitialTrending') { 894 return <TrendingInterstitial /> 895 } else if (row.type === 'liveEventFeedsAndTrendingBanner') { 896 return <DiscoverFeedLiveEventFeedsAndTrendingBanner /> 897 } else if (row.type === 'composerPrompt') { 898 return <ComposerPrompt /> 899 } else if (row.type === 'interstitialTrendingVideos') { 900 return <TrendingVideosInterstitial /> 901 } else if (row.type === 'fallbackMarker') { 902 // HACK 903 // tell the user we fell back to discover 904 // see home.ts (feed api) for more info 905 // -prf 906 return <DiscoverFallbackHeader /> 907 } else if (row.type === 'sliceItem') { 908 const slice = row.slice 909 const indexInSlice = row.indexInSlice 910 const item = slice.items[indexInSlice] 911 return ( 912 <PostFeedItem 913 post={item.post} 914 record={item.record} 915 reason={indexInSlice === 0 ? slice.reason : undefined} 916 feedContext={slice.feedContext} 917 reqId={slice.reqId} 918 moderation={item.moderation} 919 parentAuthor={item.parentAuthor} 920 showReplyTo={row.showReplyTo} 921 isThreadParent={isThreadParentAt(slice.items, indexInSlice)} 922 isThreadChild={isThreadChildAt(slice.items, indexInSlice)} 923 isThreadLastChild={ 924 isThreadChildAt(slice.items, indexInSlice) && 925 slice.items.length === indexInSlice + 1 926 } 927 isParentBlocked={item.isParentBlocked} 928 isParentNotFound={item.isParentNotFound} 929 hideTopBorder={rowIndex === 0 && indexInSlice === 0} 930 rootPost={slice.items[0].post} 931 onShowLess={onPressShowLess} 932 /> 933 ) 934 } else if (row.type === 'reposts') { 935 return <PostFeedItemCarousel items={row.items} /> 936 } else if (row.type === 'sliceViewFullThread') { 937 return <ViewFullThread uri={row.uri} /> 938 } else if (row.type === 'videoGridRowPlaceholder') { 939 return ( 940 <View> 941 <PostFeedVideoGridRowPlaceholder /> 942 <PostFeedVideoGridRowPlaceholder /> 943 <PostFeedVideoGridRowPlaceholder /> 944 </View> 945 ) 946 } else if (row.type === 'videoGridRow') { 947 let sourceContext: VideoFeedSourceContext 948 if (feedType === 'author') { 949 sourceContext = { 950 type: 'author', 951 did: feedUriOrActorDid, 952 filter: feedTab as AuthorFilter, 953 } 954 } else { 955 sourceContext = { 956 type: 'feedgen', 957 uri: row.sourceFeedUri, 958 sourceInterstitial: feedCacheKey ?? 'none', 959 } 960 } 961 962 return ( 963 <PostFeedVideoGridRow 964 items={row.items} 965 sourceContext={sourceContext} 966 /> 967 ) 968 } else if (row.type === 'showLessFollowup') { 969 return <ShowLessFollowup /> 970 } else { 971 return null 972 } 973 }, 974 [ 975 renderEmptyState, 976 feed, 977 error, 978 onPressTryAgain, 979 savedFeedConfig, 980 _, 981 onPressRetryLoadMore, 982 feedType, 983 feedUriOrActorDid, 984 feedTab, 985 feedCacheKey, 986 onPressShowLess, 987 ], 988 ) 989 990 const shouldRenderEndOfFeed = 991 !hasNextPage && !isEmpty && !isFetching && !isError && !!renderEndOfFeed 992 const FeedFooter = useCallback(() => { 993 /** 994 * A bit of padding at the bottom of the feed as you scroll and when you 995 * reach the end, so that content isn't cut off by the bottom of the 996 * screen. 997 */ 998 const offset = Math.max(headerOffset, 32) * (IS_WEB ? 1 : 2) 999 1000 return isFetchingNextPage ? ( 1001 <View style={[styles.feedFooter]}> 1002 <ActivityIndicator color={t.palette.primary_500} /> 1003 <View style={{height: offset}} /> 1004 </View> 1005 ) : shouldRenderEndOfFeed ? ( 1006 <View style={{minHeight: offset}}>{renderEndOfFeed()}</View> 1007 ) : ( 1008 <View style={{height: offset}} /> 1009 ) 1010 }, [isFetchingNextPage, shouldRenderEndOfFeed, renderEndOfFeed, headerOffset]) 1011 1012 const liveNowConfig = useLiveNowConfig() 1013 1014 const seenActorWithStatusRef = useRef<Set<string>>(new Set()) 1015 const seenPostUrisRef = useRef<Set<string>>(new Set()) 1016 1017 // Helper to calculate position in feed (count only root posts, not interstitials or thread replies) 1018 const getPostPosition = useNonReactiveCallback( 1019 (type: FeedRow['type'], key: string) => { 1020 // Calculate position: find the row index in feedItems, then calculate position 1021 const rowIndex = feedItems.findIndex( 1022 row => row.type === 'sliceItem' && row.key === key, 1023 ) 1024 1025 if (rowIndex >= 0) { 1026 let position = 0 1027 for (let i = 0; i < rowIndex && i < feedItems.length; i++) { 1028 const row = feedItems[i] 1029 if (row.type === 'sliceItem') { 1030 // Only count root posts (indexInSlice === 0), not thread replies 1031 if (row.indexInSlice === 0) { 1032 position++ 1033 } 1034 } else if (row.type === 'videoGridRow') { 1035 // Count each video in the grid row 1036 position += row.items.length 1037 } 1038 } 1039 return position 1040 } 1041 }, 1042 ) 1043 1044 const onItemSeen = useCallback( 1045 (item: FeedRow) => { 1046 feedFeedback.onItemSeen(item) 1047 1048 // Track post:view events 1049 if (item.type === 'sliceItem') { 1050 const slice = item.slice 1051 const indexInSlice = item.indexInSlice 1052 const postItem = slice.items[indexInSlice] 1053 const post = postItem.post 1054 1055 // Only track the root post of each slice (index 0) to avoid double-counting thread items 1056 if (indexInSlice === 0 && !seenPostUrisRef.current.has(post.uri)) { 1057 seenPostUrisRef.current.add(post.uri) 1058 1059 const position = getPostPosition('sliceItem', item.key) 1060 1061 ax.metric('post:view', { 1062 uri: post.uri, 1063 authorDid: post.author.did, 1064 logContext: 'FeedItem', 1065 feedDescriptor: feedFeedback.feedDescriptor || feed, 1066 position, 1067 }) 1068 } 1069 1070 // Live status tracking (existing code) 1071 const actor = post.author 1072 if ( 1073 actor.status && 1074 isStatusValidForViewers(actor.status, liveNowConfig) && 1075 isStatusStillActive(actor.status.expiresAt) 1076 ) { 1077 if (!seenActorWithStatusRef.current.has(actor.did)) { 1078 seenActorWithStatusRef.current.add(actor.did) 1079 ax.metric('live:view:post', { 1080 subject: actor.did, 1081 feed, 1082 }) 1083 } 1084 } 1085 } else if (item.type === 'videoGridRow') { 1086 // Track each video in the grid row 1087 for (let i = 0; i < item.items.length; i++) { 1088 const postItem = item.items[i] 1089 const post = postItem.post 1090 1091 if (!seenPostUrisRef.current.has(post.uri)) { 1092 seenPostUrisRef.current.add(post.uri) 1093 1094 const position = getPostPosition('videoGridRow', item.key) 1095 1096 ax.metric('post:view', { 1097 uri: post.uri, 1098 authorDid: post.author.did, 1099 logContext: 'FeedItem', 1100 feedDescriptor: feedFeedback.feedDescriptor || feed, 1101 position, 1102 }) 1103 } 1104 } 1105 } 1106 }, 1107 [feedFeedback, feed, liveNowConfig, getPostPosition, ax], 1108 ) 1109 1110 return ( 1111 <View testID={testID} style={style}> 1112 <List 1113 testID={testID ? `${testID}-flatlist` : undefined} 1114 ref={scrollElRef} 1115 data={feedItems} 1116 keyExtractor={item => item.key} 1117 renderItem={renderItem} 1118 ListFooterComponent={FeedFooter} 1119 ListHeaderComponent={ListHeaderComponent} 1120 refreshing={isPTRing} 1121 onRefresh={onRefresh} 1122 headerOffset={headerOffset} 1123 progressViewOffset={progressViewOffset} 1124 contentContainerStyle={{ 1125 minHeight: Dimensions.get('window').height * 1.5, 1126 }} 1127 onScrolledDownChange={handleScrolledDownChange} 1128 onEndReached={onEndReached} 1129 onEndReachedThreshold={2} // number of posts left to trigger load more 1130 removeClippedSubviews={true} 1131 extraData={extraData} 1132 desktopFixedHeight={ 1133 desktopFixedHeightOffset ? desktopFixedHeightOffset : true 1134 } 1135 initialNumToRender={initialNumToRenderOverride ?? initialNumToRender} 1136 windowSize={9} 1137 maxToRenderPerBatch={IS_IOS ? 5 : 1} 1138 updateCellsBatchingPeriod={40} 1139 onItemSeen={onItemSeen} 1140 /> 1141 </View> 1142 ) 1143} 1144PostFeed = memo(PostFeed) 1145export {PostFeed} 1146 1147const styles = StyleSheet.create({ 1148 feedFooter: {paddingTop: 20}, 1149}) 1150 1151export function isThreadParentAt<T>(arr: Array<T>, i: number) { 1152 if (arr.length === 1) { 1153 return false 1154 } 1155 return i < arr.length - 1 1156} 1157 1158export function isThreadChildAt<T>(arr: Array<T>, i: number) { 1159 if (arr.length === 1) { 1160 return false 1161 } 1162 return i > 0 1163}