Mirror — see github.com/blacksky-algorithms/blacksky.community
6
fork

Configure Feed

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

Remove unsupported sections from Explore page

Remove Suggested Accounts, Discover New Feeds, Starter Packs,
and feed previews from the search/explore page since the Blacksky
appview doesn't support those endpoints.

+13 -972
+13 -972
src/screens/Search/Explore.tsx
··· 1 1 import {useCallback, useMemo, useRef, useState} from 'react' 2 2 import {View, type ViewabilityConfig} from 'react-native' 3 - import { 4 - type AppBskyActorDefs, 5 - type AppBskyFeedDefs, 6 - type AppBskyGraphDefs, 7 - } from '@atproto/api' 8 - import {msg, Trans} from '@lingui/macro' 9 - import {useLingui} from '@lingui/react' 10 3 import {useQueryClient} from '@tanstack/react-query' 11 4 import * as bcp47Match from 'bcp-47-match' 12 5 13 - import {popularInterests, useInterestsDisplayNames} from '#/lib/interests' 14 - import {cleanError} from '#/lib/strings/errors' 15 - import {sanitizeHandle} from '#/lib/strings/handles' 16 6 import {useLanguagePrefs} from '#/state/preferences/languages' 17 - import {useModerationOpts} from '#/state/preferences/moderation-opts' 18 - import {RQKEY_ROOT as useActorSearchQueryKeyRoot} from '#/state/queries/actor-search' 19 - import { 20 - type FeedPreviewItem, 21 - useFeedPreviews, 22 - } from '#/state/queries/explore-feed-previews' 23 - import {useGetPopularFeedsQuery} from '#/state/queries/feed' 24 7 import {Nux, useNux} from '#/state/queries/nuxs' 25 - import {usePreferencesQuery} from '#/state/queries/preferences' 26 - import { 27 - createGetSuggestedFeedsQueryKey, 28 - useGetSuggestedFeedsQuery, 29 - } from '#/state/queries/trending/useGetSuggestedFeedsQuery' 30 - import {getSuggestedUsersQueryKeyRoot} from '#/state/queries/trending/useGetSuggestedUsersQuery' 31 8 import {createGetTrendsQueryKey} from '#/state/queries/trending/useGetTrendsQuery' 32 - import { 33 - createSuggestedStarterPacksQueryKey, 34 - useSuggestedStarterPacksQuery, 35 - } from '#/state/queries/useSuggestedStarterPacksQuery' 36 - import {isThreadChildAt, isThreadParentAt} from '#/view/com/posts/PostFeed' 37 - import {PostFeedItem} from '#/view/com/posts/PostFeedItem' 38 - import {ViewFullThread} from '#/view/com/posts/ViewFullThread' 39 9 import {List} from '#/view/com/util/List' 40 - import {FeedFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 41 - import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn' 42 - import { 43 - StarterPackCard, 44 - StarterPackCardSkeleton, 45 - } from '#/screens/Search/components/StarterPackCard' 46 10 import {ExploreInterestsCard} from '#/screens/Search/modules/ExploreInterestsCard' 47 - import {ExploreRecommendations} from '#/screens/Search/modules/ExploreRecommendations' 48 11 import {ExploreTrendingTopics} from '#/screens/Search/modules/ExploreTrendingTopics' 49 - import {ExploreTrendingVideos} from '#/screens/Search/modules/ExploreTrendingVideos' 50 - import {useSuggestedUsers} from '#/screens/Search/util/useSuggestedUsers' 51 - import {atoms as a, native, platform, useTheme} from '#/alf' 52 - import {Admonition} from '#/components/Admonition' 53 - import {Button} from '#/components/Button' 54 - import * as FeedCard from '#/components/FeedCard' 55 - import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon} from '#/components/icons/Chevron' 56 - import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 57 - import { 58 - type Props as IcoProps, 59 - type Props as SVGIconProps, 60 - } from '#/components/icons/common' 61 - import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle' 62 - import {StarterPack} from '#/components/icons/StarterPack' 63 - import {UserCircle_Stroke2_Corner0_Rounded as Person} from '#/components/icons/UserCircle' 64 - import {boostInterests} from '#/components/InterestTabs' 65 - import {Loader} from '#/components/Loader' 66 - import * as ProfileCard from '#/components/ProfileCard' 67 - import {SubtleHover} from '#/components/SubtleHover' 68 - import {Text} from '#/components/Typography' 12 + import {atoms as a, native, useTheme} from '#/alf' 69 13 import {type Metrics, useAnalytics} from '#/analytics' 70 14 import {ExploreScreenLiveEventFeedsBanner} from '#/features/liveEvents/components/ExploreScreenLiveEventFeedsBanner' 71 - import * as ModuleHeader from './components/ModuleHeader' 72 - import { 73 - SuggestedAccountsTabBar, 74 - SuggestedProfileCard, 75 - } from './modules/ExploreSuggestedAccounts' 76 - 77 - function 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 15 112 16 type ExploreScreenItems = 113 17 | { ··· 115 19 key: string 116 20 } 117 21 | { 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 22 type: 'trendingTopics' 144 23 key: string 145 24 } 146 25 | { 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 26 type: 'interests-card' 202 27 key: 'interests-card' 203 28 } ··· 207 32 } 208 33 209 34 export function Explore({ 210 - focusSearchInput, 35 + focusSearchInput: _focusSearchInput, 211 36 }: { 212 37 focusSearchInput: (tab: 'user' | 'profile' | 'feed') => void 213 38 headerHeight: number 214 39 }) { 215 40 const ax = useAnalytics() 216 - const {_} = useLingui() 217 41 const t = useTheme() 218 - const {data: preferences, error: preferencesError} = usePreferencesQuery() 219 - const moderationOpts = useModerationOpts() 220 - const [selectedInterest, setSelectedInterest] = useState<string | null>(null) 221 42 222 - /* 223 - * Begin special language handling 224 - */ 225 43 const {contentLanguages} = useLanguagePrefs() 226 44 const useFullExperience = useMemo(() => { 227 45 if (contentLanguages.length === 0) return true 228 46 return bcp47Match.basicFilter('en', contentLanguages).length > 0 229 47 }, [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 48 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 49 const interestsNux = useNux(Nux.ExploreInterestsCard) 255 50 const showInterestsNux = 256 51 interestsNux.status === 'ready' && !interestsNux.nux?.completed 257 52 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 53 const qc = useQueryClient() 303 54 const [isPTR, setIsPTR] = useState(false) 304 55 const onPTR = useCallback(async () => { 305 56 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 - ]) 57 + await qc.resetQueries({ 58 + queryKey: createGetTrendsQueryKey(), 59 + }) 323 60 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 - ]) 61 + }, [qc]) 347 62 348 63 const topBorder = useMemo( 349 64 () => ({type: 'topBorder', key: 'top-border'}) as const, ··· 353 68 () => ({type: 'trendingTopics', key: 'trending-topics'}) as const, 354 69 [], 355 70 ) 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 71 673 72 const interestsNuxModule = useMemo<ExploreScreenItems[]>(() => { 674 73 if (!showInterestsNux) return [] ··· 683 82 const items = useMemo<ExploreScreenItems[]>(() => { 684 83 const i: ExploreScreenItems[] = [] 685 84 686 - // Dynamic module ordering 687 - 688 85 i.push(topBorder) 689 86 i.push(...interestsNuxModule) 690 - 691 87 i.push({type: 'liveEventFeedsBanner', key: 'liveEventFeedsBanner'}) 692 88 693 89 if (useFullExperience) { 694 90 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 91 } 702 92 703 93 return i 704 - }, [ 705 - topBorder, 706 - suggestedFollowsModule, 707 - suggestedStarterPacksModule, 708 - suggestedFeedsModule, 709 - trendingTopicsModule, 710 - feedPreviewsModule, 711 - interestsNuxModule, 712 - useFullExperience, 713 - ]) 94 + }, [topBorder, trendingTopicsModule, interestsNuxModule, useFullExperience]) 714 95 715 96 const renderItem = useCallback( 716 - ({item, index}: {item: ExploreScreenItems; index: number}) => { 97 + ({item}: {item: ExploreScreenItems}) => { 717 98 switch (item.type) { 718 99 case 'topBorder': 719 100 return ( 720 101 <View style={[a.w_full, t.atoms.border_contrast_low, a.border_t]} /> 721 102 ) 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 103 case 'trendingTopics': { 762 104 return ( 763 105 <View style={[a.pb_md]}> ··· 765 107 </View> 766 108 ) 767 109 } 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 110 case 'interests-card': { 995 111 return <ExploreInterestsCard /> 996 112 } ··· 999 115 } 1000 116 } 1001 117 }, 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 - ], 118 + [t.atoms.border_contrast_low], 1017 119 ) 1018 120 1019 121 const stickyHeaderIndices = useMemo( 1020 122 () => 1021 123 items.reduce( 1022 124 (acc, curr) => 1023 - ['topBorder', 'preview:header'].includes(curr.type) 1024 - ? acc.concat(items.indexOf(curr)) 1025 - : acc, 125 + curr.type === 'topBorder' ? acc.concat(items.indexOf(curr)) : acc, 1026 126 [] as number[], 1027 127 ), 1028 128 [items], 1029 129 ) 1030 130 1031 - // track headers and report module viewability 1032 131 const alreadyReportedRef = useRef<Map<string, string>>(new Map()) 1033 - const seenProfilesRef = useRef<Set<string>>(new Set()) 1034 132 const onItemSeen = useCallback( 1035 133 (item: ExploreScreenItems) => { 1036 134 let module: Metrics['explore:module:seen']['module'] 1037 - if (item.type === 'trendingTopics' || item.type === 'trendingVideos') { 135 + if (item.type === 'trendingTopics') { 1038 136 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 137 } else { 1062 138 return 1063 139 } ··· 1066 142 ax.metric('explore:module:seen', {module}) 1067 143 } 1068 144 }, 1069 - [ax, suggestedFollowsModule], 145 + [ax], 1070 146 ) 1071 147 1072 148 return ( ··· 1081 157 stickyHeaderIndices={native(stickyHeaderIndices)} 1082 158 viewabilityConfig={viewabilityConfig} 1083 159 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 160 refreshing={isPTR} 1120 161 onRefresh={onPTR} 1121 162 /> 1122 163 ) 1123 164 } 1124 165 1125 - function keyExtractor(item: FeedPreviewItem) { 166 + function keyExtractor(item: ExploreScreenItems) { 1126 167 return item.key 1127 168 } 1128 169