[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:
@@ -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]}>
|
||||||
<Layout.Header.Outer noBottomBorder>
|
{context === 'Standalone' && (
|
||||||
<Layout.Header.BackButton />
|
<Layout.Header.Outer noBottomBorder>
|
||||||
<Layout.Header.Content />
|
<Layout.Header.BackButton />
|
||||||
<Layout.Header.Slot />
|
<Layout.Header.Content />
|
||||||
</Layout.Header.Outer>
|
<Layout.Header.Slot />
|
||||||
|
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,70 +46,61 @@ 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>
|
||||||
<OnboardingControls.Provider>
|
<View style={[a.absolute, a.inset_0, t.atoms.bg]}>
|
||||||
<OnboardingHeaderSlot.Provider>
|
<OnboardingControls.Provider>
|
||||||
<Context.Provider
|
<OnboardingHeaderSlot.Provider>
|
||||||
value={useMemo(
|
<Context.Provider
|
||||||
() => ({state, dispatch, interestsDisplayNames}),
|
value={useMemo(() => ({state, dispatch}), [state, dispatch])}>
|
||||||
[state, dispatch, interestsDisplayNames],
|
|
||||||
)}>
|
|
||||||
<Layout>
|
|
||||||
<ScreenTransition
|
<ScreenTransition
|
||||||
key={state.activeStep}
|
key={state.activeStep}
|
||||||
direction={state.stepTransitionDirection}>
|
direction={state.stepTransitionDirection}
|
||||||
{state.activeStep === 'profile' && <StepProfile />}
|
style={a.flex_1}>
|
||||||
{state.activeStep === 'interests' && <StepInterests />}
|
{/* FindContactsFlow cannot be nested in Layout */}
|
||||||
{state.activeStep === 'suggested-accounts' && (
|
{state.activeStep === 'find-contacts' ? (
|
||||||
<StepSuggestedAccounts />
|
<StepFindContacts
|
||||||
|
flowState={contactsFlowState}
|
||||||
|
flowDispatch={contactsFlowDispatch}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Layout>
|
||||||
|
{state.activeStep === 'profile' && <StepProfile />}
|
||||||
|
{state.activeStep === 'interests' && <StepInterests />}
|
||||||
|
{state.activeStep === 'suggested-accounts' && (
|
||||||
|
<StepSuggestedAccounts />
|
||||||
|
)}
|
||||||
|
{state.activeStep === 'suggested-starterpacks' && (
|
||||||
|
<StepSuggestedStarterpacks />
|
||||||
|
)}
|
||||||
|
{state.activeStep === 'find-contacts-intro' && (
|
||||||
|
<StepFindContactsIntro />
|
||||||
|
)}
|
||||||
|
{state.activeStep === 'finished' && <StepFinished />}
|
||||||
|
</Layout>
|
||||||
)}
|
)}
|
||||||
{state.activeStep === 'suggested-starterpacks' && (
|
|
||||||
<StepSuggestedStarterpacks />
|
|
||||||
)}
|
|
||||||
{state.activeStep === 'finished' && <StepFinished />}
|
|
||||||
</ScreenTransition>
|
</ScreenTransition>
|
||||||
</Layout>
|
</Context.Provider>
|
||||||
</Context.Provider>
|
</OnboardingHeaderSlot.Provider>
|
||||||
</OnboardingHeaderSlot.Provider>
|
</OnboardingControls.Provider>
|
||||||
</OnboardingControls.Provider>
|
</View>
|
||||||
</Portal>
|
</Portal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+107
-54
@@ -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'
|
||||||
|
|
||||||
|
type OnboardingScreen =
|
||||||
|
| 'profile'
|
||||||
|
| 'interests'
|
||||||
|
| 'suggested-accounts'
|
||||||
|
| 'suggested-starterpacks'
|
||||||
|
| 'find-contacts-intro'
|
||||||
|
| 'find-contacts'
|
||||||
|
| 'finished'
|
||||||
|
|
||||||
export type OnboardingState = {
|
export type OnboardingState = {
|
||||||
hasPrev: boolean
|
screens: Record<OnboardingScreen, boolean>
|
||||||
totalSteps: number
|
activeStep: OnboardingScreen
|
||||||
activeStep:
|
|
||||||
| 'profile'
|
|
||||||
| 'interests'
|
|
||||||
| 'suggested-accounts'
|
|
||||||
| 'suggested-starterpacks'
|
|
||||||
| 'finished'
|
|
||||||
activeStepIndex: number
|
|
||||||
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,31 +72,45 @@ 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,
|
||||||
|
}
|
||||||
|
|
||||||
interestsStepResults: {
|
return {
|
||||||
selectedInterests: [],
|
screens,
|
||||||
},
|
activeStep: 'profile',
|
||||||
profileStepResults: {
|
stepTransitionDirection: 'Forward',
|
||||||
isCreatedAvatar: false,
|
interestsStepResults: {
|
||||||
image: undefined,
|
selectedInterests: [],
|
||||||
imageUri: '',
|
},
|
||||||
imageMime: '',
|
profileStepResults: {
|
||||||
},
|
isCreatedAvatar: false,
|
||||||
|
image: undefined,
|
||||||
|
imageUri: '',
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user