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

Configure Feed

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

at 20bf2cd11723d81fe75cc74dc13abf085113dd4d 1176 lines 36 kB view raw
1import {useCallback, useMemo, useRef, useState} from 'react' 2import {View, type ViewabilityConfig} from 'react-native' 3import { 4 type AppBskyActorDefs, 5 type AppBskyFeedDefs, 6 type AppBskyGraphDefs, 7} from '@atproto/api' 8import {msg} from '@lingui/core/macro' 9import {useLingui} from '@lingui/react' 10import {Trans} from '@lingui/react/macro' 11import {useQueryClient} from '@tanstack/react-query' 12import * as bcp47Match from 'bcp-47-match' 13 14import {popularInterests, useInterestsDisplayNames} from '#/lib/interests' 15import {cleanError} from '#/lib/strings/errors' 16import {sanitizeHandle} from '#/lib/strings/handles' 17import {useLanguagePrefs} from '#/state/preferences/languages' 18import {useModerationOpts} from '#/state/preferences/moderation-opts' 19import {RQKEY_ROOT as useActorSearchQueryKeyRoot} from '#/state/queries/actor-search' 20import { 21 type FeedPreviewItem, 22 useFeedPreviews, 23} from '#/state/queries/explore-feed-previews' 24import {useGetPopularFeedsQuery} from '#/state/queries/feed' 25import {Nux, useNux} from '#/state/queries/nuxs' 26import {usePreferencesQuery} from '#/state/queries/preferences' 27import { 28 createGetSuggestedFeedsQueryKey, 29 useGetSuggestedFeedsQuery, 30} from '#/state/queries/trending/useGetSuggestedFeedsQuery' 31import {getSuggestedUsersQueryKeyRoot} from '#/state/queries/trending/useGetSuggestedUsersQuery' 32import {createGetTrendsQueryKey} from '#/state/queries/trending/useGetTrendsQuery' 33import { 34 createSuggestedStarterPacksQueryKey, 35 useSuggestedStarterPacksQuery, 36} from '#/state/queries/useSuggestedStarterPacksQuery' 37import {isThreadChildAt, isThreadParentAt} from '#/view/com/posts/PostFeed' 38import {PostFeedItem} from '#/view/com/posts/PostFeedItem' 39import {ViewFullThread} from '#/view/com/posts/ViewFullThread' 40import {List} from '#/view/com/util/List' 41import {FeedFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 42import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn' 43import { 44 StarterPackCard, 45 StarterPackCardSkeleton, 46} from '#/screens/Search/components/StarterPackCard' 47import {ExploreInterestsCard} from '#/screens/Search/modules/ExploreInterestsCard' 48import {ExploreRecommendations} from '#/screens/Search/modules/ExploreRecommendations' 49import {ExploreTrendingTopics} from '#/screens/Search/modules/ExploreTrendingTopics' 50import {ExploreTrendingVideos} from '#/screens/Search/modules/ExploreTrendingVideos' 51import {useSuggestedUsers} from '#/screens/Search/util/useSuggestedUsers' 52import {atoms as a, native, platform, useTheme} from '#/alf' 53import {Admonition} from '#/components/Admonition' 54import {Button} from '#/components/Button' 55import * as FeedCard from '#/components/FeedCard' 56import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon} from '#/components/icons/Chevron' 57import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 58import { 59 type Props as IcoProps, 60 type Props as SVGIconProps, 61} from '#/components/icons/common' 62import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle' 63import {StarterPack} from '#/components/icons/StarterPack' 64import {UserCircle_Stroke2_Corner0_Rounded as Person} from '#/components/icons/UserCircle' 65import {boostInterests} from '#/components/InterestTabs' 66import {Loader} from '#/components/Loader' 67import * as ProfileCard from '#/components/ProfileCard' 68import {SubtleHover} from '#/components/SubtleHover' 69import {Text} from '#/components/Typography' 70import {type Metrics, useAnalytics} from '#/analytics' 71import {ExploreScreenLiveEventFeedsBanner} from '#/features/liveEvents/components/ExploreScreenLiveEventFeedsBanner' 72import * as ModuleHeader from './components/ModuleHeader' 73import { 74 SuggestedAccountsTabBar, 75 SuggestedProfileCard, 76} from './modules/ExploreSuggestedAccounts' 77 78function LoadMore({item}: {item: ExploreScreenItems & {type: 'loadMore'}}) { 79 const t = useTheme() 80 const {_} = useLingui() 81 82 const handleOnPress = () => { 83 void item.onLoadMore() 84 } 85 86 return ( 87 <Button 88 label={_(msg`Load more`)} 89 onPress={handleOnPress} 90 style={[a.relative, a.w_full]}> 91 {({hovered, pressed}) => ( 92 <> 93 <SubtleHover hover={hovered || pressed} /> 94 <View 95 style={[ 96 a.flex_1, 97 a.flex_row, 98 a.align_center, 99 a.justify_center, 100 a.px_lg, 101 a.py_md, 102 a.gap_sm, 103 ]}> 104 <Text style={[a.leading_snug]}>{item.message}</Text> 105 {item.isLoadingMore ? ( 106 <Loader size="sm" /> 107 ) : ( 108 <ChevronDownIcon size="sm" style={t.atoms.text_contrast_medium} /> 109 )} 110 </View> 111 </> 112 )} 113 </Button> 114 ) 115} 116 117type ExploreScreenItems = 118 | { 119 type: 'topBorder' 120 key: string 121 } 122 | { 123 type: 'header' 124 key: string 125 title: string 126 icon: React.ComponentType<SVGIconProps> 127 iconSize?: IcoProps['size'] 128 bottomBorder?: boolean 129 searchButton?: { 130 label: string 131 metricsTag: Metrics['explore:module:searchButtonPress']['module'] 132 tab: 'user' | 'profile' | 'feed' 133 } 134 } 135 | { 136 type: 'tabbedHeader' 137 key: string 138 title: string 139 icon: React.ComponentType<SVGIconProps> 140 searchButton?: { 141 label: string 142 metricsTag: Metrics['explore:module:searchButtonPress']['module'] 143 tab: 'user' | 'profile' | 'feed' 144 } 145 hideDefaultTab?: boolean 146 } 147 | { 148 type: 'trendingTopics' 149 key: string 150 } 151 | { 152 type: 'trendingVideos' 153 key: string 154 } 155 | { 156 type: 'recommendations' 157 key: string 158 } 159 | { 160 type: 'profile' 161 key: string 162 profile: AppBskyActorDefs.ProfileView 163 recId?: number 164 } 165 | { 166 type: 'profileEmpty' 167 key: 'profileEmpty' 168 } 169 | { 170 type: 'feed' 171 key: string 172 feed: AppBskyFeedDefs.GeneratorView 173 } 174 | { 175 type: 'loadMore' 176 key: string 177 message: string 178 isLoadingMore: boolean 179 onLoadMore: () => void | Promise<void> 180 } 181 | { 182 type: 'profilePlaceholder' 183 key: string 184 } 185 | { 186 type: 'feedPlaceholder' 187 key: string 188 } 189 | { 190 type: 'error' 191 key: string 192 message: string 193 error: string 194 } 195 | { 196 type: 'starterPack' 197 key: string 198 view: AppBskyGraphDefs.StarterPackView 199 } 200 | { 201 type: 'starterPackSkeleton' 202 key: string 203 } 204 | FeedPreviewItem 205 | { 206 type: 'interests-card' 207 key: 'interests-card' 208 } 209 | { 210 type: 'liveEventFeedsBanner' 211 key: string 212 } 213 214export function Explore({ 215 focusSearchInput, 216}: { 217 focusSearchInput: (tab: 'user' | 'profile' | 'feed') => void 218 headerHeight: number 219}) { 220 const ax = useAnalytics() 221 const {_} = useLingui() 222 const t = useTheme() 223 const {data: preferences, error: preferencesError} = usePreferencesQuery() 224 const moderationOpts = useModerationOpts() 225 const [selectedInterest, setSelectedInterest] = useState<string | null>(null) 226 227 /* 228 * Begin special language handling 229 */ 230 const {contentLanguages} = useLanguagePrefs() 231 const useFullExperience = useMemo(() => { 232 if (contentLanguages.length === 0) return true 233 return bcp47Match.basicFilter('en', contentLanguages).length > 0 234 }, [contentLanguages]) 235 const personalizedInterests = preferences?.interests?.tags 236 const interestsDisplayNames = useInterestsDisplayNames() 237 const interests = Object.keys(interestsDisplayNames) 238 .sort(boostInterests(popularInterests)) 239 .sort(boostInterests(personalizedInterests)) 240 const { 241 data: suggestedUsers, 242 isLoading: suggestedUsersIsLoading, 243 error: suggestedUsersError, 244 isRefetching: suggestedUsersIsRefetching, 245 } = useSuggestedUsers({ 246 category: selectedInterest || (useFullExperience ? null : interests[0]), 247 search: !useFullExperience, 248 }) 249 /* End special language handling */ 250 251 const { 252 data: feeds, 253 hasNextPage: hasNextFeedsPage, 254 isLoading: isLoadingFeeds, 255 isFetchingNextPage: isFetchingNextFeedsPage, 256 error: feedsError, 257 fetchNextPage: fetchNextFeedsPage, 258 } = useGetPopularFeedsQuery({limit: 10, enabled: useFullExperience}) 259 const interestsNux = useNux(Nux.ExploreInterestsCard) 260 const showInterestsNux = 261 interestsNux.status === 'ready' && !interestsNux.nux?.completed 262 263 const { 264 data: suggestedSPs, 265 isLoading: isLoadingSuggestedSPs, 266 error: suggestedSPsError, 267 isRefetching: isRefetchingSuggestedSPs, 268 } = useSuggestedStarterPacksQuery({enabled: useFullExperience}) 269 270 const isLoadingMoreFeeds = isFetchingNextFeedsPage && !isLoadingFeeds 271 const [hasPressedLoadMoreFeeds, setHasPressedLoadMoreFeeds] = useState(false) 272 const onLoadMoreFeeds = useCallback(async () => { 273 if (isFetchingNextFeedsPage || !hasNextFeedsPage || feedsError) return 274 if (!hasPressedLoadMoreFeeds) { 275 setHasPressedLoadMoreFeeds(true) 276 return 277 } 278 try { 279 await fetchNextFeedsPage() 280 } catch (err) { 281 ax.logger.error('Failed to load more suggested follows', {message: err}) 282 } 283 }, [ 284 ax, 285 isFetchingNextFeedsPage, 286 hasNextFeedsPage, 287 feedsError, 288 fetchNextFeedsPage, 289 hasPressedLoadMoreFeeds, 290 ]) 291 292 const {data: suggestedFeeds, error: suggestedFeedsError} = 293 useGetSuggestedFeedsQuery({ 294 enabled: useFullExperience, 295 }) 296 const { 297 data: feedPreviewSlices, 298 query: { 299 isPending: isPendingFeedPreviews, 300 isFetchingNextPage: isFetchingNextPageFeedPreviews, 301 fetchNextPage: fetchNextPageFeedPreviews, 302 hasNextPage: hasNextPageFeedPreviews, 303 error: feedPreviewSlicesError, 304 }, 305 } = useFeedPreviews(suggestedFeeds?.feeds ?? [], useFullExperience) 306 307 const qc = useQueryClient() 308 const [isPTR, setIsPTR] = useState(false) 309 const onPTR = useCallback(async () => { 310 setIsPTR(true) 311 await Promise.all([ 312 qc.resetQueries({ 313 queryKey: createGetTrendsQueryKey(), 314 }), 315 qc.resetQueries({ 316 queryKey: createSuggestedStarterPacksQueryKey(), 317 }), 318 qc.resetQueries({ 319 queryKey: [getSuggestedUsersQueryKeyRoot], 320 }), 321 qc.resetQueries({ 322 queryKey: [useActorSearchQueryKeyRoot], 323 }), 324 qc.resetQueries({ 325 queryKey: createGetSuggestedFeedsQueryKey(), 326 }), 327 ]) 328 setIsPTR(false) 329 }, [qc, setIsPTR]) 330 331 const onLoadMoreFeedPreviews = useCallback(async () => { 332 if ( 333 isPendingFeedPreviews || 334 isFetchingNextPageFeedPreviews || 335 !hasNextPageFeedPreviews || 336 feedPreviewSlicesError 337 ) 338 return 339 try { 340 await fetchNextPageFeedPreviews() 341 } catch (err) { 342 ax.logger.error('Failed to load more feed previews', {message: err}) 343 } 344 }, [ 345 ax, 346 isPendingFeedPreviews, 347 isFetchingNextPageFeedPreviews, 348 hasNextPageFeedPreviews, 349 feedPreviewSlicesError, 350 fetchNextPageFeedPreviews, 351 ]) 352 353 const topBorder = useMemo( 354 () => 355 ({ 356 type: 'topBorder', 357 key: 'top-border', 358 }) as const, 359 [], 360 ) 361 const trendingTopicsModule = useMemo( 362 () => 363 ({ 364 type: 'trendingTopics', 365 key: 'trending-topics', 366 }) as const, 367 [], 368 ) 369 const suggestedFollowsModule = useMemo(() => { 370 const i: ExploreScreenItems[] = [] 371 i.push({ 372 type: 'tabbedHeader', 373 key: 'suggested-accounts-header', 374 title: _(msg`Suggested accounts`), 375 icon: Person, 376 searchButton: { 377 label: _(msg`Search for more accounts`), 378 metricsTag: 'suggestedAccounts', 379 tab: 'user', 380 }, 381 hideDefaultTab: !useFullExperience, 382 }) 383 384 if (suggestedUsersIsLoading || suggestedUsersIsRefetching) { 385 i.push({type: 'profilePlaceholder', key: 'profilePlaceholder'}) 386 } else if (suggestedUsersError) { 387 i.push({ 388 type: 'error', 389 key: 'suggestedUsersError', 390 message: _(msg`Failed to load suggested follows`), 391 error: cleanError(suggestedUsersError), 392 }) 393 } else { 394 if (suggestedUsers !== undefined) { 395 if (suggestedUsers.actors.length > 0 && moderationOpts) { 396 // Currently the responses contain duplicate items. 397 // Needs to be fixed on backend, but let's dedupe to be safe. 398 let seen = new Set() 399 const profileItems: ExploreScreenItems[] = [] 400 for (const actor of suggestedUsers.actors) { 401 // checking for following still necessary if search data is used 402 if (!seen.has(actor.did) && !actor.viewer?.following) { 403 seen.add(actor.did) 404 profileItems.push({ 405 type: 'profile', 406 key: actor.did, 407 profile: actor, 408 }) 409 } 410 } 411 412 if (profileItems.length === 0) { 413 i.push({ 414 type: 'profileEmpty', 415 key: 'profileEmpty', 416 }) 417 } else { 418 if (selectedInterest === null && useFullExperience) { 419 // First "For You" tab, only show 5 to keep screen short 420 i.push(...profileItems.slice(0, 5)) 421 } else { 422 i.push(...profileItems) 423 } 424 } 425 } else { 426 i.push({ 427 type: 'profileEmpty', 428 key: 'profileEmpty', 429 }) 430 } 431 } else { 432 i.push({type: 'profilePlaceholder', key: 'profilePlaceholder'}) 433 } 434 } 435 return i 436 }, [ 437 _, 438 moderationOpts, 439 suggestedUsers, 440 suggestedUsersIsLoading, 441 suggestedUsersIsRefetching, 442 suggestedUsersError, 443 selectedInterest, 444 useFullExperience, 445 ]) 446 const suggestedFeedsModule = useMemo(() => { 447 const i: ExploreScreenItems[] = [] 448 i.push({ 449 type: 'header', 450 key: 'suggested-feeds-header', 451 title: _(msg`Discover new feeds`), 452 icon: ListSparkle, 453 searchButton: { 454 label: _(msg`Search for more feeds`), 455 metricsTag: 'suggestedFeeds', 456 tab: 'feed', 457 }, 458 }) 459 460 if (useFullExperience) { 461 if (suggestedFeeds && preferences) { 462 let seen = new Set() 463 const feedItems: ExploreScreenItems[] = [] 464 for (const feed of suggestedFeeds.feeds) { 465 if (!seen.has(feed.uri)) { 466 seen.add(feed.uri) 467 feedItems.push({ 468 type: 'feed', 469 key: feed.uri, 470 feed, 471 }) 472 } 473 } 474 475 // feeds errors can occur during pagination, so feeds is truthy 476 if (suggestedFeedsError) { 477 i.push({ 478 type: 'error', 479 key: 'suggestedFeedsError', 480 message: _(msg`Failed to load suggested feeds`), 481 error: cleanError(suggestedFeedsError), 482 }) 483 } else if (preferencesError) { 484 i.push({ 485 type: 'error', 486 key: 'preferencesError', 487 message: _(msg`Failed to load feeds preferences`), 488 error: cleanError(preferencesError), 489 }) 490 } else { 491 if (feedItems.length === 0) { 492 i.pop() 493 } else { 494 // This query doesn't follow the limit very well, so the first press of the 495 // load more button just unslices the array back to ~10 items 496 if (!hasPressedLoadMoreFeeds) { 497 i.push(...feedItems.slice(0, 6)) 498 } else { 499 i.push(...feedItems) 500 } 501 502 for (const [index, item] of feedItems.entries()) { 503 if (item.type !== 'feed') { 504 continue 505 } 506 // don't log the ones we've already sent 507 if (hasPressedLoadMoreFeeds && index < 6) { 508 continue 509 } 510 ax.metric('feed:suggestion:seen', {feedUrl: item.feed.uri}) 511 } 512 } 513 if (!hasPressedLoadMoreFeeds) { 514 i.push({ 515 type: 'loadMore', 516 key: 'loadMoreFeeds', 517 message: _(msg`Load more suggested feeds`), 518 isLoadingMore: isLoadingMoreFeeds, 519 onLoadMore: onLoadMoreFeeds, 520 }) 521 } 522 } 523 } else { 524 if (feedsError) { 525 i.push({ 526 type: 'error', 527 key: 'feedsError', 528 message: _(msg`Failed to load feeds`), 529 error: cleanError(feedsError), 530 }) 531 } else if (suggestedFeedsError) { 532 i.push({ 533 type: 'error', 534 key: 'suggestedFeedsError', 535 message: _(msg`Failed to load suggested feeds`), 536 error: cleanError(suggestedFeedsError), 537 }) 538 } else if (preferencesError) { 539 i.push({ 540 type: 'error', 541 key: 'preferencesError', 542 message: _(msg`Failed to load feeds preferences`), 543 error: cleanError(preferencesError), 544 }) 545 } else { 546 i.push({type: 'feedPlaceholder', key: 'feedPlaceholder'}) 547 } 548 } 549 } else { 550 if (feeds && preferences) { 551 // Currently the responses contain duplicate items. 552 // Needs to be fixed on backend, but let's dedupe to be safe. 553 let seen = new Set() 554 const feedItems: ExploreScreenItems[] = [] 555 for (const page of feeds.pages) { 556 for (const feed of page.feeds) { 557 if (!seen.has(feed.uri)) { 558 seen.add(feed.uri) 559 feedItems.push({ 560 type: 'feed', 561 key: feed.uri, 562 feed, 563 }) 564 } 565 } 566 } 567 568 // feeds errors can occur during pagination, so feeds is truthy 569 if (feedsError) { 570 i.push({ 571 type: 'error', 572 key: 'feedsError', 573 message: _(msg`Failed to load feeds`), 574 error: cleanError(feedsError), 575 }) 576 } else if (suggestedFeedsError) { 577 i.push({ 578 type: 'error', 579 key: 'suggestedFeedsError', 580 message: _(msg`Failed to load suggested feeds`), 581 error: cleanError(suggestedFeedsError), 582 }) 583 } else if (preferencesError) { 584 i.push({ 585 type: 'error', 586 key: 'preferencesError', 587 message: _(msg`Failed to load feeds preferences`), 588 error: cleanError(preferencesError), 589 }) 590 } else { 591 if (feedItems.length === 0) { 592 if (!hasNextFeedsPage) { 593 i.pop() 594 } 595 } else { 596 // This query doesn't follow the limit very well, so the first press of the 597 // load more button just unslices the array back to ~10 items 598 if (!hasPressedLoadMoreFeeds) { 599 i.push(...feedItems.slice(0, 3)) 600 } else { 601 i.push(...feedItems) 602 } 603 } 604 if (hasNextFeedsPage) { 605 i.push({ 606 type: 'loadMore', 607 key: 'loadMoreFeeds', 608 message: _(msg`Load more suggested feeds`), 609 isLoadingMore: isLoadingMoreFeeds, 610 onLoadMore: onLoadMoreFeeds, 611 }) 612 } 613 } 614 } else { 615 if (feedsError) { 616 i.push({ 617 type: 'error', 618 key: 'feedsError', 619 message: _(msg`Failed to load feeds`), 620 error: cleanError(feedsError), 621 }) 622 } else if (suggestedFeedsError) { 623 i.push({ 624 type: 'error', 625 key: 'feedsError', 626 message: _(msg`Failed to load suggested feeds`), 627 error: cleanError(suggestedFeedsError), 628 }) 629 } else if (preferencesError) { 630 i.push({ 631 type: 'error', 632 key: 'preferencesError', 633 message: _(msg`Failed to load feeds preferences`), 634 error: cleanError(preferencesError), 635 }) 636 } else { 637 i.push({type: 'feedPlaceholder', key: 'feedPlaceholder'}) 638 } 639 } 640 } 641 return i 642 }, [ 643 _, 644 ax, 645 useFullExperience, 646 suggestedFeeds, 647 preferences, 648 suggestedFeedsError, 649 preferencesError, 650 feedsError, 651 hasNextFeedsPage, 652 hasPressedLoadMoreFeeds, 653 isLoadingMoreFeeds, 654 onLoadMoreFeeds, 655 feeds, 656 ]) 657 658 const suggestedStarterPacksModule = useMemo(() => { 659 const i: ExploreScreenItems[] = [] 660 i.push({ 661 type: 'header', 662 key: 'suggested-starterPacks-header', 663 title: _(msg`Starter Packs`), 664 icon: StarterPack, 665 iconSize: 'xl', 666 }) 667 668 if (isLoadingSuggestedSPs || isRefetchingSuggestedSPs) { 669 Array.from({length: 3}).forEach((__, index) => 670 i.push({ 671 type: 'starterPackSkeleton', 672 key: `starterPackSkeleton-${index}`, 673 }), 674 ) 675 } else if (suggestedSPsError || !suggestedSPs) { 676 // just get rid of the section 677 i.pop() 678 } else { 679 suggestedSPs.starterPacks.map(s => { 680 i.push({ 681 type: 'starterPack', 682 key: s.uri, 683 view: s, 684 }) 685 }) 686 } 687 return i 688 }, [ 689 suggestedSPs, 690 _, 691 isLoadingSuggestedSPs, 692 suggestedSPsError, 693 isRefetchingSuggestedSPs, 694 ]) 695 const feedPreviewsModule = useMemo(() => { 696 const i: ExploreScreenItems[] = [] 697 i.push(...feedPreviewSlices) 698 if (isFetchingNextPageFeedPreviews) { 699 i.push({ 700 type: 'preview:loading', 701 key: 'preview-loading-more', 702 }) 703 } 704 return i 705 }, [feedPreviewSlices, isFetchingNextPageFeedPreviews]) 706 707 const interestsNuxModule = useMemo<ExploreScreenItems[]>(() => { 708 if (!showInterestsNux) return [] 709 return [ 710 { 711 type: 'interests-card', 712 key: 'interests-card', 713 }, 714 ] 715 }, [showInterestsNux]) 716 717 const items = useMemo<ExploreScreenItems[]>(() => { 718 const i: ExploreScreenItems[] = [] 719 720 // Dynamic module ordering 721 722 i.push(topBorder) 723 i.push(...interestsNuxModule) 724 725 i.push({type: 'liveEventFeedsBanner', key: 'liveEventFeedsBanner'}) 726 727 if (useFullExperience) { 728 i.push(trendingTopicsModule) 729 i.push(...suggestedFeedsModule) 730 i.push(...suggestedFollowsModule) 731 i.push(...suggestedStarterPacksModule) 732 i.push(...feedPreviewsModule) 733 } else { 734 i.push(...suggestedFollowsModule) 735 } 736 737 return i 738 }, [ 739 topBorder, 740 suggestedFollowsModule, 741 suggestedStarterPacksModule, 742 suggestedFeedsModule, 743 trendingTopicsModule, 744 feedPreviewsModule, 745 interestsNuxModule, 746 useFullExperience, 747 ]) 748 749 const renderItem = useCallback( 750 ({item, index}: {item: ExploreScreenItems; index: number}) => { 751 const handleOnPressRetry = () => { 752 void fetchNextPageFeedPreviews() 753 } 754 switch (item.type) { 755 case 'topBorder': 756 return ( 757 <View style={[a.w_full, t.atoms.border_contrast_low, a.border_t]} /> 758 ) 759 case 'header': { 760 return ( 761 <ModuleHeader.Container bottomBorder={item.bottomBorder}> 762 <ModuleHeader.Icon icon={item.icon} size={item.iconSize} /> 763 <ModuleHeader.TitleText>{item.title}</ModuleHeader.TitleText> 764 {item.searchButton && ( 765 <ModuleHeader.SearchButton 766 {...item.searchButton} 767 onPress={() => 768 focusSearchInput(item.searchButton?.tab || 'user') 769 } 770 /> 771 )} 772 </ModuleHeader.Container> 773 ) 774 } 775 case 'tabbedHeader': { 776 return ( 777 <View style={[a.pb_md]}> 778 <ModuleHeader.Container style={[a.pb_xs]}> 779 <ModuleHeader.Icon icon={item.icon} /> 780 <ModuleHeader.TitleText>{item.title}</ModuleHeader.TitleText> 781 {item.searchButton && ( 782 <ModuleHeader.SearchButton 783 {...item.searchButton} 784 onPress={() => 785 focusSearchInput(item.searchButton?.tab || 'user') 786 } 787 /> 788 )} 789 </ModuleHeader.Container> 790 <SuggestedAccountsTabBar 791 selectedInterest={selectedInterest} 792 onSelectInterest={setSelectedInterest} 793 hideDefaultTab={item.hideDefaultTab} 794 /> 795 </View> 796 ) 797 } 798 case 'trendingTopics': { 799 return ( 800 <View style={[a.pb_md]}> 801 <ExploreTrendingTopics /> 802 </View> 803 ) 804 } 805 case 'trendingVideos': { 806 return <ExploreTrendingVideos /> 807 } 808 case 'recommendations': { 809 return <ExploreRecommendations /> 810 } 811 case 'profile': { 812 return ( 813 <SuggestedProfileCard 814 profile={item.profile} 815 moderationOpts={moderationOpts!} 816 recId={item.recId} 817 position={index} 818 /> 819 ) 820 } 821 case 'profileEmpty': { 822 return ( 823 <View style={[a.px_lg, a.pb_lg]}> 824 <Admonition> 825 {selectedInterest ? ( 826 <Trans> 827 No results for "{interestsDisplayNames[selectedInterest]}". 828 </Trans> 829 ) : ( 830 <Trans>No results.</Trans> 831 )} 832 </Admonition> 833 </View> 834 ) 835 } 836 case 'feed': { 837 return ( 838 <View 839 style={[ 840 a.border_t, 841 t.atoms.border_contrast_low, 842 a.px_lg, 843 a.py_lg, 844 ]}> 845 <FeedCard.Default 846 view={item.feed} 847 onPress={() => { 848 if (!useFullExperience) { 849 return 850 } 851 ax.metric('feed:suggestion:press', { 852 feedUrl: item.feed.uri, 853 }) 854 }} 855 /> 856 </View> 857 ) 858 } 859 case 'starterPack': { 860 return ( 861 <View style={[a.px_lg, a.pb_lg]}> 862 <StarterPackCard view={item.view} /> 863 </View> 864 ) 865 } 866 case 'starterPackSkeleton': { 867 return ( 868 <View style={[a.px_lg, a.pb_lg]}> 869 <StarterPackCardSkeleton /> 870 </View> 871 ) 872 } 873 case 'loadMore': { 874 return ( 875 <View style={[a.border_t, t.atoms.border_contrast_low]}> 876 <LoadMore item={item} /> 877 </View> 878 ) 879 } 880 case 'profilePlaceholder': { 881 return ( 882 <> 883 {Array.from({length: 3}).map((__, i) => ( 884 <View 885 style={[ 886 a.px_lg, 887 a.py_lg, 888 a.border_t, 889 t.atoms.border_contrast_low, 890 ]} 891 key={i}> 892 <ProfileCard.Outer> 893 <ProfileCard.Header> 894 <ProfileCard.AvatarPlaceholder /> 895 <ProfileCard.NameAndHandlePlaceholder /> 896 </ProfileCard.Header> 897 <ProfileCard.DescriptionPlaceholder numberOfLines={2} /> 898 </ProfileCard.Outer> 899 </View> 900 ))} 901 </> 902 ) 903 } 904 case 'feedPlaceholder': { 905 return <FeedFeedLoadingPlaceholder /> 906 } 907 case 'error': 908 case 'preview:error': { 909 return ( 910 <View 911 style={[ 912 a.border_t, 913 a.pt_md, 914 a.px_md, 915 t.atoms.border_contrast_low, 916 ]}> 917 <View 918 style={[ 919 a.flex_row, 920 a.gap_md, 921 a.p_lg, 922 a.rounded_sm, 923 t.atoms.bg_contrast_25, 924 ]}> 925 <CircleInfo size="md" fill={t.palette.negative_400} /> 926 <View style={[a.flex_1, a.gap_sm]}> 927 <Text style={[a.font_semi_bold, a.leading_snug]}> 928 {item.message} 929 </Text> 930 <Text 931 style={[ 932 a.italic, 933 a.leading_snug, 934 t.atoms.text_contrast_medium, 935 ]}> 936 {item.error} 937 </Text> 938 </View> 939 </View> 940 </View> 941 ) 942 } 943 // feed previews 944 case 'preview:spacer': { 945 return <View style={[a.w_full, a.pt_4xl]} /> 946 } 947 case 'preview:empty': { 948 return null // what should we do here? 949 } 950 case 'preview:loading': { 951 return ( 952 <View style={[a.py_2xl, a.flex_1, a.align_center]}> 953 <Loader size="lg" /> 954 </View> 955 ) 956 } 957 case 'preview:header': { 958 return ( 959 <ModuleHeader.Container style={[a.pt_xs]} bottomBorder> 960 {/* Very non-scientific way to avoid small gap on scroll */} 961 <View style={[a.absolute, a.inset_0, t.atoms.bg, {top: -2}]} /> 962 <ModuleHeader.FeedLink feed={item.feed}> 963 <ModuleHeader.FeedAvatar feed={item.feed} /> 964 <View style={[a.flex_1, a.gap_2xs]}> 965 <ModuleHeader.TitleText style={[a.text_lg]}> 966 {item.feed.displayName} 967 </ModuleHeader.TitleText> 968 <ModuleHeader.SubtitleText> 969 <Trans> 970 By {sanitizeHandle(item.feed.creator.handle, '@')} 971 </Trans> 972 </ModuleHeader.SubtitleText> 973 </View> 974 </ModuleHeader.FeedLink> 975 <ModuleHeader.PinButton feed={item.feed} /> 976 </ModuleHeader.Container> 977 ) 978 } 979 case 'preview:footer': { 980 return ( 981 <View 982 style={[ 983 a.border_t, 984 t.atoms.border_contrast_low, 985 a.w_full, 986 a.pt_4xl, 987 ]} 988 /> 989 ) 990 } 991 case 'preview:sliceItem': { 992 const slice = item.slice 993 const indexInSlice = item.indexInSlice 994 const subItem = slice.items[indexInSlice] 995 return ( 996 <PostFeedItem 997 post={subItem.post} 998 record={subItem.record} 999 reason={indexInSlice === 0 ? slice.reason : undefined} 1000 feedContext={slice.feedContext} 1001 reqId={slice.reqId} 1002 moderation={subItem.moderation} 1003 parentAuthor={subItem.parentAuthor} 1004 showReplyTo={item.showReplyTo} 1005 isThreadParent={isThreadParentAt(slice.items, indexInSlice)} 1006 isThreadChild={isThreadChildAt(slice.items, indexInSlice)} 1007 isThreadLastChild={ 1008 isThreadChildAt(slice.items, indexInSlice) && 1009 slice.items.length === indexInSlice + 1 1010 } 1011 isParentBlocked={subItem.isParentBlocked} 1012 isParentNotFound={subItem.isParentNotFound} 1013 hideTopBorder={item.hideTopBorder} 1014 rootPost={slice.items[0].post} 1015 /> 1016 ) 1017 } 1018 case 'preview:sliceViewFullThread': { 1019 return <ViewFullThread uri={item.uri} /> 1020 } 1021 case 'preview:loadMoreError': { 1022 return ( 1023 <LoadMoreRetryBtn 1024 label={_( 1025 msg`There was an issue fetching posts. Tap here to try again.`, 1026 )} 1027 onPress={handleOnPressRetry} 1028 /> 1029 ) 1030 } 1031 case 'interests-card': { 1032 return <ExploreInterestsCard /> 1033 } 1034 case 'liveEventFeedsBanner': { 1035 return <ExploreScreenLiveEventFeedsBanner /> 1036 } 1037 } 1038 }, 1039 [ 1040 ax, 1041 t.atoms.border_contrast_low, 1042 t.atoms.bg_contrast_25, 1043 t.atoms.text_contrast_medium, 1044 t.atoms.bg, 1045 t.palette.negative_400, 1046 focusSearchInput, 1047 selectedInterest, 1048 moderationOpts, 1049 interestsDisplayNames, 1050 useFullExperience, 1051 _, 1052 fetchNextPageFeedPreviews, 1053 ], 1054 ) 1055 1056 const stickyHeaderIndices = useMemo( 1057 () => 1058 items.reduce( 1059 (acc, curr) => 1060 ['topBorder', 'preview:header'].includes(curr.type) 1061 ? acc.concat(items.indexOf(curr)) 1062 : acc, 1063 [] as number[], 1064 ), 1065 [items], 1066 ) 1067 1068 // track headers and report module viewability 1069 const alreadyReportedRef = useRef<Map<string, string>>(new Map()) 1070 const seenProfilesRef = useRef<Set<string>>(new Set()) 1071 const onItemSeen = useCallback( 1072 (item: ExploreScreenItems) => { 1073 let module: Metrics['explore:module:seen']['module'] 1074 if (item.type === 'trendingTopics' || item.type === 'trendingVideos') { 1075 module = item.type 1076 } else if (item.type === 'profile') { 1077 module = 'suggestedAccounts' 1078 // Track individual profile seen events 1079 if (!seenProfilesRef.current.has(item.profile.did)) { 1080 seenProfilesRef.current.add(item.profile.did) 1081 const position = suggestedFollowsModule.findIndex( 1082 i => i.type === 'profile' && i.profile.did === item.profile.did, 1083 ) 1084 ax.metric('suggestedUser:seen', { 1085 logContext: 'Explore', 1086 recId: item.recId, 1087 position: position !== -1 ? position - 1 : 0, // -1 to account for header 1088 suggestedDid: item.profile.did, 1089 category: null, 1090 }) 1091 } 1092 } else if (item.type === 'feed') { 1093 module = 'suggestedFeeds' 1094 } else if (item.type === 'starterPack') { 1095 module = 'suggestedStarterPacks' 1096 } else if (item.type === 'preview:sliceItem') { 1097 module = `feed:feedgen|${item.feed.uri}` 1098 } else { 1099 return 1100 } 1101 if (!alreadyReportedRef.current.has(module)) { 1102 alreadyReportedRef.current.set(module, module) 1103 ax.metric('explore:module:seen', {module}) 1104 } 1105 }, 1106 [ax, suggestedFollowsModule], 1107 ) 1108 1109 const handleOnEndReached = () => { 1110 void onLoadMoreFeedPreviews() 1111 } 1112 1113 const handleOnRefresh = () => { 1114 void onPTR() 1115 } 1116 1117 return ( 1118 <List 1119 data={items} 1120 renderItem={renderItem} 1121 keyExtractor={keyExtractor} 1122 desktopFixedHeight 1123 contentContainerStyle={{paddingBottom: 100}} 1124 keyboardShouldPersistTaps="handled" 1125 keyboardDismissMode="on-drag" 1126 stickyHeaderIndices={native(stickyHeaderIndices)} 1127 viewabilityConfig={viewabilityConfig} 1128 onItemSeen={onItemSeen} 1129 onEndReached={handleOnEndReached} 1130 /** 1131 * Default: 2 1132 */ 1133 onEndReachedThreshold={4} 1134 /** 1135 * Default: 10 1136 */ 1137 initialNumToRender={10} 1138 /** 1139 * Default: 21 1140 */ 1141 windowSize={platform({android: 11})} 1142 /** 1143 * Default: 10 1144 * 1145 * NOTE: This was 1 on Android. Unfortunately this leads to the list totally freaking out 1146 * when the sticky headers changed. I made a minimal reproduction and yeah, it's this prop. 1147 * Totally fine when the sticky headers are static, but when they're dynamic, it's a mess. 1148 * 1149 * Repro: https://github.com/mozzius/stickyindices-repro 1150 * 1151 * I then found doubling this prop on iOS also reduced it freaking out there as well. 1152 * 1153 * Trades off seeing more blank space due to it having to render more items before it can show anything. 1154 * -sfn 1155 */ 1156 maxToRenderPerBatch={platform({android: 10, ios: 20})} 1157 /** 1158 * Default: 50 1159 * 1160 * NOTE: This was 25 on Android. However, due to maxToRenderPerBatch being set to 10, 1161 * the lower batching period is no longer necessary (?) 1162 */ 1163 updateCellsBatchingPeriod={50} 1164 refreshing={isPTR} 1165 onRefresh={handleOnRefresh} 1166 /> 1167 ) 1168} 1169 1170function keyExtractor(item: FeedPreviewItem) { 1171 return item.key 1172} 1173 1174const viewabilityConfig: ViewabilityConfig = { 1175 itemVisiblePercentThreshold: 100, 1176}