[Onboarding] Add starterpacks step (#9007)

* add onboarding starterpacks step

* tweak starter pack cards

* fix web styling

* use new API

* add screen transitions to onboarding

* skip host account

* bump limit to 6

* tweak font weight

* rm commented out code

* fix yarn.lock

* limit starter packs experiment to english speakers
This commit is contained in:
Samuel Newman
2025-10-15 17:32:38 +03:00
committed by GitHub
parent e4485da4ff
commit 1127e50248
10 changed files with 1621 additions and 1208 deletions
+1
View File
@@ -8,6 +8,7 @@ export type Gate =
| 'feed_reply_button_open_thread'
| 'old_postonboarding'
| 'onboarding_add_video_feed'
| 'onboarding_suggested_starterpacks'
| 'post_follow_profile_suggested_accounts'
| 'remove_show_latest_button'
| 'test_gate_1'
@@ -0,0 +1,174 @@
import {useState} from 'react'
import {View} from 'react-native'
import {type AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {batchedUpdates} from '#/lib/batchedUpdates'
import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted'
import {logger} from '#/logger'
import {updateProfileShadow} from '#/state/cache/profile-shadow'
import {getAllListMembers} from '#/state/queries/list-members'
import {useAgent, useSession} from '#/state/session'
import {bulkWriteFollows} from '#/screens/Onboarding/util'
import {AvatarStack} from '#/screens/Search/components/StarterPackCard'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky'
const IGNORED_ACCOUNT = 'did:plc:pifkcjimdcfwaxkanzhwxufp'
export function StarterPackCard({
view,
}: {
view: AppBskyGraphDefs.StarterPackView
}) {
const t = useTheme()
const {_} = useLingui()
const {currentAccount} = useSession()
const {gtPhone} = useBreakpoints()
const agent = useAgent()
const queryClient = useQueryClient()
const record = view.record
const [isProcessing, setIsProcessing] = useState(false)
const [isFollowingAll, setIsFollowingAll] = useState(false)
const onFollowAll = async () => {
if (!view.list) return
setIsProcessing(true)
let listItems: AppBskyGraphDefs.ListItemView[] = []
try {
listItems = await getAllListMembers(agent, view.list.uri)
} catch (e) {
setIsProcessing(false)
Toast.show(_(msg`An error occurred while trying to follow all`), {
type: 'error',
})
logger.error('Failed to get list members for starter pack', {
safeMessage: e,
})
return
}
const dids = listItems
.filter(
li =>
li.subject.did !== IGNORED_ACCOUNT &&
li.subject.did !== currentAccount?.did &&
!isBlockedOrBlocking(li.subject) &&
!isMuted(li.subject) &&
!li.subject.viewer?.following,
)
.map(li => li.subject.did)
let followUris: Map<string, string>
try {
followUris = await bulkWriteFollows(agent, dids)
} catch (e) {
setIsProcessing(false)
Toast.show(_(msg`An error occurred while trying to follow all`), {
type: 'error',
})
logger.error('Failed to follow all accounts', {safeMessage: e})
}
setIsFollowingAll(true)
setIsProcessing(false)
batchedUpdates(() => {
for (let did of dids) {
updateProfileShadow(queryClient, did, {
followingUri: followUris.get(did),
})
}
})
Toast.show(_(msg`All accounts have been followed!`), {type: 'success'})
logger.metric('starterPack:followAll', {
logContext: 'Onboarding',
starterPack: view.uri,
count: dids.length,
})
}
if (
!bsky.dangerousIsType<AppBskyGraphStarterpack.Record>(
record,
AppBskyGraphStarterpack.isRecord,
)
) {
return null
}
const profileCount = gtPhone ? 11 : 8
const profiles = view.listItemsSample
?.slice(0, profileCount)
.map(item => item.subject)
return (
<View
style={[
a.w_full,
a.p_lg,
a.gap_md,
a.border,
a.rounded_lg,
a.overflow_hidden,
t.atoms.border_contrast_medium,
]}>
<AvatarStack
profiles={profiles ?? []}
numPending={profileCount}
total={view.list?.listItemCount}
/>
<View
style={[
a.w_full,
a.flex_row,
a.align_end,
a.gap_lg,
web({
position: 'static',
zIndex: 'unset',
}),
]}>
<View style={[a.flex_1, a.gap_2xs]}>
<Text
emoji
style={[a.text_md, a.font_semi_bold, a.leading_snug]}
numberOfLines={1}>
{record.name}
</Text>
<Text
emoji
style={[a.text_xs, t.atoms.text_contrast_medium, a.leading_snug]}
numberOfLines={2}>
{record.description}
</Text>
</View>
<Button
label={_(msg`Follow all`)}
disabled={isProcessing || isFollowingAll}
onPress={onFollowAll}
color="secondary"
size="small"
style={[a.z_50]}>
<ButtonText>
<Trans>Follow all</Trans>
</ButtonText>
{isFollowingAll ? (
<ButtonIcon icon={CheckIcon} />
) : (
isProcessing && <ButtonIcon icon={Loader} />
)}
</Button>
</View>
</View>
)
}
@@ -0,0 +1,131 @@
import {useCallback, useContext} from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useOnboardingSuggestedStarterPacksQuery} from '#/state/queries/useOnboardingSuggestedStarterPacksQuery'
import {useOnboardingDispatch} from '#/state/shell'
import {OnboardingControls} from '#/screens/Onboarding/Layout'
import {Context} from '#/screens/Onboarding/state'
import {atoms as a, useBreakpoints} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwiseIcon} from '#/components/icons/ArrowRotateCounterClockwise'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {StarterPackCard} from './StarterPackCard'
export function StepSuggestedStarterpacks() {
const {_} = useLingui()
const {gtMobile} = useBreakpoints()
const moderationOpts = useModerationOpts()
const {state, dispatch} = useContext(Context)
const onboardDispatch = useOnboardingDispatch()
const {
data: suggestedStarterPacks,
isLoading,
error,
isRefetching,
refetch,
} = useOnboardingSuggestedStarterPacksQuery({
enabled: true,
overrideInterests: state.interestsStepResults.selectedInterests,
})
const isError = !!error
const skipOnboarding = useCallback(() => {
onboardDispatch({type: 'finish'})
dispatch({type: 'finish'})
}, [onboardDispatch, dispatch])
return (
<View style={[a.align_start]} testID="onboardingInterests">
<Text style={[a.font_bold, a.text_3xl]}>
<Trans comment="Accounts suggested to the user for them to follow">
Suggested for you
</Trans>
</Text>
<View
style={[
a.overflow_hidden,
a.mt_lg,
a.flex_1,
a.justify_start,
a.w_full,
]}>
{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]}>
<Admonition type="error">
<Trans>
An error occurred while fetching suggested accounts.
</Trans>
</Admonition>
</View>
) : (
<View style={[a.flex_1, a.mt_md]}>
{suggestedStarterPacks?.starterPacks.map(starterPack => (
<View style={[a.pb_lg]} key={starterPack.uri}>
<StarterPackCard view={starterPack} />
</View>
))}
</View>
)}
</View>
<OnboardingControls.Portal>
{isError ? (
<View style={[a.gap_md, gtMobile ? a.flex_row : a.flex_col]}>
<Button
disabled={isRefetching}
color="secondary"
size="large"
label={_(msg`Retry`)}
onPress={() => refetch()}>
<ButtonText>
<Trans>Retry</Trans>
</ButtonText>
<ButtonIcon icon={ArrowRotateCounterClockwiseIcon} />
</Button>
<Button
color="secondary"
size="large"
label={_(msg`Skip this flow`)}
onPress={skipOnboarding}>
<ButtonText>
<Trans>Skip</Trans>
</ButtonText>
</Button>
</View>
) : (
<View style={[a.gap_md, gtMobile ? a.flex_row : a.flex_col]}>
<Button
color="primary"
size="large"
label={_(msg`Continue to next step`)}
onPress={() => dispatch({type: 'next'})}>
<ButtonText>
<Trans>Continue</Trans>
</ButtonText>
</Button>
</View>
)}
</OnboardingControls.Portal>
</View>
)
}
+34 -9
View File
@@ -1,7 +1,10 @@
import {useMemo, useReducer} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import * as bcp47Match from 'bcp-47-match'
import {useGate} from '#/lib/statsig/statsig'
import {useLanguagePrefs} from '#/state/preferences'
import {
Layout,
OnboardingControls,
@@ -12,19 +15,34 @@ import {StepFinished} from '#/screens/Onboarding/StepFinished'
import {StepInterests} from '#/screens/Onboarding/StepInterests'
import {StepProfile} from '#/screens/Onboarding/StepProfile'
import {Portal} from '#/components/Portal'
import {ScreenTransition} from '#/components/ScreenTransition'
import {ENV} from '#/env'
import {StepSuggestedAccounts} from './StepSuggestedAccounts'
import {StepSuggestedStarterpacks} from './StepSuggestedStarterpacks'
export function Onboarding() {
const {_} = useLingui()
const gate = useGate()
const {contentLanguages} = useLanguagePrefs()
const probablySpeaksEnglish = useMemo(() => {
if (contentLanguages.length === 0) return true
return bcp47Match.basicFilter('en', contentLanguages).length > 0
}, [contentLanguages])
// starter packs screen is currently geared towards english-speaking accounts
const showSuggestedStarterpacks =
ENV !== 'e2e' &&
probablySpeaksEnglish &&
gate('onboarding_suggested_starterpacks')
const [state, dispatch] = useReducer(reducer, {
...initialState,
totalSteps: 4,
totalSteps: 4 + (showSuggestedStarterpacks ? 1 : 0),
experiments: {
// let's leave this flag logic in for now to avoid rebase churn
// TODO: remove this flag logic once we've finished with all experiments -sfn
onboarding_suggested_accounts: true,
onboarding_value_prop: true,
onboarding_suggested_starterpacks: showSuggestedStarterpacks,
},
})
@@ -65,12 +83,19 @@ export function Onboarding() {
[state, dispatch, interestsDisplayNames],
)}>
<Layout>
{state.activeStep === 'profile' && <StepProfile />}
{state.activeStep === 'interests' && <StepInterests />}
{state.activeStep === 'suggested-accounts' && (
<StepSuggestedAccounts />
)}
{state.activeStep === 'finished' && <StepFinished />}
<ScreenTransition
key={state.activeStep}
direction={state.stepTransitionDirection}>
{state.activeStep === 'profile' && <StepProfile />}
{state.activeStep === 'interests' && <StepInterests />}
{state.activeStep === 'suggested-accounts' && (
<StepSuggestedAccounts />
)}
{state.activeStep === 'suggested-starterpacks' && (
<StepSuggestedStarterpacks />
)}
{state.activeStep === 'finished' && <StepFinished />}
</ScreenTransition>
</Layout>
</Context.Provider>
</OnboardingHeaderSlot.Provider>
+34 -40
View File
@@ -11,8 +11,14 @@ import {
export type OnboardingState = {
hasPrev: boolean
totalSteps: number
activeStep: 'profile' | 'interests' | 'suggested-accounts' | 'finished'
activeStep:
| 'profile'
| 'interests'
| 'suggested-accounts'
| 'suggested-starterpacks'
| 'finished'
activeStepIndex: number
stepTransitionDirection: 'Forward' | 'Backward'
interestsStepResults: {
selectedInterests: string[]
@@ -38,6 +44,7 @@ export type OnboardingState = {
experiments?: {
onboarding_suggested_accounts?: boolean
onboarding_value_prop?: boolean
onboarding_suggested_starterpacks?: boolean
}
}
@@ -131,6 +138,7 @@ export const initialState: OnboardingState = {
totalSteps: 3,
activeStep: 'profile',
activeStepIndex: 1,
stepTransitionDirection: 'Forward',
interestsStepResults: {
selectedInterests: [],
@@ -163,52 +171,38 @@ export function reducer(
): OnboardingState {
let next = {...s}
const stepOrder: OnboardingState['activeStep'][] = [
'profile',
'interests',
...(s.experiments?.onboarding_suggested_accounts
? (['suggested-accounts'] as const)
: []),
...(s.experiments?.onboarding_suggested_starterpacks
? (['suggested-starterpacks'] as const)
: []),
'finished',
]
switch (a.type) {
case 'next': {
if (s.experiments?.onboarding_suggested_accounts) {
if (s.activeStep === 'profile') {
next.activeStep = 'interests'
next.activeStepIndex = 2
} else if (s.activeStep === 'interests') {
next.activeStep = 'suggested-accounts'
next.activeStepIndex = 3
}
if (s.activeStep === 'suggested-accounts') {
next.activeStep = 'finished'
next.activeStepIndex = 4
}
} else {
if (s.activeStep === 'profile') {
next.activeStep = 'interests'
next.activeStepIndex = 2
} else if (s.activeStep === 'interests') {
next.activeStep = 'finished'
next.activeStepIndex = 3
}
// 1-indexed for some reason
const nextIndex = s.activeStepIndex
const nextStep = stepOrder[nextIndex]
if (nextStep) {
next.activeStep = nextStep
next.activeStepIndex = nextIndex + 1
}
next.stepTransitionDirection = 'Forward'
break
}
case 'prev': {
if (s.experiments?.onboarding_suggested_accounts) {
if (s.activeStep === 'interests') {
next.activeStep = 'profile'
next.activeStepIndex = 1
} else if (s.activeStep === 'suggested-accounts') {
next.activeStep = 'interests'
next.activeStepIndex = 2
} else if (s.activeStep === 'finished') {
next.activeStep = 'suggested-accounts'
next.activeStepIndex = 3
}
} else {
if (s.activeStep === 'interests') {
next.activeStep = 'profile'
next.activeStepIndex = 1
} else if (s.activeStep === 'finished') {
next.activeStep = 'interests'
next.activeStepIndex = 2
}
const prevIndex = s.activeStepIndex - 2
const prevStep = stepOrder[prevIndex]
if (prevStep) {
next.activeStep = prevStep
next.activeStepIndex = prevIndex + 1
}
next.stepTransitionDirection = 'Backward'
break
}
case 'finish': {
@@ -445,7 +445,7 @@ function Header({
<ButtonText>
<Trans>Follow all</Trans>
</ButtonText>
{isProcessing && <Loader size="xs" />}
{isProcessing && <ButtonIcon icon={Loader} />}
</Button>
)}
<OverflowMenu
@@ -0,0 +1,49 @@
import {useQuery} from '@tanstack/react-query'
import {
aggregateUserInterests,
createBskyTopicsHeader,
} from '#/lib/api/feed/utils'
import {getContentLanguages} from '#/state/preferences/languages'
import {STALE} from '#/state/queries'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useAgent} from '#/state/session'
export const createOnboardingSuggestedStarterPacksQueryKey = (
interests?: string[],
) => ['onboarding-suggested-starter-packs', interests?.join(',')]
export function useOnboardingSuggestedStarterPacksQuery({
enabled,
overrideInterests,
}: {
enabled?: boolean
overrideInterests?: string[]
}) {
const agent = useAgent()
const {data: preferences} = usePreferencesQuery()
const contentLangs = getContentLanguages().join(',')
return useQuery({
enabled: !!preferences && enabled !== false,
staleTime: STALE.MINUTES.THREE,
queryKey: createOnboardingSuggestedStarterPacksQueryKey(overrideInterests),
queryFn: async () => {
const {data} =
await agent.app.bsky.unspecced.getOnboardingSuggestedStarterPacks(
{limit: 6},
{
headers: {
...createBskyTopicsHeader(
overrideInterests
? overrideInterests.join(',')
: aggregateUserInterests(preferences),
),
'Accept-Language': contentLangs,
},
},
)
return data
},
})
}
@@ -9,11 +9,18 @@ import {STALE} from '#/state/queries'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useAgent} from '#/state/session'
export const createSuggestedStarterPacksQueryKey = () => [
export const createSuggestedStarterPacksQueryKey = (interests?: string[]) => [
'suggested-starter-packs',
interests?.join(','),
]
export function useSuggestedStarterPacksQuery({enabled}: {enabled?: boolean}) {
export function useSuggestedStarterPacksQuery({
enabled,
overrideInterests,
}: {
enabled?: boolean
overrideInterests?: string[]
}) {
const agent = useAgent()
const {data: preferences} = usePreferencesQuery()
const contentLangs = getContentLanguages().join(',')
@@ -21,13 +28,17 @@ export function useSuggestedStarterPacksQuery({enabled}: {enabled?: boolean}) {
return useQuery({
enabled: !!preferences && enabled !== false,
staleTime: STALE.MINUTES.THREE,
queryKey: createSuggestedStarterPacksQueryKey(),
async queryFn() {
queryKey: createSuggestedStarterPacksQueryKey(overrideInterests),
queryFn: async () => {
const {data} = await agent.app.bsky.unspecced.getSuggestedStarterPacks(
undefined,
{
headers: {
...createBskyTopicsHeader(aggregateUserInterests(preferences)),
...createBskyTopicsHeader(
overrideInterests
? overrideInterests.join(',')
: aggregateUserInterests(preferences),
),
'Accept-Language': contentLangs,
},
},