[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
This commit is contained in:
Samuel Newman
2025-03-28 18:35:32 +02:00
committed by Eric Bailey
parent 522eab8dcb
commit 52fe45ea41
11 changed files with 731 additions and 391 deletions
+18 -14
View File
@@ -1,8 +1,8 @@
import React from 'react' import React from 'react'
import {GestureResponderEvent, View} from 'react-native' import {type GestureResponderEvent, View} from 'react-native'
import { import {
AppBskyFeedDefs, type AppBskyFeedDefs,
AppBskyGraphDefs, type AppBskyGraphDefs,
AtUri, AtUri,
RichText as RichTextApi, RichText as RichTextApi,
} from '@atproto/api' } from '@atproto/api'
@@ -23,15 +23,14 @@ import * as Toast from '#/view/com/util/Toast'
import {UserAvatar} from '#/view/com/util/UserAvatar' import {UserAvatar} from '#/view/com/util/UserAvatar'
import {useTheme} from '#/alf' import {useTheme} from '#/alf'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin'
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' import {Link as InternalLink, type LinkProps} from '#/components/Link'
import {Link as InternalLink, LinkProps} from '#/components/Link'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import * as Prompt from '#/components/Prompt' 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 {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
type Props = { type Props = {
view: AppBskyFeedDefs.GeneratorView view: AppBskyFeedDefs.GeneratorView
@@ -294,14 +293,19 @@ function SaveButtonInner({
disabled={isPending} disabled={isPending}
label={_(msg`Add this feed to your feeds`)} label={_(msg`Add this feed to your feeds`)}
size="small" size="small"
variant="ghost" variant="solid"
color="secondary" color={savedFeedConfig ? 'secondary' : 'primary'}
shape="square"
onPress={savedFeedConfig ? onPrompRemoveFeed : toggleSave}> onPress={savedFeedConfig ? onPrompRemoveFeed : toggleSave}>
{savedFeedConfig ? ( {savedFeedConfig ? (
<ButtonIcon size="md" icon={isPending ? Loader : Trash} /> <>
{isPending && <ButtonIcon size="md" icon={Loader} />}
<ButtonText>Unpin Feed</ButtonText>
</>
) : ( ) : (
<ButtonIcon size="md" icon={isPending ? Loader : Plus} /> <>
<ButtonIcon size="md" icon={isPending ? Loader : PinIcon} />
<ButtonText>Pin Feed</ButtonText>
</>
)} )}
</Button> </Button>
+19 -8
View File
@@ -1,14 +1,14 @@
import React from 'react' import React from 'react'
import {GestureResponderEvent, View} from 'react-native' import {type GestureResponderEvent, View} from 'react-native'
import { import {
moderateProfile, moderateProfile,
ModerationOpts, type ModerationOpts,
RichText as RichTextApi, RichText as RichTextApi,
} from '@atproto/api' } from '@atproto/api'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' 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 {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
import {useProfileShadow} from '#/state/cache/profile-shadow' 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 * as Toast from '#/view/com/util/Toast'
import {UserAvatar} from '#/view/com/util/UserAvatar' import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf' 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 {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 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 {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
export function Default({ export function Default({
profile, profile,
@@ -286,6 +291,7 @@ export type FollowButtonProps = {
LogEvents['profile:unfollow']['logContext'] LogEvents['profile:unfollow']['logContext']
colorInverted?: boolean colorInverted?: boolean
onFollow?: () => void onFollow?: () => void
withIcon?: boolean
} & Partial<ButtonProps> } & Partial<ButtonProps>
export function FollowButton(props: FollowButtonProps) { export function FollowButton(props: FollowButtonProps) {
@@ -301,6 +307,7 @@ export function FollowButtonInner({
onPress: onPressProp, onPress: onPressProp,
onFollow, onFollow,
colorInverted, colorInverted,
withIcon = true,
...rest ...rest
}: FollowButtonProps) { }: FollowButtonProps) {
const {_} = useLingui() const {_} = useLingui()
@@ -386,7 +393,9 @@ export function FollowButtonInner({
color="secondary" color="secondary"
{...rest} {...rest}
onPress={onPressUnfollow}> onPress={onPressUnfollow}>
<ButtonIcon icon={Check} position={isRound ? undefined : 'left'} /> {withIcon && (
<ButtonIcon icon={Check} position={isRound ? undefined : 'left'} />
)}
{isRound ? null : <ButtonText>{unfollowLabel}</ButtonText>} {isRound ? null : <ButtonText>{unfollowLabel}</ButtonText>}
</Button> </Button>
) : ( ) : (
@@ -397,7 +406,9 @@ export function FollowButtonInner({
color={colorInverted ? 'secondary_inverted' : 'primary'} color={colorInverted ? 'secondary_inverted' : 'primary'}
{...rest} {...rest}
onPress={onPressFollow}> onPress={onPressFollow}>
<ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} /> {withIcon && (
<ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} />
)}
{isRound ? null : <ButtonText>{followLabel}</ButtonText>} {isRound ? null : <ButtonText>{followLabel}</ButtonText>}
</Button> </Button>
)} )}
@@ -5,7 +5,7 @@ import Animated, {
LinearTransition, LinearTransition,
ZoomInEasyDown, ZoomInEasyDown,
} from 'react-native-reanimated' } 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 {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@@ -19,8 +19,8 @@ import {useActorSearchPaginated} from '#/state/queries/actor-search'
import {usePreferencesQuery} from '#/state/queries/preferences' import {usePreferencesQuery} from '#/state/queries/preferences'
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows' import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {Follow10ProgressGuide} from '#/state/shell/progress-guide' import {type Follow10ProgressGuide} from '#/state/shell/progress-guide'
import {ListMethods} from '#/view/com/util/List' import {type ListMethods} from '#/view/com/util/List'
import { import {
popularInterests, popularInterests,
useInterestsDisplayNames, useInterestsDisplayNames,
@@ -31,7 +31,7 @@ import {
tokens, tokens,
useBreakpoints, useBreakpoints,
useTheme, useTheme,
ViewStyleProp, type ViewStyleProp,
web, web,
} from '#/alf' } from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
@@ -452,12 +452,14 @@ let Tabs = ({
selectedInterest, selectedInterest,
hasSearchText, hasSearchText,
interestsDisplayNames, interestsDisplayNames,
TabComponent = Tab,
}: { }: {
onSelectTab: (tab: string) => void onSelectTab: (tab: string) => void
interests: string[] interests: string[]
selectedInterest: string selectedInterest: string
hasSearchText: boolean hasSearchText: boolean
interestsDisplayNames: Record<string, string> interestsDisplayNames: Record<string, string>
TabComponent?: React.ComponentType<React.ComponentProps<typeof Tab>>
}): React.ReactNode => { }): React.ReactNode => {
const listRef = useRef<ScrollView>(null) const listRef = useRef<ScrollView>(null)
const [scrollX, setScrollX] = useState(0) const [scrollX, setScrollX] = useState(0)
@@ -532,7 +534,7 @@ let Tabs = ({
{interests.map((interest, i) => { {interests.map((interest, i) => {
const active = interest === selectedInterest && !hasSearchText const active = interest === selectedInterest && !hasSearchText
return ( return (
<Tab <TabComponent
key={interest} key={interest}
onSelectTab={handleSelectTab} onSelectTab={handleSelectTab}
active={active} active={active}
@@ -547,6 +549,7 @@ let Tabs = ({
) )
} }
Tabs = memo(Tabs) Tabs = memo(Tabs)
export {Tabs}
let Tab = ({ let Tab = ({
onSelectTab, onSelectTab,
@@ -822,7 +825,7 @@ function Empty({message}: {message: string}) {
) )
} }
function boostInterests(boosts?: string[]) { export function boostInterests(boosts?: string[]) {
return (_a: string, _b: string) => { return (_a: string, _b: string) => {
const indexA = boosts?.indexOf(_a) ?? -1 const indexA = boosts?.indexOf(_a) ?? -1
const indexB = boosts?.indexOf(_b) ?? -1 const indexB = boosts?.indexOf(_b) ?? -1
+3 -3
View File
@@ -1,5 +1,5 @@
import {StyleSheet, TextProps} from 'react-native' import {StyleSheet, type TextProps} from 'react-native'
import type {PathProps, SvgProps} from 'react-native-svg' import {type PathProps, type SvgProps} from 'react-native-svg'
import {Defs, LinearGradient, Stop} from 'react-native-svg' import {Defs, LinearGradient, Stop} from 'react-native-svg'
import {nanoid} from 'nanoid/non-secure' import {nanoid} from 'nanoid/non-secure'
@@ -19,7 +19,7 @@ export const sizes = {
lg: 24, lg: 24,
xl: 28, xl: 28,
'2xl': 32, '2xl': 32,
} } as const
export function useCommonSVGProps(props: Props) { export function useCommonSVGProps(props: Props) {
const t = useTheme() const t = useTheme()
+1
View File
@@ -2,6 +2,7 @@ export type Gate =
// Keep this alphabetic please. // Keep this alphabetic please.
| 'debug_show_feedcontext' | 'debug_show_feedcontext'
| 'debug_subscriptions' | 'debug_subscriptions'
| 'explore_show_suggested_feeds'
| 'old_postonboarding' | 'old_postonboarding'
| 'onboarding_add_video_feed' | 'onboarding_add_video_feed'
| 'remove_show_latest_button' | 'remove_show_latest_button'
+20
View File
@@ -1,3 +1,5 @@
import {type FeedDescriptor} from '#/state/queries/post-feed'
export type MetricEvents = { export type MetricEvents = {
// App events // App events
init: { init: {
@@ -202,6 +204,7 @@ export type MetricEvents = {
| 'ProfileHeaderSuggestedFollows' | 'ProfileHeaderSuggestedFollows'
| 'PostOnboardingFindFollows' | 'PostOnboardingFindFollows'
| 'ImmersiveVideo' | 'ImmersiveVideo'
| 'ExploreSuggestedAccounts'
} }
'suggestedUser:follow': { 'suggestedUser:follow': {
logContext: logContext:
@@ -239,6 +242,7 @@ export type MetricEvents = {
| 'ProfileHeaderSuggestedFollows' | 'ProfileHeaderSuggestedFollows'
| 'PostOnboardingFindFollows' | 'PostOnboardingFindFollows'
| 'ImmersiveVideo' | 'ImmersiveVideo'
| 'ExploreSuggestedAccounts'
} }
'chat:create': { 'chat:create': {
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog' logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
@@ -318,6 +322,22 @@ export type MetricEvents = {
context: 'interstitial:discover' | 'interstitial:explore' | 'feed' 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:hide': {}
'progressGuide:followDialog:open': {} 'progressGuide:followDialog:open': {}
+373 -352
View File
@@ -1,135 +1,49 @@
import {useCallback, useMemo} from 'react' import {useCallback, useMemo, useRef, useState} from 'react'
import {View} from 'react-native' import {View, type ViewabilityConfig, type ViewToken} from 'react-native'
import { import {type AppBskyActorDefs, type AppBskyFeedDefs} from '@atproto/api'
type AppBskyActorDefs, import {msg} from '@lingui/macro'
type AppBskyFeedDefs,
moderateProfile,
type ModerationDecision,
type ModerationOpts,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useGate} from '#/lib/statsig/statsig'
import {cleanError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger' import {logger} from '#/logger'
import {isWeb} from '#/platform/detection' import {type MetricEvents} from '#/logger/metrics'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorSearchPaginated} from '#/state/queries/actor-search'
import {useGetPopularFeedsQuery} from '#/state/queries/feed' import {useGetPopularFeedsQuery} from '#/state/queries/feed'
import {usePreferencesQuery} from '#/state/queries/preferences' import {usePreferencesQuery} from '#/state/queries/preferences'
import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows' 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 {List} from '#/view/com/util/List'
import { import {
FeedFeedLoadingPlaceholder, FeedFeedLoadingPlaceholder,
ProfileCardFeedLoadingPlaceholder, ProfileCardFeedLoadingPlaceholder,
} from '#/view/com/util/LoadingPlaceholder' } from '#/view/com/util/LoadingPlaceholder'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {ExploreRecommendations} from '#/screens/Search/modules/ExploreRecommendations' import {ExploreRecommendations} from '#/screens/Search/modules/ExploreRecommendations'
import {ExploreTrendingTopics} from '#/screens/Search/modules/ExploreTrendingTopics' import {ExploreTrendingTopics} from '#/screens/Search/modules/ExploreTrendingTopics'
import {ExploreTrendingVideos} from '#/screens/Search/modules/ExploreTrendingVideos' 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 {Button} from '#/components/Button'
import * as FeedCard from '#/components/FeedCard' 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 {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {type Props as SVGIconProps} from '#/components/icons/common' import {type Props as SVGIconProps} from '#/components/icons/common'
import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle' import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle'
import {UserCircle_Stroke2_Corner0_Rounded as Person} from '#/components/icons/UserCircle' import {UserCircle_Stroke2_Corner0_Rounded as Person} from '#/components/icons/UserCircle'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import * as ModuleHeader from './components/ModuleHeader'
import {
SuggestedAccountsTabBar,
SuggestedProfileCard,
} from './modules/ExploreSuggestedAccounts'
function SuggestedItemsHeader({ function LoadMore({item}: {item: ExploreScreenItems & {type: 'loadMore'}}) {
title,
description,
style,
icon: Icon,
}: {
title: string
description: string
icon: React.ComponentType<SVGIconProps>
} & ViewStyleProp) {
const t = useTheme()
return (
<View
style={[
isWeb
? [a.flex_row, a.px_lg, a.py_lg, a.pt_2xl, a.gap_md]
: [{flexDirection: 'row-reverse'}, a.p_lg, a.pt_2xl, a.gap_md],
a.border_b,
t.atoms.border_contrast_low,
style,
]}>
<View style={[a.flex_1, a.gap_sm]}>
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<Icon
size="lg"
fill={t.palette.primary_500}
style={{marginLeft: -2}}
/>
<Text style={[a.text_2xl, a.font_heavy, t.atoms.text]}>{title}</Text>
</View>
<Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
{description}
</Text>
</View>
</View>
)
}
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
}) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() 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 ( return (
<View style={[]}> <View style={[a.pb_2xl]}>
<Button <Button
label={_(msg`Load more`)} label={_(msg`Load more`)}
onPress={item.onLoadMore} onPress={item.onLoadMore}
@@ -140,93 +54,27 @@ function LoadMore({
a.flex_1, a.flex_1,
a.flex_row, a.flex_row,
a.align_center, a.align_center,
a.justify_center,
a.px_lg, a.px_lg,
a.py_md, a.py_md,
a.gap_sm,
(hovered || pressed) && t.atoms.bg_contrast_25, (hovered || pressed) && t.atoms.bg_contrast_25,
]}> ]}>
<View
style={[
a.relative,
{
height: 32,
width: 32 + 15 * items.length,
},
]}>
<View
style={[
a.align_center,
a.justify_center,
t.atoms.bg_contrast_25,
a.absolute,
{
width: 30,
height: 30,
left: 0,
borderWidth: 1,
backgroundColor: t.palette.primary_500,
borderColor: t.atoms.bg.backgroundColor,
borderRadius: type === 'profile' ? 999 : 4,
zIndex: 4,
},
]}>
<ArrowBottom fill={t.palette.white} />
</View>
{items.map((_item, i) => {
return (
<View
key={_item.key}
style={[
t.atoms.bg_contrast_25,
a.absolute,
{
width: 30,
height: 30,
left: (i + 1) * 15,
borderWidth: 1,
borderColor: t.atoms.bg.backgroundColor,
borderRadius: _item.type === 'profile' ? 999 : 4,
zIndex: 3 - i,
},
]}>
{moderationOpts && (
<>
{_item.type === 'profile' ? (
<UserAvatar
size={28}
avatar={_item.avatar}
moderation={_item.moderation.ui('avatar')}
type="user"
/>
) : _item.type === 'feed' ? (
<UserAvatar
size={28}
avatar={_item.avatar}
type="algo"
/>
) : null}
</>
)}
</View>
)
})}
</View>
<Text <Text
style={[ style={[
a.pl_sm,
a.leading_snug, a.leading_snug,
hovered ? t.atoms.text : t.atoms.text_contrast_medium, hovered ? t.atoms.text : t.atoms.text_contrast_medium,
]}> ]}>
{type === 'profile' ? ( {item.message}
<Trans>Load more suggested follows</Trans>
) : (
<Trans>Load more suggested feeds</Trans>
)}
</Text> </Text>
{item.isLoadingMore ? (
<View style={[a.flex_1, a.align_end]}> <Loader size="sm" />
{item.isLoadingMore && <Loader size="lg" />} ) : (
</View> <ChevronDownIcon
size="sm"
style={hovered ? t.atoms.text : t.atoms.text_contrast_medium}
/>
)}
</View> </View>
)} )}
</Button> </Button>
@@ -235,13 +83,31 @@ function LoadMore({
} }
type ExploreScreenItems = type ExploreScreenItems =
| {
type: 'topBorder'
key: string
}
| { | {
type: 'header' type: 'header'
key: string key: string
title: string title: string
description: string
style?: ViewStyleProp['style']
icon: React.ComponentType<SVGIconProps> 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' type: 'trendingTopics'
@@ -269,9 +135,9 @@ type ExploreScreenItems =
| { | {
type: 'loadMore' type: 'loadMore'
key: string key: string
message: string
isLoadingMore: boolean isLoadingMore: boolean
onLoadMore: () => void onLoadMore: () => void
items: ExploreScreenItems[]
} }
| { | {
type: 'profilePlaceholder' type: 'profilePlaceholder'
@@ -288,19 +154,38 @@ type ExploreScreenItems =
error: string error: string
} }
export function Explore() { export function Explore({
focusSearchInput,
}: {
focusSearchInput: (tab: 'user' | 'profile' | 'feed') => void
}) {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
const {data: preferences, error: preferencesError} = usePreferencesQuery() const {data: preferences, error: preferencesError} = usePreferencesQuery()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const gate = useGate()
const guide = useProgressGuide('follow-10')
const [selectedInterest, setSelectedInterest] = useState<string | null>(null)
const { const {
data: profiles, data: suggestedProfiles,
hasNextPage: hasNextProfilesPage, hasNextPage: hasNextSuggestedProfilesPage,
isLoading: isLoadingProfiles, isLoading: isLoadingSuggestedProfiles,
isFetchingNextPage: isFetchingNextProfilesPage, isFetchingNextPage: isFetchingNextSuggestedProfilesPage,
error: profilesError, error: suggestedProfilesError,
fetchNextPage: fetchNextProfilesPage, fetchNextPage: fetchNextSuggestedProfilesPage,
} = useSuggestedFollowsQuery({limit: 6, subsequentPageLimit: 10}) } = 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 { const {
data: feeds, data: feeds,
hasNextPage: hasNextFeedsPage, hasNextPage: hasNextFeedsPage,
@@ -310,6 +195,24 @@ export function Explore() {
fetchNextPage: fetchNextFeedsPage, fetchNextPage: fetchNextFeedsPage,
} = useGetPopularFeedsQuery({limit: 10}) } = 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 isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles
const onLoadMoreProfiles = useCallback(async () => { const onLoadMoreProfiles = useCallback(async () => {
if (isFetchingNextProfilesPage || !hasNextProfilesPage || profilesError) if (isFetchingNextProfilesPage || !hasNextProfilesPage || profilesError)
@@ -327,8 +230,13 @@ export function Explore() {
]) ])
const isLoadingMoreFeeds = isFetchingNextFeedsPage && !isLoadingFeeds const isLoadingMoreFeeds = isFetchingNextFeedsPage && !isLoadingFeeds
const [hasPressedLoadMoreFeeds, setHasPressedLoadMoreFeeds] = useState(false)
const onLoadMoreFeeds = useCallback(async () => { const onLoadMoreFeeds = useCallback(async () => {
if (isFetchingNextFeedsPage || !hasNextFeedsPage || feedsError) return if (isFetchingNextFeedsPage || !hasNextFeedsPage || feedsError) return
if (!hasPressedLoadMoreFeeds) {
setHasPressedLoadMoreFeeds(true)
return
}
try { try {
await fetchNextFeedsPage() await fetchNextFeedsPage()
} catch (err) { } catch (err) {
@@ -339,156 +247,198 @@ export function Explore() {
hasNextFeedsPage, hasNextFeedsPage,
feedsError, feedsError,
fetchNextFeedsPage, fetchNextFeedsPage,
hasPressedLoadMoreFeeds,
]) ])
const items = useMemo<ExploreScreenItems[]>(() => { const items = useMemo<ExploreScreenItems[]>(() => {
const i: ExploreScreenItems[] = [] const i: ExploreScreenItems[] = []
i.push({ const addTopBorder = () => {
type: 'trendingTopics', i.push({
key: `trending-topics`, type: 'topBorder',
}) key: `top-border`,
})
}
// temp - disable trending videos const addTrendingTopicsModule = () => {
// if (isNative) { i.push({
// i.push({ type: 'trendingTopics',
// type: 'trendingVideos', key: `trending-topics`,
// key: `trending-videos`, })
// })
// }
i.push({ // temp - disable trending videos
type: 'header', // if (isNative) {
key: 'suggested-follows-header', // i.push({
title: _(msg`Suggested accounts`), // type: 'trendingVideos',
description: _( // key: `trending-videos`,
msg`Follow more accounts to get connected to your interests and build your network.`, // })
), // }
icon: Person, }
})
if (profiles) { const addSuggestedFollowsModule = () => {
// Currently the responses contain duplicate items. i.push({
// Needs to be fixed on backend, but let's dedupe to be safe. type: 'tabbedHeader',
let seen = new Set() key: 'suggested-accounts-header',
const profileItems: ExploreScreenItems[] = [] title: _(msg`Suggested Accounts`),
for (const page of profiles.pages) { icon: Person,
for (const actor of page.actors) { searchButton: {
if (!seen.has(actor.did)) { label: _(msg`Search for more accounts`),
seen.add(actor.did) metricsTag: 'suggestedAccounts',
profileItems.push({ tab: 'user',
type: 'profile', },
key: actor.did, })
profile: actor,
recId: page.recId, 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 { } else {
i.push(...profileItems) if (profilesError) {
} i.push({
} else { type: 'error',
if (profilesError) { key: 'profilesError',
i.push({ message: _(msg`Failed to load suggested follows`),
type: 'error', error: cleanError(profilesError),
key: 'profilesError', })
message: _(msg`Failed to load suggested follows`), } else {
error: cleanError(profilesError), i.push({type: 'profilePlaceholder', key: 'profilePlaceholder'})
}) }
} else {
i.push({type: 'profilePlaceholder', key: 'profilePlaceholder'})
} }
} }
i.push({ const addSuggestedFeedsModule = () => {
type: 'header', i.push({
key: 'suggested-feeds-header', type: 'header',
title: _(msg`Discover new feeds`), key: 'suggested-feeds-header',
description: _( title: _(msg`Discover Feeds`),
msg`Choose your own timeline! Feeds built by the community help you find content you love.`, icon: ListSparkle,
), searchButton: {
style: [a.pt_5xl], label: _(msg`Search for more feeds`),
icon: ListSparkle, metricsTag: 'suggestedFeeds',
}) tab: 'feed',
},
})
if (feeds && preferences) { if (feeds && preferences) {
// Currently the responses contain duplicate items. // Currently the responses contain duplicate items.
// Needs to be fixed on backend, but let's dedupe to be safe. // Needs to be fixed on backend, but let's dedupe to be safe.
let seen = new Set() let seen = new Set()
const feedItems: ExploreScreenItems[] = [] const feedItems: ExploreScreenItems[] = []
for (const page of feeds.pages) { for (const page of feeds.pages) {
for (const feed of page.feeds) { for (const feed of page.feeds) {
if (!seen.has(feed.uri)) { if (!seen.has(feed.uri)) {
seen.add(feed.uri) seen.add(feed.uri)
feedItems.push({ feedItems.push({
type: 'feed', type: 'feed',
key: feed.uri, key: feed.uri,
feed, feed,
}) })
}
} }
} }
}
// feeds errors can occur during pagination, so feeds is truthy // feeds errors can occur during pagination, so feeds is truthy
if (feedsError) { if (feedsError) {
i.push({ i.push({
type: 'error', type: 'error',
key: 'feedsError', key: 'feedsError',
message: _(msg`Failed to load suggested feeds`), message: _(msg`Failed to load suggested feeds`),
error: cleanError(feedsError), error: cleanError(feedsError),
}) })
} else if (preferencesError) { } else if (preferencesError) {
i.push({ i.push({
type: 'error', type: 'error',
key: 'preferencesError', key: 'preferencesError',
message: _(msg`Failed to load feeds preferences`), message: _(msg`Failed to load feeds preferences`),
error: cleanError(preferencesError), error: cleanError(preferencesError),
}) })
} else if (hasNextFeedsPage) { } else {
const preview = feedItems.splice(-3) if (feedItems.length === 0) {
i.push(...feedItems) i.pop()
i.push({ } else {
type: 'loadMore', // This query doesn't follow the limit very well, so the first press of the
key: 'loadMoreFeeds', // load more button just unslices the array back to ~10 items
isLoadingMore: isLoadingMoreFeeds, if (!hasPressedLoadMoreFeeds) {
onLoadMore: onLoadMoreFeeds, i.push(...feedItems.slice(0, 3))
items: preview, } else {
}) i.push(...feedItems)
}
if (hasNextFeedsPage) {
i.push({
type: 'loadMore',
key: 'loadMoreFeeds',
message: _(msg`Load more suggested feeds`),
isLoadingMore: isLoadingMoreFeeds,
onLoadMore: onLoadMoreFeeds,
})
}
}
}
} else { } 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 { } else {
if (feedsError) { addTrendingTopicsModule()
i.push({ addSuggestedFollowsModule()
type: 'error', }
key: 'feedsError',
message: _(msg`Failed to load suggested feeds`), if (gate('explore_show_suggested_feeds')) {
error: cleanError(feedsError), addSuggestedFeedsModule()
})
} 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 return i
@@ -506,19 +456,61 @@ export function Explore() {
preferencesError, preferencesError,
hasNextProfilesPage, hasNextProfilesPage,
hasNextFeedsPage, hasNextFeedsPage,
guide,
gate,
moderationOpts,
hasPressedLoadMoreFeeds,
]) ])
const renderItem = useCallback( const renderItem = useCallback(
({item, index}: {item: ExploreScreenItems; index: number}) => { ({item, index}: {item: ExploreScreenItems; index: number}) => {
switch (item.type) { switch (item.type) {
case 'topBorder':
return (
<View style={[a.w_full, t.atoms.border_contrast_low, a.border_t]} />
)
case 'header': { case 'header': {
return ( return (
<SuggestedItemsHeader <ModuleHeader.Container>
title={item.title} <ModuleHeader.Icon icon={item.icon} />
description={item.description} <ModuleHeader.TitleText>{item.title}</ModuleHeader.TitleText>
style={item.style} {item.searchButton && (
icon={item.icon} <ModuleHeader.SearchButton
/> {...item.searchButton}
onPress={() =>
focusSearchInput(item.searchButton?.tab || 'user')
}
/>
)}
</ModuleHeader.Container>
)
}
case 'tabbedHeader': {
return (
<View
style={[
a.border_b,
t.atoms.border_contrast_low,
a.pb_md,
t.atoms.bg,
]}>
<ModuleHeader.Container style={a.border_transparent}>
<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': { case 'trendingTopics': {
@@ -532,29 +524,12 @@ export function Explore() {
} }
case 'profile': { case 'profile': {
return ( return (
<View style={[a.border_b, t.atoms.border_contrast_low]}> <SuggestedProfileCard
<ProfileCardWithFollowBtn profile={item.profile}
profile={item.profile} moderationOpts={moderationOpts!}
noBg recId={item.recId}
noBorder position={index}
showKnownFollowers />
onPress={() => {
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,
})
}}
/>
</View>
) )
} }
case 'feed': { case 'feed': {
@@ -571,7 +546,7 @@ export function Explore() {
) )
} }
case 'loadMore': { case 'loadMore': {
return <LoadMore item={item} moderationOpts={moderationOpts} /> return <LoadMore item={item} />
} }
case 'profilePlaceholder': { case 'profilePlaceholder': {
return <ProfileCardFeedLoadingPlaceholder /> return <ProfileCardFeedLoadingPlaceholder />
@@ -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<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 {
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 ( return (
<List <List
data={items} data={items}
@@ -630,6 +644,13 @@ export function Explore() {
contentContainerStyle={{paddingBottom: 100}} contentContainerStyle={{paddingBottom: 100}}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag" keyboardDismissMode="on-drag"
stickyHeaderIndices={stickyHeaderIndices}
viewabilityConfig={viewabilityConfig}
onViewableItemsChanged={onViewableItemsChanged}
/> />
) )
} }
const viewabilityConfig: ViewabilityConfig = {
itemVisiblePercentThreshold: 100,
}
+8 -1
View File
@@ -266,6 +266,10 @@ export function SearchScreenShell({
} }
}, [setShowAutocomplete]) }, [setShowAutocomplete])
const focusSearchInput = useCallback(() => {
textInput.current?.focus()
}, [])
const showHeader = !gtMobile || navButton !== 'menu' const showHeader = !gtMobile || navButton !== 'menu'
return ( return (
@@ -399,6 +403,7 @@ export function SearchScreenShell({
query={query} query={query}
queryWithParams={queryWithParams} queryWithParams={queryWithParams}
headerHeight={headerHeight} headerHeight={headerHeight}
focusSearchInput={focusSearchInput}
/> />
</View> </View>
</Layout.Screen> </Layout.Screen>
@@ -409,10 +414,12 @@ let SearchScreenInner = ({
query, query,
queryWithParams, queryWithParams,
headerHeight, headerHeight,
focusSearchInput,
}: { }: {
query: string query: string
queryWithParams: string queryWithParams: string
headerHeight: number headerHeight: number
focusSearchInput: () => void
}): React.ReactNode => { }): React.ReactNode => {
const t = useTheme() const t = useTheme()
const setMinimalShellMode = useSetMinimalShellMode() const setMinimalShellMode = useSetMinimalShellMode()
@@ -438,7 +445,7 @@ let SearchScreenInner = ({
onPageSelected={onPageSelected} onPageSelected={onPageSelected}
/> />
) : hasSession ? ( ) : hasSession ? (
<Explore /> <Explore focusSearchInput={focusSearchInput} />
) : ( ) : (
<Layout.Center> <Layout.Center>
<View style={a.flex_1}> <View style={a.flex_1}>
@@ -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 (
<View
style={[
t.atoms.bg,
a.border_b,
t.atoms.border_contrast_low,
a.flex_row,
a.align_center,
gutters,
a.pt_2xs,
a.pb_sm,
a.gap_sm,
style,
]}>
{children}
</View>
)
}
export function Icon({
icon: Comp,
size = 'lg',
}: Pick<React.ComponentProps<typeof ButtonIcon>, 'icon' | 'size'>) {
const iconSize = iconSizes[size]
return (
<View style={[a.z_20, {width: iconSize, height: iconSize, marginLeft: -2}]}>
<Comp width={iconSize} />
</View>
)
}
export function TitleText({style, ...props}: TextProps) {
return <Text style={[a.font_bold, a.flex_1, a.text_xl, style]} {...props} />
}
export function SearchButton({
label,
metricsTag,
onPress,
}: {
label: string
metricsTag: 'suggestedAccounts' | 'suggestedFeeds'
onPress?: () => void
}) {
return (
<Button
label={label}
size="small"
variant="ghost"
color="secondary"
shape="round"
PressableComponent={native(PressableScale)}
onPress={() => {
logger.metric('explore:module:searchButtonPress', {module: metricsTag})
onPress?.()
}}>
<ButtonIcon icon={SearchIcon} size="lg" />
</Button>
)
}
@@ -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 (
<BlockDrawerGesture>
<Tabs
interests={['all', ...interests]}
selectedInterest={selectedInterest || 'all'}
onSelectTab={tab => {
logger.metric('explore:suggestedAccounts:tabPressed', {tab: tab})
onSelectInterest(tab === 'all' ? null : tab)
}}
hasSearchText={false}
interestsDisplayNames={{
all: _(msg`All`),
...interestsDisplayNames,
}}
TabComponent={Tab}
/>
</BlockDrawerGesture>
)
}
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 (
<View
key={interest}
onLayout={e =>
onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width)
}>
<Button
label={_(msg`Search for "${interestsDisplayName}"${activeText}`)}
onPress={() => onSelectTab(index)}>
{({hovered, pressed, focused}) => (
<View
style={[
a.rounded_full,
a.px_lg,
a.py_sm,
a.border,
active || hovered || pressed || focused
? [
t.atoms.bg_contrast_25,
{borderColor: t.atoms.bg_contrast_25.backgroundColor},
]
: [t.atoms.bg, t.atoms.border_contrast_low],
]}>
<Text
style={[
/* TODO: medium weight */
active || hovered || pressed || focused
? t.atoms.text
: t.atoms.text_contrast_medium,
]}>
{interestsDisplayName}
</Text>
</View>
)}
</Button>
</View>
)
}
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 (
<ProfileCard.Link
profile={profile}
style={[a.flex_1]}
onPress={() => {
logger.metric('suggestedUser:press', {
logContext: 'Explore',
recId,
position,
})
}}>
<View
style={[
a.w_full,
a.py_md,
a.px_lg,
a.border_b,
t.atoms.border_contrast_low,
a.flex_1,
]}>
<ProfileCard.Outer>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.NameAndHandle
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.FollowButton
profile={profile}
moderationOpts={moderationOpts}
withIcon={false}
logContext="ExploreSuggestedAccounts"
onFollow={() => {
logger.metric('suggestedUser:follow', {
logContext: 'Explore',
location: 'Card',
recId,
position,
})
}}
/>
</ProfileCard.Header>
<ProfileCard.Description profile={profile} numberOfLines={3} />
</ProfileCard.Outer>
</View>
</ProfileCard.Link>
)
}
SuggestedProfileCard = memo(SuggestedProfileCard)
export {SuggestedProfileCard}
+16 -7
View File
@@ -1,9 +1,12 @@
import {AppBskyActorDefs, AppBskyActorSearchActors} from '@atproto/api'
import { import {
InfiniteData, type AppBskyActorDefs,
type AppBskyActorSearchActors,
} from '@atproto/api'
import {
type InfiniteData,
keepPreviousData, keepPreviousData,
QueryClient, type QueryClient,
QueryKey, type QueryKey,
useInfiniteQuery, useInfiniteQuery,
useQuery, useQuery,
} from '@tanstack/react-query' } from '@tanstack/react-query'
@@ -15,7 +18,11 @@ const RQKEY_ROOT = 'actor-search'
export const RQKEY = (query: string) => [RQKEY_ROOT, query] export const RQKEY = (query: string) => [RQKEY_ROOT, query]
const RQKEY_ROOT_PAGINATED = `${RQKEY_ROOT}_paginated` 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({ export function useActorSearch({
query, query,
@@ -42,10 +49,12 @@ export function useActorSearchPaginated({
query, query,
enabled, enabled,
maintainData, maintainData,
limit = 25,
}: { }: {
query: string query: string
enabled?: boolean enabled?: boolean
maintainData?: boolean maintainData?: boolean
limit?: number
}) { }) {
const agent = useAgent() const agent = useAgent()
return useInfiniteQuery< return useInfiniteQuery<
@@ -56,11 +65,11 @@ export function useActorSearchPaginated({
string | undefined string | undefined
>({ >({
staleTime: STALE.MINUTES.FIVE, staleTime: STALE.MINUTES.FIVE,
queryKey: RQKEY_PAGINATED(query), queryKey: RQKEY_PAGINATED(query, limit),
queryFn: async ({pageParam}) => { queryFn: async ({pageParam}) => {
const res = await agent.searchActors({ const res = await agent.searchActors({
q: query, q: query,
limit: 25, limit,
cursor: pageParam, cursor: pageParam,
}) })
return res.data return res.data