[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:
@@ -0,0 +1,77 @@
|
||||
import {useCallback, useLayoutEffect, useState} from 'react'
|
||||
import {LayoutAnimationConfig} from 'react-native-reanimated'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {usePreventRemove} from '@react-navigation/native'
|
||||
|
||||
import {useEnableKeyboardControllerScreen} from '#/lib/hooks/useEnableKeyboardController'
|
||||
import {
|
||||
type AllNavigatorParams,
|
||||
type NativeStackScreenProps,
|
||||
} from '#/lib/routes/types'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useSetMinimalShellMode} from '#/state/shell'
|
||||
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
|
||||
import {FindContactsFlow} from '#/components/contacts/FindContactsFlow'
|
||||
import {useFindContactsFlowState} from '#/components/contacts/state'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import {ScreenTransition} from '#/components/ScreenTransition'
|
||||
|
||||
type Props = NativeStackScreenProps<AllNavigatorParams, 'FindContactsFlow'>
|
||||
export function FindContactsFlowScreen({navigation}: Props) {
|
||||
const {_} = useLingui()
|
||||
|
||||
const [state, dispatch] = useFindContactsFlowState()
|
||||
|
||||
const [transitionDirection, setTransitionDirection] = useState<
|
||||
'Forward' | 'Backward'
|
||||
>('Forward')
|
||||
|
||||
const overrideGoBack = state.step === '2: verify number'
|
||||
|
||||
usePreventRemove(overrideGoBack, () => {
|
||||
setTransitionDirection('Backward')
|
||||
dispatch({type: 'BACK'})
|
||||
setTimeout(() => {
|
||||
setTransitionDirection('Forward')
|
||||
})
|
||||
})
|
||||
|
||||
useEnableKeyboardControllerScreen(true)
|
||||
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
const effect = useCallback(() => {
|
||||
setMinimalShellMode(true)
|
||||
return () => setMinimalShellMode(false)
|
||||
}, [setMinimalShellMode])
|
||||
useLayoutEffect(effect)
|
||||
|
||||
return (
|
||||
<Layout.Screen>
|
||||
{isNative ? (
|
||||
<LayoutAnimationConfig skipEntering skipExiting>
|
||||
<ScreenTransition key={state.step} direction={transitionDirection}>
|
||||
<FindContactsFlow
|
||||
state={state}
|
||||
dispatch={dispatch}
|
||||
onCancel={() =>
|
||||
navigation.canGoBack()
|
||||
? navigation.goBack()
|
||||
: navigation.navigate('FindContactsFlow', undefined, {
|
||||
pop: true,
|
||||
})
|
||||
}
|
||||
context="Standalone"
|
||||
/>
|
||||
</ScreenTransition>
|
||||
</LayoutAnimationConfig>
|
||||
) : (
|
||||
<ErrorScreen
|
||||
title={_(msg`Not available on this platform.`)}
|
||||
message={_(msg`Please use the native app to sync your contacts.`)}
|
||||
showHeader
|
||||
/>
|
||||
)}
|
||||
</Layout.Screen>
|
||||
)
|
||||
}
|
||||
@@ -29,7 +29,7 @@ import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {type DialogControlProps, useDialogControl} from '#/components/Dialog'
|
||||
import {NewChat} from '#/components/dms/dialogs/NewChatDialog'
|
||||
import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus'
|
||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotateCounterClockwise'
|
||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo'
|
||||
import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
|
||||
|
||||
@@ -37,7 +37,7 @@ import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus'
|
||||
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow'
|
||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotateCounterClockwise'
|
||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate'
|
||||
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo'
|
||||
import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message'
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {useCleanError} from '#/lib/hooks/useCleanError'
|
||||
import {OUTER_SPACE} from '#/screens/PostThread/const'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotateCounterClockwise'
|
||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {atoms as a, native, useTheme, type ViewStyleProp} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import * as FeedCard from '#/components/FeedCard'
|
||||
import {sizes as iconSizes} from '#/components/icons/common'
|
||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2'
|
||||
import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass'
|
||||
import {Link} from '#/components/Link'
|
||||
import {Text, type TextProps} from '#/components/Typography'
|
||||
|
||||
|
||||
@@ -0,0 +1,562 @@
|
||||
import {useCallback, useEffect, useState} from 'react'
|
||||
import {type ListRenderItemInfo, View} from 'react-native'
|
||||
import * as Contacts from 'expo-contacts'
|
||||
import {
|
||||
type AppBskyContactDefs,
|
||||
type AppBskyContactGetSyncStatus,
|
||||
type ModerationOpts,
|
||||
} from '@atproto/api'
|
||||
import {msg, Plural, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useIsFocused} from '@react-navigation/native'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {wait} from '#/lib/async/wait'
|
||||
import {HITSLOP_10} from '#/lib/constants'
|
||||
import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted'
|
||||
import {
|
||||
type AllNavigatorParams,
|
||||
type NativeStackScreenProps,
|
||||
} from '#/lib/routes/types'
|
||||
import {cleanError, isNetworkError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {
|
||||
updateProfileShadow,
|
||||
useProfileShadow,
|
||||
} from '#/state/cache/profile-shadow'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {
|
||||
findContactsStatusQueryKey,
|
||||
optimisticRemoveMatch,
|
||||
useContactsMatchesQuery,
|
||||
useContactsSyncStatusQuery,
|
||||
} from '#/state/queries/find-contacts'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
|
||||
import {List} from '#/view/com/util/List'
|
||||
import {atoms as a, tokens, useGutters, useTheme} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {ContactsHeroImage} from '#/components/contacts/components/HeroImage'
|
||||
import {ArrowRotateClockwise_Stroke2_Corner0_Rounded as ResyncIcon} from '#/components/icons/ArrowRotate'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
||||
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import {InlineLinkText, Link} from '#/components/Link'
|
||||
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 '../Onboarding/util'
|
||||
|
||||
type Props = NativeStackScreenProps<AllNavigatorParams, 'FindContactsSettings'>
|
||||
export function FindContactsSettingsScreen({}: Props) {
|
||||
const {_} = useLingui()
|
||||
|
||||
const {data, error, refetch} = useContactsSyncStatusQuery()
|
||||
|
||||
const isFocused = useIsFocused()
|
||||
useEffect(() => {
|
||||
if (data && isFocused) {
|
||||
logger.metric('contacts:settings:presented', {
|
||||
hasPreviouslySynced: !!data.syncStatus,
|
||||
matchCount: data.syncStatus?.matchesCount,
|
||||
})
|
||||
}
|
||||
}, [data, isFocused])
|
||||
|
||||
return (
|
||||
<Layout.Screen>
|
||||
<Layout.Header.Outer>
|
||||
<Layout.Header.BackButton />
|
||||
<Layout.Header.Content>
|
||||
<Layout.Header.TitleText>
|
||||
<Trans>Find Friends</Trans>
|
||||
</Layout.Header.TitleText>
|
||||
</Layout.Header.Content>
|
||||
<Layout.Header.Slot />
|
||||
</Layout.Header.Outer>
|
||||
{isNative ? (
|
||||
data ? (
|
||||
!data.syncStatus ? (
|
||||
<Intro />
|
||||
) : (
|
||||
<SyncStatus info={data.syncStatus} refetchStatus={refetch} />
|
||||
)
|
||||
) : error ? (
|
||||
<ErrorScreen
|
||||
title={_(msg`Error getting the latest data.`)}
|
||||
message={cleanError(error)}
|
||||
onPressTryAgain={refetch}
|
||||
/>
|
||||
) : (
|
||||
<View style={[a.flex_1, a.justify_center, a.align_center]}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
)
|
||||
) : (
|
||||
<ErrorScreen
|
||||
title={_(msg`Not available on this platform.`)}
|
||||
message={_(msg`Please use the native app to sync your contacts.`)}
|
||||
/>
|
||||
)}
|
||||
</Layout.Screen>
|
||||
)
|
||||
}
|
||||
|
||||
function Intro() {
|
||||
const gutter = useGutters(['base'])
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
const {data: isAvailable, isSuccess} = useQuery({
|
||||
queryKey: ['contacts-available'],
|
||||
queryFn: async () => await Contacts.isAvailableAsync(),
|
||||
})
|
||||
|
||||
return (
|
||||
<Layout.Content contentContainerStyle={[gutter, a.gap_lg]}>
|
||||
<ContactsHeroImage />
|
||||
<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({
|
||||
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>
|
||||
</Text>
|
||||
{isAvailable ? (
|
||||
<Link
|
||||
to={{screen: 'FindContactsFlow'}}
|
||||
label={_(msg`Import contacts`)}
|
||||
size="large"
|
||||
color="primary"
|
||||
style={[a.flex_1, a.justify_center]}>
|
||||
<ButtonText>
|
||||
<Trans>Import contacts</Trans>
|
||||
</ButtonText>
|
||||
</Link>
|
||||
) : (
|
||||
isSuccess && (
|
||||
<Admonition type="error">
|
||||
<Trans>
|
||||
Contact sync is not available on this device, as the app is unable
|
||||
to access your contacts.
|
||||
</Trans>
|
||||
</Admonition>
|
||||
)
|
||||
)}
|
||||
</Layout.Content>
|
||||
)
|
||||
}
|
||||
|
||||
function SyncStatus({
|
||||
info,
|
||||
refetchStatus,
|
||||
}: {
|
||||
info: AppBskyContactDefs.SyncStatus
|
||||
refetchStatus: () => Promise<any>
|
||||
}) {
|
||||
const agent = useAgent()
|
||||
const queryClient = useQueryClient()
|
||||
const {_} = useLingui()
|
||||
const moderationOpts = useModerationOpts()
|
||||
|
||||
const {
|
||||
data,
|
||||
isPending,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
refetch: refetchMatches,
|
||||
} = useContactsMatchesQuery()
|
||||
|
||||
const [isPTR, setIsPTR] = useState(false)
|
||||
|
||||
const onRefresh = () => {
|
||||
setIsPTR(true)
|
||||
Promise.all([refetchStatus(), refetchMatches()]).finally(() => {
|
||||
setIsPTR(false)
|
||||
})
|
||||
}
|
||||
|
||||
const {mutate: dismissMatch} = useMutation({
|
||||
mutationFn: async (did: string) => {
|
||||
await agent.app.bsky.contact.dismissMatch({subject: did})
|
||||
},
|
||||
onMutate: async (did: string) => {
|
||||
logger.metric('contacts:settings:dismiss', {})
|
||||
optimisticRemoveMatch(queryClient, did)
|
||||
},
|
||||
onError: err => {
|
||||
refetchMatches()
|
||||
if (isNetworkError(err)) {
|
||||
Toast.show(
|
||||
_(
|
||||
msg`Could not follow all matches - please check your network connection.`,
|
||||
),
|
||||
{type: 'error'},
|
||||
)
|
||||
} else {
|
||||
logger.error('Failed to follow all matches', {safeMessage: err})
|
||||
Toast.show(_(msg`Could not follow all matches. ${cleanError(err)}`), {
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const profiles = data?.pages?.flatMap(page => page.matches) ?? []
|
||||
|
||||
const numProfiles = profiles.length
|
||||
const isAnyUnfollowed = profiles.some(profile => !profile.viewer?.following)
|
||||
|
||||
const renderItem = useCallback(
|
||||
({item, index}: ListRenderItemInfo<bsky.profile.AnyProfileView>) => {
|
||||
if (!moderationOpts) return null
|
||||
return (
|
||||
<MatchItem
|
||||
profile={item}
|
||||
isFirst={index === 0}
|
||||
isLast={index === numProfiles - 1}
|
||||
moderationOpts={moderationOpts}
|
||||
dismissMatch={dismissMatch}
|
||||
/>
|
||||
)
|
||||
},
|
||||
[numProfiles, moderationOpts, dismissMatch],
|
||||
)
|
||||
|
||||
const onEndReached = () => {
|
||||
if (!hasNextPage || isFetchingNextPage) return
|
||||
fetchNextPage()
|
||||
}
|
||||
|
||||
return (
|
||||
<List
|
||||
data={profiles}
|
||||
renderItem={renderItem}
|
||||
ListHeaderComponent={
|
||||
<StatusHeader
|
||||
numMatches={info.matchesCount}
|
||||
isPending={isPending}
|
||||
isAnyUnfollowed={isAnyUnfollowed}
|
||||
/>
|
||||
}
|
||||
ListFooterComponent={<StatusFooter syncedAt={info.syncedAt} />}
|
||||
onRefresh={onRefresh}
|
||||
refreshing={isPTR}
|
||||
onEndReached={onEndReached}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MatchItem({
|
||||
profile,
|
||||
isFirst,
|
||||
isLast,
|
||||
moderationOpts,
|
||||
dismissMatch,
|
||||
}: {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
isFirst: boolean
|
||||
isLast: boolean
|
||||
moderationOpts: ModerationOpts
|
||||
dismissMatch: (did: string) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const shadow = useProfileShadow(profile)
|
||||
|
||||
return (
|
||||
<View style={[a.px_xl]}>
|
||||
<View
|
||||
style={[
|
||||
a.p_md,
|
||||
a.border_t,
|
||||
a.border_x,
|
||||
t.atoms.border_contrast_high,
|
||||
isFirst && [
|
||||
a.curve_continuous,
|
||||
{borderTopLeftRadius: tokens.borderRadius.lg},
|
||||
{borderTopRightRadius: tokens.borderRadius.lg},
|
||||
],
|
||||
isLast && [
|
||||
a.border_b,
|
||||
a.curve_continuous,
|
||||
{borderBottomLeftRadius: tokens.borderRadius.lg},
|
||||
{borderBottomRightRadius: tokens.borderRadius.lg},
|
||||
a.mb_sm,
|
||||
],
|
||||
]}>
|
||||
<ProfileCard.Header>
|
||||
<ProfileCard.Avatar
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
<ProfileCard.NameAndHandle
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
<ProfileCard.FollowButton
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
logContext="FindContacts"
|
||||
onFollow={() => logger.metric('contacts:settings:follow', {})}
|
||||
/>
|
||||
{!shadow.viewer?.following && (
|
||||
<Button
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
label={_(msg`Remove suggestion`)}
|
||||
onPress={() => dismissMatch(profile.did)}
|
||||
hoverStyle={[a.bg_transparent, {opacity: 0.5}]}
|
||||
hitSlop={8}>
|
||||
<ButtonIcon icon={XIcon} />
|
||||
</Button>
|
||||
)}
|
||||
</ProfileCard.Header>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusHeader({
|
||||
numMatches,
|
||||
isPending,
|
||||
isAnyUnfollowed,
|
||||
}: {
|
||||
numMatches: number
|
||||
isPending: boolean
|
||||
isAnyUnfollowed: boolean
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const agent = useAgent()
|
||||
const queryClient = useQueryClient()
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
const {
|
||||
mutate: onFollowAll,
|
||||
isPending: isFollowingAll,
|
||||
isSuccess: hasFollowedAll,
|
||||
} = useMutation({
|
||||
mutationFn: async () => {
|
||||
const didsToFollow = []
|
||||
|
||||
let cursor: string | undefined
|
||||
do {
|
||||
const page = await agent.app.bsky.contact.getMatches({
|
||||
limit: 100,
|
||||
cursor,
|
||||
})
|
||||
cursor = page.data.cursor
|
||||
for (const profile of page.data.matches) {
|
||||
if (
|
||||
profile.did !== currentAccount?.did &&
|
||||
!isBlockedOrBlocking(profile) &&
|
||||
!isMuted(profile) &&
|
||||
!profile.viewer?.following
|
||||
) {
|
||||
didsToFollow.push(profile.did)
|
||||
}
|
||||
}
|
||||
} while (cursor)
|
||||
|
||||
logger.metric('contacts:settings:followAll', {
|
||||
followCount: didsToFollow.length,
|
||||
})
|
||||
|
||||
const uris = await wait(500, bulkWriteFollows(agent, didsToFollow))
|
||||
|
||||
for (const did of didsToFollow) {
|
||||
const uri = uris.get(did)
|
||||
updateProfileShadow(queryClient, did, {
|
||||
followingUri: uri,
|
||||
})
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
Toast.show(_(msg`Followed all matches`), {type: 'success'})
|
||||
},
|
||||
onError: err => {
|
||||
if (isNetworkError(err)) {
|
||||
Toast.show(
|
||||
_(
|
||||
msg`Could not follow all matches - please check your network connection.`,
|
||||
),
|
||||
{type: 'error'},
|
||||
)
|
||||
} else {
|
||||
logger.error('Failed to follow all matches', {safeMessage: err})
|
||||
Toast.show(_(msg`Could not follow all matches. ${cleanError(err)}`), {
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
if (numMatches > 0) {
|
||||
if (isPending) {
|
||||
return (
|
||||
<View style={[a.w_full, a.py_3xl, a.align_center]}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.pt_xl,
|
||||
a.px_xl,
|
||||
a.pb_md,
|
||||
a.flex_row,
|
||||
a.justify_between,
|
||||
a.align_center,
|
||||
]}>
|
||||
<Text style={[a.text_md, a.font_semi_bold]}>
|
||||
<Plural
|
||||
value={numMatches}
|
||||
one="1 contact found"
|
||||
other="# contacts found"
|
||||
/>
|
||||
</Text>
|
||||
{isAnyUnfollowed && (
|
||||
<Button
|
||||
label={_(msg`Follow all`)}
|
||||
color="primary"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
onPress={() => onFollowAll()}
|
||||
disabled={isFollowingAll || hasFollowedAll}
|
||||
hitSlop={HITSLOP_10}
|
||||
style={[a.px_0, a.py_0, a.rounded_0]}
|
||||
hoverStyle={[a.bg_transparent, {opacity: 0.5}]}>
|
||||
<ButtonText>
|
||||
<Trans>Follow all</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function StatusFooter({syncedAt}: {syncedAt: string}) {
|
||||
const {_, i18n} = useLingui()
|
||||
const t = useTheme()
|
||||
const agent = useAgent()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const {mutate: removeData, isPending} = useMutation({
|
||||
mutationFn: async () => {
|
||||
await agent.app.bsky.contact.removeData({})
|
||||
},
|
||||
onMutate: () => logger.metric('contacts:settings:removeData', {}),
|
||||
onSuccess: () => {
|
||||
Toast.show(_(msg`Contacts removed`))
|
||||
queryClient.setQueryData<AppBskyContactGetSyncStatus.OutputSchema>(
|
||||
findContactsStatusQueryKey,
|
||||
{syncStatus: undefined},
|
||||
)
|
||||
},
|
||||
onError: err => {
|
||||
if (isNetworkError(err)) {
|
||||
Toast.show(
|
||||
_(
|
||||
msg`Failed to remove data due to a network error, please check your internet connection.`,
|
||||
),
|
||||
{type: 'error'},
|
||||
)
|
||||
} else {
|
||||
logger.error('Remove data failed', {safeMessage: err})
|
||||
Toast.show(_(msg`Failed to remove data. ${cleanError(err)}`), {
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<View style={[a.px_xl, a.py_xl, a.gap_4xl]}>
|
||||
<View style={[a.gap_xs, a.align_start]}>
|
||||
<Text style={[a.text_md, a.font_semi_bold]}>
|
||||
<Trans>Contacts uploaded</Trans>
|
||||
</Text>
|
||||
<View style={[a.gap_2xs]}>
|
||||
<Text
|
||||
style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||
<Trans>We will notify you when we find your friends.</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||
<Trans>
|
||||
Uploaded on{' '}
|
||||
{i18n.date(new Date(syncedAt), {
|
||||
dateStyle: 'long',
|
||||
})}
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<Link
|
||||
label={_(msg`Resync contacts`)}
|
||||
to={{screen: 'FindContactsFlow'}}
|
||||
onPress={() => {
|
||||
const daysSinceLastSync = Math.floor(
|
||||
(Date.now() - new Date(syncedAt).getTime()) /
|
||||
(1000 * 60 * 60 * 24),
|
||||
)
|
||||
logger.metric('contacts:settings:resync', {
|
||||
daysSinceLastSync,
|
||||
})
|
||||
}}
|
||||
size="small"
|
||||
color="primary_subtle"
|
||||
style={[a.mt_xs]}>
|
||||
<ButtonIcon icon={ResyncIcon} />
|
||||
<ButtonText>
|
||||
<Trans>Resync contacts</Trans>
|
||||
</ButtonText>
|
||||
</Link>
|
||||
</View>
|
||||
|
||||
<View style={[a.gap_xs, a.align_start]}>
|
||||
<Text style={[a.text_md, a.font_semi_bold]}>
|
||||
<Trans>Delete contacts</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||
<Trans>
|
||||
Bluesky stores your contacts as encoded data. Removing your contacts
|
||||
will immediately delete this data.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Button
|
||||
label={_(msg`Remove all contacts`)}
|
||||
onPress={() => removeData()}
|
||||
size="small"
|
||||
color="negative_subtle"
|
||||
disabled={isPending}
|
||||
style={[a.mt_xs]}>
|
||||
<ButtonIcon icon={isPending ? Loader : TrashIcon} />
|
||||
<ButtonText>
|
||||
<Trans>Remove all contacts</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import {atoms as a, platform, tokens, useBreakpoints, useTheme} from '#/alf'
|
||||
import {AgeAssuranceDismissibleNotice} from '#/components/ageAssurance/AgeAssuranceDismissibleNotice'
|
||||
import {AvatarStackWithFetch} from '#/components/AvatarStack'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {useIsFindContactsFeatureEnabledBasedOnGeolocation} from '#/components/contacts/country-allowlist'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount'
|
||||
import {Accessibility_Stroke2_Corner2_Rounded as AccessibilityIcon} from '#/components/icons/Accessibility'
|
||||
@@ -45,6 +46,7 @@ import {BubbleInfo_Stroke2_Corner2_Rounded as BubbleInfoIcon} from '#/components
|
||||
import {ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon} from '#/components/icons/Chevron'
|
||||
import {CircleQuestion_Stroke2_Corner2_Rounded as CircleQuestionIcon} from '#/components/icons/CircleQuestion'
|
||||
import {CodeBrackets_Stroke2_Corner2_Rounded as CodeBracketsIcon} from '#/components/icons/CodeBrackets'
|
||||
import {Contacts_Stroke2_Corner2_Rounded as ContactsIcon} from '#/components/icons/Contacts'
|
||||
import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
|
||||
import {Earth_Stroke2_Corner2_Rounded as EarthIcon} from '#/components/icons/Globe'
|
||||
import {Lock_Stroke2_Corner2_Rounded as LockIcon} from '#/components/icons/Lock'
|
||||
@@ -89,6 +91,8 @@ export function SettingsScreen({}: Props) {
|
||||
const {pendingDid, onPressSwitchAccount} = useAccountSwitcher()
|
||||
const [showAccounts, setShowAccounts] = useState(false)
|
||||
const [showDevOptions, setShowDevOptions] = useState(false)
|
||||
const findContactsEnabled =
|
||||
useIsFindContactsFeatureEnabledBasedOnGeolocation()
|
||||
|
||||
return (
|
||||
<Layout.Screen>
|
||||
@@ -207,6 +211,16 @@ export function SettingsScreen({}: Props) {
|
||||
<Trans>Content and media</Trans>
|
||||
</SettingsList.ItemText>
|
||||
</SettingsList.LinkItem>
|
||||
{isNative && findContactsEnabled && (
|
||||
<SettingsList.LinkItem
|
||||
to="/settings/find-contacts"
|
||||
label={_(msg`Find friends from contacts`)}>
|
||||
<SettingsList.ItemIcon icon={ContactsIcon} />
|
||||
<SettingsList.ItemText>
|
||||
<Trans>Find friends from contacts</Trans>
|
||||
</SettingsList.ItemText>
|
||||
</SettingsList.LinkItem>
|
||||
)}
|
||||
<SettingsList.LinkItem
|
||||
to="/settings/appearance"
|
||||
label={_(msg`Appearance`)}>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {useMutation, useQuery} from '@tanstack/react-query'
|
||||
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotateCounterClockwise'
|
||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate'
|
||||
import {Shapes_Stroke2_Corner0_Rounded as ShapesIcon} from '#/components/icons/Shapes'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as SettingsList from '../components/SettingsList'
|
||||
|
||||
Reference in New Issue
Block a user