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