import {useCallback, useMemo, useRef, useState} from 'react' import {View, type ViewabilityConfig} 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 {useQueryClient} from '@tanstack/react-query' import * as bcp47Match from 'bcp-47-match' import {cleanError} from '#/lib/strings/errors' import {sanitizeHandle} from '#/lib/strings/handles' import {logger} from '#/logger' import {type MetricEvents} from '#/logger/metrics' import {useLanguagePrefs} from '#/state/preferences/languages' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {RQKEY_ROOT_PAGINATED as useActorSearchPaginatedQueryKeyRoot} from '#/state/queries/actor-search' import { type FeedPreviewItem, useFeedPreviews, } from '#/state/queries/explore-feed-previews' import {useGetPopularFeedsQuery} from '#/state/queries/feed' import {Nux, useNux} from '#/state/queries/nuxs' import {usePreferencesQuery} from '#/state/queries/preferences' import { createGetSuggestedFeedsQueryKey, useGetSuggestedFeedsQuery, } from '#/state/queries/trending/useGetSuggestedFeedsQuery' import {getSuggestedUsersQueryKeyRoot} from '#/state/queries/trending/useGetSuggestedUsersQuery' import {createGetTrendsQueryKey} from '#/state/queries/trending/useGetTrendsQuery' import { createSuggestedStarterPacksQueryKey, useSuggestedStarterPacksQuery, } from '#/state/queries/useSuggestedStarterPacksQuery' 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 { popularInterests, useInterestsDisplayNames, } from '#/screens/Onboarding/state' import { StarterPackCard, StarterPackCardSkeleton, } from '#/screens/Search/components/StarterPackCard' import {ExploreInterestsCard} from '#/screens/Search/modules/ExploreInterestsCard' import {ExploreRecommendations} from '#/screens/Search/modules/ExploreRecommendations' import {ExploreTrendingTopics} from '#/screens/Search/modules/ExploreTrendingTopics' import {ExploreTrendingVideos} from '#/screens/Search/modules/ExploreTrendingVideos' import {useSuggestedUsers} from '#/screens/Search/util/useSuggestedUsers' import {atoms as a, native, platform, useTheme} from '#/alf' import {Admonition} from '#/components/Admonition' 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 IcoProps, 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 {boostInterests} from '#/components/ProgressGuide/FollowDialog' import {SubtleHover} from '#/components/SubtleHover' import {Text} from '#/components/Typography' import * as ModuleHeader from './components/ModuleHeader' import { SuggestedAccountsTabBar, SuggestedProfileCard, } from './modules/ExploreSuggestedAccounts' function LoadMore({item}: {item: ExploreScreenItems & {type: 'loadMore'}}) { const t = useTheme() const {_} = useLingui() return ( ) } type ExploreScreenItems = | { type: 'topBorder' key: string } | { type: 'header' key: string title: string icon: React.ComponentType iconSize?: IcoProps['size'] bottomBorder?: boolean searchButton?: { label: string metricsTag: MetricEvents['explore:module:searchButtonPress']['module'] tab: 'user' | 'profile' | 'feed' } } | { type: 'tabbedHeader' key: string title: string icon: React.ComponentType searchButton?: { label: string metricsTag: MetricEvents['explore:module:searchButtonPress']['module'] tab: 'user' | 'profile' | 'feed' } hideDefaultTab?: boolean } | { type: 'trendingTopics' key: string } | { type: 'trendingVideos' key: string } | { type: 'recommendations' key: string } | { type: 'profile' key: string profile: AppBskyActorDefs.ProfileView recId?: number } | { type: 'profileEmpty' key: 'profileEmpty' } | { 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 | { type: 'interests-card' key: 'interests-card' } export function Explore({ focusSearchInput, }: { focusSearchInput: (tab: 'user' | 'profile' | 'feed') => void headerHeight: number }) { const {_} = useLingui() const t = useTheme() const {data: preferences, error: preferencesError} = usePreferencesQuery() const moderationOpts = useModerationOpts() const [selectedInterest, setSelectedInterest] = useState(null) /* * Begin special language handling */ const {contentLanguages} = useLanguagePrefs() const useFullExperience = useMemo(() => { if (contentLanguages.length === 0) return true return bcp47Match.basicFilter('en', contentLanguages).length > 0 }, [contentLanguages]) const personalizedInterests = preferences?.interests?.tags const interestsDisplayNames = useInterestsDisplayNames() const interests = Object.keys(interestsDisplayNames) .sort(boostInterests(popularInterests)) .sort(boostInterests(personalizedInterests)) const { data: suggestedUsers, isLoading: suggestedUsersIsLoading, error: suggestedUsersError, isRefetching: suggestedUsersIsRefetching, } = useSuggestedUsers({ category: selectedInterest || (useFullExperience ? null : interests[0]), search: !useFullExperience, }) /* End special language handling */ const { data: feeds, hasNextPage: hasNextFeedsPage, isLoading: isLoadingFeeds, isFetchingNextPage: isFetchingNextFeedsPage, error: feedsError, fetchNextPage: fetchNextFeedsPage, } = useGetPopularFeedsQuery({limit: 10, enabled: useFullExperience}) const interestsNux = useNux(Nux.ExploreInterestsCard) const showInterestsNux = interestsNux.status === 'ready' && !interestsNux.nux?.completed const { data: suggestedSPs, isLoading: isLoadingSuggestedSPs, error: suggestedSPsError, isRefetching: isRefetchingSuggestedSPs, } = useSuggestedStarterPacksQuery({enabled: useFullExperience}) 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, error: suggestedFeedsError} = useGetSuggestedFeedsQuery({ enabled: useFullExperience, }) const { data: feedPreviewSlices, query: { isPending: isPendingFeedPreviews, isFetchingNextPage: isFetchingNextPageFeedPreviews, fetchNextPage: fetchNextPageFeedPreviews, hasNextPage: hasNextPageFeedPreviews, error: feedPreviewSlicesError, }, } = useFeedPreviews(suggestedFeeds?.feeds ?? [], useFullExperience) const qc = useQueryClient() const [isPTR, setIsPTR] = useState(false) const onPTR = useCallback(async () => { setIsPTR(true) await Promise.all([ qc.resetQueries({ queryKey: createGetTrendsQueryKey(), }), qc.resetQueries({ queryKey: createSuggestedStarterPacksQueryKey(), }), qc.resetQueries({ queryKey: [getSuggestedUsersQueryKeyRoot], }), qc.resetQueries({ queryKey: [useActorSearchPaginatedQueryKeyRoot], }), qc.resetQueries({ queryKey: createGetSuggestedFeedsQueryKey(), }), ]) setIsPTR(false) }, [qc, setIsPTR]) 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 topBorder = useMemo( () => ({type: 'topBorder', key: 'top-border'} as const), [], ) const trendingTopicsModule = useMemo( () => ({type: 'trendingTopics', key: 'trending-topics'} as const), [], ) const suggestedFollowsModule = useMemo(() => { const i: ExploreScreenItems[] = [] 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', }, hideDefaultTab: !useFullExperience, }) if (suggestedUsersIsLoading || suggestedUsersIsRefetching) { i.push({type: 'profilePlaceholder', key: 'profilePlaceholder'}) } else if (suggestedUsersError) { i.push({ type: 'error', key: 'suggestedUsersError', message: _(msg`Failed to load suggested follows`), error: cleanError(suggestedUsersError), }) } else { if (suggestedUsers !== undefined) { if (suggestedUsers.actors.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 actor of suggestedUsers.actors) { // checking for following still necessary if search data is used if (!seen.has(actor.did) && !actor.viewer?.following) { seen.add(actor.did) profileItems.push({ type: 'profile', key: actor.did, profile: actor, }) } } if (profileItems.length === 0) { i.push({ type: 'profileEmpty', key: 'profileEmpty', }) } else { if (selectedInterest === null && useFullExperience) { // First "For You" tab, only show 5 to keep screen short i.push(...profileItems.slice(0, 5)) } else { i.push(...profileItems) } } } else { i.push({ type: 'profileEmpty', key: 'profileEmpty', }) } } else { i.push({type: 'profilePlaceholder', key: 'profilePlaceholder'}) } } return i }, [ _, moderationOpts, suggestedUsers, suggestedUsersIsLoading, suggestedUsersIsRefetching, suggestedUsersError, selectedInterest, useFullExperience, ]) const suggestedFeedsModule = useMemo(() => { const i: ExploreScreenItems[] = [] 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 (useFullExperience) { if (suggestedFeeds && preferences) { let seen = new Set() const feedItems: ExploreScreenItems[] = [] for (const feed of suggestedFeeds.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 (suggestedFeedsError) { 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) { 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, 6)) } else { i.push(...feedItems) } for (const [index, item] of feedItems.entries()) { if (item.type !== 'feed') { continue } // don't log the ones we've already sent if (hasPressedLoadMoreFeeds && index < 6) { continue } logger.metric( 'feed:suggestion:seen', {feedUrl: item.feed.uri}, {statsig: false}, ) } } if (!hasPressedLoadMoreFeeds) { 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'}) } } } else { 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'}) } } } return i }, [ _, useFullExperience, suggestedFeeds, preferences, suggestedFeedsError, preferencesError, feedsError, hasNextFeedsPage, hasPressedLoadMoreFeeds, isLoadingMoreFeeds, onLoadMoreFeeds, feeds, ]) const suggestedStarterPacksModule = useMemo(() => { const i: ExploreScreenItems[] = [] i.push({ type: 'header', key: 'suggested-starterPacks-header', title: _(msg`Starter Packs`), icon: StarterPack, iconSize: 'xl', }) if (isLoadingSuggestedSPs || isRefetchingSuggestedSPs) { 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, }) }) } return i }, [ suggestedSPs, _, isLoadingSuggestedSPs, suggestedSPsError, isRefetchingSuggestedSPs, ]) const feedPreviewsModule = useMemo(() => { const i: ExploreScreenItems[] = [] i.push(...feedPreviewSlices) if (isFetchingNextPageFeedPreviews) { i.push({ type: 'preview:loading', key: 'preview-loading-more', }) } return i }, [feedPreviewSlices, isFetchingNextPageFeedPreviews]) const interestsNuxModule = useMemo(() => { if (!showInterestsNux) return [] return [ { type: 'interests-card', key: 'interests-card', }, ] }, [showInterestsNux]) const items = useMemo(() => { const i: ExploreScreenItems[] = [] // Dynamic module ordering i.push(topBorder) i.push(...interestsNuxModule) if (useFullExperience) { i.push(trendingTopicsModule) i.push(...suggestedFeedsModule) i.push(...suggestedFollowsModule) i.push(...suggestedStarterPacksModule) i.push(...feedPreviewsModule) } else { i.push(...suggestedFollowsModule) } return i }, [ topBorder, suggestedFollowsModule, suggestedStarterPacksModule, suggestedFeedsModule, trendingTopicsModule, feedPreviewsModule, interestsNuxModule, useFullExperience, ]) const renderItem = useCallback( ({item, index}: {item: ExploreScreenItems; index: number}) => { switch (item.type) { case 'topBorder': return ( ) case 'header': { return ( {item.title} {item.searchButton && ( focusSearchInput(item.searchButton?.tab || 'user') } /> )} ) } case 'tabbedHeader': { return ( {item.title} {item.searchButton && ( focusSearchInput(item.searchButton?.tab || 'user') } /> )} ) } case 'trendingTopics': { return ( ) } case 'trendingVideos': { return } case 'recommendations': { return } case 'profile': { return ( ) } case 'profileEmpty': { return ( {selectedInterest ? ( No results for "{interestsDisplayNames[selectedInterest]}". ) : ( No results. )} ) } case 'feed': { return ( { if (!useFullExperience) { return } logger.metric('feed:suggestion:press', { feedUrl: item.feed.uri, }) }} /> ) } case 'starterPack': { return ( ) } case 'starterPackSkeleton': { return ( ) } case 'loadMore': { return ( ) } case 'profilePlaceholder': { return ( <> {Array.from({length: 3}).map((__, i) => ( ))} ) } case 'feedPlaceholder': { return } case 'error': case 'preview:error': { return ( {item.message} {item.error} ) } // feed previews case 'preview:spacer': { return } case 'preview:empty': { return null // what should we do here? } case 'preview:loading': { return ( ) } case 'preview:header': { return ( {/* Very non-scientific way to avoid small gap on scroll */} {item.feed.displayName} By {sanitizeHandle(item.feed.creator.handle, '@')} ) } case 'preview:footer': { return ( ) } case 'preview:sliceItem': { const slice = item.slice const indexInSlice = item.indexInSlice const subItem = slice.items[indexInSlice] return ( ) } case 'preview:sliceViewFullThread': { return } case 'preview:loadMoreError': { return ( ) } case 'interests-card': { return } } }, [ t.atoms.border_contrast_low, t.atoms.bg_contrast_25, t.atoms.text_contrast_medium, t.atoms.bg, t.palette.negative_400, focusSearchInput, selectedInterest, moderationOpts, interestsDisplayNames, useFullExperience, _, fetchNextPageFeedPreviews, ], ) 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>(new Map()) const onItemSeen = useCallback((item: ExploreScreenItems) => { 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 === 'starterPack') { module = 'suggestedStarterPacks' } else if (item.type === 'preview:sliceItem') { module = `feed:feedgen|${item.feed.uri}` } else { return } if (!alreadyReportedRef.current.has(module)) { alreadyReportedRef.current.set(module, module) logger.metric('explore:module:seen', {module}, {statsig: false}) } }, []) return ( ) } function keyExtractor(item: FeedPreviewItem) { return item.key } const viewabilityConfig: ViewabilityConfig = { itemVisiblePercentThreshold: 100, }