phone input screen
This commit is contained in:
@@ -1,13 +1,22 @@
|
||||
import {useState} from 'react'
|
||||
|
||||
import {ScreenTransition} from '#/components/ScreenTransition'
|
||||
import {GetContacts} from './screens/GetContacts'
|
||||
import {PhoneInput} from './screens/PhoneInput'
|
||||
import {VerifyNumber} from './screens/VerifyNumber'
|
||||
import {ViewMatches} from './screens/ViewMatches'
|
||||
import {type Action, type State} from './state'
|
||||
|
||||
export function SyncContactsFlow({
|
||||
state,
|
||||
dispatch,
|
||||
onSkip,
|
||||
context = 'Standalone',
|
||||
}: {
|
||||
state: State
|
||||
dispatch: React.Dispatch<Action>
|
||||
onSkip: () => void
|
||||
context: 'Onboarding' | 'Standalone'
|
||||
}) {
|
||||
const [transitionDirection, _setTransitionDirection] = useState<
|
||||
'Forward' | 'Backward'
|
||||
@@ -15,7 +24,23 @@ export function SyncContactsFlow({
|
||||
|
||||
return (
|
||||
<ScreenTransition direction={transitionDirection} key={state.step}>
|
||||
<></>
|
||||
{state.step === '1: phone input' && (
|
||||
<PhoneInput
|
||||
state={state}
|
||||
dispatch={dispatch}
|
||||
showSkipButton={context === 'Onboarding'}
|
||||
onSkip={onSkip}
|
||||
/>
|
||||
)}
|
||||
{state.step === '2: verify number' && (
|
||||
<VerifyNumber state={state} dispatch={dispatch} />
|
||||
)}
|
||||
{state.step === '3: get contacts' && (
|
||||
<GetContacts state={state} dispatch={dispatch} />
|
||||
)}
|
||||
{state.step === '4: view matches' && (
|
||||
<ViewMatches state={state} dispatch={dispatch} />
|
||||
)}
|
||||
</ScreenTransition>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import {type Action, type State} from '../state'
|
||||
|
||||
export function GetContacts({}: {
|
||||
state: State
|
||||
dispatch: React.Dispatch<Action>
|
||||
}) {
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import {useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {KeyboardAvoidingView} from 'react-native-keyboard-controller'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
|
||||
import {getDefaultCountry} from '#/lib/international-telephone-codes'
|
||||
import {isNetworkError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
import {isAndroid} from '#/platform/detection'
|
||||
import {useGeolocationStatus} from '#/state/geolocation'
|
||||
import {atoms as a, tokens, useGutters, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {InternationalPhoneCodeSelect} from '#/components/InternationalPhoneCodeSelect'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {P, Text} from '#/components/Typography'
|
||||
import {type Action, type State} from '../state'
|
||||
|
||||
export function PhoneInput({
|
||||
state,
|
||||
dispatch,
|
||||
showSkipButton,
|
||||
onSkip,
|
||||
}: {
|
||||
state: Extract<State, {step: '1: phone input'}>
|
||||
dispatch: React.Dispatch<Action>
|
||||
showSkipButton: boolean
|
||||
onSkip: () => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {location} = useGeolocationStatus()
|
||||
const [phoneCode, setPhoneCode] = useState(
|
||||
() => state.phoneCode ?? getDefaultCountry(location),
|
||||
)
|
||||
const [phoneNumber, setPhoneNumber] = useState(state.phoneNumber ?? '')
|
||||
const gutters = useGutters([0, 'wide'])
|
||||
const insets = useSafeAreaInsets()
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const {mutate: submit, isPending} = useMutation({
|
||||
mutationFn: async ({}: {phoneCode: string; phoneNumber: string}) => {
|
||||
// get otp
|
||||
await new Promise(resolve => {
|
||||
setTimeout(resolve, 1000)
|
||||
})
|
||||
},
|
||||
onSuccess: (_data, {phoneCode, phoneNumber}) => {
|
||||
dispatch({type: 'VERIFY_PHONE', payload: {phoneCode, phoneNumber}})
|
||||
},
|
||||
onMutate: () => setError(''),
|
||||
onError: err => {
|
||||
if (isNetworkError(err)) {
|
||||
setError(
|
||||
_(
|
||||
msg`A network error occurred. Please check your internet connection.`,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
logger.error('Verify phone number failed', {safeMessage: err})
|
||||
setError(_(msg`An error occurred. Please try again later.`))
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<View style={[a.h_full]}>
|
||||
<Layout.Header.Outer noBottomBorder>
|
||||
<Layout.Header.BackButton />
|
||||
<Layout.Header.Content />
|
||||
{showSkipButton ? (
|
||||
<Button
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
label={_(msg`Skip contact sharing and continue to the app`)}
|
||||
onPress={onSkip}>
|
||||
<ButtonText>
|
||||
<Trans>Skip</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
) : (
|
||||
<Layout.Header.Slot />
|
||||
)}
|
||||
</Layout.Header.Outer>
|
||||
<Layout.Content contentContainerStyle={[gutters, a.pt_sm, a.flex_1]}>
|
||||
<Text style={[a.font_bold, a.text_3xl]}>
|
||||
<Trans>Verify phone number</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
t.atoms.text_contrast_medium,
|
||||
a.leading_snug,
|
||||
a.mt_sm,
|
||||
]}>
|
||||
<Trans>
|
||||
We need to verify your number before we can look for your friends. A
|
||||
verification code will be sent to this number.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<View style={[a.mt_2xl]}>
|
||||
<TextField.LabelText>
|
||||
<Trans>Phone number</Trans>
|
||||
</TextField.LabelText>
|
||||
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
|
||||
<View>
|
||||
<InternationalPhoneCodeSelect
|
||||
value={phoneCode}
|
||||
onChange={value => setPhoneCode(value)}
|
||||
/>
|
||||
</View>
|
||||
<View style={[a.flex_1]}>
|
||||
<TextField.Root>
|
||||
<TextField.Input
|
||||
label={_(msg`Phone number`)}
|
||||
value={phoneNumber}
|
||||
onChangeText={setPhoneNumber}
|
||||
placeholder={null}
|
||||
keyboardType="phone-pad"
|
||||
autoComplete={isAndroid ? 'tel-national' : 'tel'}
|
||||
/>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
{error && (
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
t.atoms.text_contrast_medium,
|
||||
a.leading_snug,
|
||||
a.mt_xl,
|
||||
]}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
<View style={[a.mt_auto, a.py_xl]}>
|
||||
<LegalDisclaimer />
|
||||
</View>
|
||||
</Layout.Content>
|
||||
<KeyboardAvoidingView
|
||||
behavior="padding"
|
||||
keyboardVerticalOffset={insets.top - insets.bottom + tokens.space.xl}>
|
||||
<View style={[gutters, {paddingBottom: insets.bottom}]}>
|
||||
<Button
|
||||
disabled={!phoneNumber || isPending}
|
||||
label={_(msg`Next`)}
|
||||
size="large"
|
||||
color="primary"
|
||||
onPress={() => submit({phoneCode, phoneNumber})}>
|
||||
<ButtonText>
|
||||
<Trans>Next</Trans>
|
||||
</ButtonText>
|
||||
{isPending && <ButtonIcon icon={Loader} />}
|
||||
</Button>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function LegalDisclaimer() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
const style = [a.text_xs, t.atoms.text_contrast_medium, a.leading_snug]
|
||||
|
||||
return (
|
||||
<View style={[a.gap_xs]}>
|
||||
<P style={[style, a.font_medium]}>
|
||||
<Trans>How we use your number:</Trans>
|
||||
</P>
|
||||
<P style={style}>
|
||||
• <Trans>Sent to a trusted third party for verification</Trans>
|
||||
</P>
|
||||
<P style={style}>
|
||||
• <Trans>Deleted by the verifier after verification</Trans>
|
||||
</P>
|
||||
<P style={style}>
|
||||
•{' '}
|
||||
<Trans>Held by Bluesky for 7 days to prevent abuse, then deleted</Trans>
|
||||
</P>
|
||||
<P style={style}>
|
||||
•{' '}
|
||||
<Trans>Stored as part of a secret code for matching with others</Trans>
|
||||
</P>
|
||||
<P style={[style, a.mt_xs]}>
|
||||
<Trans>
|
||||
By continuing, you consent to this use. You may change your mind any
|
||||
time by visiting settings.{' '}
|
||||
<InlineLinkText
|
||||
to="#"
|
||||
label={_(msg`Learn more`)}
|
||||
style={[a.text_xs, a.leading_snug]}>
|
||||
TODO: Learn more
|
||||
</InlineLinkText>
|
||||
</Trans>
|
||||
</P>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import {type Action, type State} from '../state'
|
||||
|
||||
export function VerifyNumber({}: {
|
||||
state: State
|
||||
dispatch: React.Dispatch<Action>
|
||||
}) {
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import {type Action, type State} from '../state'
|
||||
|
||||
export function ViewMatches({}: {
|
||||
state: State
|
||||
dispatch: React.Dispatch<Action>
|
||||
}) {
|
||||
return null
|
||||
}
|
||||
@@ -22,12 +22,12 @@ export type State =
|
||||
| {
|
||||
step: '1: phone input'
|
||||
phoneCode?: string
|
||||
phone?: string
|
||||
phoneNumber?: string
|
||||
}
|
||||
| {
|
||||
step: '2: verify number'
|
||||
phoneCode: string
|
||||
phone: string
|
||||
phoneNumber: string
|
||||
lastSentAt: Date
|
||||
error?: string
|
||||
}
|
||||
@@ -46,7 +46,7 @@ export type Action =
|
||||
type: 'VERIFY_PHONE'
|
||||
payload: {
|
||||
phoneCode: string
|
||||
phone: string
|
||||
phoneNumber: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -101,7 +101,7 @@ function reducer(state: State, action: Action): State {
|
||||
assertCurrentStep(state, '2: verify number')
|
||||
return {
|
||||
step: '1: phone input',
|
||||
phone: state.phone,
|
||||
phoneNumber: state.phoneNumber,
|
||||
phoneCode: state.phoneCode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,17 +141,28 @@ export function useSharedInputStyles() {
|
||||
}, [t])
|
||||
}
|
||||
|
||||
export type InputProps = Omit<TextInputProps, 'value' | 'onChangeText'> & {
|
||||
export type InputProps = Omit<
|
||||
TextInputProps,
|
||||
'value' | 'onChangeText' | 'placeholder'
|
||||
> & {
|
||||
label: string
|
||||
/**
|
||||
* @deprecated Controlled inputs are *strongly* discouraged. Use `defaultValue` instead where possible.
|
||||
*
|
||||
* See https://github.com/facebook/react-native-website/pull/4247
|
||||
*
|
||||
* Note: This guidance no longer applies once we migrate to the New Architecture!
|
||||
*/
|
||||
value?: string
|
||||
onChangeText?: (value: string) => void
|
||||
isInvalid?: boolean
|
||||
inputRef?: React.RefObject<TextInput | null> | React.ForwardedRef<TextInput>
|
||||
/**
|
||||
* Note: this currently falls back to the label if not specified. However,
|
||||
* most new designs have no placeholder. We should eventually remove this fallback
|
||||
* behaviour, but for now just pass `null` if you want no placeholder -sfn
|
||||
*/
|
||||
placeholder?: string | null | undefined
|
||||
}
|
||||
|
||||
export function createInput(Component: typeof TextInput) {
|
||||
@@ -255,7 +266,7 @@ export function createInput(Component: typeof TextInput) {
|
||||
ctx.onBlur()
|
||||
onBlur?.(e)
|
||||
}}
|
||||
placeholder={placeholder || label}
|
||||
placeholder={placeholder === null ? undefined : placeholder || label}
|
||||
placeholderTextColor={t.palette.contrast_500}
|
||||
keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
|
||||
style={flattened}
|
||||
@@ -292,12 +303,7 @@ export function LabelText({
|
||||
return (
|
||||
<Text
|
||||
nativeID={nativeID}
|
||||
style={[
|
||||
a.text_sm,
|
||||
a.font_semi_bold,
|
||||
t.atoms.text_contrast_medium,
|
||||
a.mb_sm,
|
||||
]}>
|
||||
style={[a.text_sm, a.font_medium, t.atoms.text_contrast_medium, a.mb_sm]}>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import {useCallback} from 'react'
|
||||
import {BackHandler} from 'react-native'
|
||||
import {useCallback, useLayoutEffect} from 'react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
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 {useSyncContactsFlowState} from '#/components/contacts/state'
|
||||
import {SyncContactsFlow} from '#/components/contacts/SyncContactsFlow'
|
||||
@@ -22,31 +23,28 @@ export function SyncContactsFlowScreen({navigation}: Props) {
|
||||
|
||||
const overrideGoBack = state.step === '2: verify number'
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (overrideGoBack) {
|
||||
navigation.setOptions({
|
||||
gestureEnabled: false,
|
||||
})
|
||||
usePreventRemove(overrideGoBack, () => {
|
||||
dispatch({type: 'BACK'})
|
||||
})
|
||||
|
||||
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||
dispatch({type: 'BACK'})
|
||||
return true
|
||||
})
|
||||
return () => {
|
||||
navigation.setOptions({
|
||||
gestureEnabled: true,
|
||||
})
|
||||
sub.remove()
|
||||
}
|
||||
}
|
||||
}, [overrideGoBack, dispatch, navigation]),
|
||||
)
|
||||
useEnableKeyboardControllerScreen(true)
|
||||
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
const effect = useCallback(() => {
|
||||
setMinimalShellMode(true)
|
||||
return () => setMinimalShellMode(false)
|
||||
}, [setMinimalShellMode])
|
||||
useLayoutEffect(effect)
|
||||
|
||||
return (
|
||||
<Layout.Screen>
|
||||
{isNative ? (
|
||||
<SyncContactsFlow state={state} dispatch={dispatch} />
|
||||
<SyncContactsFlow
|
||||
state={state}
|
||||
dispatch={dispatch}
|
||||
onSkip={() => navigation.goBack()}
|
||||
context="Standalone"
|
||||
/>
|
||||
) : (
|
||||
<ErrorScreen
|
||||
title={_(msg`Not available on this platform.`)}
|
||||
|
||||
Reference in New Issue
Block a user