From fe0329a6eb026cb32e56f0d347c4f7d64d948167 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 28 Nov 2025 12:26:05 +0200 Subject: [PATCH] phone input screen --- src/components/contacts/SyncContactsFlow.tsx | 27 ++- .../contacts/screens/GetContacts.tsx | 8 + .../contacts/screens/PhoneInput.tsx | 208 ++++++++++++++++++ .../contacts/screens/VerifyNumber.tsx | 8 + .../contacts/screens/ViewMatches.tsx | 8 + src/components/contacts/state.ts | 8 +- src/components/forms/TextField.tsx | 22 +- src/screens/SyncContactsFlowScreen.tsx | 44 ++-- 8 files changed, 297 insertions(+), 36 deletions(-) create mode 100644 src/components/contacts/screens/GetContacts.tsx create mode 100644 src/components/contacts/screens/PhoneInput.tsx create mode 100644 src/components/contacts/screens/VerifyNumber.tsx create mode 100644 src/components/contacts/screens/ViewMatches.tsx diff --git a/src/components/contacts/SyncContactsFlow.tsx b/src/components/contacts/SyncContactsFlow.tsx index f75d8f03af..abe1afeaed 100644 --- a/src/components/contacts/SyncContactsFlow.tsx +++ b/src/components/contacts/SyncContactsFlow.tsx @@ -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 + onSkip: () => void + context: 'Onboarding' | 'Standalone' }) { const [transitionDirection, _setTransitionDirection] = useState< 'Forward' | 'Backward' @@ -15,7 +24,23 @@ export function SyncContactsFlow({ return ( - <> + {state.step === '1: phone input' && ( + + )} + {state.step === '2: verify number' && ( + + )} + {state.step === '3: get contacts' && ( + + )} + {state.step === '4: view matches' && ( + + )} ) } diff --git a/src/components/contacts/screens/GetContacts.tsx b/src/components/contacts/screens/GetContacts.tsx new file mode 100644 index 0000000000..4056e7b53b --- /dev/null +++ b/src/components/contacts/screens/GetContacts.tsx @@ -0,0 +1,8 @@ +import {type Action, type State} from '../state' + +export function GetContacts({}: { + state: State + dispatch: React.Dispatch +}) { + return null +} diff --git a/src/components/contacts/screens/PhoneInput.tsx b/src/components/contacts/screens/PhoneInput.tsx new file mode 100644 index 0000000000..841f044815 --- /dev/null +++ b/src/components/contacts/screens/PhoneInput.tsx @@ -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 + dispatch: React.Dispatch + 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 ( + + + + + {showSkipButton ? ( + + ) : ( + + )} + + + + Verify phone number + + + + We need to verify your number before we can look for your friends. A + verification code will be sent to this number. + + + + + + Phone number + + + + setPhoneCode(value)} + /> + + + + + + + + + {error && ( + + {error} + + )} + + + + + + + + + + + ) +} + +function LegalDisclaimer() { + const t = useTheme() + const {_} = useLingui() + + const style = [a.text_xs, t.atoms.text_contrast_medium, a.leading_snug] + + return ( + +

+ How we use your number: +

+

+ • Sent to a trusted third party for verification +

+

+ • Deleted by the verifier after verification +

+

+ •{' '} + Held by Bluesky for 7 days to prevent abuse, then deleted +

+

+ •{' '} + Stored as part of a secret code for matching with others +

+

+ + By continuing, you consent to this use. You may change your mind any + time by visiting settings.{' '} + + TODO: Learn more + + +

+
+ ) +} diff --git a/src/components/contacts/screens/VerifyNumber.tsx b/src/components/contacts/screens/VerifyNumber.tsx new file mode 100644 index 0000000000..1a0dd82c55 --- /dev/null +++ b/src/components/contacts/screens/VerifyNumber.tsx @@ -0,0 +1,8 @@ +import {type Action, type State} from '../state' + +export function VerifyNumber({}: { + state: State + dispatch: React.Dispatch +}) { + return null +} diff --git a/src/components/contacts/screens/ViewMatches.tsx b/src/components/contacts/screens/ViewMatches.tsx new file mode 100644 index 0000000000..c9be5997f1 --- /dev/null +++ b/src/components/contacts/screens/ViewMatches.tsx @@ -0,0 +1,8 @@ +import {type Action, type State} from '../state' + +export function ViewMatches({}: { + state: State + dispatch: React.Dispatch +}) { + return null +} diff --git a/src/components/contacts/state.ts b/src/components/contacts/state.ts index 2c4870e067..29c59b8b55 100644 --- a/src/components/contacts/state.ts +++ b/src/components/contacts/state.ts @@ -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, } } diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx index 62f30db47b..6e8846ddd6 100644 --- a/src/components/forms/TextField.tsx +++ b/src/components/forms/TextField.tsx @@ -141,17 +141,28 @@ export function useSharedInputStyles() { }, [t]) } -export type InputProps = Omit & { +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 | React.ForwardedRef + /** + * 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 ( + style={[a.text_sm, a.font_medium, t.atoms.text_contrast_medium, a.mb_sm]}> {children} ) diff --git a/src/screens/SyncContactsFlowScreen.tsx b/src/screens/SyncContactsFlowScreen.tsx index f3753ea7b6..6c9f1a22e4 100644 --- a/src/screens/SyncContactsFlowScreen.tsx +++ b/src/screens/SyncContactsFlowScreen.tsx @@ -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 ( {isNative ? ( - + navigation.goBack()} + context="Standalone" + /> ) : (