[Contacts] Onboarding step (#9489)

* add a bunch of number parsing logic with libphonenumber

* restructure onboarding to better support dynamic screens

* integrate existing flow into onboarding

* add intro step

* lift state up to allow going back freely

* gate onboarding by geo if unsupported country

* add done button to standalone flow
This commit is contained in:
Samuel Newman
2025-12-10 21:55:23 +02:00
parent 0b6a7de270
commit d4dedf463a
16 changed files with 402 additions and 261 deletions
+12 -5
View File
@@ -2,21 +2,23 @@ import {GetContacts} from './screens/GetContacts'
import {PhoneInput} from './screens/PhoneInput' import {PhoneInput} from './screens/PhoneInput'
import {VerifyNumber} from './screens/VerifyNumber' import {VerifyNumber} from './screens/VerifyNumber'
import {ViewMatches} from './screens/ViewMatches' import {ViewMatches} from './screens/ViewMatches'
import {type Action, type State} from './state' import {type Action, FindContactsGoBackContext, type State} from './state'
export function FindContactsFlow({ export function FindContactsFlow({
state, state,
dispatch, dispatch,
onBack,
onCancel, onCancel,
context = 'Standalone', context = 'Standalone',
}: { }: {
state: State state: State
dispatch: React.Dispatch<Action> dispatch: React.ActionDispatch<[Action]>
onBack?: () => void
onCancel: () => void onCancel: () => void
context: 'Onboarding' | 'Standalone' context: 'Onboarding' | 'Standalone'
}) { }) {
return ( return (
<> <FindContactsGoBackContext value={onBack}>
{state.step === '1: phone input' && ( {state.step === '1: phone input' && (
<PhoneInput <PhoneInput
state={state} state={state}
@@ -37,8 +39,13 @@ export function FindContactsFlow({
<GetContacts state={state} dispatch={dispatch} onCancel={onCancel} /> <GetContacts state={state} dispatch={dispatch} onCancel={onCancel} />
)} )}
{state.step === '4: view matches' && ( {state.step === '4: view matches' && (
<ViewMatches state={state} dispatch={dispatch} /> <ViewMatches
state={state}
dispatch={dispatch}
context={context}
onNext={onCancel}
/>
)} )}
</> </FindContactsGoBackContext>
) )
} }
@@ -26,7 +26,7 @@ import {
getCountryCodeFromPastedNumber, getCountryCodeFromPastedNumber,
processPhoneNumber, processPhoneNumber,
} from '../phone-number' } from '../phone-number'
import {type Action, type State} from '../state' import {type Action, type State, useOnPressBackButton} from '../state'
export function PhoneInput({ export function PhoneInput({
state, state,
@@ -103,10 +103,12 @@ export function PhoneInput({
const paddingBottom = Math.max(insets.bottom, tokens.space.xl) const paddingBottom = Math.max(insets.bottom, tokens.space.xl)
const onPressBack = useOnPressBackButton()
return ( return (
<View style={[a.h_full]}> <View style={[a.h_full]}>
<Layout.Header.Outer noBottomBorder> <Layout.Header.Outer noBottomBorder>
<Layout.Header.BackButton /> <Layout.Header.BackButton onPress={onPressBack} />
<Layout.Header.Content /> <Layout.Header.Content />
{showSkipButton ? ( {showSkipButton ? (
<Button <Button
@@ -204,12 +206,12 @@ export function PhoneInput({
<View style={[gutters, {paddingBottom}]}> <View style={[gutters, {paddingBottom}]}>
<Button <Button
disabled={!phoneNumber || isPending} disabled={!phoneNumber || isPending}
label={_(msg`Next`)} label={_(msg`Send code`)}
size="large" size="large"
color="primary" color="primary"
onPress={onSubmitNumber}> onPress={onSubmitNumber}>
<ButtonText> <ButtonText>
<Trans>Next</Trans> <Trans>Send code</Trans>
</ButtonText> </ButtonText>
{isPending && <ButtonIcon icon={Loader} />} {isPending && <ButtonIcon icon={Loader} />}
</Button> </Button>
@@ -19,7 +19,7 @@ import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {OTPInput} from '../components/OTPInput' import {OTPInput} from '../components/OTPInput'
import {type Action, type State} from '../state' import {type Action, type State, useOnPressBackButton} from '../state'
export function VerifyNumber({ export function VerifyNumber({
state, state,
dispatch, dispatch,
@@ -121,10 +121,12 @@ export function VerifyNumber({
[state.phoneCountryCode], [state.phoneCountryCode],
) )
const onPressBack = useOnPressBackButton()
return ( return (
<View style={[a.h_full]}> <View style={[a.h_full]}>
<Layout.Header.Outer noBottomBorder> <Layout.Header.Outer noBottomBorder>
<Layout.Header.BackButton /> <Layout.Header.BackButton onPress={onPressBack} />
<Layout.Header.Content /> <Layout.Header.Content />
{showSkipButton ? ( {showSkipButton ? (
<Button <Button
@@ -1,5 +1,6 @@
import {useMemo, useRef, useState} from 'react' import {useMemo, useRef, useState} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import * as SMS from 'expo-sms' import * as SMS from 'expo-sms'
import {type ModerationOpts} from '@atproto/api' import {type ModerationOpts} from '@atproto/api'
import {msg, Plural, Trans} from '@lingui/macro' import {msg, Plural, Trans} from '@lingui/macro'
@@ -19,7 +20,7 @@ import {useAgent, useSession} from '#/state/session'
import {List, type ListMethods} from '#/view/com/util/List' import {List, type ListMethods} from '#/view/com/util/List'
import {UserAvatar} from '#/view/com/util/UserAvatar' import {UserAvatar} from '#/view/com/util/UserAvatar'
import {bulkWriteFollows} from '#/screens/Onboarding/util' import {bulkWriteFollows} from '#/screens/Onboarding/util'
import {atoms as a, useGutters, useTheme} from '#/alf' import {atoms as a, tokens, useGutters, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {SearchInput} from '#/components/forms/SearchInput' import {SearchInput} from '#/components/forms/SearchInput'
import {useInteractionState} from '#/components/hooks/useInteractionState' import {useInteractionState} from '#/components/hooks/useInteractionState'
@@ -68,9 +69,13 @@ type Item =
export function ViewMatches({ export function ViewMatches({
state, state,
dispatch, dispatch,
context,
onNext,
}: { }: {
state: Extract<State, {step: '4: view matches'}> state: Extract<State, {step: '4: view matches'}>
dispatch: React.ActionDispatch<[Action]> dispatch: React.ActionDispatch<[Action]>
context: 'Onboarding' | 'Standalone'
onNext: () => void
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
@@ -78,6 +83,7 @@ export function ViewMatches({
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const agent = useAgent() const agent = useAgent()
const insets = useSafeAreaInsets()
const listRef = useRef<ListMethods>(null) const listRef = useRef<ListMethods>(null)
// TEMP!!! // TEMP!!!
@@ -322,13 +328,15 @@ export function ViewMatches({
return ( return (
<View style={[a.h_full]}> <View style={[a.h_full]}>
{context === 'Standalone' && (
<Layout.Header.Outer noBottomBorder> <Layout.Header.Outer noBottomBorder>
<Layout.Header.BackButton /> <Layout.Header.BackButton />
<Layout.Header.Content /> <Layout.Header.Content />
<Layout.Header.Slot /> <Layout.Header.Slot />
</Layout.Header.Outer> </Layout.Header.Outer>
)}
{!isTotallyEmpty && ( {!isTotallyEmpty && (
<View style={[gutter, a.mb_md]}> <View style={[gutter, a.mb_md, context === 'Onboarding' && a.mt_sm]}>
<SearchInput <SearchInput
placeholder={_(msg`Search contacts`)} placeholder={_(msg`Search contacts`)}
value={search} value={search}
@@ -355,6 +363,31 @@ export function ViewMatches({
ListFooterComponent={!isEmpty ? <ListFooter /> : null} ListFooterComponent={!isEmpty ? <ListFooter /> : null}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
/> />
<View
style={[
t.atoms.bg,
t.atoms.border_contrast_low,
a.border_t,
a.align_center,
a.align_stretch,
gutter,
a.pt_md,
{paddingBottom: insets.bottom + tokens.space.md},
]}>
<Button
label={context === 'Onboarding' ? _(msg`Next`) : _(msg`Done`)}
onPress={onNext}
size="large"
color="primary">
<ButtonText>
{context === 'Onboarding' ? (
<Trans>Next</Trans>
) : (
<Trans>Done</Trans>
)}
</ButtonText>
</Button>
</View>
</View> </View>
) )
} }
+16 -1
View File
@@ -1,4 +1,5 @@
import {useReducer} from 'react' import {createContext, useContext, useReducer} from 'react'
import {type GestureResponderEvent} from 'react-native'
import {type ExistingContact} from 'expo-contacts' import {type ExistingContact} from 'expo-contacts'
import {type CountryCode} from '#/lib/international-telephone-codes' import {type CountryCode} from '#/lib/international-telephone-codes'
@@ -176,3 +177,17 @@ export function useFindContactsFlowState(
) { ) {
return useReducer(reducer, initialState) return useReducer(reducer, initialState)
} }
export const FindContactsGoBackContext = createContext<
(() => void) | undefined
>(undefined)
export function useOnPressBackButton() {
const goBack = useContext(FindContactsGoBackContext)
if (!goBack) {
return undefined
}
return (evt: GestureResponderEvent) => {
evt.preventDefault()
goBack()
}
}
+1
View File
@@ -3,6 +3,7 @@ export type Gate =
| 'alt_share_icon' | 'alt_share_icon'
| 'debug_show_feedcontext' | 'debug_show_feedcontext'
| 'debug_subscriptions' | 'debug_subscriptions'
| 'disable_onboarding_find_contacts'
| 'explore_show_suggested_feeds' | 'explore_show_suggested_feeds'
| 'feed_reply_button_open_thread' | 'feed_reply_button_open_thread'
| 'old_postonboarding' | 'old_postonboarding'
+5 -5
View File
@@ -7,7 +7,7 @@ import {useLingui} from '@lingui/react'
import {isAndroid, isWeb} from '#/platform/detection' import {isAndroid, isWeb} from '#/platform/detection'
import {useOnboardingDispatch} from '#/state/shell' import {useOnboardingDispatch} from '#/state/shell'
import {Context} from '#/screens/Onboarding/state' import {useOnboardingInternalState} from '#/screens/Onboarding/state'
import { import {
atoms as a, atoms as a,
native, native,
@@ -34,7 +34,7 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const onboardDispatch = useOnboardingDispatch() const onboardDispatch = useOnboardingDispatch()
const {state, dispatch} = React.useContext(Context) const {state, dispatch} = useOnboardingInternalState()
const scrollview = React.useRef<ScrollView>(null) const scrollview = React.useRef<ScrollView>(null)
const prevActiveStep = React.useRef<string>(state.activeStep) const prevActiveStep = React.useRef<string>(state.activeStep)
@@ -104,7 +104,7 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
a.justify_between, a.justify_between,
{maxWidth: ONBOARDING_COL_WIDTH}, {maxWidth: ONBOARDING_COL_WIDTH},
]}> ]}>
{state.hasPrev ? ( {state.canGoBack ? (
<Button <Button
key={state.activeStep} // remove focus state on nav key={state.activeStep} // remove focus state on nav
color="secondary" color="secondary"
@@ -157,7 +157,7 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
t.atoms.bg_contrast_50, t.atoms.bg_contrast_50,
{ {
backgroundColor: backgroundColor:
i + 1 <= state.activeStepIndex i <= state.activeStepIndex
? t.palette.primary_500 ? t.palette.primary_500
: t.palette.contrast_100, : 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 && [a.flex_row, a.justify_between, a.align_center],
]}> ]}>
{gtMobile && {gtMobile &&
(state.hasPrev ? ( (state.canGoBack ? (
<Button <Button
key={state.activeStep} // remove focus state on nav key={state.activeStep} // remove focus state on nav
color="secondary" color="secondary"
@@ -0,0 +1,55 @@
import {useCallback, useState} from 'react'
import {LayoutAnimationConfig} from 'react-native-reanimated'
import {SafeAreaView} from 'react-native-safe-area-context'
import {FindContactsFlow} from '#/components/contacts/FindContactsFlow'
import {type Action, type State} from '#/components/contacts/state'
import {ScreenTransition} from '#/components/ScreenTransition'
import {useOnboardingInternalState} from '../state'
export function StepFindContacts({
flowState,
flowDispatch,
}: {
flowState: State
flowDispatch: React.ActionDispatch<[Action]>
}) {
const {dispatch} = useOnboardingInternalState()
const [transitionDirection, setTransitionDirection] = useState<
'Forward' | 'Backward'
>('Forward')
const onSkip = useCallback(() => {
dispatch({type: 'next'})
}, [dispatch])
const canGoBack = flowState.step === '2: verify number'
const onBack = useCallback(() => {
if (canGoBack) {
setTransitionDirection('Backward')
flowDispatch({type: 'BACK'})
setTimeout(() => {
setTransitionDirection('Forward')
})
} else {
dispatch({type: 'prev'})
}
}, [dispatch, flowDispatch, canGoBack])
return (
<SafeAreaView edges={['left', 'top', 'right']}>
<LayoutAnimationConfig skipEntering skipExiting>
<ScreenTransition key={flowState.step} direction={transitionDirection}>
<FindContactsFlow
context="Onboarding"
state={flowState}
dispatch={flowDispatch}
onCancel={onSkip}
onBack={onBack}
/>
</ScreenTransition>
</LayoutAnimationConfig>
</SafeAreaView>
)
}
@@ -0,0 +1,78 @@
import {View} from 'react-native'
import * as Contacts from 'expo-contacts'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQuery} from '@tanstack/react-query'
import {atoms as a, useTheme} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonText} from '#/components/Button'
import {ContactsHeroImage} from '#/components/contacts/components/HeroImage'
import {InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography'
import {OnboardingControls} from '../Layout'
import {useOnboardingInternalState} from '../state'
export function StepFindContactsIntro() {
const {_} = useLingui()
const t = useTheme()
const {dispatch} = useOnboardingInternalState()
const {data: isAvailable, isSuccess} = useQuery({
queryKey: ['contacts-available'],
queryFn: async () => await Contacts.isAvailableAsync(),
})
return (
<View style={[a.w_full, a.gap_lg]}>
<ContactsHeroImage />
<Text style={[a.text_3xl, a.leading_snug, a.font_bold]}>
<Trans>Bluesky is more fun with Friends</Trans>
</Text>
<Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_medium]}>
<Trans>
Find your friends on Bluesky by verifying your phone number and
matching with your contacts. We protect your information and you
control what happens next.{' '}
<InlineLinkText
to="#"
label={_(msg`Learn more`)}
style={[a.text_md, a.leading_snug]}>
TODO: Learn more
</InlineLinkText>
</Trans>
</Text>
{!isAvailable && isSuccess && (
<Admonition type="error">
<Trans>
Contact sync is not available on this device, as the app is unable
to access your contacts.
</Trans>
</Admonition>
)}
<OnboardingControls.Portal>
<View style={[a.gap_md]}>
<Button
onPress={() => dispatch({type: 'next'})}
label={_(msg`Upload contacts`)}
size="large"
color="primary">
<ButtonText>
<Trans>Upload contacts</Trans>
</ButtonText>
</Button>
<Button
onPress={() => dispatch({type: 'skip-contacts'})}
label={_(msg`Skip`)}
size="large"
color="secondary">
<ButtonText>
<Trans>Skip</Trans>
</ButtonText>
</Button>
</View>
</OnboardingControls.Portal>
</View>
)
}
+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 {View} from 'react-native'
import { import {
type AppBskyActorDefs, type AppBskyActorDefs,
@@ -35,28 +35,23 @@ import {
useSetActiveStarterPack, useSetActiveStarterPack,
} from '#/state/shell/starter-pack' } from '#/state/shell/starter-pack'
import { import {
DescriptionText,
OnboardingControls, OnboardingControls,
OnboardingHeaderSlot, OnboardingHeaderSlot,
TitleText,
} from '#/screens/Onboarding/Layout' } 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 {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 {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {IconCircle} from '#/components/IconCircle'
import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRight} from '#/components/icons/Arrow' 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 {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky' import * as bsky from '#/types/bsky'
import {ValuePropositionPager} from './ValuePropositionPager' import {ValuePropositionPager} from './ValuePropositionPager'
export function StepFinished() { export function StepFinished() {
const {state, dispatch} = useContext(Context) const {state, dispatch} = useOnboardingInternalState()
const onboardDispatch = useOnboardingDispatch() const onboardDispatch = useOnboardingDispatch()
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -247,18 +242,12 @@ export function StepFinished() {
gate, gate,
]) ])
return state.experiments?.onboarding_value_prop ? ( return (
<ValueProposition <ValueProposition
finishOnboarding={finishOnboarding} finishOnboarding={finishOnboarding}
saving={saving} saving={saving}
state={state} 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, OnboardingControls,
TitleText, TitleText,
} from '#/screens/Onboarding/Layout' } from '#/screens/Onboarding/Layout'
import {Context} from '#/screens/Onboarding/state' import {useOnboardingInternalState} from '#/screens/Onboarding/state'
import {InterestButton} from '#/screens/Onboarding/StepInterests/InterestButton' import {InterestButton} from '#/screens/Onboarding/StepInterests/InterestButton'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
@@ -26,7 +26,7 @@ export function StepInterests() {
const {_} = useLingui() const {_} = useLingui()
const interestsDisplayNames = useInterestsDisplayNames() const interestsDisplayNames = useInterestsDisplayNames()
const {state, dispatch} = React.useContext(Context) const {state, dispatch} = useOnboardingInternalState()
const [saving, setSaving] = React.useState(false) const [saving, setSaving] = React.useState(false)
const [selectedInterests, setSelectedInterests] = React.useState<string[]>( const [selectedInterests, setSelectedInterests] = React.useState<string[]>(
state.interestsStepResults.selectedInterests.map(i => i), state.interestsStepResults.selectedInterests.map(i => i),
+2 -2
View File
@@ -23,7 +23,7 @@ import {
OnboardingControls, OnboardingControls,
TitleText, TitleText,
} from '#/screens/Onboarding/Layout' } from '#/screens/Onboarding/Layout'
import {Context} from '#/screens/Onboarding/state' import {useOnboardingInternalState} from '#/screens/Onboarding/state'
import {AvatarCircle} from '#/screens/Onboarding/StepProfile/AvatarCircle' import {AvatarCircle} from '#/screens/Onboarding/StepProfile/AvatarCircle'
import {AvatarCreatorCircle} from '#/screens/Onboarding/StepProfile/AvatarCreatorCircle' import {AvatarCreatorCircle} from '#/screens/Onboarding/StepProfile/AvatarCreatorCircle'
import {AvatarCreatorItems} from '#/screens/Onboarding/StepProfile/AvatarCreatorItems' import {AvatarCreatorItems} from '#/screens/Onboarding/StepProfile/AvatarCreatorItems'
@@ -78,7 +78,7 @@ export function StepProfile() {
const creatorControl = Dialog.useDialogControl() const creatorControl = Dialog.useDialogControl()
const [error, setError] = React.useState('') const [error, setError] = React.useState('')
const {state, dispatch} = React.useContext(Context) const {state, dispatch} = useOnboardingInternalState()
const [avatar, setAvatar] = React.useState<Avatar>({ const [avatar, setAvatar] = React.useState<Avatar>({
image: state.profileStepResults?.image, image: state.profileStepResults?.image,
placeholder: state.profileStepResults.creatorState?.emoji || emojiItems.at, placeholder: state.profileStepResults.creatorState?.emoji || emojiItems.at,
@@ -1,11 +1,4 @@
import { import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {type ModerationOpts} from '@atproto/api' import {type ModerationOpts} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
@@ -23,7 +16,7 @@ import {useLanguagePrefs} from '#/state/preferences'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
import {OnboardingControls} from '#/screens/Onboarding/Layout' 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 {useSuggestedUsers} from '#/screens/Search/util/useSuggestedUsers'
import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf' import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf'
import {Admonition} from '#/components/Admonition' import {Admonition} from '#/components/Admonition'
@@ -47,7 +40,7 @@ export function StepSuggestedAccounts() {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {state, dispatch} = useContext(Context) const {state, dispatch} = useOnboardingInternalState()
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 // 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 {View} from 'react-native'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@@ -6,7 +5,7 @@ import {useLingui} from '@lingui/react'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useOnboardingSuggestedStarterPacksQuery} from '#/state/queries/useOnboardingSuggestedStarterPacksQuery' import {useOnboardingSuggestedStarterPacksQuery} from '#/state/queries/useOnboardingSuggestedStarterPacksQuery'
import {OnboardingControls} from '#/screens/Onboarding/Layout' 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 {atoms as a, useBreakpoints} from '#/alf'
import {Admonition} from '#/components/Admonition' import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
@@ -20,7 +19,7 @@ export function StepSuggestedStarterpacks() {
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const {state, dispatch} = useContext(Context) const {state, dispatch} = useOnboardingInternalState()
const { const {
data: suggestedStarterPacks, data: suggestedStarterPacks,
+47 -46
View File
@@ -1,28 +1,38 @@
import {useMemo, useReducer} from 'react' import {useMemo, useReducer} from 'react'
import {msg} from '@lingui/macro' import {View} from 'react-native'
import {useLingui} from '@lingui/react'
import * as bcp47Match from 'bcp-47-match' import * as bcp47Match from 'bcp-47-match'
import {useEnableKeyboardControllerScreen} from '#/lib/hooks/useEnableKeyboardController'
import {useGate} from '#/lib/statsig/statsig' import {useGate} from '#/lib/statsig/statsig'
import {isNative} from '#/platform/detection'
import {useLanguagePrefs} from '#/state/preferences' import {useLanguagePrefs} from '#/state/preferences'
import { import {
Layout, Layout,
OnboardingControls, OnboardingControls,
OnboardingHeaderSlot, OnboardingHeaderSlot,
} from '#/screens/Onboarding/Layout' } 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 {StepFinished} from '#/screens/Onboarding/StepFinished'
import {StepInterests} from '#/screens/Onboarding/StepInterests' import {StepInterests} from '#/screens/Onboarding/StepInterests'
import {StepProfile} from '#/screens/Onboarding/StepProfile' import {StepProfile} from '#/screens/Onboarding/StepProfile'
import {atoms as a, useTheme} from '#/alf'
import {useIsFindContactsFeatureEnabledBasedOnGeolocation} from '#/components/contacts/country-whitelist'
import {useFindContactsFlowState} from '#/components/contacts/state'
import {Portal} from '#/components/Portal' import {Portal} from '#/components/Portal'
import {ScreenTransition} from '#/components/ScreenTransition' import {ScreenTransition} from '#/components/ScreenTransition'
import {ENV} from '#/env' import {ENV} from '#/env'
import {StepFindContacts} from './StepFindContacts'
import {StepFindContactsIntro} from './StepFindContactsIntro'
import {StepSuggestedAccounts} from './StepSuggestedAccounts' import {StepSuggestedAccounts} from './StepSuggestedAccounts'
import {StepSuggestedStarterpacks} from './StepSuggestedStarterpacks' import {StepSuggestedStarterpacks} from './StepSuggestedStarterpacks'
export function Onboarding() { export function Onboarding() {
const {_} = useLingui()
const gate = useGate() const gate = useGate()
const t = useTheme()
const {contentLanguages} = useLanguagePrefs() const {contentLanguages} = useLanguagePrefs()
const probablySpeaksEnglish = useMemo(() => { const probablySpeaksEnglish = useMemo(() => {
@@ -36,56 +46,42 @@ export function Onboarding() {
probablySpeaksEnglish && probablySpeaksEnglish &&
gate('onboarding_suggested_starterpacks') gate('onboarding_suggested_starterpacks')
const [state, dispatch] = useReducer(reducer, { const findContactsEnabled =
...initialState, useIsFindContactsFeatureEnabledBasedOnGeolocation()
totalSteps: 4 + (showSuggestedStarterpacks ? 1 : 0), const showFindContacts =
experiments: { isNative && findContactsEnabled && !gate('disable_onboarding_find_contacts')
onboarding_suggested_accounts: true,
onboarding_value_prop: true,
onboarding_suggested_starterpacks: showSuggestedStarterpacks,
},
})
const interestsDisplayNames = useMemo(() => { const [state, dispatch] = useReducer(
return { reducer,
news: _(msg`News`), {
journalism: _(msg`Journalism`), starterPacksStepEnabled: showSuggestedStarterpacks,
nature: _(msg`Nature`), findContactsStepEnabled: showFindContacts,
art: _(msg`Art`), },
comics: _(msg`Comics`), createInitialOnboardingState,
writers: _(msg`Writers`), )
culture: _(msg`Culture`), const [contactsFlowState, contactsFlowDispatch] = useFindContactsFlowState()
sports: _(msg`Sports`),
pets: _(msg`Pets`), useEnableKeyboardControllerScreen(true)
animals: _(msg`Animals`),
books: _(msg`Books`),
education: _(msg`Education`),
climate: _(msg`Climate`),
science: _(msg`Science`),
politics: _(msg`Politics`),
fitness: _(msg`Fitness`),
tech: _(msg`Tech`),
dev: _(msg`Software Dev`),
comedy: _(msg`Comedy`),
gaming: _(msg`Video Games`),
food: _(msg`Food`),
cooking: _(msg`Cooking`),
}
}, [_])
return ( return (
<Portal> <Portal>
<View style={[a.absolute, a.inset_0, t.atoms.bg]}>
<OnboardingControls.Provider> <OnboardingControls.Provider>
<OnboardingHeaderSlot.Provider> <OnboardingHeaderSlot.Provider>
<Context.Provider <Context.Provider
value={useMemo( value={useMemo(() => ({state, dispatch}), [state, dispatch])}>
() => ({state, dispatch, interestsDisplayNames}),
[state, dispatch, interestsDisplayNames],
)}>
<Layout>
<ScreenTransition <ScreenTransition
key={state.activeStep} key={state.activeStep}
direction={state.stepTransitionDirection}> direction={state.stepTransitionDirection}
style={a.flex_1}>
{/* FindContactsFlow cannot be nested in Layout */}
{state.activeStep === 'find-contacts' ? (
<StepFindContacts
flowState={contactsFlowState}
flowDispatch={contactsFlowDispatch}
/>
) : (
<Layout>
{state.activeStep === 'profile' && <StepProfile />} {state.activeStep === 'profile' && <StepProfile />}
{state.activeStep === 'interests' && <StepInterests />} {state.activeStep === 'interests' && <StepInterests />}
{state.activeStep === 'suggested-accounts' && ( {state.activeStep === 'suggested-accounts' && (
@@ -94,12 +90,17 @@ export function Onboarding() {
{state.activeStep === 'suggested-starterpacks' && ( {state.activeStep === 'suggested-starterpacks' && (
<StepSuggestedStarterpacks /> <StepSuggestedStarterpacks />
)} )}
{state.activeStep === 'find-contacts-intro' && (
<StepFindContactsIntro />
)}
{state.activeStep === 'finished' && <StepFinished />} {state.activeStep === 'finished' && <StepFinished />}
</ScreenTransition>
</Layout> </Layout>
)}
</ScreenTransition>
</Context.Provider> </Context.Provider>
</OnboardingHeaderSlot.Provider> </OnboardingHeaderSlot.Provider>
</OnboardingControls.Provider> </OnboardingControls.Provider>
</View>
</Portal> </Portal>
) )
} }
+94 -41
View File
@@ -1,4 +1,4 @@
import React from 'react' import {createContext, useContext, useMemo} from 'react'
import {logger} from '#/logger' import {logger} from '#/logger'
import { import {
@@ -6,16 +6,18 @@ import {
type Emoji, type Emoji,
} from '#/screens/Onboarding/StepProfile/types' } from '#/screens/Onboarding/StepProfile/types'
export type OnboardingState = { type OnboardingScreen =
hasPrev: boolean
totalSteps: number
activeStep:
| 'profile' | 'profile'
| 'interests' | 'interests'
| 'suggested-accounts' | 'suggested-accounts'
| 'suggested-starterpacks' | 'suggested-starterpacks'
| 'find-contacts-intro'
| 'find-contacts'
| 'finished' | 'finished'
activeStepIndex: number
export type OnboardingState = {
screens: Record<OnboardingScreen, boolean>
activeStep: OnboardingScreen
stepTransitionDirection: 'Forward' | 'Backward' stepTransitionDirection: 'Forward' | 'Backward'
interestsStepResults: { interestsStepResults: {
@@ -37,12 +39,6 @@ export type OnboardingState = {
backgroundColor: AvatarColor backgroundColor: AvatarColor
} }
} }
experiments?: {
onboarding_suggested_accounts?: boolean
onboarding_value_prop?: boolean
onboarding_suggested_starterpacks?: boolean
}
} }
export type OnboardingAction = export type OnboardingAction =
@@ -52,6 +48,9 @@ export type OnboardingAction =
| { | {
type: 'prev' type: 'prev'
} }
| {
type: 'skip-contacts'
}
| { | {
type: 'finish' type: 'finish'
} }
@@ -73,13 +72,29 @@ export type OnboardingAction =
| undefined | undefined
} }
export const initialState: OnboardingState = { export function createInitialOnboardingState(
hasPrev: false, {
totalSteps: 3, starterPacksStepEnabled,
activeStep: 'profile', findContactsStepEnabled,
activeStepIndex: 1, }: {
stepTransitionDirection: 'Forward', 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-intro': findContactsStepEnabled,
'find-contacts': findContactsStepEnabled,
finished: true,
}
return {
screens,
activeStep: 'profile',
stepTransitionDirection: 'Forward',
interestsStepResults: { interestsStepResults: {
selectedInterests: [], selectedInterests: [],
}, },
@@ -89,15 +104,13 @@ export const initialState: OnboardingState = {
imageUri: '', imageUri: '',
imageMime: '', imageMime: '',
}, },
}
} }
export const Context = React.createContext<{ export const Context = createContext<{
state: OnboardingState state: OnboardingState
dispatch: React.Dispatch<OnboardingAction> dispatch: React.Dispatch<OnboardingAction>
}>({ } | null>(null)
state: {...initialState},
dispatch: () => {},
})
Context.displayName = 'OnboardingContext' Context.displayName = 'OnboardingContext'
export function reducer( export function reducer(
@@ -106,42 +119,39 @@ export function reducer(
): OnboardingState { ): OnboardingState {
let next = {...s} let next = {...s}
const stepOrder: OnboardingState['activeStep'][] = [ const stepOrder = getStepOrder(s)
'profile',
'interests',
...(s.experiments?.onboarding_suggested_accounts
? (['suggested-accounts'] as const)
: []),
...(s.experiments?.onboarding_suggested_starterpacks
? (['suggested-starterpacks'] as const)
: []),
'finished',
]
switch (a.type) { switch (a.type) {
case 'next': { case 'next': {
// 1-indexed for some reason const nextIndex = stepOrder.indexOf(next.activeStep) + 1
const nextIndex = s.activeStepIndex
const nextStep = stepOrder[nextIndex] const nextStep = stepOrder[nextIndex]
if (nextStep) { if (nextStep) {
next.activeStep = nextStep next.activeStep = nextStep
next.activeStepIndex = nextIndex + 1
} }
next.stepTransitionDirection = 'Forward' next.stepTransitionDirection = 'Forward'
break break
} }
case 'prev': { case 'prev': {
const prevIndex = s.activeStepIndex - 2 const prevIndex = stepOrder.indexOf(next.activeStep) - 1
const prevStep = stepOrder[prevIndex] const prevStep = stepOrder[prevIndex]
if (prevStep) { if (prevStep) {
next.activeStep = prevStep next.activeStep = prevStep
next.activeStepIndex = prevIndex + 1
} }
next.stepTransitionDirection = 'Backward' next.stepTransitionDirection = 'Backward'
break break
} }
case 'skip-contacts': {
const nextIndex = stepOrder.indexOf('find-contacts') + 1
const nextStep = stepOrder[nextIndex] ?? 'finished'
next.activeStep = nextStep
next.stepTransitionDirection = 'Forward'
break
}
case 'finish': { case 'finish': {
next = initialState next = createInitialOnboardingState({
starterPacksStepEnabled: s.screens['suggested-starterpacks'],
findContactsStepEnabled: s.screens['find-contacts'],
})
break break
} }
case 'setInterestsStepResults': { case 'setInterestsStepResults': {
@@ -170,7 +180,6 @@ export function reducer(
logger.debug(`onboarding`, { logger.debug(`onboarding`, {
hasPrev: state.hasPrev, hasPrev: state.hasPrev,
activeStep: state.activeStep, activeStep: state.activeStep,
activeStepIndex: state.activeStepIndex,
interestsStepResults: { interestsStepResults: {
selectedInterests: state.interestsStepResults.selectedInterests, selectedInterests: state.interestsStepResults.selectedInterests,
}, },
@@ -183,3 +192,47 @@ export function reducer(
return state 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-intro'] && ('find-contacts-intro' 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,
}
}