restructure onboarding to better support dynamic screens

This commit is contained in:
Samuel Newman
2025-12-05 16:21:40 +02:00
parent 29af9831a9
commit e9a8cf5c26
9 changed files with 133 additions and 191 deletions
+1
View File
@@ -3,6 +3,7 @@ export type Gate =
| 'alt_share_icon'
| 'debug_show_feedcontext'
| 'debug_subscriptions'
| 'disable_onboarding_find_contacts'
| 'explore_show_suggested_feeds'
| 'feed_reply_button_open_thread'
| 'old_postonboarding'
+5 -5
View File
@@ -7,7 +7,7 @@ import {useLingui} from '@lingui/react'
import {isAndroid, isWeb} from '#/platform/detection'
import {useOnboardingDispatch} from '#/state/shell'
import {Context} from '#/screens/Onboarding/state'
import {useOnboardingInternalState} from '#/screens/Onboarding/state'
import {
atoms as a,
native,
@@ -34,7 +34,7 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
const insets = useSafeAreaInsets()
const {gtMobile} = useBreakpoints()
const onboardDispatch = useOnboardingDispatch()
const {state, dispatch} = React.useContext(Context)
const {state, dispatch} = useOnboardingInternalState()
const scrollview = React.useRef<ScrollView>(null)
const prevActiveStep = React.useRef<string>(state.activeStep)
@@ -104,7 +104,7 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
a.justify_between,
{maxWidth: ONBOARDING_COL_WIDTH},
]}>
{state.hasPrev ? (
{state.canGoBack ? (
<Button
key={state.activeStep} // remove focus state on nav
color="secondary"
@@ -157,7 +157,7 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
t.atoms.bg_contrast_50,
{
backgroundColor:
i + 1 <= state.activeStepIndex
i <= state.activeStepIndex
? t.palette.primary_500
: t.palette.contrast_100,
},
@@ -200,7 +200,7 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
gtMobile && [a.flex_row, a.justify_between, a.align_center],
]}>
{gtMobile &&
(state.hasPrev ? (
(state.canGoBack ? (
<Button
key={state.activeStep} // remove focus state on nav
color="secondary"
+8 -106
View File
@@ -1,4 +1,4 @@
import {useCallback, useContext, useState} from 'react'
import {useCallback, useState} from 'react'
import {View} from 'react-native'
import {
type AppBskyActorDefs,
@@ -35,28 +35,23 @@ import {
useSetActiveStarterPack,
} from '#/state/shell/starter-pack'
import {
DescriptionText,
OnboardingControls,
OnboardingHeaderSlot,
TitleText,
} from '#/screens/Onboarding/Layout'
import {Context, type OnboardingState} from '#/screens/Onboarding/state'
import {
type OnboardingState,
useOnboardingInternalState,
} from '#/screens/Onboarding/state'
import {bulkWriteFollows} from '#/screens/Onboarding/util'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {atoms as a, useBreakpoints} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {IconCircle} from '#/components/IconCircle'
import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRight} from '#/components/icons/Arrow'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth'
import {News2_Stroke2_Corner0_Rounded as News} from '#/components/icons/News2'
import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky'
import {ValuePropositionPager} from './ValuePropositionPager'
export function StepFinished() {
const {state, dispatch} = useContext(Context)
const {state, dispatch} = useOnboardingInternalState()
const onboardDispatch = useOnboardingDispatch()
const [saving, setSaving] = useState(false)
const queryClient = useQueryClient()
@@ -247,18 +242,12 @@ export function StepFinished() {
gate,
])
return state.experiments?.onboarding_value_prop ? (
return (
<ValueProposition
finishOnboarding={finishOnboarding}
saving={saving}
state={state}
/>
) : (
<LegacyFinalStep
finishOnboarding={finishOnboarding}
saving={saving}
state={state}
/>
)
}
@@ -359,90 +348,3 @@ function ValueProposition({
</>
)
}
function LegacyFinalStep({
finishOnboarding,
saving,
state,
}: {
finishOnboarding: () => void
saving: boolean
state: OnboardingState
}) {
const t = useTheme()
const {_} = useLingui()
return (
<View style={[a.align_start]}>
<IconCircle icon={Check} style={[a.mb_2xl]} />
<TitleText>
<Trans>You're ready to go!</Trans>
</TitleText>
<DescriptionText>
<Trans>We hope you have a wonderful time. Remember, Bluesky is:</Trans>
</DescriptionText>
<View style={[a.pt_5xl, a.gap_3xl]}>
<View style={[a.flex_row, a.align_center, a.w_full, a.gap_lg]}>
<IconCircle icon={Growth} size="lg" style={{width: 48, height: 48}} />
<View style={[a.flex_1, a.gap_xs]}>
<Text style={[a.font_semi_bold, a.text_lg]}>
<Trans>Public</Trans>
</Text>
<Text
style={[t.atoms.text_contrast_medium, a.text_md, a.leading_snug]}>
<Trans>
Your posts, likes, and blocks are public. Mutes are private.
</Trans>
</Text>
</View>
</View>
<View style={[a.flex_row, a.align_center, a.w_full, a.gap_lg]}>
<IconCircle icon={News} size="lg" style={{width: 48, height: 48}} />
<View style={[a.flex_1, a.gap_xs]}>
<Text style={[a.font_semi_bold, a.text_lg]}>
<Trans>Open</Trans>
</Text>
<Text
style={[t.atoms.text_contrast_medium, a.text_md, a.leading_snug]}>
<Trans>Never lose access to your followers or data.</Trans>
</Text>
</View>
</View>
<View style={[a.flex_row, a.align_center, a.w_full, a.gap_lg]}>
<IconCircle
icon={Trending}
size="lg"
style={{width: 48, height: 48}}
/>
<View style={[a.flex_1, a.gap_xs]}>
<Text style={[a.font_semi_bold, a.text_lg]}>
<Trans>Flexible</Trans>
</Text>
<Text
style={[t.atoms.text_contrast_medium, a.text_md, a.leading_snug]}>
<Trans>Choose the algorithms that power your custom feeds.</Trans>
</Text>
</View>
</View>
</View>
<OnboardingControls.Portal>
<Button
testID="onboardingFinish"
disabled={saving}
key={state.activeStep} // remove focus state on nav
color="primary"
size="large"
label={_(msg`Complete onboarding and start using your account`)}
onPress={finishOnboarding}>
<ButtonText>
{saving ? <Trans>Finalizing</Trans> : <Trans>Let's go!</Trans>}
</ButtonText>
{saving && <ButtonIcon icon={Loader} position="right" />}
</Button>
</OnboardingControls.Portal>
</View>
)
}
@@ -12,7 +12,7 @@ import {
OnboardingControls,
TitleText,
} from '#/screens/Onboarding/Layout'
import {Context} from '#/screens/Onboarding/state'
import {useOnboardingInternalState} from '#/screens/Onboarding/state'
import {InterestButton} from '#/screens/Onboarding/StepInterests/InterestButton'
import {atoms as a} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
@@ -26,7 +26,7 @@ export function StepInterests() {
const {_} = useLingui()
const interestsDisplayNames = useInterestsDisplayNames()
const {state, dispatch} = React.useContext(Context)
const {state, dispatch} = useOnboardingInternalState()
const [saving, setSaving] = React.useState(false)
const [selectedInterests, setSelectedInterests] = React.useState<string[]>(
state.interestsStepResults.selectedInterests.map(i => i),
+2 -2
View File
@@ -23,7 +23,7 @@ import {
OnboardingControls,
TitleText,
} from '#/screens/Onboarding/Layout'
import {Context} from '#/screens/Onboarding/state'
import {useOnboardingInternalState} from '#/screens/Onboarding/state'
import {AvatarCircle} from '#/screens/Onboarding/StepProfile/AvatarCircle'
import {AvatarCreatorCircle} from '#/screens/Onboarding/StepProfile/AvatarCreatorCircle'
import {AvatarCreatorItems} from '#/screens/Onboarding/StepProfile/AvatarCreatorItems'
@@ -78,7 +78,7 @@ export function StepProfile() {
const creatorControl = Dialog.useDialogControl()
const [error, setError] = React.useState('')
const {state, dispatch} = React.useContext(Context)
const {state, dispatch} = useOnboardingInternalState()
const [avatar, setAvatar] = React.useState<Avatar>({
image: state.profileStepResults?.image,
placeholder: state.profileStepResults.creatorState?.emoji || emojiItems.at,
@@ -1,11 +1,4 @@
import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {View} from 'react-native'
import {type ModerationOpts} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
@@ -23,7 +16,7 @@ import {useLanguagePrefs} from '#/state/preferences'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useAgent, useSession} from '#/state/session'
import {OnboardingControls} from '#/screens/Onboarding/Layout'
import {Context} from '#/screens/Onboarding/state'
import {useOnboardingInternalState} from '#/screens/Onboarding/state'
import {useSuggestedUsers} from '#/screens/Search/util/useSuggestedUsers'
import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf'
import {Admonition} from '#/components/Admonition'
@@ -47,7 +40,7 @@ export function StepSuggestedAccounts() {
const {currentAccount} = useSession()
const queryClient = useQueryClient()
const {state, dispatch} = useContext(Context)
const {state, dispatch} = useOnboardingInternalState()
const [selectedInterest, setSelectedInterest] = useState<string | null>(null)
// keeping track of who was followed via the follow all button
@@ -1,4 +1,3 @@
import {useContext} from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -6,7 +5,7 @@ import {useLingui} from '@lingui/react'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useOnboardingSuggestedStarterPacksQuery} from '#/state/queries/useOnboardingSuggestedStarterPacksQuery'
import {OnboardingControls} from '#/screens/Onboarding/Layout'
import {Context} from '#/screens/Onboarding/state'
import {useOnboardingInternalState} from '#/screens/Onboarding/state'
import {atoms as a, useBreakpoints} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
@@ -20,7 +19,7 @@ export function StepSuggestedStarterpacks() {
const {gtMobile} = useBreakpoints()
const moderationOpts = useModerationOpts()
const {state, dispatch} = useContext(Context)
const {state, dispatch} = useOnboardingInternalState()
const {
data: suggestedStarterPacks,
+16 -9
View File
@@ -4,13 +4,18 @@ import {useLingui} from '@lingui/react'
import * as bcp47Match from 'bcp-47-match'
import {useGate} from '#/lib/statsig/statsig'
import {isNative} from '#/platform/detection'
import {useLanguagePrefs} from '#/state/preferences'
import {
Layout,
OnboardingControls,
OnboardingHeaderSlot,
} from '#/screens/Onboarding/Layout'
import {Context, initialState, reducer} from '#/screens/Onboarding/state'
import {
Context,
createInitialOnboardingState,
reducer,
} from '#/screens/Onboarding/state'
import {StepFinished} from '#/screens/Onboarding/StepFinished'
import {StepInterests} from '#/screens/Onboarding/StepInterests'
import {StepProfile} from '#/screens/Onboarding/StepProfile'
@@ -36,15 +41,16 @@ export function Onboarding() {
probablySpeaksEnglish &&
gate('onboarding_suggested_starterpacks')
const [state, dispatch] = useReducer(reducer, {
...initialState,
totalSteps: 4 + (showSuggestedStarterpacks ? 1 : 0),
experiments: {
onboarding_suggested_accounts: true,
onboarding_value_prop: true,
onboarding_suggested_starterpacks: showSuggestedStarterpacks,
const showFindContacts = isNative && !gate('disable_onboarding_find_contacts')
const [state, dispatch] = useReducer(
reducer,
{
starterPacksStepEnabled: showSuggestedStarterpacks,
findContactsStepEnabled: showFindContacts,
},
})
createInitialOnboardingState,
)
const interestsDisplayNames = useMemo(() => {
return {
@@ -94,6 +100,7 @@ export function Onboarding() {
{state.activeStep === 'suggested-starterpacks' && (
<StepSuggestedStarterpacks />
)}
{state.activeStep === 'find-contacts' && null}
{state.activeStep === 'finished' && <StepFinished />}
</ScreenTransition>
</Layout>
+94 -54
View File
@@ -1,4 +1,4 @@
import React from 'react'
import {createContext, useContext, useMemo} from 'react'
import {logger} from '#/logger'
import {
@@ -6,16 +6,17 @@ import {
type Emoji,
} from '#/screens/Onboarding/StepProfile/types'
type OnboardingScreen =
| 'profile'
| 'interests'
| 'suggested-accounts'
| 'suggested-starterpacks'
| 'find-contacts'
| 'finished'
export type OnboardingState = {
hasPrev: boolean
totalSteps: number
activeStep:
| 'profile'
| 'interests'
| 'suggested-accounts'
| 'suggested-starterpacks'
| 'finished'
activeStepIndex: number
screens: Record<OnboardingScreen, boolean>
activeStep: OnboardingScreen
stepTransitionDirection: 'Forward' | 'Backward'
interestsStepResults: {
@@ -37,12 +38,6 @@ export type OnboardingState = {
backgroundColor: AvatarColor
}
}
experiments?: {
onboarding_suggested_accounts?: boolean
onboarding_value_prop?: boolean
onboarding_suggested_starterpacks?: boolean
}
}
export type OnboardingAction =
@@ -73,31 +68,44 @@ export type OnboardingAction =
| undefined
}
export const initialState: OnboardingState = {
hasPrev: false,
totalSteps: 3,
activeStep: 'profile',
activeStepIndex: 1,
stepTransitionDirection: 'Forward',
export function createInitialOnboardingState(
{
starterPacksStepEnabled,
findContactsStepEnabled,
}: {
starterPacksStepEnabled: boolean
findContactsStepEnabled: boolean
} = {starterPacksStepEnabled: true, findContactsStepEnabled: false},
): OnboardingState {
const screens: OnboardingState['screens'] = {
profile: true,
interests: true,
'suggested-accounts': true,
'suggested-starterpacks': starterPacksStepEnabled,
'find-contacts': findContactsStepEnabled,
finished: true,
}
interestsStepResults: {
selectedInterests: [],
},
profileStepResults: {
isCreatedAvatar: false,
image: undefined,
imageUri: '',
imageMime: '',
},
return {
screens,
activeStep: 'profile',
stepTransitionDirection: 'Forward',
interestsStepResults: {
selectedInterests: [],
},
profileStepResults: {
isCreatedAvatar: false,
image: undefined,
imageUri: '',
imageMime: '',
},
}
}
export const Context = React.createContext<{
export const Context = createContext<{
state: OnboardingState
dispatch: React.Dispatch<OnboardingAction>
}>({
state: {...initialState},
dispatch: () => {},
})
} | null>(null)
Context.displayName = 'OnboardingContext'
export function reducer(
@@ -106,42 +114,32 @@ 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',
]
const stepOrder = getStepOrder(s)
switch (a.type) {
case 'next': {
// 1-indexed for some reason
const nextIndex = s.activeStepIndex
const nextIndex = stepOrder.indexOf(next.activeStep) + 1
const nextStep = stepOrder[nextIndex]
if (nextStep) {
next.activeStep = nextStep
next.activeStepIndex = nextIndex + 1
}
next.stepTransitionDirection = 'Forward'
break
}
case 'prev': {
const prevIndex = s.activeStepIndex - 2
const prevIndex = stepOrder.indexOf(next.activeStep) - 1
const prevStep = stepOrder[prevIndex]
if (prevStep) {
next.activeStep = prevStep
next.activeStepIndex = prevIndex + 1
}
next.stepTransitionDirection = 'Backward'
break
}
case 'finish': {
next = initialState
next = createInitialOnboardingState({
starterPacksStepEnabled: s.screens['suggested-starterpacks'],
findContactsStepEnabled: s.screens['find-contacts'],
})
break
}
case 'setInterestsStepResults': {
@@ -170,7 +168,6 @@ export function reducer(
logger.debug(`onboarding`, {
hasPrev: state.hasPrev,
activeStep: state.activeStep,
activeStepIndex: state.activeStepIndex,
interestsStepResults: {
selectedInterests: state.interestsStepResults.selectedInterests,
},
@@ -183,3 +180,46 @@ export function reducer(
return state
}
function getStepOrder(s: OnboardingState): OnboardingScreen[] {
return [
s.screens.profile && ('profile' as const),
s.screens.interests && ('interests' as const),
s.screens['suggested-accounts'] && ('suggested-accounts' as const),
s.screens['suggested-starterpacks'] && ('suggested-starterpacks' as const),
s.screens['find-contacts'] && ('find-contacts' as const),
s.screens.finished && ('finished' as const),
].filter(x => !!x)
}
/**
* Note: not to be confused with `useOnboardingState`, which just determines if onboarding is active.
* This hook is for internal state of the onboarding flow (i.e. active step etc).
*
* This adds additional derived state to the onboarding context reducer.
*/
export function useOnboardingInternalState() {
const ctx = useContext(Context)
if (!ctx) {
throw new Error(
'useOnboardingInternalState must be used within OnboardingContext',
)
}
const {state, dispatch} = ctx
return {
state: useMemo(() => {
const stepOrder = getStepOrder(state)
const canGoBack = state.activeStep !== stepOrder[0]
return {
...state,
canGoBack,
totalSteps: stepOrder.length,
activeStepIndex: stepOrder.indexOf(state.activeStep),
}
}, [state]),
dispatch,
}
}