otp screen
This commit is contained in:
@@ -625,6 +625,7 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
|
||||
options={{
|
||||
title: title(msg`Sync Contacts`),
|
||||
requireAuth: true,
|
||||
gestureEnabled: false,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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<Action>
|
||||
onSkip: () => void
|
||||
onCancel: () => void
|
||||
context: 'Onboarding' | 'Standalone'
|
||||
}) {
|
||||
const [transitionDirection, _setTransitionDirection] = useState<
|
||||
'Forward' | 'Backward'
|
||||
>('Forward')
|
||||
|
||||
return (
|
||||
<ScreenTransition direction={transitionDirection} key={state.step}>
|
||||
<>
|
||||
{state.step === '1: phone input' && (
|
||||
<PhoneInput
|
||||
state={state}
|
||||
dispatch={dispatch}
|
||||
showSkipButton={context === 'Onboarding'}
|
||||
onSkip={onSkip}
|
||||
onSkip={onCancel}
|
||||
/>
|
||||
)}
|
||||
{state.step === '2: verify number' && (
|
||||
<VerifyNumber state={state} dispatch={dispatch} />
|
||||
<VerifyNumber
|
||||
state={state}
|
||||
dispatch={dispatch}
|
||||
showSkipButton={context === 'Onboarding'}
|
||||
onSkip={onCancel}
|
||||
/>
|
||||
)}
|
||||
{state.step === '3: get contacts' && (
|
||||
<GetContacts state={state} dispatch={dispatch} />
|
||||
<GetContacts state={state} dispatch={dispatch} onCancel={onCancel} />
|
||||
)}
|
||||
{state.step === '4: view matches' && (
|
||||
<ViewMatches state={state} dispatch={dispatch} />
|
||||
)}
|
||||
</ScreenTransition>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<TextInput>
|
||||
numberOfDigits?: number
|
||||
onComplete?: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const innerRef = useRef<TextInput>(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 (
|
||||
<Pressable
|
||||
accessibilityLabel={_(msg`Focus OTP input`)}
|
||||
accessibilityRole="button"
|
||||
accessibilityHint=""
|
||||
style={[a.w_full, a.relative]}
|
||||
onPress={() => {
|
||||
innerRef.current?.focus()
|
||||
innerRef.current?.clear()
|
||||
}}>
|
||||
<View style={[a.w_full, a.flex_row, a.gap_sm]}>
|
||||
{[...value.padEnd(numberOfDigits, ' ')].map((digit, index) => {
|
||||
const selected = focused
|
||||
? selection.start === selection.end
|
||||
? selection.start === index
|
||||
: index >= selection.start && index < selection.end
|
||||
: false
|
||||
|
||||
return (
|
||||
<View
|
||||
key={index}
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
t.atoms.bg_contrast_50,
|
||||
{
|
||||
height: 64,
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
borderColor: selected
|
||||
? t.palette.primary_500
|
||||
: t.atoms.bg_contrast_50.backgroundColor,
|
||||
},
|
||||
]}>
|
||||
<Text style={[a.text_2xl, a.text_center, a.font_bold]}>
|
||||
{digit}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
<TextInput
|
||||
accessible
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint=""
|
||||
accessibilityRole="text"
|
||||
ref={mergeRefs(ref ? [ref, innerRef] : [innerRef])}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
onSelectionChange={onSelectionChange}
|
||||
keyboardAppearance={t.scheme}
|
||||
keyboardType="number-pad"
|
||||
autoComplete={platform({
|
||||
android: 'sms-otp',
|
||||
ios: 'one-time-code',
|
||||
})}
|
||||
autoFocus
|
||||
onFocus={() => {
|
||||
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
|
||||
/>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
@@ -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<State, {step: '3: get contacts'}>
|
||||
dispatch: React.Dispatch<Action>
|
||||
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 (
|
||||
<View style={[a.h_full]}>
|
||||
<Layout.Content contentContainerStyle={[gutters, a.flex_1, a.pt_xl]}>
|
||||
<Text style={[a.font_bold, a.text_3xl]}>
|
||||
<Trans>Share Your Contacts to Find Friends</Trans>
|
||||
</Text>
|
||||
<Text style={style}>
|
||||
<Trans>
|
||||
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.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text style={style}>
|
||||
• <Trans>We only suggest follows if both people consent</Trans>
|
||||
</Text>
|
||||
<Text style={style}>
|
||||
• <Trans>We never store plain phone numbers</Trans>
|
||||
</Text>
|
||||
<Text style={style}>
|
||||
•{' '}
|
||||
<Trans>We save only non-matching hashes for future matching</Trans>
|
||||
</Text>
|
||||
<Text style={style}>
|
||||
• <Trans>You can always opt out</Trans>
|
||||
</Text>
|
||||
<Text style={style}>
|
||||
<Trans>
|
||||
We apply the highest privacy standards just in case just in case
|
||||
your contacts include minors. We never share, or sell your contact
|
||||
information.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Layout.Content>
|
||||
<View
|
||||
style={[
|
||||
gutters,
|
||||
a.pt_xs,
|
||||
{paddingBottom: Math.max(insets.bottom, tokens.space.xl)},
|
||||
a.gap_md,
|
||||
]}>
|
||||
<Text style={[a.text_sm, a.pb_xs]}>
|
||||
<Trans>
|
||||
I consent to Bluesky using my contacts for mutual friend discovery
|
||||
and to retain hashed data for matching until I opt out.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Button label={_(msg`Find my friends`)} size="large" color="primary">
|
||||
<ButtonText>
|
||||
<Trans>Find my friends</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
label={_(msg`Cancel`)}
|
||||
size="large"
|
||||
color="secondary"
|
||||
onPress={onCancel}>
|
||||
<ButtonText>
|
||||
<Trans>Cancel</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<View style={[a.h_full]}>
|
||||
<Layout.Header.Outer noBottomBorder>
|
||||
@@ -88,7 +95,9 @@ export function PhoneInput({
|
||||
<Layout.Header.Slot />
|
||||
)}
|
||||
</Layout.Header.Outer>
|
||||
<Layout.Content contentContainerStyle={[gutters, a.pt_sm, a.flex_1]}>
|
||||
<Layout.Content
|
||||
contentContainerStyle={[gutters, a.pt_sm, a.flex_1]}
|
||||
keyboardShouldPersistTaps="handled">
|
||||
<Text style={[a.font_bold, a.text_3xl]}>
|
||||
<Trans>Verify phone number</Trans>
|
||||
</Text>
|
||||
@@ -112,8 +121,8 @@ export function PhoneInput({
|
||||
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
|
||||
<View>
|
||||
<InternationalPhoneCodeSelect
|
||||
value={phoneCode}
|
||||
onChange={value => setPhoneCode(value)}
|
||||
value={countryCode}
|
||||
onChange={value => setCountryCode(value)}
|
||||
/>
|
||||
</View>
|
||||
<View style={[a.flex_1]}>
|
||||
@@ -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})}
|
||||
/>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
@@ -147,14 +158,14 @@ export function PhoneInput({
|
||||
</Layout.Content>
|
||||
<KeyboardAvoidingView
|
||||
behavior="padding"
|
||||
keyboardVerticalOffset={insets.top - insets.bottom + tokens.space.xl}>
|
||||
<View style={[gutters, {paddingBottom: insets.bottom}]}>
|
||||
keyboardVerticalOffset={insets.top - paddingBottom + tokens.space.xl}>
|
||||
<View style={[gutters, {paddingBottom}]}>
|
||||
<Button
|
||||
disabled={!phoneNumber || isPending}
|
||||
label={_(msg`Next`)}
|
||||
size="large"
|
||||
color="primary"
|
||||
onPress={() => submit({phoneCode, phoneNumber})}>
|
||||
onPress={() => submit({countryCode, phoneNumber})}>
|
||||
<ButtonText>
|
||||
<Trans>Next</Trans>
|
||||
</ButtonText>
|
||||
@@ -174,24 +185,24 @@ function LegalDisclaimer() {
|
||||
|
||||
return (
|
||||
<View style={[a.gap_xs]}>
|
||||
<P style={[style, a.font_medium]}>
|
||||
<Text style={[style, a.font_medium]}>
|
||||
<Trans>How we use your number:</Trans>
|
||||
</P>
|
||||
<P style={style}>
|
||||
</Text>
|
||||
<Text style={style}>
|
||||
• <Trans>Sent to a trusted third party for verification</Trans>
|
||||
</P>
|
||||
<P style={style}>
|
||||
</Text>
|
||||
<Text style={style}>
|
||||
• <Trans>Deleted by the verifier after verification</Trans>
|
||||
</P>
|
||||
<P style={style}>
|
||||
</Text>
|
||||
<Text style={style}>
|
||||
•{' '}
|
||||
<Trans>Held by Bluesky for 7 days to prevent abuse, then deleted</Trans>
|
||||
</P>
|
||||
<P style={style}>
|
||||
</Text>
|
||||
<Text style={style}>
|
||||
•{' '}
|
||||
<Trans>Stored as part of a secret code for matching with others</Trans>
|
||||
</P>
|
||||
<P style={[style, a.mt_xs]}>
|
||||
</Text>
|
||||
<Text style={[style, a.mt_xs]}>
|
||||
<Trans>
|
||||
By continuing, you consent to this use. You may change your mind any
|
||||
time by visiting settings.{' '}
|
||||
@@ -202,7 +213,7 @@ function LegalDisclaimer() {
|
||||
TODO: Learn more
|
||||
</InlineLinkText>
|
||||
</Trans>
|
||||
</P>
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,272 @@
|
||||
import {type Action, type State} from '../state'
|
||||
import {useMemo, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
|
||||
export function VerifyNumber({}: {
|
||||
state: State
|
||||
import {wait} from '#/lib/async/wait'
|
||||
import {getPhoneCodeFromCountryCode} from '#/lib/international-telephone-codes'
|
||||
import {cleanError, isNetworkError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
import {atoms as a, useGutters, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotateCounterClockwise'
|
||||
import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheckIcon} from '#/components/icons/CircleCheck'
|
||||
import {type Props as SVGIconProps} from '#/components/icons/common'
|
||||
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {OTPInput} from '../components/OTPInput'
|
||||
import {type Action, type State} from '../state'
|
||||
export function VerifyNumber({
|
||||
state,
|
||||
dispatch,
|
||||
showSkipButton,
|
||||
onSkip,
|
||||
}: {
|
||||
state: Extract<State, {step: '2: verify number'}>
|
||||
dispatch: React.Dispatch<Action>
|
||||
showSkipButton: boolean
|
||||
onSkip: () => void
|
||||
}) {
|
||||
return null
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const gutters = useGutters([0, 'wide'])
|
||||
|
||||
const [otpCode, setOtpCode] = useState('')
|
||||
const [error, setError] = useState<{
|
||||
retryable: boolean
|
||||
isResendError: boolean
|
||||
message: string
|
||||
} | null>(null)
|
||||
|
||||
const [prevOtpCode, setPrevOtpCode] = useState(otpCode)
|
||||
if (otpCode !== prevOtpCode) {
|
||||
setPrevOtpCode(otpCode)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const {
|
||||
mutate: verifyNumber,
|
||||
isPending,
|
||||
isSuccess,
|
||||
} = useMutation({
|
||||
mutationFn: async (_code: string) => {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
return 'success'
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await wait(2e3, () => {})
|
||||
dispatch({type: 'VERIFY_PHONE_NUMBER_SUCCESS'})
|
||||
},
|
||||
onMutate: () => setError(null),
|
||||
onError: err => {
|
||||
if (isNetworkError(err)) {
|
||||
setError({
|
||||
retryable: true,
|
||||
isResendError: false,
|
||||
message: _(
|
||||
msg`A network error occurred. Please check your internet connection.`,
|
||||
),
|
||||
})
|
||||
// TODO: Check error with invalid code error!!
|
||||
} else if (true) {
|
||||
setError({
|
||||
retryable: false,
|
||||
isResendError: false,
|
||||
message: _(msg`This code is invalid. Resend to get a new code.`),
|
||||
})
|
||||
} else {
|
||||
logger.error('Verify phone number failed', {safeMessage: err})
|
||||
setError({
|
||||
retryable: true,
|
||||
isResendError: false,
|
||||
message: _(msg`An error occurred. ${cleanError(err)}`),
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const {mutate: resendCode} = useMutation({
|
||||
mutationFn: async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
},
|
||||
onSuccess: () => Toast.show(_(msg`Code resent`)),
|
||||
onMutate: () => {
|
||||
setError(null)
|
||||
},
|
||||
onError: err => {
|
||||
setError({
|
||||
retryable: true,
|
||||
isResendError: true,
|
||||
message: _(msg`An error occurred while resending the code.`),
|
||||
})
|
||||
if (!isNetworkError(err)) {
|
||||
logger.error('Resend code failed', {safeMessage: err})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const phoneCode = useMemo(
|
||||
() => getPhoneCodeFromCountryCode(state.phoneCountryCode),
|
||||
[state.phoneCountryCode],
|
||||
)
|
||||
|
||||
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]}
|
||||
keyboardShouldPersistTaps="always">
|
||||
<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>
|
||||
Enter the 6 digit code sent to {phoneCode} {state.phoneNumber}
|
||||
</Trans>
|
||||
</Text>
|
||||
<View style={[a.mt_2xl]}>
|
||||
<OTPInput
|
||||
label={_(
|
||||
msg`Enter 6-digit code that was sent to your phone number`,
|
||||
)}
|
||||
value={otpCode}
|
||||
onChange={setOtpCode}
|
||||
onComplete={() => verifyNumber(otpCode)}
|
||||
/>
|
||||
</View>
|
||||
<View style={[a.mt_sm]}>
|
||||
<OTPStatus
|
||||
error={error}
|
||||
isPending={isPending}
|
||||
isSuccess={isSuccess}
|
||||
onResend={() => resendCode()}
|
||||
onRetry={() => verifyNumber(otpCode)}
|
||||
/>
|
||||
</View>
|
||||
</Layout.Content>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<SVGIconProps> | 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 (
|
||||
<View style={[a.w_full, a.gap_2xl, a.align_center]}>
|
||||
{text && (
|
||||
<View style={[a.gap_xs, a.flex_row, a.align_center]}>
|
||||
{Icon && <Icon size="xs" color={textColor} />}
|
||||
<Text
|
||||
style={[
|
||||
{color: textColor},
|
||||
a.text_sm,
|
||||
a.leading_snug,
|
||||
a.text_center,
|
||||
]}>
|
||||
{text}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{showRetryButton && (
|
||||
<Button
|
||||
size="small"
|
||||
color="secondary_inverted"
|
||||
label={_(msg`Retry`)}
|
||||
onPress={onRetry}>
|
||||
<ButtonIcon icon={RetryIcon} />
|
||||
<ButtonText>
|
||||
<Trans>Retry</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showResendButton && (
|
||||
<Button
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
label={_(msg`Resend code`)}
|
||||
onPress={onResend}>
|
||||
<ButtonText>
|
||||
<Trans>Resend code</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {type Action, type State} from '../state'
|
||||
|
||||
export function ViewMatches({}: {
|
||||
state: State
|
||||
state: Extract<State, {step: '4: view matches'}>
|
||||
dispatch: React.Dispatch<Action>
|
||||
}) {
|
||||
return null
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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<AllNavigatorParams, 'SyncContactsFlow'>
|
||||
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 (
|
||||
<Layout.Screen>
|
||||
{isNative ? (
|
||||
<SyncContactsFlow
|
||||
state={state}
|
||||
dispatch={dispatch}
|
||||
onSkip={() => navigation.goBack()}
|
||||
context="Standalone"
|
||||
/>
|
||||
<ScreenTransition key={state.step} direction={transitionDirection}>
|
||||
<SyncContactsFlow
|
||||
state={state}
|
||||
dispatch={dispatch}
|
||||
onCancel={() =>
|
||||
navigation.canGoBack()
|
||||
? navigation.goBack()
|
||||
: navigation.navigate('SyncContactsFlow', undefined, {
|
||||
pop: true,
|
||||
})
|
||||
}
|
||||
context="Standalone"
|
||||
/>
|
||||
</ScreenTransition>
|
||||
) : (
|
||||
<ErrorScreen
|
||||
title={_(msg`Not available on this platform.`)}
|
||||
|
||||
Reference in New Issue
Block a user