get suggested accounts working on native

This commit is contained in:
Samuel Newman
2025-08-21 15:16:34 +03:00
parent 67aa7872b3
commit 2d8c2e0f18
8 changed files with 293 additions and 48 deletions
+4
View File
@@ -90,6 +90,9 @@ export type MetricEvents = {
selectedInterests: string[] selectedInterests: string[]
selectedInterestsLength: number selectedInterestsLength: number
} }
'onboarding:suggestedAccounts:tabPressed': {
tab: string
}
'onboarding:suggestedAccounts:nextPressed': { 'onboarding:suggestedAccounts:nextPressed': {
selectedAccountsLength: number selectedAccountsLength: number
skipped: boolean skipped: boolean
@@ -249,6 +252,7 @@ export type MetricEvents = {
| 'InterstitialDiscover' | 'InterstitialDiscover'
| 'InterstitialProfile' | 'InterstitialProfile'
| 'Profile' | 'Profile'
| 'Onboarding'
location: 'Card' | 'Profile' location: 'Card' | 'Profile'
recId?: number recId?: number
position: number position: number
+7 -12
View File
@@ -23,7 +23,7 @@ import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft} from '#/components/icons
import {createPortalGroup} from '#/components/Portal' import {createPortalGroup} from '#/components/Portal'
import {P, Text} from '#/components/Typography' import {P, Text} from '#/components/Typography'
const COL_WIDTH = 420 const ONBOARDING_COL_WIDTH = 420
export const OnboardingControls = createPortalGroup() export const OnboardingControls = createPortalGroup()
@@ -92,7 +92,8 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
top: paddingTop.paddingTop + insets.top - 1, top: paddingTop.paddingTop + insets.top - 1,
}, },
]}> ]}>
<View style={[a.w_full, a.align_start, {maxWidth: COL_WIDTH}]}> <View
style={[a.w_full, a.align_start, {maxWidth: ONBOARDING_COL_WIDTH}]}>
<Button <Button
key={state.activeStep} // remove focus state on nav key={state.activeStep} // remove focus state on nav
color="secondary" color="secondary"
@@ -113,16 +114,11 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
style={[a.h_full, a.w_full, {paddingTop: insets.top}]} style={[a.h_full, a.w_full, {paddingTop: insets.top}]}
contentContainerStyle={{borderWidth: 0}} contentContainerStyle={{borderWidth: 0}}
scrollIndicatorInsets={{bottom: footerHeight - insets.bottom}} scrollIndicatorInsets={{bottom: footerHeight - insets.bottom}}
// @ts-ignore web only --prf // @ts-expect-error web only --prf
dataSet={{'stable-gutters': 1}}> dataSet={{'stable-gutters': 1}}>
<View <View
style={[ style={[a.flex_row, a.justify_center, gtMobile ? a.px_5xl : a.px_xl]}>
a.flex_row, <View style={[a.flex_1, {maxWidth: ONBOARDING_COL_WIDTH}]}>
a.justify_center,
gtMobile ? a.px_5xl : a.px_xl,
a.debug,
]}>
<View style={[a.flex_1, {maxWidth: COL_WIDTH}]}>
<View style={[a.w_full, a.align_center, paddingTop]}> <View style={[a.w_full, a.align_center, paddingTop]}>
<View <View
style={[ style={[
@@ -166,7 +162,6 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
<View <View
onLayout={evt => setFooterHeight(evt.nativeEvent.layout.height)} onLayout={evt => setFooterHeight(evt.nativeEvent.layout.height)}
style={[ style={[
// @ts-ignore web only -prf
isWeb ? a.fixed : a.absolute, isWeb ? a.fixed : a.absolute,
{bottom: 0, left: 0, right: 0}, {bottom: 0, left: 0, right: 0},
t.atoms.bg, t.atoms.bg,
@@ -184,7 +179,7 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
<View <View
style={[ style={[
a.w_full, a.w_full,
{maxWidth: COL_WIDTH}, {maxWidth: ONBOARDING_COL_WIDTH},
gtMobile && [a.flex_row, a.justify_between, a.align_center], gtMobile && [a.flex_row, a.justify_between, a.align_center],
]}> ]}>
{gtMobile && {gtMobile &&
+10 -1
View File
@@ -160,7 +160,16 @@ export function StepInterests() {
<View style={[a.w_full, a.pt_2xl]}> <View style={[a.w_full, a.pt_2xl]}>
{isLoading ? ( {isLoading ? (
<Loader size="xl" /> <View
style={[
a.flex_1,
a.mt_md,
a.align_center,
a.justify_center,
{minHeight: 400},
]}>
<Loader size="xl" />
</View>
) : isError || !data ? ( ) : isError || !data ? (
<View <View
style={[ style={[
@@ -1,29 +1,82 @@
import {useCallback, useContext, useState} from 'react' import {useCallback, useContext, useMemo, useState} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {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'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import * as bcp47Match from 'bcp-47-match'
import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted'
import {logger} from '#/logger' import {logger} from '#/logger'
import {updateProfileShadow} from '#/state/cache/profile-shadow'
import {useLanguagePrefs} from '#/state/preferences'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useAgent, useSession} from '#/state/session'
import {useOnboardingDispatch} from '#/state/shell' import {useOnboardingDispatch} from '#/state/shell'
import {OnboardingControls} from '#/screens/Onboarding/Layout' import {OnboardingControls} from '#/screens/Onboarding/Layout'
import {Context} from '#/screens/Onboarding/state' import {
Context,
popularInterests,
useInterestsDisplayNames,
} from '#/screens/Onboarding/state'
import {SuggestedAccountsTabBar} from '#/screens/Search/modules/ExploreSuggestedAccounts' import {SuggestedAccountsTabBar} from '#/screens/Search/modules/ExploreSuggestedAccounts'
import {atoms as a, useBreakpoints} from '#/alf' import {useSuggestedUsers} from '#/screens/Search/util/useSuggestedUsers'
import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwise} from '#/components/icons/ArrowRotateCounterClockwise' import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwiseIcon} from '#/components/icons/ArrowRotateCounterClockwise'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard'
import {boostInterests} from '#/components/ProgressGuide/FollowDialog'
import {SubtleHover} from '#/components/SubtleHover'
import * as toast from '#/components/Toast'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
import {bulkWriteFollows} from '../util'
export function StepSuggestedAccounts() { export function StepSuggestedAccounts() {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const moderationOpts = useModerationOpts()
const agent = useAgent()
const {currentAccount} = useSession()
const queryClient = useQueryClient()
const {dispatch} = useContext(Context) const {state, dispatch} = useContext(Context)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const onboardDispatch = useOnboardingDispatch() const onboardDispatch = useOnboardingDispatch()
const [selectedInterest, setSelectedInterest] = useState<string | null>(null) const [selectedInterest, setSelectedInterest] = useState<string | null>(null)
// keeping track of who was followed via the follow all button
// so we can enable/disable the button without having to dig through the shadow cache
const [followedUsers, setFollowedUsers] = useState<string[]>([])
/*
* Special language handling copied wholesale from the Explore screen
*/
// FIGURE OUT BEFORE MERGE: IS THIS POPULATED BEFORE FIRST ONBOARDING
const {contentLanguages} = useLanguagePrefs()
const useFullExperience = useMemo(() => {
if (contentLanguages.length === 0) return true
return bcp47Match.basicFilter('en', contentLanguages).length > 0
}, [contentLanguages])
const interestsDisplayNames = useInterestsDisplayNames()
const interests = Object.keys(interestsDisplayNames)
.sort(boostInterests(popularInterests))
.sort(boostInterests(state.interestsStepResults.selectedInterests))
const {
data: suggestedUsers,
isLoading,
error,
isRefetching,
refetch,
} = useSuggestedUsers({
category: selectedInterest || (useFullExperience ? null : interests[0]),
search: !useFullExperience,
})
const isError = !!error
const saveInterests = useCallback(async () => { const saveInterests = useCallback(async () => {
setSaving(true) setSaving(true)
@@ -42,7 +95,47 @@ export function StepSuggestedAccounts() {
dispatch({type: 'finish'}) dispatch({type: 'finish'})
}, [onboardDispatch, dispatch]) }, [onboardDispatch, dispatch])
const isError = false const followableDids =
suggestedUsers?.actors
.filter(
user =>
user.did !== currentAccount?.did &&
!isBlockedOrBlocking(user) &&
!isMuted(user) &&
!user.viewer?.following &&
!followedUsers.includes(user.did),
)
.map(user => user.did) ?? []
const {mutate: followAll, isPending: isFollowingAll} = useMutation({
mutationFn: async () => {
for (const did of followableDids) {
updateProfileShadow(queryClient, did, {
followingUri: 'pending',
})
}
const uris = await bulkWriteFollows(agent, followableDids)
for (const did of followableDids) {
const uri = uris.get(did)
updateProfileShadow(queryClient, did, {
followingUri: uri,
})
}
return followableDids
},
onSuccess: newlyFollowed => {
toast.show(_(msg`Done!`), {type: 'success'})
setFollowedUsers(followed => [...followed, ...newlyFollowed])
},
onError: () => {
toast.show(
_(msg`Failed to follow all suggested accounts, please try again`),
{type: 'error'},
)
},
})
const canFollowAll = followableDids.length > 0 && !isFollowingAll
return ( return (
<View style={[a.align_start]} testID="onboardingInterests"> <View style={[a.align_start]} testID="onboardingInterests">
@@ -50,29 +143,76 @@ export function StepSuggestedAccounts() {
<Trans>Suggested Accounts</Trans> <Trans>Suggested Accounts</Trans>
</Text> </Text>
<SuggestedAccountsTabBar <View
selectedInterest={selectedInterest} style={[
onSelectInterest={setSelectedInterest} a.overflow_hidden,
/> a.mt_sm,
!gtMobile ? {marginHorizontal: tokens.space.xl * -1} : a.max_w_full,
a.flex_1,
a.justify_start,
]}>
<SuggestedAccountsTabBar
selectedInterest={selectedInterest}
onSelectInterest={setSelectedInterest}
priorityInterests={state.interestsStepResults.selectedInterests}
leftPadding={gtMobile ? 0 : tokens.space.xl}
/>
{isLoading || !moderationOpts ? (
<View
style={[
a.flex_1,
a.mt_md,
a.align_center,
a.justify_center,
{minHeight: 400},
]}>
<Loader size="xl" />
</View>
) : isError ? (
<View style={[a.flex_1, a.px_xl, a.pt_5xl]}>
<Text
style={[a.text_center, a.text_md, t.atoms.text_contrast_medium]}>
<Trans>
An error occurred while fetching suggested accounts.
</Trans>
</Text>
</View>
) : (
<View
style={[
a.flex_1,
a.mt_md,
a.border_b,
t.atoms.border_contrast_low,
]}>
{suggestedUsers?.actors.map((user, index) => (
<SuggestedProfileCard
key={user.did}
profile={user}
moderationOpts={moderationOpts}
position={index}
/>
))}
</View>
)}
</View>
<OnboardingControls.Portal> <OnboardingControls.Portal>
{isError ? ( {isError ? (
<View style={[a.gap_md, gtMobile ? a.flex_row : a.flex_col]}> <View style={[a.gap_md, gtMobile ? a.flex_row : a.flex_col]}>
<Button <Button
// disabled={isFetching} disabled={isRefetching}
variant="solid"
color="secondary" color="secondary"
size="large" size="large"
label={_(msg`Retry`)} label={_(msg`Retry`)}
// onPress={() => refetch()} onPress={() => refetch()}>
>
<ButtonText> <ButtonText>
<Trans>Retry</Trans> <Trans>Retry</Trans>
</ButtonText> </ButtonText>
<ButtonIcon icon={ArrowRotateCounterClockwise} position="right" /> <ButtonIcon icon={ArrowRotateCounterClockwiseIcon} />
</Button> </Button>
<Button <Button
variant="outline"
color="secondary" color="secondary"
size="large" size="large"
label={_(msg`Skip this flow`)} label={_(msg`Skip this flow`)}
@@ -85,17 +225,18 @@ export function StepSuggestedAccounts() {
) : ( ) : (
<View style={[a.gap_md, gtMobile ? a.flex_row : a.flex_col]}> <View style={[a.gap_md, gtMobile ? a.flex_row : a.flex_col]}>
<Button <Button
disabled={saving} disabled={saving || !canFollowAll}
color="secondary" color="secondary"
size="large" size="large"
label={_(msg`Follow all accounts`)}> label={_(msg`Follow all accounts`)}
onPress={() => followAll()}>
<ButtonText> <ButtonText>
<Trans>Follow all</Trans> <Trans>Follow all</Trans>
</ButtonText> </ButtonText>
{isFollowingAll && <ButtonIcon icon={Loader} />}
</Button> </Button>
<Button <Button
disabled={saving} disabled={saving}
variant="solid"
color="primary" color="primary"
size="large" size="large"
label={_(msg`Continue to next step`)} label={_(msg`Continue to next step`)}
@@ -103,7 +244,7 @@ export function StepSuggestedAccounts() {
<ButtonText> <ButtonText>
<Trans>Continue</Trans> <Trans>Continue</Trans>
</ButtonText> </ButtonText>
<ButtonIcon icon={saving ? Loader : ChevronRight} /> <ButtonIcon icon={saving ? Loader : ChevronRightIcon} />
</Button> </Button>
</View> </View>
)} )}
@@ -111,3 +252,79 @@ export function StepSuggestedAccounts() {
</View> </View>
) )
} }
function SuggestedProfileCard({
profile,
moderationOpts,
recId,
position,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
recId?: number
position: number
}) {
const t = useTheme()
return (
<ProfileCard.Link
profile={profile}
onPress={() => {
logger.metric(
'suggestedUser:press',
{
logContext: 'Explore',
recId,
position,
},
{statsig: true},
)
}}>
{s => (
<>
<SubtleHover hover={s.hovered || s.pressed} />
<View
style={[
a.flex_1,
a.w_full,
a.py_lg,
a.px_xl,
a.border_t,
t.atoms.border_contrast_low,
]}>
<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,
},
{statsig: true},
)
}}
/>
</ProfileCard.Header>
<ProfileCard.Description profile={profile} numberOfLines={2} />
</ProfileCard.Outer>
</View>
</>
)}
</ProfileCard.Link>
)
}
+7 -7
View File
@@ -1,9 +1,9 @@
import { import {
$Typed, type $Typed,
AppBskyGraphFollow, type AppBskyGraphFollow,
AppBskyGraphGetFollows, type AppBskyGraphGetFollows,
BskyAgent, type BskyAgent,
ComAtprotoRepoApplyWrites, type ComAtprotoRepoApplyWrites,
} from '@atproto/api' } from '@atproto/api'
import {TID} from '@atproto/common-web' import {TID} from '@atproto/common-web'
import chunk from 'lodash.chunk' import chunk from 'lodash.chunk'
@@ -42,10 +42,10 @@ export async function bulkWriteFollows(agent: BskyAgent, dids: string[]) {
} }
await whenFollowsIndexed(agent, session.did, res => !!res.data.follows.length) await whenFollowsIndexed(agent, session.did, res => !!res.data.follows.length)
const followUris = new Map() const followUris = new Map<string, string>()
for (const r of followWrites) { for (const r of followWrites) {
followUris.set( followUris.set(
r.value.subject, r.value.subject as string,
`at://${session.did}/app.bsky.graph.follow/${r.rkey}`, `at://${session.did}/app.bsky.graph.follow/${r.rkey}`,
) )
} }
+1
View File
@@ -751,6 +751,7 @@ export function Explore({
selectedInterest={selectedInterest} selectedInterest={selectedInterest}
onSelectInterest={setSelectedInterest} onSelectInterest={setSelectedInterest}
hideDefaultTab={item.hideDefaultTab} hideDefaultTab={item.hideDefaultTab}
logContext="Explore"
/> />
</View> </View>
) )
@@ -12,7 +12,7 @@ import {
popularInterests, popularInterests,
useInterestsDisplayNames, useInterestsDisplayNames,
} from '#/screens/Onboarding/state' } from '#/screens/Onboarding/state'
import {useTheme} from '#/alf' import {tokens, useTheme} from '#/alf'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
import * as ProfileCard from '#/components/ProfileCard' import * as ProfileCard from '#/components/ProfileCard'
@@ -55,14 +55,21 @@ export function useLoadEnoughProfiles({
} }
} }
// TODO: Move to own file -sfn
export function SuggestedAccountsTabBar({ export function SuggestedAccountsTabBar({
selectedInterest, selectedInterest,
onSelectInterest, onSelectInterest,
hideDefaultTab, hideDefaultTab,
priorityInterests,
leftPadding = tokens.space.md,
logContext = 'Explore',
}: { }: {
selectedInterest: string | null selectedInterest: string | null
onSelectInterest: (interest: string | null) => void onSelectInterest: (interest: string | null) => void
priorityInterests?: string[]
hideDefaultTab?: boolean hideDefaultTab?: boolean
leftPadding?: number
logContext?: 'Explore' | 'Onboarding'
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const interestsDisplayNames = useInterestsDisplayNames() const interestsDisplayNames = useInterestsDisplayNames()
@@ -71,6 +78,8 @@ export function SuggestedAccountsTabBar({
const interests = Object.keys(interestsDisplayNames) const interests = Object.keys(interestsDisplayNames)
.sort(boostInterests(popularInterests)) .sort(boostInterests(popularInterests))
.sort(boostInterests(personalizedInterests)) .sort(boostInterests(personalizedInterests))
.sort(boostInterests(priorityInterests))
return ( return (
<BlockDrawerGesture> <BlockDrawerGesture>
<Tabs <Tabs
@@ -79,11 +88,19 @@ export function SuggestedAccountsTabBar({
selectedInterest || (hideDefaultTab ? interests[0] : 'all') selectedInterest || (hideDefaultTab ? interests[0] : 'all')
} }
onSelectTab={tab => { onSelectTab={tab => {
logger.metric( if (logContext === 'Explore') {
'explore:suggestedAccounts:tabPressed', logger.metric(
{tab: tab}, 'explore:suggestedAccounts:tabPressed',
{statsig: true}, {tab: tab},
) {statsig: true},
)
} else {
logger.metric(
'onboarding:suggestedAccounts:tabPressed',
{tab: tab},
{statsig: true},
)
}
onSelectInterest(tab === 'all' ? null : tab) onSelectInterest(tab === 'all' ? null : tab)
}} }}
hasSearchText={false} hasSearchText={false}
@@ -99,7 +116,7 @@ export function SuggestedAccountsTabBar({
contentContainerStyle={[ contentContainerStyle={[
{ {
// visual alignment // visual alignment
paddingLeft: a.px_md.paddingLeft, paddingLeft: leftPadding,
}, },
]} ]}
/> />
@@ -43,6 +43,7 @@ export function useSuggestedUsers({
isLoading: searched.isLoading, isLoading: searched.isLoading,
error: searched.error, error: searched.error,
isRefetching: searched.isRefetching, isRefetching: searched.isRefetching,
refetch: searched.refetch,
} }
} else { } else {
return { return {
@@ -50,6 +51,7 @@ export function useSuggestedUsers({
isLoading: curated.isLoading, isLoading: curated.isLoading,
error: curated.error, error: curated.error,
isRefetching: curated.isRefetching, isRefetching: curated.isRefetching,
refetch: curated.refetch,
} }
} }
}, [curated, searched, search]) }, [curated, searched, search])