otp screen

This commit is contained in:
Samuel Newman
2025-11-28 15:58:38 +02:00
parent df00994bb3
commit 8b9737a873
9 changed files with 585 additions and 83 deletions
@@ -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 cant be decoded with a key.
</Trans>
</Text>
<Text style={style}>
&bull; <Trans>We only suggest follows if both people consent</Trans>
</Text>
<Text style={style}>
&bull; <Trans>We never store plain phone numbers</Trans>
</Text>
<Text style={style}>
&bull;{' '}
<Trans>We save only non-matching hashes for future matching</Trans>
</Text>
<Text style={style}>
&bull; <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>
)
}
+43 -32
View File
@@ -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}>
&bull; <Trans>Sent to a trusted third party for verification</Trans>
</P>
<P style={style}>
</Text>
<Text style={style}>
&bull; <Trans>Deleted by the verifier after verification</Trans>
</P>
<P style={style}>
</Text>
<Text style={style}>
&bull;{' '}
<Trans>Held by Bluesky for 7 days to prevent abuse, then deleted</Trans>
</P>
<P style={style}>
</Text>
<Text style={style}>
&bull;{' '}
<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