diff --git a/src/Navigation.tsx b/src/Navigation.tsx index a5d4bc6f39..0b1a6e1c17 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -625,6 +625,7 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) { options={{ title: title(msg`Sync Contacts`), requireAuth: true, + gestureEnabled: false, }} /> diff --git a/src/components/contacts/SyncContactsFlow.tsx b/src/components/contacts/SyncContactsFlow.tsx index abe1afeaed..044a5843e2 100644 --- a/src/components/contacts/SyncContactsFlow.tsx +++ b/src/components/contacts/SyncContactsFlow.tsx @@ -1,6 +1,3 @@ -import {useState} from 'react' - -import {ScreenTransition} from '#/components/ScreenTransition' import {GetContacts} from './screens/GetContacts' import {PhoneInput} from './screens/PhoneInput' import {VerifyNumber} from './screens/VerifyNumber' @@ -10,37 +7,38 @@ import {type Action, type State} from './state' export function SyncContactsFlow({ state, dispatch, - onSkip, + onCancel, context = 'Standalone', }: { state: State dispatch: React.Dispatch - onSkip: () => void + onCancel: () => void context: 'Onboarding' | 'Standalone' }) { - const [transitionDirection, _setTransitionDirection] = useState< - 'Forward' | 'Backward' - >('Forward') - 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/components/OTPInput.tsx b/src/components/contacts/components/OTPInput.tsx new file mode 100644 index 0000000000..0d0e96c74b --- /dev/null +++ b/src/components/contacts/components/OTPInput.tsx @@ -0,0 +1,143 @@ +import {useRef, useState} from 'react' +import { + Pressable, + TextInput, + type TextInputSelectionChangeEvent, + View, +} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {mergeRefs} from '#/lib/merge-refs' +import {isIOS} from '#/platform/detection' +import {atoms as a, platform, useTheme} from '#/alf' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import {Text} from '#/components/Typography' + +export function OTPInput({ + label, + value, + onChange, + ref, + numberOfDigits = 6, + onComplete, +}: { + label: string + value: string + onChange: (text: string) => void + ref?: React.Ref + numberOfDigits?: number + onComplete?: () => void +}) { + const t = useTheme() + const {_} = useLingui() + const innerRef = useRef(null) + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() + const [selection, setSelection] = useState({start: 0, end: 0}) + + const onChangeText = (text: string) => { + // only numbers + text = text.replace(/[^0-9]/g, '') + text = text.slice(0, numberOfDigits) + onChange(text) + if (text.length === numberOfDigits) { + onComplete?.() + innerRef.current?.blur() + } + } + + const onSelectionChange = (evt: TextInputSelectionChangeEvent) => { + setSelection(evt.nativeEvent.selection) + } + + return ( + { + innerRef.current?.focus() + innerRef.current?.clear() + }}> + + {[...value.padEnd(numberOfDigits, ' ')].map((digit, index) => { + const selected = focused + ? selection.start === selection.end + ? selection.start === index + : index >= selection.start && index < selection.end + : false + + return ( + + + {digit} + + + ) + })} + + { + onChangeText('') + setSelection({start: 0, end: 0}) + onFocus() + }} + onBlur={onBlur} + maxLength={numberOfDigits} + style={[ + a.absolute, + a.inset_0, + // roughly vibe align the characters + // with the visible ones so that + // moving the caret via long press + // still kinda sorta works + { + fontVariant: ['tabular-nums'], + textAlignVertical: 'center', + letterSpacing: 24, + fontSize: 60, + paddingLeft: 6, + }, + platform({ + // completely transparent inputs on iOS cannot be pasted into + ios: {opacity: 0.02, color: 'transparent'}, + android: {opacity: 0}, + }), + ]} + caretHidden={isIOS} + clearTextOnFocus + /> + + ) +} diff --git a/src/components/contacts/screens/GetContacts.tsx b/src/components/contacts/screens/GetContacts.tsx index 4056e7b53b..0f28a38ef9 100644 --- a/src/components/contacts/screens/GetContacts.tsx +++ b/src/components/contacts/screens/GetContacts.tsx @@ -1,8 +1,90 @@ +import {View} from 'react-native' +import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {atoms as a, tokens, useGutters} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import * as Layout from '#/components/Layout' +import {Text} from '#/components/Typography' import {type Action, type State} from '../state' -export function GetContacts({}: { - state: State +export function GetContacts({ + onCancel, +}: { + state: Extract dispatch: React.Dispatch + onCancel: () => void }) { - return null + const {_} = useLingui() + const insets = useSafeAreaInsets() + + const gutters = useGutters([0, 'wide']) + + const style = [a.text_md, a.leading_snug, a.mt_sm] + + return ( + + + + Share Your Contacts to Find Friends + + + + Bluesky helps friends find each other by creating “hashes” of number + combinations and looking for matching hashes. Hashes are sets of + numbers and letters that can’t be decoded with a key. + + + + • We only suggest follows if both people consent + + + • We never store plain phone numbers + + + •{' '} + We save only non-matching hashes for future matching + + + • You can always opt out + + + + We apply the highest privacy standards just in case just in case + your contacts include minors. We never share, or sell your contact + information. + + + + + + + I consent to Bluesky using my contacts for mutual friend discovery + and to retain hashed data for matching until I opt out. + + + + + + + ) } diff --git a/src/components/contacts/screens/PhoneInput.tsx b/src/components/contacts/screens/PhoneInput.tsx index 841f044815..afb1ef4bf4 100644 --- a/src/components/contacts/screens/PhoneInput.tsx +++ b/src/components/contacts/screens/PhoneInput.tsx @@ -1,5 +1,5 @@ import {useState} from 'react' -import {View} from 'react-native' +import {Keyboard, 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' @@ -7,18 +7,17 @@ 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 {cleanError, 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 {android, 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 {Text} from '#/components/Typography' import {type Action, type State} from '../state' export function PhoneInput({ @@ -35,8 +34,8 @@ export function PhoneInput({ const {_} = useLingui() const t = useTheme() const {location} = useGeolocationStatus() - const [phoneCode, setPhoneCode] = useState( - () => state.phoneCode ?? getDefaultCountry(location), + const [countryCode, setCountryCode] = useState( + () => state.phoneCountryCode ?? getDefaultCountry(location), ) const [phoneNumber, setPhoneNumber] = useState(state.phoneNumber ?? '') const gutters = useGutters([0, 'wide']) @@ -44,30 +43,38 @@ export function PhoneInput({ const [error, setError] = useState('') const {mutate: submit, isPending} = useMutation({ - mutationFn: async ({}: {phoneCode: string; phoneNumber: string}) => { + mutationFn: async ({}: {countryCode: string; phoneNumber: string}) => { // get otp await new Promise(resolve => { setTimeout(resolve, 1000) }) }, - onSuccess: (_data, {phoneCode, phoneNumber}) => { - dispatch({type: 'VERIFY_PHONE', payload: {phoneCode, phoneNumber}}) + onSuccess: (_data, {countryCode, phoneNumber}) => { + dispatch({ + type: 'SUBMIT_PHONE_NUMBER', + payload: {phoneCountryCode: countryCode, phoneNumber}, + }) + }, + onMutate: () => { + Keyboard.dismiss() + setError('') }, - onMutate: () => setError(''), onError: err => { if (isNetworkError(err)) { setError( _( - msg`A network error occurred. Please check your internet connection.`, + 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.`)) + setError(_(msg`An error occurred. ${cleanError(err)}`)) } }, }) + const paddingBottom = Math.max(insets.bottom, tokens.space.xl) + return ( @@ -88,7 +95,9 @@ export function PhoneInput({ )} - + Verify phone number @@ -112,8 +121,8 @@ export function PhoneInput({ setPhoneCode(value)} + value={countryCode} + onChange={value => setCountryCode(value)} /> @@ -124,7 +133,9 @@ export function PhoneInput({ onChangeText={setPhoneNumber} placeholder={null} keyboardType="phone-pad" - autoComplete={isAndroid ? 'tel-national' : 'tel'} + autoComplete="tel" + returnKeyType={android('next')} + onSubmitEditing={() => submit({countryCode, phoneNumber})} /> @@ -147,14 +158,14 @@ export function PhoneInput({ - + keyboardVerticalOffset={insets.top - paddingBottom + tokens.space.xl}> + + ) : ( + + )} + + + + Verify phone number + + + + Enter the 6 digit code sent to {phoneCode} {state.phoneNumber} + + + + verifyNumber(otpCode)} + /> + + + resendCode()} + onRetry={() => verifyNumber(otpCode)} + /> + + + + ) +} + +/** + * Horrible component that takes all the state above and figures out what messages + * and buttons to display. + */ +function OTPStatus({ + error, + isPending, + isSuccess, + onResend, + onRetry, +}: { + error: { + retryable: boolean + isResendError: boolean + message: string + } | null + isPending: boolean + isSuccess: boolean + onResend: () => void + onRetry: () => void +}) { + const {_} = useLingui() + const t = useTheme() + + let Icon: React.ComponentType | null = null + let text = '' + let textColor = t.atoms.text_contrast_medium.color + let showResendButton = false + let showRetryButton = false + + if (isSuccess) { + Icon = CircleCheckIcon + text = _(msg`Phone verified`) + textColor = t.palette.positive_500 + } else if (isPending) { + text = _(msg`Wait a moment...`) + } else if (error) { + Icon = WarningIcon + text = error.message + textColor = t.palette.negative_500 + if (error.retryable) { + if (error.isResendError) { + showResendButton = true + } else { + showRetryButton = true + } + } + } else { + showResendButton = true + } + + return ( + + {text && ( + + {Icon && } + + {text} + + + )} + + {showRetryButton && ( + + )} + + {showResendButton && ( + + )} + + ) } diff --git a/src/components/contacts/screens/ViewMatches.tsx b/src/components/contacts/screens/ViewMatches.tsx index c9be5997f1..a5d9550477 100644 --- a/src/components/contacts/screens/ViewMatches.tsx +++ b/src/components/contacts/screens/ViewMatches.tsx @@ -1,7 +1,7 @@ import {type Action, type State} from '../state' export function ViewMatches({}: { - state: State + state: Extract dispatch: React.Dispatch }) { return null diff --git a/src/components/contacts/state.ts b/src/components/contacts/state.ts index 29c59b8b55..2ff1f6fb07 100644 --- a/src/components/contacts/state.ts +++ b/src/components/contacts/state.ts @@ -21,15 +21,14 @@ export type Match = { export type State = | { step: '1: phone input' - phoneCode?: string + phoneCountryCode?: string phoneNumber?: string } | { step: '2: verify number' - phoneCode: string + phoneCountryCode: string phoneNumber: string lastSentAt: Date - error?: string } | { step: '3: get contacts' @@ -43,20 +42,14 @@ export type State = export type Action = | { - type: 'VERIFY_PHONE' + type: 'SUBMIT_PHONE_NUMBER' payload: { - phoneCode: string + phoneCountryCode: string phoneNumber: string } } | { - type: 'VERIFY_OTP_ERROR' - payload: { - error: string - } - } - | { - type: 'VERIFY_OTP_SUCCESS' + type: 'VERIFY_PHONE_NUMBER_SUCCESS' } | { type: 'GET_CONTACTS_SUCCESS' @@ -76,7 +69,7 @@ export type Action = function reducer(state: State, action: Action): State { switch (action.type) { - case 'VERIFY_PHONE': { + case 'SUBMIT_PHONE_NUMBER': { assertCurrentStep(state, '1: phone input') return { step: '2: verify number', @@ -84,14 +77,7 @@ function reducer(state: State, action: Action): State { lastSentAt: new Date(), } } - case 'VERIFY_OTP_ERROR': { - assertCurrentStep(state, '2: verify number') - return { - ...state, - error: action.payload.error, - } - } - case 'VERIFY_OTP_SUCCESS': { + case 'VERIFY_PHONE_NUMBER_SUCCESS': { assertCurrentStep(state, '2: verify number') return { step: '3: get contacts', @@ -102,7 +88,7 @@ function reducer(state: State, action: Action): State { return { step: '1: phone input', phoneNumber: state.phoneNumber, - phoneCode: state.phoneCode, + phoneCountryCode: state.phoneCountryCode, } } case 'GET_CONTACTS_SUCCESS': { diff --git a/src/screens/SyncContactsFlowScreen.tsx b/src/screens/SyncContactsFlowScreen.tsx index 6c9f1a22e4..2781b48063 100644 --- a/src/screens/SyncContactsFlowScreen.tsx +++ b/src/screens/SyncContactsFlowScreen.tsx @@ -1,4 +1,4 @@ -import {useCallback, useLayoutEffect} from 'react' +import {useCallback, useLayoutEffect, useState} from 'react' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {usePreventRemove} from '@react-navigation/native' @@ -14,6 +14,7 @@ import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' import {useSyncContactsFlowState} from '#/components/contacts/state' import {SyncContactsFlow} from '#/components/contacts/SyncContactsFlow' import * as Layout from '#/components/Layout' +import {ScreenTransition} from '#/components/ScreenTransition' type Props = NativeStackScreenProps export function SyncContactsFlowScreen({navigation}: Props) { @@ -21,10 +22,18 @@ export function SyncContactsFlowScreen({navigation}: Props) { const [state, dispatch] = useSyncContactsFlowState() + 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) @@ -39,12 +48,20 @@ export function SyncContactsFlowScreen({navigation}: Props) { return ( {isNative ? ( - navigation.goBack()} - context="Standalone" - /> + + + navigation.canGoBack() + ? navigation.goBack() + : navigation.navigate('SyncContactsFlow', undefined, { + pop: true, + }) + } + context="Standalone" + /> + ) : (