From 52fe45ea410348afb1cf61723e689261d19db706 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 28 Mar 2025 18:35:32 +0200 Subject: [PATCH] [Explore] Dynamic module ordering (#8066) * Dynamic module ordering * [Explore] New headers, metrics (#8067) * new sticky headers * improve spacing between modules * view metric on modules * update metrics names * [Explore] Suggested accounts module (#8072) * use modern profile card, update load more * add tab bar * tabbed suggested accounts * [Explore] Discover feeds module (#8073) * cap number of feeds to 3 * change feed pin button --- src/components/FeedCard.tsx | 32 +- src/components/ProfileCard.tsx | 27 +- src/components/ProgressGuide/FollowDialog.tsx | 15 +- src/components/icons/common.tsx | 6 +- src/lib/statsig/gates.ts | 1 + src/logger/metrics.ts | 20 + src/screens/Search/Explore.tsx | 725 +++++++++--------- src/screens/Search/Shell.tsx | 9 +- .../Search/components/ModuleHeader.tsx | 83 ++ .../modules/ExploreSuggestedAccounts.tsx | 181 +++++ src/state/queries/actor-search.ts | 23 +- 11 files changed, 731 insertions(+), 391 deletions(-) create mode 100644 src/screens/Search/components/ModuleHeader.tsx create mode 100644 src/screens/Search/modules/ExploreSuggestedAccounts.tsx diff --git a/src/components/FeedCard.tsx b/src/components/FeedCard.tsx index f20e517d43..b94881dd50 100644 --- a/src/components/FeedCard.tsx +++ b/src/components/FeedCard.tsx @@ -1,8 +1,8 @@ import React from 'react' -import {GestureResponderEvent, View} from 'react-native' +import {type GestureResponderEvent, View} from 'react-native' import { - AppBskyFeedDefs, - AppBskyGraphDefs, + type AppBskyFeedDefs, + type AppBskyGraphDefs, AtUri, RichText as RichTextApi, } from '@atproto/api' @@ -23,15 +23,14 @@ import * as Toast from '#/view/com/util/Toast' import {UserAvatar} from '#/view/com/util/UserAvatar' import {useTheme} from '#/alf' import {atoms as a} from '#/alf' -import {Button, ButtonIcon} from '#/components/Button' -import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' -import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' -import {Link as InternalLink, LinkProps} from '#/components/Link' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin' +import {Link as InternalLink, type LinkProps} from '#/components/Link' import {Loader} from '#/components/Loader' import * as Prompt from '#/components/Prompt' -import {RichText, RichTextProps} from '#/components/RichText' +import {RichText, type RichTextProps} from '#/components/RichText' import {Text} from '#/components/Typography' -import * as bsky from '#/types/bsky' +import type * as bsky from '#/types/bsky' type Props = { view: AppBskyFeedDefs.GeneratorView @@ -294,14 +293,19 @@ function SaveButtonInner({ disabled={isPending} label={_(msg`Add this feed to your feeds`)} size="small" - variant="ghost" - color="secondary" - shape="square" + variant="solid" + color={savedFeedConfig ? 'secondary' : 'primary'} onPress={savedFeedConfig ? onPrompRemoveFeed : toggleSave}> {savedFeedConfig ? ( - + <> + {isPending && } + Unpin Feed + ) : ( - + <> + + Pin Feed + )} diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx index b56112dcf2..9bf015b8a1 100644 --- a/src/components/ProfileCard.tsx +++ b/src/components/ProfileCard.tsx @@ -1,14 +1,14 @@ import React from 'react' -import {GestureResponderEvent, View} from 'react-native' +import {type GestureResponderEvent, View} from 'react-native' import { moderateProfile, - ModerationOpts, + type ModerationOpts, RichText as RichTextApi, } from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {LogEvents} from '#/lib/statsig/statsig' +import {type LogEvents} from '#/lib/statsig/statsig' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {useProfileShadow} from '#/state/cache/profile-shadow' @@ -18,13 +18,18 @@ import {ProfileCardPills} from '#/view/com/profile/ProfileCard' import * as Toast from '#/view/com/util/Toast' import {UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, useTheme} from '#/alf' -import {Button, ButtonIcon, ButtonProps, ButtonText} from '#/components/Button' +import { + Button, + ButtonIcon, + type ButtonProps, + ButtonText, +} from '#/components/Button' import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' -import {Link as InternalLink, LinkProps} from '#/components/Link' +import {Link as InternalLink, type LinkProps} from '#/components/Link' import {RichText} from '#/components/RichText' import {Text} from '#/components/Typography' -import * as bsky from '#/types/bsky' +import type * as bsky from '#/types/bsky' export function Default({ profile, @@ -286,6 +291,7 @@ export type FollowButtonProps = { LogEvents['profile:unfollow']['logContext'] colorInverted?: boolean onFollow?: () => void + withIcon?: boolean } & Partial export function FollowButton(props: FollowButtonProps) { @@ -301,6 +307,7 @@ export function FollowButtonInner({ onPress: onPressProp, onFollow, colorInverted, + withIcon = true, ...rest }: FollowButtonProps) { const {_} = useLingui() @@ -386,7 +393,9 @@ export function FollowButtonInner({ color="secondary" {...rest} onPress={onPressUnfollow}> - + {withIcon && ( + + )} {isRound ? null : {unfollowLabel}} ) : ( @@ -397,7 +406,9 @@ export function FollowButtonInner({ color={colorInverted ? 'secondary_inverted' : 'primary'} {...rest} onPress={onPressFollow}> - + {withIcon && ( + + )} {isRound ? null : {followLabel}} )} diff --git a/src/components/ProgressGuide/FollowDialog.tsx b/src/components/ProgressGuide/FollowDialog.tsx index 006f86574b..41c3d41d89 100644 --- a/src/components/ProgressGuide/FollowDialog.tsx +++ b/src/components/ProgressGuide/FollowDialog.tsx @@ -5,7 +5,7 @@ import Animated, { LinearTransition, ZoomInEasyDown, } from 'react-native-reanimated' -import {AppBskyActorDefs, ModerationOpts} from '@atproto/api' +import {type AppBskyActorDefs, type ModerationOpts} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -19,8 +19,8 @@ import {useActorSearchPaginated} from '#/state/queries/actor-search' import {usePreferencesQuery} from '#/state/queries/preferences' import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows' import {useSession} from '#/state/session' -import {Follow10ProgressGuide} from '#/state/shell/progress-guide' -import {ListMethods} from '#/view/com/util/List' +import {type Follow10ProgressGuide} from '#/state/shell/progress-guide' +import {type ListMethods} from '#/view/com/util/List' import { popularInterests, useInterestsDisplayNames, @@ -31,7 +31,7 @@ import { tokens, useBreakpoints, useTheme, - ViewStyleProp, + type ViewStyleProp, web, } from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' @@ -452,12 +452,14 @@ let Tabs = ({ selectedInterest, hasSearchText, interestsDisplayNames, + TabComponent = Tab, }: { onSelectTab: (tab: string) => void interests: string[] selectedInterest: string hasSearchText: boolean interestsDisplayNames: Record + TabComponent?: React.ComponentType> }): React.ReactNode => { const listRef = useRef(null) const [scrollX, setScrollX] = useState(0) @@ -532,7 +534,7 @@ let Tabs = ({ {interests.map((interest, i) => { const active = interest === selectedInterest && !hasSearchText return ( - { const indexA = boosts?.indexOf(_a) ?? -1 const indexB = boosts?.indexOf(_b) ?? -1 diff --git a/src/components/icons/common.tsx b/src/components/icons/common.tsx index 996ecb626c..bc1e045a48 100644 --- a/src/components/icons/common.tsx +++ b/src/components/icons/common.tsx @@ -1,5 +1,5 @@ -import {StyleSheet, TextProps} from 'react-native' -import type {PathProps, SvgProps} from 'react-native-svg' +import {StyleSheet, type TextProps} from 'react-native' +import {type PathProps, type SvgProps} from 'react-native-svg' import {Defs, LinearGradient, Stop} from 'react-native-svg' import {nanoid} from 'nanoid/non-secure' @@ -19,7 +19,7 @@ export const sizes = { lg: 24, xl: 28, '2xl': 32, -} +} as const export function useCommonSVGProps(props: Props) { const t = useTheme() diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index c88a97c751..d3334d82f6 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -2,6 +2,7 @@ export type Gate = // Keep this alphabetic please. | 'debug_show_feedcontext' | 'debug_subscriptions' + | 'explore_show_suggested_feeds' | 'old_postonboarding' | 'onboarding_add_video_feed' | 'remove_show_latest_button' diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts index 33cdc25e58..6467583692 100644 --- a/src/logger/metrics.ts +++ b/src/logger/metrics.ts @@ -1,3 +1,5 @@ +import {type FeedDescriptor} from '#/state/queries/post-feed' + export type MetricEvents = { // App events init: { @@ -202,6 +204,7 @@ export type MetricEvents = { | 'ProfileHeaderSuggestedFollows' | 'PostOnboardingFindFollows' | 'ImmersiveVideo' + | 'ExploreSuggestedAccounts' } 'suggestedUser:follow': { logContext: @@ -239,6 +242,7 @@ export type MetricEvents = { | 'ProfileHeaderSuggestedFollows' | 'PostOnboardingFindFollows' | 'ImmersiveVideo' + | 'ExploreSuggestedAccounts' } 'chat:create': { logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog' @@ -318,6 +322,22 @@ export type MetricEvents = { context: 'interstitial:discover' | 'interstitial:explore' | 'feed' } + 'explore:module:seen': { + module: + | 'trendingTopics' + | 'trendingVideos' + | 'suggestedAccounts' + | 'suggestedFeeds' + | 'suggestedStarterPacks' + | `feed:${FeedDescriptor}` + } + 'explore:module:searchButtonPress': { + module: 'suggestedAccounts' | 'suggestedFeeds' + } + 'explore:suggestedAccounts:tabPressed': { + tab: string + } + 'progressGuide:hide': {} 'progressGuide:followDialog:open': {} diff --git a/src/screens/Search/Explore.tsx b/src/screens/Search/Explore.tsx index 699bf10bc7..a2008accc2 100644 --- a/src/screens/Search/Explore.tsx +++ b/src/screens/Search/Explore.tsx @@ -1,135 +1,49 @@ -import {useCallback, useMemo} from 'react' -import {View} from 'react-native' -import { - type AppBskyActorDefs, - type AppBskyFeedDefs, - moderateProfile, - type ModerationDecision, - type ModerationOpts, -} from '@atproto/api' -import {msg, Trans} from '@lingui/macro' +import {useCallback, useMemo, useRef, useState} from 'react' +import {View, type ViewabilityConfig, type ViewToken} from 'react-native' +import {type AppBskyActorDefs, type AppBskyFeedDefs} from '@atproto/api' +import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useGate} from '#/lib/statsig/statsig' import {cleanError} from '#/lib/strings/errors' import {logger} from '#/logger' -import {isWeb} from '#/platform/detection' +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 {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard' +import {useProgressGuide} from '#/state/shell/progress-guide' import {List} from '#/view/com/util/List' import { FeedFeedLoadingPlaceholder, ProfileCardFeedLoadingPlaceholder, } from '#/view/com/util/LoadingPlaceholder' -import {UserAvatar} from '#/view/com/util/UserAvatar' 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, useTheme, type ViewStyleProp} from '#/alf' +import {atoms as a, useTheme} from '#/alf' import {Button} from '#/components/Button' import * as FeedCard from '#/components/FeedCard' -import {ArrowBottom_Stroke2_Corner0_Rounded as ArrowBottom} from '#/components/icons/Arrow' +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 {UserCircle_Stroke2_Corner0_Rounded as Person} from '#/components/icons/UserCircle' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' +import * as ModuleHeader from './components/ModuleHeader' +import { + SuggestedAccountsTabBar, + SuggestedProfileCard, +} from './modules/ExploreSuggestedAccounts' -function SuggestedItemsHeader({ - title, - description, - style, - icon: Icon, -}: { - title: string - description: string - icon: React.ComponentType -} & ViewStyleProp) { - const t = useTheme() - - return ( - - - - - {title} - - - {description} - - - - ) -} - -type LoadMoreItem = - | { - type: 'profile' - key: string - avatar: string | undefined - moderation: ModerationDecision - } - | { - type: 'feed' - key: string - avatar: string | undefined - moderation: undefined - } - -function LoadMore({ - item, - moderationOpts, -}: { - item: ExploreScreenItems & {type: 'loadMore'} - moderationOpts?: ModerationOpts -}) { +function LoadMore({item}: {item: ExploreScreenItems & {type: 'loadMore'}}) { const t = useTheme() const {_} = useLingui() - const items: LoadMoreItem[] = useMemo(() => { - return item.items - .map(_item => { - let loadMoreItem: LoadMoreItem | undefined - if (_item.type === 'profile') { - loadMoreItem = { - type: 'profile', - key: _item.profile.did, - avatar: _item.profile.avatar, - moderation: moderateProfile(_item.profile, moderationOpts!), - } - } else if (_item.type === 'feed') { - loadMoreItem = { - type: 'feed', - key: _item.feed.uri, - avatar: _item.feed.avatar, - moderation: undefined, - } - } - return loadMoreItem - }) - .filter(n => !!n) - }, [item.items, moderationOpts]) - - if (items.length === 0) return null - - const type = items[0].type return ( - + @@ -235,13 +83,31 @@ function LoadMore({ } type ExploreScreenItems = + | { + type: 'topBorder' + key: string + } | { type: 'header' key: string title: string - description: string - style?: ViewStyleProp['style'] icon: React.ComponentType + 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' + } } | { type: 'trendingTopics' @@ -269,9 +135,9 @@ type ExploreScreenItems = | { type: 'loadMore' key: string + message: string isLoadingMore: boolean onLoadMore: () => void - items: ExploreScreenItems[] } | { type: 'profilePlaceholder' @@ -288,19 +154,38 @@ type ExploreScreenItems = error: string } -export function Explore() { +export function Explore({ + focusSearchInput, +}: { + focusSearchInput: (tab: 'user' | 'profile' | 'feed') => void +}) { 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(null) const { - data: profiles, - hasNextPage: hasNextProfilesPage, - isLoading: isLoadingProfiles, - isFetchingNextPage: isFetchingNextProfilesPage, - error: profilesError, - fetchNextPage: fetchNextProfilesPage, - } = useSuggestedFollowsQuery({limit: 6, subsequentPageLimit: 10}) + 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 { data: feeds, hasNextPage: hasNextFeedsPage, @@ -310,6 +195,24 @@ export function Explore() { fetchNextPage: fetchNextFeedsPage, } = useGetPopularFeedsQuery({limit: 10}) + const profiles: typeof suggestedProfiles & typeof interestProfiles = + !selectedInterest ? suggestedProfiles : interestProfiles + const hasNextProfilesPage = !selectedInterest + ? hasNextSuggestedProfilesPage + : hasNextInterestProfilesPage + const isLoadingProfiles = !selectedInterest + ? isLoadingSuggestedProfiles + : isLoadingInterestProfiles + const isFetchingNextProfilesPage = !selectedInterest + ? isFetchingNextSuggestedProfilesPage + : isFetchingNextInterestProfilesPage + const profilesError = !selectedInterest + ? suggestedProfilesError + : interestProfilesError + const fetchNextProfilesPage = !selectedInterest + ? fetchNextSuggestedProfilesPage + : fetchNextInterestProfilesPage + const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles const onLoadMoreProfiles = useCallback(async () => { if (isFetchingNextProfilesPage || !hasNextProfilesPage || profilesError) @@ -327,8 +230,13 @@ export function Explore() { ]) 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) { @@ -339,156 +247,198 @@ export function Explore() { hasNextFeedsPage, feedsError, fetchNextFeedsPage, + hasPressedLoadMoreFeeds, ]) const items = useMemo(() => { const i: ExploreScreenItems[] = [] - i.push({ - type: 'trendingTopics', - key: `trending-topics`, - }) + const addTopBorder = () => { + i.push({ + type: 'topBorder', + key: `top-border`, + }) + } - // temp - disable trending videos - // if (isNative) { - // i.push({ - // type: 'trendingVideos', - // key: `trending-videos`, - // }) - // } + const addTrendingTopicsModule = () => { + i.push({ + type: 'trendingTopics', + key: `trending-topics`, + }) - i.push({ - type: 'header', - key: 'suggested-follows-header', - title: _(msg`Suggested accounts`), - description: _( - msg`Follow more accounts to get connected to your interests and build your network.`, - ), - icon: Person, - }) + // temp - disable trending videos + // if (isNative) { + // i.push({ + // type: 'trendingVideos', + // key: `trending-videos`, + // }) + // } + } - if (profiles) { - // 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)) { - seen.add(actor.did) - profileItems.push({ - type: 'profile', - key: actor.did, - profile: actor, - recId: page.recId, + 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 (profiles && 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)) { + seen.add(actor.did) + profileItems.push({ + type: 'profile', + key: actor.did, + profile: actor, + recId: page.recId, + }) + } + } + } + + if (profileItems.length === 0) { + // 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, }) } } - } - - if (hasNextProfilesPage) { - // splice off 3 as previews if we have a next page - const previews = profileItems.splice(-3) - // push remainder - i.push(...profileItems) - i.push({ - type: 'loadMore', - key: 'loadMoreProfiles', - isLoadingMore: isLoadingMoreProfiles, - onLoadMore: onLoadMoreProfiles, - items: previews, - }) } else { - i.push(...profileItems) - } - } else { - if (profilesError) { - i.push({ - type: 'error', - key: 'profilesError', - message: _(msg`Failed to load suggested follows`), - error: cleanError(profilesError), - }) - } else { - i.push({type: 'profilePlaceholder', key: 'profilePlaceholder'}) + if (profilesError) { + i.push({ + type: 'error', + key: 'profilesError', + message: _(msg`Failed to load suggested follows`), + error: cleanError(profilesError), + }) + } else { + i.push({type: 'profilePlaceholder', key: 'profilePlaceholder'}) + } } } - i.push({ - type: 'header', - key: 'suggested-feeds-header', - title: _(msg`Discover new feeds`), - description: _( - msg`Choose your own timeline! Feeds built by the community help you find content you love.`, - ), - style: [a.pt_5xl], - icon: ListSparkle, - }) + 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, - }) + 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 (hasNextFeedsPage) { - const preview = feedItems.splice(-3) - i.push(...feedItems) - i.push({ - type: 'loadMore', - key: 'loadMoreFeeds', - isLoadingMore: isLoadingMoreFeeds, - onLoadMore: onLoadMoreFeeds, - items: preview, - }) + // 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) { + 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 { - i.push(...feedItems) + 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'}) + } } + } + + // Dynamic module ordering + + addTopBorder() + + if (guide?.guide === 'follow-10' && !guide.isComplete) { + addSuggestedFollowsModule() + addTrendingTopicsModule() } 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'}) - } + addTrendingTopicsModule() + addSuggestedFollowsModule() + } + + if (gate('explore_show_suggested_feeds')) { + addSuggestedFeedsModule() } return i @@ -506,19 +456,61 @@ export function Explore() { preferencesError, hasNextProfilesPage, hasNextFeedsPage, + guide, + gate, + moderationOpts, + hasPressedLoadMoreFeeds, ]) 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': { @@ -532,29 +524,12 @@ export function Explore() { } case 'profile': { return ( - - { - logger.metric('suggestedUser:press', { - logContext: 'Explore', - recId: item.recId, - position: index, - }) - }} - onFollow={() => { - logger.metric('suggestedUser:follow', { - logContext: 'Explore', - location: 'Card', - recId: item.recId, - position: index, - }) - }} - /> - + ) } case 'feed': { @@ -571,7 +546,7 @@ export function Explore() { ) } case 'loadMore': { - return + return } case 'profilePlaceholder': { return @@ -616,11 +591,50 @@ export function Explore() { } } }, - [t, moderationOpts], + [t, focusSearchInput, moderationOpts, selectedInterest], + ) + + const stickyHeaderIndices = useMemo( + () => + items.reduce( + (acc, curr) => + ['topBorder', 'header', 'tabbedHeader'].includes(curr.type) + ? acc.concat(items.indexOf(curr)) + : acc, + [] as number[], + ), + [items], + ) + + // track headers and report module viewability + const alreadyReportedRef = useRef>(new Map()) + const onViewableItemsChanged = useCallback( + ({ + viewableItems, + }: { + viewableItems: ViewToken[] + changed: ViewToken[] + }) => { + 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 { + continue + } + if (!alreadyReportedRef.current.has(module)) { + alreadyReportedRef.current.set(module, module) + logger.metric('explore:module:seen', {module}) + } + } + }, + [], ) - // note: actually not a screen, instead it's nested within - // the search screen. so we don't need Layout.Screen return ( ) } + +const viewabilityConfig: ViewabilityConfig = { + itemVisiblePercentThreshold: 100, +} diff --git a/src/screens/Search/Shell.tsx b/src/screens/Search/Shell.tsx index 477380e711..c46405b276 100644 --- a/src/screens/Search/Shell.tsx +++ b/src/screens/Search/Shell.tsx @@ -266,6 +266,10 @@ export function SearchScreenShell({ } }, [setShowAutocomplete]) + const focusSearchInput = useCallback(() => { + textInput.current?.focus() + }, []) + const showHeader = !gtMobile || navButton !== 'menu' return ( @@ -399,6 +403,7 @@ export function SearchScreenShell({ query={query} queryWithParams={queryWithParams} headerHeight={headerHeight} + focusSearchInput={focusSearchInput} /> @@ -409,10 +414,12 @@ let SearchScreenInner = ({ query, queryWithParams, headerHeight, + focusSearchInput, }: { query: string queryWithParams: string headerHeight: number + focusSearchInput: () => void }): React.ReactNode => { const t = useTheme() const setMinimalShellMode = useSetMinimalShellMode() @@ -438,7 +445,7 @@ let SearchScreenInner = ({ onPageSelected={onPageSelected} /> ) : hasSession ? ( - + ) : ( diff --git a/src/screens/Search/components/ModuleHeader.tsx b/src/screens/Search/components/ModuleHeader.tsx new file mode 100644 index 0000000000..6fc8164f6a --- /dev/null +++ b/src/screens/Search/components/ModuleHeader.tsx @@ -0,0 +1,83 @@ +import {View} from 'react-native' + +import {PressableScale} from '#/lib/custom-animations/PressableScale' +import {logger} from '#/logger' +import { + atoms as a, + native, + useGutters, + useTheme, + type ViewStyleProp, +} from '#/alf' +import {Button, ButtonIcon} from '#/components/Button' +import {sizes as iconSizes} from '#/components/icons/common' +import {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2' +import {Text, type TextProps} from '#/components/Typography' + +export function Container({ + style, + children, +}: {children: React.ReactNode} & ViewStyleProp) { + const t = useTheme() + const gutters = useGutters([0, 'base']) + return ( + + {children} + + ) +} + +export function Icon({ + icon: Comp, + size = 'lg', +}: Pick, 'icon' | 'size'>) { + const iconSize = iconSizes[size] + + return ( + + + + ) +} + +export function TitleText({style, ...props}: TextProps) { + return +} + +export function SearchButton({ + label, + metricsTag, + onPress, +}: { + label: string + metricsTag: 'suggestedAccounts' | 'suggestedFeeds' + onPress?: () => void +}) { + return ( + + ) +} diff --git a/src/screens/Search/modules/ExploreSuggestedAccounts.tsx b/src/screens/Search/modules/ExploreSuggestedAccounts.tsx new file mode 100644 index 0000000000..aa93256106 --- /dev/null +++ b/src/screens/Search/modules/ExploreSuggestedAccounts.tsx @@ -0,0 +1,181 @@ +import {memo} from 'react' +import {View} from 'react-native' +import {type ModerationOpts} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {logger} from '#/logger' +import {usePreferencesQuery} from '#/state/queries/preferences' +import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' +import { + popularInterests, + useInterestsDisplayNames, +} from '#/screens/Onboarding/state' +import {useTheme} from '#/alf' +import {atoms as a} from '#/alf' +import {Button} from '#/components/Button' +import * as ProfileCard from '#/components/ProfileCard' +import {boostInterests, Tabs} from '#/components/ProgressGuide/FollowDialog' +import {Text} from '#/components/Typography' +import type * as bsky from '#/types/bsky' + +export function SuggestedAccountsTabBar({ + selectedInterest, + onSelectInterest, +}: { + selectedInterest: string | null + onSelectInterest: (interest: string | null) => void +}) { + const {_} = useLingui() + const interestsDisplayNames = useInterestsDisplayNames() + const {data: preferences} = usePreferencesQuery() + const personalizedInterests = preferences?.interests?.tags + const interests = Object.keys(interestsDisplayNames) + .sort(boostInterests(popularInterests)) + .sort(boostInterests(personalizedInterests)) + return ( + + { + logger.metric('explore:suggestedAccounts:tabPressed', {tab: tab}) + onSelectInterest(tab === 'all' ? null : tab) + }} + hasSearchText={false} + interestsDisplayNames={{ + all: _(msg`All`), + ...interestsDisplayNames, + }} + TabComponent={Tab} + /> + + ) +} + +let Tab = ({ + onSelectTab, + interest, + active, + index, + interestsDisplayName, + onLayout, +}: { + onSelectTab: (index: number) => void + interest: string + active: boolean + index: number + interestsDisplayName: string + onLayout: (index: number, x: number, width: number) => void +}): React.ReactNode => { + const t = useTheme() + const {_} = useLingui() + const activeText = active ? _(msg` (active)`) : '' + return ( + + onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width) + }> + + + ) +} +Tab = memo(Tab) + +/** + * Profile card for suggested accounts. Note: border is on the bottom edge + */ +let SuggestedProfileCard = ({ + profile, + moderationOpts, + recId, + position, +}: { + profile: bsky.profile.AnyProfileView + moderationOpts: ModerationOpts + recId?: number + position: number +}): React.ReactNode => { + const t = useTheme() + return ( + { + logger.metric('suggestedUser:press', { + logContext: 'Explore', + recId, + position, + }) + }}> + + + + + + { + logger.metric('suggestedUser:follow', { + logContext: 'Explore', + location: 'Card', + recId, + position, + }) + }} + /> + + + + + + ) +} +SuggestedProfileCard = memo(SuggestedProfileCard) +export {SuggestedProfileCard} diff --git a/src/state/queries/actor-search.ts b/src/state/queries/actor-search.ts index 6d6c46e040..0b5de23037 100644 --- a/src/state/queries/actor-search.ts +++ b/src/state/queries/actor-search.ts @@ -1,9 +1,12 @@ -import {AppBskyActorDefs, AppBskyActorSearchActors} from '@atproto/api' import { - InfiniteData, + type AppBskyActorDefs, + type AppBskyActorSearchActors, +} from '@atproto/api' +import { + type InfiniteData, keepPreviousData, - QueryClient, - QueryKey, + type QueryClient, + type QueryKey, useInfiniteQuery, useQuery, } from '@tanstack/react-query' @@ -15,7 +18,11 @@ const RQKEY_ROOT = 'actor-search' export const RQKEY = (query: string) => [RQKEY_ROOT, query] const RQKEY_ROOT_PAGINATED = `${RQKEY_ROOT}_paginated` -export const RQKEY_PAGINATED = (query: string) => [RQKEY_ROOT_PAGINATED, query] +export const RQKEY_PAGINATED = (query: string, limit?: number) => [ + RQKEY_ROOT_PAGINATED, + query, + limit, +] export function useActorSearch({ query, @@ -42,10 +49,12 @@ export function useActorSearchPaginated({ query, enabled, maintainData, + limit = 25, }: { query: string enabled?: boolean maintainData?: boolean + limit?: number }) { const agent = useAgent() return useInfiniteQuery< @@ -56,11 +65,11 @@ export function useActorSearchPaginated({ string | undefined >({ staleTime: STALE.MINUTES.FIVE, - queryKey: RQKEY_PAGINATED(query), + queryKey: RQKEY_PAGINATED(query, limit), queryFn: async ({pageParam}) => { const res = await agent.searchActors({ q: query, - limit: 25, + limit, cursor: pageParam, }) return res.data