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

Configure Feed

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

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