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

Configure Feed

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

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