diff options
Diffstat (limited to 'src/screens/Search/Explore.tsx')
-rw-r--r-- | src/screens/Search/Explore.tsx | 923 |
1 files changed, 923 insertions, 0 deletions
diff --git a/src/screens/Search/Explore.tsx b/src/screens/Search/Explore.tsx new file mode 100644 index 000000000..86877677a --- /dev/null +++ b/src/screens/Search/Explore.tsx @@ -0,0 +1,923 @@ +import {useCallback, useMemo, useRef, useState} from 'react' +import {View, type ViewabilityConfig, type ViewToken} from 'react-native' +import { + type AppBskyActorDefs, + type AppBskyFeedDefs, + type AppBskyGraphDefs, +} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useGate} from '#/lib/statsig/statsig' +import {cleanError} from '#/lib/strings/errors' +import {sanitizeHandle} from '#/lib/strings/handles' +import {logger} from '#/logger' +import {type MetricEvents} from '#/logger/metrics' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useActorSearchPaginated} from '#/state/queries/actor-search' +import {useGetPopularFeedsQuery} from '#/state/queries/feed' +import {usePreferencesQuery} from '#/state/queries/preferences' +import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows' +import {useGetSuggestedFeedsQuery} from '#/state/queries/trending/useGetSuggestedFeedsQuery' +import {useSuggestedStarterPacksQuery} from '#/state/queries/useSuggestedStarterPacksQuery' +import {useProgressGuide} from '#/state/shell/progress-guide' +import {isThreadChildAt, isThreadParentAt} from '#/view/com/posts/PostFeed' +import {PostFeedItem} from '#/view/com/posts/PostFeedItem' +import {ViewFullThread} from '#/view/com/posts/ViewFullThread' +import {List} from '#/view/com/util/List' +import {FeedFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' +import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn' +import { + StarterPackCard, + StarterPackCardSkeleton, +} from '#/screens/Search/components/StarterPackCard' +import {ExploreRecommendations} from '#/screens/Search/modules/ExploreRecommendations' +import {ExploreTrendingTopics} from '#/screens/Search/modules/ExploreTrendingTopics' +import {ExploreTrendingVideos} from '#/screens/Search/modules/ExploreTrendingVideos' +import {atoms as a, native, useTheme, web} from '#/alf' +import {Button} from '#/components/Button' +import * as FeedCard from '#/components/FeedCard' +import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon} from '#/components/icons/Chevron' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' +import {type Props as SVGIconProps} from '#/components/icons/common' +import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle' +import {StarterPack} from '#/components/icons/StarterPack' +import {UserCircle_Stroke2_Corner0_Rounded as Person} from '#/components/icons/UserCircle' +import {Loader} from '#/components/Loader' +import * as ProfileCard from '#/components/ProfileCard' +import {Text} from '#/components/Typography' +import * as ModuleHeader from './components/ModuleHeader' +import { + type FeedPreviewItem, + useFeedPreviews, +} from './modules/ExploreFeedPreviews' +import { + SuggestedAccountsTabBar, + SuggestedProfileCard, + useLoadEnoughProfiles, +} from './modules/ExploreSuggestedAccounts' + +function LoadMore({item}: {item: ExploreScreenItems & {type: 'loadMore'}}) { + const t = useTheme() + const {_} = useLingui() + + return ( + <Button + label={_(msg`Load more`)} + onPress={item.onLoadMore} + style={[a.relative, a.w_full]}> + {({hovered, pressed}) => ( + <View + style={[ + a.flex_1, + a.flex_row, + a.align_center, + a.justify_center, + a.px_lg, + a.py_md, + a.gap_sm, + (hovered || pressed) && t.atoms.bg_contrast_25, + ]}> + <Text + style={[ + a.leading_snug, + hovered ? t.atoms.text : t.atoms.text_contrast_medium, + ]}> + {item.message} + </Text> + {item.isLoadingMore ? ( + <Loader size="sm" /> + ) : ( + <ChevronDownIcon + size="sm" + style={hovered ? t.atoms.text : t.atoms.text_contrast_medium} + /> + )} + </View> + )} + </Button> + ) +} + +type ExploreScreenItems = + | { + type: 'topBorder' + key: string + } + | { + type: 'header' + key: string + title: string + icon: React.ComponentType<SVGIconProps> + searchButton?: { + label: string + metricsTag: MetricEvents['explore:module:searchButtonPress']['module'] + tab: 'user' | 'profile' | 'feed' + } + } + | { + type: 'tabbedHeader' + key: string + title: string + icon: React.ComponentType<SVGIconProps> + searchButton?: { + label: string + metricsTag: MetricEvents['explore:module:searchButtonPress']['module'] + tab: 'user' | 'profile' | 'feed' + } + } + | { + type: 'trendingTopics' + key: string + } + | { + type: 'trendingVideos' + key: string + } + | { + type: 'recommendations' + key: string + } + | { + type: 'profile' + key: string + profile: AppBskyActorDefs.ProfileView + recId?: number + } + | { + type: 'feed' + key: string + feed: AppBskyFeedDefs.GeneratorView + } + | { + type: 'loadMore' + key: string + message: string + isLoadingMore: boolean + onLoadMore: () => void + } + | { + type: 'profilePlaceholder' + key: string + } + | { + type: 'feedPlaceholder' + key: string + } + | { + type: 'error' + key: string + message: string + error: string + } + | { + type: 'starterPack' + key: string + view: AppBskyGraphDefs.StarterPackView + } + | { + type: 'starterPackSkeleton' + key: string + } + | FeedPreviewItem + +export function Explore({ + focusSearchInput, + headerHeight, +}: { + focusSearchInput: (tab: 'user' | 'profile' | 'feed') => void + headerHeight: number +}) { + const {_} = useLingui() + const t = useTheme() + const {data: preferences, error: preferencesError} = usePreferencesQuery() + const moderationOpts = useModerationOpts() + const gate = useGate() + const guide = useProgressGuide('follow-10') + const [selectedInterest, setSelectedInterest] = useState<string | null>(null) + const { + data: suggestedProfiles, + hasNextPage: hasNextSuggestedProfilesPage, + isLoading: isLoadingSuggestedProfiles, + isFetchingNextPage: isFetchingNextSuggestedProfilesPage, + error: suggestedProfilesError, + fetchNextPage: fetchNextSuggestedProfilesPage, + } = useSuggestedFollowsQuery({limit: 3, subsequentPageLimit: 10}) + const { + data: interestProfiles, + hasNextPage: hasNextInterestProfilesPage, + isLoading: isLoadingInterestProfiles, + isFetchingNextPage: isFetchingNextInterestProfilesPage, + error: interestProfilesError, + fetchNextPage: fetchNextInterestProfilesPage, + } = useActorSearchPaginated({ + query: selectedInterest || '', + enabled: !!selectedInterest, + limit: 10, + }) + const {isReady: canShowSuggestedProfiles} = useLoadEnoughProfiles({ + interest: selectedInterest, + data: interestProfiles, + isLoading: isLoadingInterestProfiles, + isFetchingNextPage: isFetchingNextInterestProfilesPage, + hasNextPage: hasNextInterestProfilesPage, + fetchNextPage: fetchNextInterestProfilesPage, + }) + const { + data: feeds, + hasNextPage: hasNextFeedsPage, + isLoading: isLoadingFeeds, + isFetchingNextPage: isFetchingNextFeedsPage, + error: feedsError, + fetchNextPage: fetchNextFeedsPage, + } = useGetPopularFeedsQuery({limit: 10}) + + const profiles: typeof suggestedProfiles & typeof interestProfiles = + !selectedInterest ? suggestedProfiles : interestProfiles + const hasNextProfilesPage = !selectedInterest + ? hasNextSuggestedProfilesPage + : hasNextInterestProfilesPage + const isLoadingProfiles = !selectedInterest + ? isLoadingSuggestedProfiles + : !canShowSuggestedProfiles + const isFetchingNextProfilesPage = !selectedInterest + ? isFetchingNextSuggestedProfilesPage + : !canShowSuggestedProfiles + const profilesError = !selectedInterest + ? suggestedProfilesError + : interestProfilesError + const fetchNextProfilesPage = !selectedInterest + ? fetchNextSuggestedProfilesPage + : fetchNextInterestProfilesPage + + const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles + const onLoadMoreProfiles = useCallback(async () => { + if (isFetchingNextProfilesPage || !hasNextProfilesPage || profilesError) + return + try { + await fetchNextProfilesPage() + } catch (err) { + logger.error('Failed to load more suggested follows', {message: err}) + } + }, [ + isFetchingNextProfilesPage, + hasNextProfilesPage, + profilesError, + fetchNextProfilesPage, + ]) + const { + data: suggestedSPs, + isLoading: isLoadingSuggestedSPs, + error: suggestedSPsError, + } = useSuggestedStarterPacksQuery() + + const isLoadingMoreFeeds = isFetchingNextFeedsPage && !isLoadingFeeds + const [hasPressedLoadMoreFeeds, setHasPressedLoadMoreFeeds] = useState(false) + const onLoadMoreFeeds = useCallback(async () => { + if (isFetchingNextFeedsPage || !hasNextFeedsPage || feedsError) return + if (!hasPressedLoadMoreFeeds) { + setHasPressedLoadMoreFeeds(true) + return + } + try { + await fetchNextFeedsPage() + } catch (err) { + logger.error('Failed to load more suggested follows', {message: err}) + } + }, [ + isFetchingNextFeedsPage, + hasNextFeedsPage, + feedsError, + fetchNextFeedsPage, + hasPressedLoadMoreFeeds, + ]) + + const {data: suggestedFeeds} = useGetSuggestedFeedsQuery() + const { + data: feedPreviewSlices, + query: { + isPending: isPendingFeedPreviews, + isFetchingNextPage: isFetchingNextPageFeedPreviews, + fetchNextPage: fetchNextPageFeedPreviews, + hasNextPage: hasNextPageFeedPreviews, + error: feedPreviewSlicesError, + }, + } = useFeedPreviews(suggestedFeeds?.feeds ?? []) + + const onLoadMoreFeedPreviews = useCallback(async () => { + if ( + isPendingFeedPreviews || + isFetchingNextPageFeedPreviews || + !hasNextPageFeedPreviews || + feedPreviewSlicesError + ) + return + try { + await fetchNextPageFeedPreviews() + } catch (err) { + logger.error('Failed to load more feed previews', {message: err}) + } + }, [ + isPendingFeedPreviews, + isFetchingNextPageFeedPreviews, + hasNextPageFeedPreviews, + feedPreviewSlicesError, + fetchNextPageFeedPreviews, + ]) + + const items = useMemo<ExploreScreenItems[]>(() => { + const i: ExploreScreenItems[] = [] + + const addTopBorder = () => { + i.push({type: 'topBorder', key: 'top-border'}) + } + + const addTrendingTopicsModule = () => { + i.push({ + type: 'trendingTopics', + key: `trending-topics`, + }) + + // temp - disable trending videos + // if (isNative) { + // i.push({ + // type: 'trendingVideos', + // key: `trending-videos`, + // }) + // } + } + + const addSuggestedFollowsModule = () => { + i.push({ + type: 'tabbedHeader', + key: 'suggested-accounts-header', + title: _(msg`Suggested Accounts`), + icon: Person, + searchButton: { + label: _(msg`Search for more accounts`), + metricsTag: 'suggestedAccounts', + tab: 'user', + }, + }) + + if (!canShowSuggestedProfiles) { + i.push({type: 'profilePlaceholder', key: 'profilePlaceholder'}) + } else if (profilesError) { + i.push({ + type: 'error', + key: 'profilesError', + message: _(msg`Failed to load suggested follows`), + error: cleanError(profilesError), + }) + } else { + if (profiles !== undefined) { + if (profiles.pages.length > 0 && moderationOpts) { + // Currently the responses contain duplicate items. + // Needs to be fixed on backend, but let's dedupe to be safe. + let seen = new Set() + const profileItems: ExploreScreenItems[] = [] + for (const page of profiles.pages) { + for (const actor of page.actors) { + if (!seen.has(actor.did) && !actor.viewer?.following) { + seen.add(actor.did) + profileItems.push({ + type: 'profile', + key: actor.did, + profile: actor, + recId: page.recId, + }) + } + } + } + + if (profileItems.length === 0) { + if (!hasNextProfilesPage) { + // no items! remove the header + i.pop() + } + } else { + i.push(...profileItems) + } + if (hasNextProfilesPage) { + i.push({ + type: 'loadMore', + key: 'loadMoreProfiles', + message: _(msg`Load more suggested accounts`), + isLoadingMore: isLoadingMoreProfiles, + onLoadMore: onLoadMoreProfiles, + }) + } + } else { + console.log('no pages') + } + } else { + i.push({type: 'profilePlaceholder', key: 'profilePlaceholder'}) + } + } + } + + const addSuggestedFeedsModule = () => { + i.push({ + type: 'header', + key: 'suggested-feeds-header', + title: _(msg`Discover Feeds`), + icon: ListSparkle, + searchButton: { + label: _(msg`Search for more feeds`), + metricsTag: 'suggestedFeeds', + tab: 'feed', + }, + }) + + if (feeds && preferences) { + // Currently the responses contain duplicate items. + // Needs to be fixed on backend, but let's dedupe to be safe. + let seen = new Set() + const feedItems: ExploreScreenItems[] = [] + for (const page of feeds.pages) { + for (const feed of page.feeds) { + if (!seen.has(feed.uri)) { + seen.add(feed.uri) + feedItems.push({ + type: 'feed', + key: feed.uri, + feed, + }) + } + } + } + + // feeds errors can occur during pagination, so feeds is truthy + if (feedsError) { + i.push({ + type: 'error', + key: 'feedsError', + message: _(msg`Failed to load suggested feeds`), + error: cleanError(feedsError), + }) + } else if (preferencesError) { + i.push({ + type: 'error', + key: 'preferencesError', + message: _(msg`Failed to load feeds preferences`), + error: cleanError(preferencesError), + }) + } else { + if (feedItems.length === 0) { + if (!hasNextFeedsPage) { + i.pop() + } + } else { + // This query doesn't follow the limit very well, so the first press of the + // load more button just unslices the array back to ~10 items + if (!hasPressedLoadMoreFeeds) { + i.push(...feedItems.slice(0, 3)) + } else { + i.push(...feedItems) + } + } + if (hasNextFeedsPage) { + i.push({ + type: 'loadMore', + key: 'loadMoreFeeds', + message: _(msg`Load more suggested feeds`), + isLoadingMore: isLoadingMoreFeeds, + onLoadMore: onLoadMoreFeeds, + }) + } + } + } else { + if (feedsError) { + i.push({ + type: 'error', + key: 'feedsError', + message: _(msg`Failed to load suggested feeds`), + error: cleanError(feedsError), + }) + } else if (preferencesError) { + i.push({ + type: 'error', + key: 'preferencesError', + message: _(msg`Failed to load feeds preferences`), + error: cleanError(preferencesError), + }) + } else { + i.push({type: 'feedPlaceholder', key: 'feedPlaceholder'}) + } + } + } + + const addSuggestedStarterPacksModule = () => { + i.push({ + type: 'header', + key: 'suggested-starterPacks-header', + title: _(msg`Starter Packs`), + icon: StarterPack, + }) + + if (isLoadingSuggestedSPs) { + Array.from({length: 3}).forEach((_, index) => + i.push({ + type: 'starterPackSkeleton', + key: `starterPackSkeleton-${index}`, + }), + ) + } else if (suggestedSPsError || !suggestedSPs) { + // just get rid of the section + i.pop() + } else { + suggestedSPs.starterPacks.map(s => { + i.push({ + type: 'starterPack', + key: s.uri, + view: s, + }) + }) + } + } + + const addFeedPreviews = () => { + i.push(...feedPreviewSlices) + if (isFetchingNextPageFeedPreviews) { + i.push({ + type: 'preview:loading', + key: 'preview-loading-more', + }) + } + } + + // Dynamic module ordering + + addTopBorder() + + if (guide?.guide === 'follow-10' && !guide.isComplete) { + addSuggestedFollowsModule() + addSuggestedStarterPacksModule() + addTrendingTopicsModule() + } else { + addTrendingTopicsModule() + addSuggestedFollowsModule() + addSuggestedStarterPacksModule() + } + + if (gate('explore_show_suggested_feeds')) { + addSuggestedFeedsModule() + } + + addFeedPreviews() + + return i + }, [ + _, + profiles, + feeds, + preferences, + onLoadMoreFeeds, + onLoadMoreProfiles, + isLoadingMoreProfiles, + isLoadingMoreFeeds, + profilesError, + feedsError, + preferencesError, + hasNextProfilesPage, + hasNextFeedsPage, + guide, + gate, + moderationOpts, + hasPressedLoadMoreFeeds, + suggestedSPs, + isLoadingSuggestedSPs, + suggestedSPsError, + feedPreviewSlices, + isFetchingNextPageFeedPreviews, + canShowSuggestedProfiles, + ]) + + const renderItem = useCallback( + ({item, index}: {item: ExploreScreenItems; index: number}) => { + switch (item.type) { + case 'topBorder': + return ( + <View + style={[ + a.w_full, + t.atoms.border_contrast_low, + a.border_t, + headerHeight && + web({ + position: 'sticky', + top: headerHeight, + }), + ]} + /> + ) + case 'header': { + return ( + <ModuleHeader.Container> + <ModuleHeader.Icon icon={item.icon} /> + <ModuleHeader.TitleText>{item.title}</ModuleHeader.TitleText> + {item.searchButton && ( + <ModuleHeader.SearchButton + {...item.searchButton} + onPress={() => + focusSearchInput(item.searchButton?.tab || 'user') + } + /> + )} + </ModuleHeader.Container> + ) + } + case 'tabbedHeader': { + return ( + <View style={[a.pb_md]}> + <ModuleHeader.Container style={[a.pb_xs]}> + <ModuleHeader.Icon icon={item.icon} /> + <ModuleHeader.TitleText>{item.title}</ModuleHeader.TitleText> + {item.searchButton && ( + <ModuleHeader.SearchButton + {...item.searchButton} + onPress={() => + focusSearchInput(item.searchButton?.tab || 'user') + } + /> + )} + </ModuleHeader.Container> + <SuggestedAccountsTabBar + selectedInterest={selectedInterest} + onSelectInterest={setSelectedInterest} + /> + </View> + ) + } + case 'trendingTopics': { + return ( + <View style={[a.pb_md]}> + <ExploreTrendingTopics /> + </View> + ) + } + case 'trendingVideos': { + return <ExploreTrendingVideos /> + } + case 'recommendations': { + return <ExploreRecommendations /> + } + case 'profile': { + return ( + <SuggestedProfileCard + profile={item.profile} + moderationOpts={moderationOpts!} + recId={item.recId} + position={index} + /> + ) + } + case 'feed': { + return ( + <View + style={[ + a.border_t, + t.atoms.border_contrast_low, + a.px_lg, + a.py_lg, + ]}> + <FeedCard.Default view={item.feed} /> + </View> + ) + } + case 'starterPack': { + return ( + <View style={[a.px_lg, a.pb_lg]}> + <StarterPackCard view={item.view} /> + </View> + ) + } + case 'starterPackSkeleton': { + return ( + <View style={[a.px_lg, a.pb_lg]}> + <StarterPackCardSkeleton /> + </View> + ) + } + case 'loadMore': { + return ( + <View style={[a.border_t, t.atoms.border_contrast_low]}> + <LoadMore item={item} /> + </View> + ) + } + case 'profilePlaceholder': { + return ( + <> + {Array.from({length: 3}).map((_, index) => ( + <View + style={[ + a.px_lg, + a.py_lg, + a.border_t, + t.atoms.border_contrast_low, + ]} + key={index}> + <ProfileCard.Outer> + <ProfileCard.Header> + <ProfileCard.AvatarPlaceholder /> + <ProfileCard.NameAndHandlePlaceholder /> + </ProfileCard.Header> + <ProfileCard.DescriptionPlaceholder numberOfLines={2} /> + </ProfileCard.Outer> + </View> + ))} + </> + ) + } + case 'feedPlaceholder': { + return <FeedFeedLoadingPlaceholder /> + } + case 'error': + case 'preview:error': { + return ( + <View + style={[ + a.border_t, + a.pt_md, + a.px_md, + t.atoms.border_contrast_low, + ]}> + <View + style={[ + a.flex_row, + a.gap_md, + a.p_lg, + a.rounded_sm, + t.atoms.bg_contrast_25, + ]}> + <CircleInfo size="md" fill={t.palette.negative_400} /> + <View style={[a.flex_1, a.gap_sm]}> + <Text style={[a.font_bold, a.leading_snug]}> + {item.message} + </Text> + <Text + style={[ + a.italic, + a.leading_snug, + t.atoms.text_contrast_medium, + ]}> + {item.error} + </Text> + </View> + </View> + </View> + ) + } + // feed previews + case 'preview:empty': { + return null // what should we do here? + } + case 'preview:loading': { + return ( + <View style={[a.py_2xl, a.flex_1, a.align_center]}> + <Loader size="lg" /> + </View> + ) + } + case 'preview:header': { + return ( + <ModuleHeader.Container + headerHeight={headerHeight} + style={[a.pt_xs, a.border_b, t.atoms.border_contrast_low]}> + <ModuleHeader.FeedLink feed={item.feed}> + <ModuleHeader.FeedAvatar feed={item.feed} /> + <View style={[a.flex_1, a.gap_xs]}> + <ModuleHeader.TitleText style={[a.text_lg]}> + {item.feed.displayName} + </ModuleHeader.TitleText> + <ModuleHeader.SubtitleText> + <Trans> + By {sanitizeHandle(item.feed.creator.handle, '@')} + </Trans> + </ModuleHeader.SubtitleText> + </View> + </ModuleHeader.FeedLink> + <ModuleHeader.PinButton feed={item.feed} /> + </ModuleHeader.Container> + ) + } + case 'preview:footer': { + return <View style={[a.w_full, a.pt_2xl]} /> + } + case 'preview:sliceItem': { + const slice = item.slice + const indexInSlice = item.indexInSlice + const subItem = slice.items[indexInSlice] + return ( + <PostFeedItem + post={subItem.post} + record={subItem.record} + reason={indexInSlice === 0 ? slice.reason : undefined} + feedContext={slice.feedContext} + moderation={subItem.moderation} + parentAuthor={subItem.parentAuthor} + showReplyTo={item.showReplyTo} + isThreadParent={isThreadParentAt(slice.items, indexInSlice)} + isThreadChild={isThreadChildAt(slice.items, indexInSlice)} + isThreadLastChild={ + isThreadChildAt(slice.items, indexInSlice) && + slice.items.length === indexInSlice + 1 + } + isParentBlocked={subItem.isParentBlocked} + isParentNotFound={subItem.isParentNotFound} + hideTopBorder={item.hideTopBorder} + rootPost={slice.items[0].post} + /> + ) + } + case 'preview:sliceViewFullThread': { + return <ViewFullThread uri={item.uri} /> + } + case 'preview:loadMoreError': { + return ( + <LoadMoreRetryBtn + label={_( + msg`There was an issue fetching posts. Tap here to try again.`, + )} + onPress={fetchNextPageFeedPreviews} + /> + ) + } + } + }, + [ + t, + focusSearchInput, + moderationOpts, + selectedInterest, + _, + fetchNextPageFeedPreviews, + headerHeight, + ], + ) + + const stickyHeaderIndices = useMemo( + () => + items.reduce( + (acc, curr) => + ['topBorder', 'preview:header'].includes(curr.type) + ? acc.concat(items.indexOf(curr)) + : acc, + [] as number[], + ), + [items], + ) + + // track headers and report module viewability + const alreadyReportedRef = useRef<Map<string, string>>(new Map()) + const onViewableItemsChanged = useCallback( + ({ + viewableItems, + }: { + viewableItems: ViewToken<ExploreScreenItems>[] + changed: ViewToken<ExploreScreenItems>[] + }) => { + for (const {item} of viewableItems.filter(vi => vi.isViewable)) { + let module: MetricEvents['explore:module:seen']['module'] + if (item.type === 'trendingTopics' || item.type === 'trendingVideos') { + module = item.type + } else if (item.type === 'profile') { + module = 'suggestedAccounts' + } else if (item.type === 'feed') { + module = 'suggestedFeeds' + } else if (item.type === 'preview:header') { + module = `feed:feedgen|${item.feed.uri}` + } else { + continue + } + if (!alreadyReportedRef.current.has(module)) { + alreadyReportedRef.current.set(module, module) + logger.metric('explore:module:seen', {module}) + } + } + }, + [], + ) + + return ( + <List + data={items} + renderItem={renderItem} + keyExtractor={item => item.key} + desktopFixedHeight + contentContainerStyle={{paddingBottom: 100}} + keyboardShouldPersistTaps="handled" + keyboardDismissMode="on-drag" + stickyHeaderIndices={native(stickyHeaderIndices)} + viewabilityConfig={viewabilityConfig} + onViewableItemsChanged={onViewableItemsChanged} + onEndReached={onLoadMoreFeedPreviews} + onEndReachedThreshold={2} + /> + ) +} + +const viewabilityConfig: ViewabilityConfig = { + itemVisiblePercentThreshold: 100, +} |