phone input screen

This commit is contained in:
Samuel Newman
2025-11-28 12:26:05 +02:00
parent 2c7ca5d9eb
commit fe0329a6eb
8 changed files with 297 additions and 36 deletions
+26 -1
View File
@@ -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}>
&bull; <Trans>Sent to a trusted third party for verification</Trans>
</P>
<P style={style}>
&bull; <Trans>Deleted by the verifier after verification</Trans>
</P>
<P style={style}>
&bull;{' '}
<Trans>Held by Bluesky for 7 days to prevent abuse, then deleted</Trans>
</P>
<P style={style}>
&bull;{' '}
<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
}
+4 -4
View File
@@ -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,
}
}
+14 -8
View File
@@ -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>
)
+21 -23
View File
@@ -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.`)}