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