[Contacts] Contacts matching flow (#9486)

* add expo-contacts

* add expo-sms

* update copy

* add basic settings screen

* state machine, flow screen

* phone input screen

* otp screen

* tweak spacing

* resend code logic

* add layoutanimationconfig

* check availablility in settings screen

* get contacts

* matches screen

* search, temp setup for matches UI

* add a bunch of number parsing logic with libphonenumber

* cast to looser type

* rename sync to find

FCF (Find Contacts Flow)

* update geolocation hook

* nicer design for settings screen

* add completed state

* up border contrast

* update expo deps

* add pending spinner

* add country whitelist

* add empty state screen

* drop add more functionality

* upload -> import

* fix typo

* fix permission string

* copy updates

* rm envelope icon

* update sms copy

* add inviteinfo component

* woke is back

* [Contacts] NUXes (#9515)

* add a bunch of number parsing logic with libphonenumber

* add banner nux

* add announcement nux

* native only nux

* rm shitty animation

* move isNative check

* [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

* [Contacts] Add `contact-match` notification type to feed (#9519)

* add a bunch of number parsing logic with libphonenumber

* add contact-joined notif type

* update string, api package

* Update NotificationFeedItem.tsx

* Update NotificationFeedItem.tsx

* Delete SyncContactsFlow.web.tsx

* fix follow back btn for this case

* [Contacts] API integration (#9487)

* api integration for flow

* copy tweak

* tweaks after running it

* wire up status page

* rename toast

* use api lib

* rm temp code

* maybe fix otp error

* clear code on error/resend

* add 1s delay to verify success

* update package versions

* Delete SyncContactsFlow.web.tsx

* try and fix yarn.lock lint

* even woker

* fix uppercase friends

* surfdude feedback

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* rm log

* allow resend on invalid code

* Update GetContacts.tsx

* overwrite country code if possible

* fix Apple's Best Feature

* devenv latest

* disable bounces

* interactive keyboard dismiss

* copy changes

* national format

* allow resending immediately

* move success state down a bit

* Update FindContactsSettings.tsx

* [Contacts] More onboarding changes (#9491)

* integrate existing flow into onboarding

* refreshed onboarding styles, rm stepper

* center content on web

* Add back dismiss button for internal onboarding

* Import sort

---------

Co-authored-by: Eric Bailey <git@esb.lol>

* add matches query to shadow

* add clockwise arrow

* update status design, fix dismiss, fix queries

* add metrics

* add more comments

* Update metrics.ts

* refetch both queries on PTR

* fix shadow state in matches page

* reduce empty space at bottom

* show contact info on matches

* filter out contacts without numbers at an earlier stage

* Error handling 

* get notifs working

* filter out businesses

* rm log

* remove TODO from learn more links

* try and exclude from web bundle

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
Samuel Newman
2025-12-12 20:32:06 +02:00
committed by GitHub
parent d3a6db4594
commit ad7d49ef8f
98 changed files with 4906 additions and 1079 deletions
+107 -95
View File
@@ -1,13 +1,12 @@
import React, {useState} from 'react'
import {useEffect, useRef, useState} from 'react'
import {ScrollView, View} from 'react-native'
import {Dimensions} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg} from '@lingui/macro'
import {msg, Trans} from '@lingui/macro'
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,
@@ -22,6 +21,7 @@ import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft} from '#/components/icons
import {HEADER_SLOT_SIZE} from '#/components/Layout'
import {createPortalGroup} from '#/components/Portal'
import {P, Text} from '#/components/Typography'
import {IS_INTERNAL} from '#/env'
const ONBOARDING_COL_WIDTH = 420
@@ -34,20 +34,20 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
const insets = useSafeAreaInsets()
const {gtMobile} = useBreakpoints()
const onboardDispatch = useOnboardingDispatch()
const {state, dispatch} = React.useContext(Context)
const scrollview = React.useRef<ScrollView>(null)
const prevActiveStep = React.useRef<string>(state.activeStep)
const {state, dispatch} = useOnboardingInternalState()
const scrollview = useRef<ScrollView>(null)
const prevActiveStep = useRef<string>(state.activeStep)
React.useEffect(() => {
useEffect(() => {
if (state.activeStep !== prevActiveStep.current) {
prevActiveStep.current = state.activeStep
scrollview.current?.scrollTo({y: 0, animated: false})
}
}, [state])
const paddingTop = gtMobile ? a.py_5xl : a.py_lg
const dialogLabel = _(msg`Set up your account`)
const [headerHeight, setHeaderHeight] = useState(0)
const [footerHeight, setFooterHeight] = useState(0)
return (
@@ -59,33 +59,12 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
accessibilityLabel={dialogLabel}
accessibilityHint={_(msg`Customizes your Bluesky experience`)}
style={[isWeb ? a.fixed : a.absolute, a.inset_0, a.flex_1, t.atoms.bg]}>
{__DEV__ && (
<Button
variant="ghost"
color="negative"
size="tiny"
onPress={() => onboardDispatch({type: 'skip'})}
// DEV ONLY
label="Clear onboarding state"
style={[
a.absolute,
a.z_10,
{
left: isWeb ? '50%' : Dimensions.get('window').width / 2 - 45,
top: insets.top + 2,
},
web({transform: [{translateX: '-50%'}]}),
]}>
<ButtonText>[DEV] Clear</ButtonText>
</Button>
)}
{!gtMobile && (
{!gtMobile ? (
<View
pointerEvents="box-none"
style={[
web(a.fixed),
native(a.absolute),
a.top_0,
a.left_0,
a.right_0,
a.flex_row,
@@ -93,85 +72,94 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
a.justify_center,
a.z_20,
a.px_xl,
{top: paddingTop.paddingTop + insets.top - 1},
]}>
{paddingTop: (web(tokens.space.lg) ?? 0) + insets.top},
native([t.atoms.bg, a.pb_xs, {minHeight: 48}]),
web(a.pointer_events_box_none),
]}
onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)}>
<View
pointerEvents="box-none"
style={[
a.w_full,
a.align_start,
a.align_center,
a.flex_row,
a.justify_between,
{maxWidth: ONBOARDING_COL_WIDTH},
web({maxWidth: ONBOARDING_COL_WIDTH}),
web(a.pointer_events_box_none),
]}>
{state.hasPrev ? (
<HeaderSlot>
{state.canGoBack && (
<Button
key={state.activeStep} // remove focus state on nav
color="secondary"
variant="ghost"
shape="round"
size="small"
label={_(msg`Go back to previous step`)}
onPress={() => dispatch({type: 'prev'})}>
<ButtonIcon icon={ArrowLeft} size="lg" />
</Button>
)}
</HeaderSlot>
{IS_INTERNAL && (
<Button
key={state.activeStep} // remove focus state on nav
color="secondary"
variant="ghost"
shape="square"
size="small"
label={_(msg`Go back to previous step`)}
onPress={() => dispatch({type: 'prev'})}
style={[a.bg_transparent]}>
<ButtonIcon icon={ArrowLeft} size="lg" />
color="negative"
size="tiny"
onPress={() => onboardDispatch({type: 'skip'})}
// DEV ONLY
label="Clear onboarding state">
<ButtonText>[DEV] Clear</ButtonText>
</Button>
) : (
<View />
)}
<OnboardingHeaderSlot.Outlet />
<HeaderSlot>
<OnboardingHeaderSlot.Outlet />
</HeaderSlot>
</View>
</View>
) : (
<>
{IS_INTERNAL && (
<View
style={[
a.absolute,
a.align_center,
a.z_10,
{top: 0, left: 0, right: 0},
]}>
<Button
variant="ghost"
color="negative"
size="tiny"
onPress={() => onboardDispatch({type: 'skip'})}
// DEV ONLY
label="Clear onboarding state">
<ButtonText>[DEV] Clear</ButtonText>
</Button>
</View>
)}
</>
)}
<ScrollView
ref={scrollview}
style={[a.h_full, a.w_full, {paddingTop: insets.top}]}
contentContainerStyle={{borderWidth: 0, minHeight: '100%'}}
style={[a.h_full, a.w_full]}
contentContainerStyle={{
borderWidth: 0,
minHeight: '100%',
paddingTop: gtMobile ? 40 : headerHeight,
paddingBottom: footerHeight,
}}
showsVerticalScrollIndicator={!isAndroid}
scrollIndicatorInsets={{bottom: footerHeight - insets.bottom}}
// @ts-expect-error web only --prf
dataSet={{'stable-gutters': 1}}>
dataSet={{'stable-gutters': 1}}
centerContent={gtMobile}>
<View
style={[a.flex_row, a.justify_center, gtMobile ? a.px_5xl : a.px_xl]}>
<View style={[a.flex_1, {maxWidth: ONBOARDING_COL_WIDTH}]}>
<View style={[a.w_full, a.align_center, paddingTop]}>
<View
style={[
a.flex_row,
a.gap_sm,
a.w_full,
a.align_center,
{height: HEADER_SLOT_SIZE, maxWidth: '60%'},
]}>
{Array(state.totalSteps)
.fill(0)
.map((__, i) => (
<View
key={i}
style={[
a.flex_1,
a.pt_xs,
a.rounded_full,
t.atoms.bg_contrast_50,
{
backgroundColor:
i + 1 <= state.activeStepIndex
? t.palette.primary_500
: t.palette.contrast_100,
},
]}
/>
))}
</View>
</View>
<View style={[a.w_full, a.h_full, a.mb_5xl, a.pt_md]}>
{children}
</View>
<View style={{height: 100 + footerHeight}} />
<View style={[a.flex_1, web({maxWidth: ONBOARDING_COL_WIDTH})]}>
<View style={[a.w_full, a.py_md]}>{children}</View>
</View>
</View>
</ScrollView>
@@ -200,7 +188,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"
@@ -221,22 +209,46 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
)
}
export function TitleText({
function HeaderSlot({children}: {children?: React.ReactNode}) {
return (
<View style={[{minHeight: HEADER_SLOT_SIZE, minWidth: HEADER_SLOT_SIZE}]}>
{children}
</View>
)
}
export function OnboardingPosition() {
const {state} = useOnboardingInternalState()
const t = useTheme()
return (
<Text style={[a.text_sm, a.font_medium, t.atoms.text_contrast_medium]}>
<Trans>
Step {state.activeStepIndex + 1} of {state.totalSteps}
</Trans>
</Text>
)
}
export function OnboardingTitleText({
children,
style,
}: React.PropsWithChildren<TextStyleProp>) {
return (
<Text
style={[a.pb_sm, a.text_4xl, a.font_semi_bold, a.leading_tight, style]}>
<Text style={[a.text_3xl, a.font_bold, a.leading_snug, style]}>
{children}
</Text>
)
}
export function DescriptionText({
export function OnboardingDescriptionText({
children,
style,
}: React.PropsWithChildren<TextStyleProp>) {
const t = useTheme()
return <P style={[t.atoms.text_contrast_medium, style]}>{children}</P>
return (
<P style={[a.text_md, a.leading_snug, t.atoms.text_contrast_medium, style]}>
{children}
</P>
)
}
@@ -0,0 +1,60 @@
import {useCallback, useState} from 'react'
import {LayoutAnimationConfig} from 'react-native-reanimated'
import {SafeAreaView} from 'react-native-safe-area-context'
import {logger} from '#/logger'
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 isFinalStep = flowState.step === '4: view matches'
const onSkip = useCallback(() => {
if (!isFinalStep) {
logger.metric('onboarding:contacts:skipPressed', {})
}
dispatch({type: 'next'})
}, [dispatch, isFinalStep])
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,3 @@
export function StepFindContacts() {
throw new Error('StepFindContacts is not available on web')
}
@@ -0,0 +1,88 @@
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} 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 {
OnboardingControls,
OnboardingDescriptionText,
OnboardingPosition,
OnboardingTitleText,
} from '../Layout'
import {useOnboardingInternalState} from '../state'
export function StepFindContactsIntro() {
const {_} = useLingui()
const {dispatch} = useOnboardingInternalState()
const {data: isAvailable, isSuccess} = useQuery({
queryKey: ['contacts-available'],
queryFn: async () => await Contacts.isAvailableAsync(),
})
return (
<View style={[a.w_full, a.gap_sm]}>
<OnboardingPosition />
<ContactsHeroImage />
<OnboardingTitleText style={[a.mt_sm]}>
<Trans>Bluesky is more fun with friends</Trans>
</OnboardingTitleText>
<OnboardingDescriptionText>
<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({
message: `Learn more about importing contacts`,
context: `english-only-resource`,
}),
)}
style={[a.text_md, a.leading_snug]}>
<Trans context="english-only-resource">Learn more</Trans>
</InlineLinkText>
</Trans>
</OnboardingDescriptionText>
{!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`Import contacts`)}
size="large"
color="primary"
disabled={!isAvailable}>
<ButtonText>
<Trans>Import 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>
)
}
@@ -0,0 +1,3 @@
export function StepFindContactsIntro() {
throw new Error('StepFindContactsIntro is not available on web')
}
+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>
)
}
+13 -16
View File
@@ -8,25 +8,23 @@ import {logEvent} from '#/lib/statsig/statsig'
import {capitalize} from '#/lib/strings/capitalize'
import {logger} from '#/logger'
import {
DescriptionText,
OnboardingControls,
TitleText,
OnboardingDescriptionText,
OnboardingPosition,
OnboardingTitleText,
} 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'
import * as Toggle from '#/components/forms/Toggle'
import {IconCircle} from '#/components/IconCircle'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
import {Loader} from '#/components/Loader'
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),
@@ -53,17 +51,16 @@ export function StepInterests() {
}, [selectedInterests, setSaving, dispatch])
return (
<View style={[a.align_start]} testID="onboardingInterests">
<IconCircle icon={Hashtag} style={[a.mb_2xl]} />
<TitleText>
<View style={[a.align_start, a.gap_sm]} testID="onboardingInterests">
<OnboardingPosition />
<OnboardingTitleText>
<Trans>What are your interests?</Trans>
</TitleText>
<DescriptionText>
</OnboardingTitleText>
<OnboardingDescriptionText>
<Trans>We'll use this to help customize your experience.</Trans>
</DescriptionText>
</OnboardingDescriptionText>
<View style={[a.w_full, a.pt_2xl]}>
<View style={[a.w_full, a.pt_lg]}>
<Toggle.Group
values={selectedInterests}
onChange={setSelectedInterests}
@@ -93,7 +90,7 @@ export function StepInterests() {
<ButtonText>
<Trans>Continue</Trans>
</ButtonText>
<ButtonIcon icon={saving ? Loader : ChevronRight} />
{saving && <ButtonIcon icon={Loader} />}
</Button>
</OnboardingControls.Portal>
</View>
+21 -25
View File
@@ -19,11 +19,12 @@ import {isCancelledError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {isNative, isWeb} from '#/platform/detection'
import {
DescriptionText,
OnboardingControls,
TitleText,
OnboardingDescriptionText,
OnboardingPosition,
OnboardingTitleText,
} 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'
@@ -32,13 +33,10 @@ import {
type PlaceholderCanvasRef,
} from '#/screens/Onboarding/StepProfile/PlaceholderCanvas'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
import {IconCircle} from '#/components/IconCircle'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {CircleInfo_Stroke2_Corner0_Rounded} from '#/components/icons/CircleInfo'
import {StreamingLive_Stroke2_Corner0_Rounded as StreamingLive} from '#/components/icons/StreamingLive'
import {Text} from '#/components/Typography'
import {type AvatarColor, avatarColors, type Emoji, emojiItems} from './types'
@@ -78,7 +76,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,
@@ -237,19 +235,21 @@ export function StepProfile() {
return (
<AvatarContext.Provider value={value}>
<View style={[a.align_start, t.atoms.bg, a.justify_between]}>
<IconCircle icon={StreamingLive} style={[a.mb_2xl]} />
<TitleText>
<Trans>Give your profile a face</Trans>
</TitleText>
<DescriptionText>
<Trans>
Help people know you're not a bot by uploading a picture or creating
an avatar.
</Trans>
</DescriptionText>
<View style={[a.align_start]}>
<View style={[a.gap_sm]}>
<OnboardingPosition />
<OnboardingTitleText>
<Trans>Give your profile a face</Trans>
</OnboardingTitleText>
<OnboardingDescriptionText>
<Trans>
Help people know you're not a bot by uploading a picture or
creating an avatar.
</Trans>
</OnboardingDescriptionText>
</View>
<View
style={[a.w_full, a.align_center, {paddingTop: gtMobile ? 80 : 40}]}>
style={[a.w_full, a.align_center, {paddingTop: gtMobile ? 80 : 60}]}>
<AvatarCircle
openLibrary={openLibrary}
openCreator={creatorControl.open}
@@ -279,7 +279,6 @@ export function StepProfile() {
<View style={[a.gap_md, gtMobile && a.flex_row_reverse]}>
<Button
testID="onboardingContinue"
variant="solid"
color="primary"
size="large"
label={_(msg`Continue to next step`)}
@@ -287,12 +286,10 @@ export function StepProfile() {
<ButtonText>
<Trans>Continue</Trans>
</ButtonText>
<ButtonIcon icon={ChevronRight} position="right" />
</Button>
<Button
testID="onboardingAvatarCreator"
variant="ghost"
color="primary"
color="primary_subtle"
size="large"
label={_(msg`Open avatar creator`)}
onPress={onSecondaryPress}>
@@ -335,7 +332,6 @@ export function StepProfile() {
</View>
<View style={[a.pt_4xl]}>
<Button
variant="solid"
color="primary"
size="large"
label={_(msg`Done`)}
@@ -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'
@@ -22,19 +15,22 @@ import {updateProfileShadow} from '#/state/cache/profile-shadow'
import {useLanguagePrefs} from '#/state/preferences'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useAgent, useSession} from '#/state/session'
import {OnboardingControls} from '#/screens/Onboarding/Layout'
import {Context} from '#/screens/Onboarding/state'
import {
OnboardingControls,
OnboardingPosition,
OnboardingTitleText,
} from '#/screens/Onboarding/Layout'
import {useOnboardingInternalState} from '#/screens/Onboarding/state'
import {useSuggestedUsers} from '#/screens/Search/util/useSuggestedUsers'
import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf'
import {atoms as a, tokens, useBreakpoints, useTheme, web} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwiseIcon} from '#/components/icons/ArrowRotateCounterClockwise'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwiseIcon} from '#/components/icons/ArrowRotate'
import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
import {boostInterests, InterestTabs} from '#/components/InterestTabs'
import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard'
import * as toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
import {bulkWriteFollows} from '../util'
@@ -47,7 +43,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
@@ -153,18 +149,21 @@ export function StepSuggestedAccounts() {
)
return (
<View style={[a.align_start]} testID="onboardingInterests">
<Text style={[a.font_bold, a.text_3xl]}>
<View style={[a.align_start, a.gap_sm]} testID="onboardingInterests">
<OnboardingPosition />
<OnboardingTitleText>
<Trans comment="Accounts suggested to the user for them to follow">
Suggested for you
</Trans>
</Text>
</OnboardingTitleText>
<View
style={[
a.overflow_hidden,
a.mt_lg,
isWeb ? a.max_w_full : {marginHorizontal: tokens.space.xl * -1},
a.mt_sm,
isWeb
? [a.max_w_full, web({minHeight: '100vh'})]
: {marginHorizontal: tokens.space.xl * -1},
a.flex_1,
a.justify_start,
]}>
@@ -1,18 +1,20 @@
import {useContext} from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useOnboardingSuggestedStarterPacksQuery} from '#/state/queries/useOnboardingSuggestedStarterPacksQuery'
import {OnboardingControls} from '#/screens/Onboarding/Layout'
import {Context} from '#/screens/Onboarding/state'
import {
OnboardingControls,
OnboardingPosition,
OnboardingTitleText,
} from '#/screens/Onboarding/Layout'
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'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwiseIcon} from '#/components/icons/ArrowRotateCounterClockwise'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwiseIcon} from '#/components/icons/ArrowRotate'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {StarterPackCard} from './StarterPackCard'
export function StepSuggestedStarterpacks() {
@@ -20,7 +22,7 @@ export function StepSuggestedStarterpacks() {
const {gtMobile} = useBreakpoints()
const moderationOpts = useModerationOpts()
const {state, dispatch} = useContext(Context)
const {state, dispatch} = useOnboardingInternalState()
const {
data: suggestedStarterPacks,
@@ -34,26 +36,26 @@ export function StepSuggestedStarterpacks() {
})
return (
<View style={[a.align_start]} testID="onboardingInterests">
<Text style={[a.font_bold, a.text_3xl]}>
<Trans comment="Accounts suggested to the user for them to follow">
Suggested for you
<View style={[a.align_start, a.gap_sm]} testID="onboardingInterests">
<OnboardingPosition />
<OnboardingTitleText>
<Trans comment="Starter packs suggested to the user for them to follow">
Find people to follow
</Trans>
</Text>
</OnboardingTitleText>
<View
style={[
a.overflow_hidden,
a.mt_lg,
a.flex_1,
a.justify_start,
a.w_full,
a.mt_sm,
]}>
{isLoading || !moderationOpts ? (
<View
style={[
a.flex_1,
a.mt_md,
a.align_center,
a.justify_center,
{minHeight: 400},
@@ -69,7 +71,7 @@ export function StepSuggestedStarterpacks() {
</Admonition>
</View>
) : (
<View style={[a.flex_1, a.mt_md]}>
<View style={[a.flex_1]}>
{suggestedStarterPacks?.starterPacks.map(starterPack => (
<View style={[a.pb_lg]} key={starterPack.uri}>
<StarterPackCard view={starterPack} />
+61 -60
View File
@@ -1,28 +1,38 @@
import {useMemo, useReducer} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {View} from 'react-native'
import * as bcp47Match from 'bcp-47-match'
import {useEnableKeyboardControllerScreen} from '#/lib/hooks/useEnableKeyboardController'
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'
import {atoms as a, useTheme} from '#/alf'
import {useIsFindContactsFeatureEnabledBasedOnGeolocation} from '#/components/contacts/country-allowlist'
import {useFindContactsFlowState} from '#/components/contacts/state'
import {Portal} from '#/components/Portal'
import {ScreenTransition} from '#/components/ScreenTransition'
import {ENV} from '#/env'
import {StepFindContacts} from './StepFindContacts'
import {StepFindContactsIntro} from './StepFindContactsIntro'
import {StepSuggestedAccounts} from './StepSuggestedAccounts'
import {StepSuggestedStarterpacks} from './StepSuggestedStarterpacks'
export function Onboarding() {
const {_} = useLingui()
const gate = useGate()
const t = useTheme()
const {contentLanguages} = useLanguagePrefs()
const probablySpeaksEnglish = useMemo(() => {
@@ -36,70 +46,61 @@ 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 findContactsEnabled =
useIsFindContactsFeatureEnabledBasedOnGeolocation()
const showFindContacts =
isNative && findContactsEnabled && !gate('disable_onboarding_find_contacts')
const interestsDisplayNames = useMemo(() => {
return {
news: _(msg`News`),
journalism: _(msg`Journalism`),
nature: _(msg`Nature`),
art: _(msg`Art`),
comics: _(msg`Comics`),
writers: _(msg`Writers`),
culture: _(msg`Culture`),
sports: _(msg`Sports`),
pets: _(msg`Pets`),
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`),
}
}, [_])
const [state, dispatch] = useReducer(
reducer,
{
starterPacksStepEnabled: showSuggestedStarterpacks,
findContactsStepEnabled: showFindContacts,
},
createInitialOnboardingState,
)
const [contactsFlowState, contactsFlowDispatch] = useFindContactsFlowState()
useEnableKeyboardControllerScreen(true)
return (
<Portal>
<OnboardingControls.Provider>
<OnboardingHeaderSlot.Provider>
<Context.Provider
value={useMemo(
() => ({state, dispatch, interestsDisplayNames}),
[state, dispatch, interestsDisplayNames],
)}>
<Layout>
<View style={[a.absolute, a.inset_0, t.atoms.bg]}>
<OnboardingControls.Provider>
<OnboardingHeaderSlot.Provider>
<Context.Provider
value={useMemo(() => ({state, dispatch}), [state, dispatch])}>
<ScreenTransition
key={state.activeStep}
direction={state.stepTransitionDirection}>
{state.activeStep === 'profile' && <StepProfile />}
{state.activeStep === 'interests' && <StepInterests />}
{state.activeStep === 'suggested-accounts' && (
<StepSuggestedAccounts />
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 === '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>
</Layout>
</Context.Provider>
</OnboardingHeaderSlot.Provider>
</OnboardingControls.Provider>
</Context.Provider>
</OnboardingHeaderSlot.Provider>
</OnboardingControls.Provider>
</View>
</Portal>
)
}
+126 -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,18 @@ import {
type Emoji,
} from '#/screens/Onboarding/StepProfile/types'
type OnboardingScreen =
| 'profile'
| 'interests'
| 'suggested-accounts'
| 'suggested-starterpacks'
| 'find-contacts-intro'
| '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 +39,6 @@ export type OnboardingState = {
backgroundColor: AvatarColor
}
}
experiments?: {
onboarding_suggested_accounts?: boolean
onboarding_value_prop?: boolean
onboarding_suggested_starterpacks?: boolean
}
}
export type OnboardingAction =
@@ -52,6 +48,9 @@ export type OnboardingAction =
| {
type: 'prev'
}
| {
type: 'skip-contacts'
}
| {
type: 'finish'
}
@@ -73,31 +72,45 @@ 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-intro': findContactsStepEnabled,
'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 +119,39 @@ 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 'skip-contacts': {
const nextIndex = stepOrder.indexOf('find-contacts') + 1
const nextStep = stepOrder[nextIndex] ?? 'finished'
next.activeStep = nextStep
next.stepTransitionDirection = 'Forward'
break
}
case 'finish': {
next = initialState
next = createInitialOnboardingState({
starterPacksStepEnabled: s.screens['suggested-starterpacks'],
findContactsStepEnabled: s.screens['find-contacts'],
})
break
}
case 'setInterestsStepResults': {
@@ -162,6 +172,15 @@ export function reducer(
}
}
if (a.type === 'next') {
if (next.activeStep === 'find-contacts-intro') {
logger.metric('onboarding:contacts:presented', {})
}
if (next.activeStep === 'find-contacts') {
logger.metric('onboarding:contacts:begin', {})
}
}
const state = {
...next,
hasPrev: next.activeStep !== 'profile',
@@ -170,7 +189,6 @@ export function reducer(
logger.debug(`onboarding`, {
hasPrev: state.hasPrev,
activeStep: state.activeStep,
activeStepIndex: state.activeStepIndex,
interestsStepResults: {
selectedInterests: state.interestsStepResults.selectedInterests,
},
@@ -183,3 +201,57 @@ 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-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).filter(
x => x !== 'find-contacts' && x !== 'finished',
) as string[]
const canGoBack = state.activeStep !== stepOrder[0]
return {
...state,
canGoBack,
/**
* Note: for *display* purposes only, do not lean on this
* for navigation purposes! we merge certain steps!
*/
activeStepIndex: stepOrder.indexOf(
state.activeStep === 'find-contacts'
? 'find-contacts-intro'
: state.activeStep,
),
totalSteps: stepOrder.length,
}
}, [state]),
dispatch,
}
}