forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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}