1a6bfd8a6a
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
393 lines
11 KiB
TypeScript
393 lines
11 KiB
TypeScript
import {useEffect, useMemo, useState} from 'react'
|
|
import {Text as NestedText, View} from 'react-native'
|
|
import {msg} from '@lingui/core/macro'
|
|
import {useLingui} from '@lingui/react'
|
|
import {Trans} from '@lingui/react/macro'
|
|
import {useMutation} from '@tanstack/react-query'
|
|
|
|
import {clamp} from '#/lib/numbers'
|
|
import {cleanError, isNetworkError} from '#/lib/strings/errors'
|
|
import {matchXrpcError} from '#/lib/xrpc-error'
|
|
import {logger} from '#/logger'
|
|
import {useAppviewClient} from '#/state/session'
|
|
import {OnboardingPosition} from '#/screens/Onboarding/Layout'
|
|
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/ArrowRotate'
|
|
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 {Loader} from '#/components/Loader'
|
|
import * as Toast from '#/components/Toast'
|
|
import {Text} from '#/components/Typography'
|
|
import {useAnalytics} from '#/analytics'
|
|
import {app} from '#/lexicons'
|
|
import {OTPInput} from '../components/OTPInput'
|
|
import {constructFullPhoneNumber, prettyPhoneNumber} from '../phone-number'
|
|
import {type Action, type State, useOnPressBackButton} from '../state'
|
|
|
|
export function VerifyNumber({
|
|
state,
|
|
dispatch,
|
|
context,
|
|
onSkip,
|
|
}: {
|
|
state: Extract<State, {step: '2: verify number'}>
|
|
dispatch: React.ActionDispatch<[Action]>
|
|
context: 'Onboarding' | 'Standalone'
|
|
onSkip: () => void
|
|
}) {
|
|
const t = useTheme()
|
|
const {_} = useLingui()
|
|
const ax = useAnalytics()
|
|
const client = useAppviewClient()
|
|
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 phone = useMemo(
|
|
() => constructFullPhoneNumber(state.phoneCountryCode, state.phoneNumber),
|
|
[state.phoneCountryCode, state.phoneNumber],
|
|
)
|
|
|
|
const prettyNumber = useMemo(() => prettyPhoneNumber(phone), [phone])
|
|
|
|
const {
|
|
mutate: verifyNumber,
|
|
isPending,
|
|
isSuccess,
|
|
} = useMutation({
|
|
mutationFn: async (code: string) => {
|
|
const data = await client.call(app.bsky.contact.verifyPhone, {
|
|
code,
|
|
phone,
|
|
})
|
|
return data.token
|
|
},
|
|
onSuccess: async token => {
|
|
// let the success state show for a moment
|
|
setTimeout(() => {
|
|
dispatch({
|
|
type: 'VERIFY_PHONE_NUMBER_SUCCESS',
|
|
payload: {
|
|
token,
|
|
},
|
|
})
|
|
}, 1000)
|
|
|
|
ax.metric('contacts:phone:phoneVerified', {entryPoint: context})
|
|
},
|
|
onMutate: () => setError(null),
|
|
onError: err => {
|
|
setOtpCode('')
|
|
if (isNetworkError(err)) {
|
|
setError({
|
|
retryable: true,
|
|
isResendError: false,
|
|
message: _(
|
|
msg`A network error occurred. Please check your internet connection.`,
|
|
),
|
|
})
|
|
return
|
|
}
|
|
switch (matchXrpcError(err, app.bsky.contact.verifyPhone)) {
|
|
case 'InvalidCode':
|
|
setError({
|
|
retryable: true,
|
|
isResendError: true,
|
|
message: _(msg`This code is invalid. Resend to get a new code.`),
|
|
})
|
|
return
|
|
case 'InvalidPhone':
|
|
setError({
|
|
retryable: false,
|
|
isResendError: false,
|
|
message: _(
|
|
msg`The verification provider was unable to send a code to your phone number. Please check your phone number and try again.`,
|
|
),
|
|
})
|
|
return
|
|
case 'RateLimitExceeded':
|
|
setError({
|
|
retryable: true,
|
|
isResendError: false,
|
|
message: _(
|
|
msg`Too many attempts. Please wait a few minutes and try again.`,
|
|
),
|
|
})
|
|
return
|
|
}
|
|
logger.error('Verify phone number failed', {safeMessage: err})
|
|
setError({
|
|
retryable: true,
|
|
isResendError: false,
|
|
message: _(msg`An error occurred. ${cleanError(err)}`),
|
|
})
|
|
},
|
|
})
|
|
|
|
const {mutate: resendCode, isPending: isResendingCode} = useMutation({
|
|
mutationFn: async () => {
|
|
await client.call(app.bsky.contact.startPhoneVerification, {phone: phone})
|
|
},
|
|
onSuccess: () => {
|
|
dispatch({type: 'RESEND_VERIFICATION_CODE'})
|
|
Toast.show(_(msg`A new code has been sent`))
|
|
},
|
|
onMutate: () => {
|
|
setOtpCode('')
|
|
setError(null)
|
|
},
|
|
onError: err => {
|
|
if (isNetworkError(err)) {
|
|
setError({
|
|
retryable: true,
|
|
isResendError: true,
|
|
message: _(
|
|
msg`A network error occurred. Please check your internet connection.`,
|
|
),
|
|
})
|
|
return
|
|
}
|
|
switch (matchXrpcError(err, app.bsky.contact.startPhoneVerification)) {
|
|
case 'InvalidPhone':
|
|
setError({
|
|
retryable: false,
|
|
isResendError: true,
|
|
message: _(
|
|
msg`The verification provider was unable to send a code to your phone number. Please check your phone number and try again.`,
|
|
),
|
|
})
|
|
return
|
|
case 'RateLimitExceeded':
|
|
setError({
|
|
retryable: true,
|
|
isResendError: true,
|
|
message: _(
|
|
msg`Too many codes sent. Please wait a few minutes and try again.`,
|
|
),
|
|
})
|
|
return
|
|
}
|
|
logger.error('Resend failed', {safeMessage: err})
|
|
setError({
|
|
retryable: true,
|
|
isResendError: true,
|
|
message: _(msg`An error occurred. ${cleanError(err)}`),
|
|
})
|
|
},
|
|
})
|
|
|
|
const onPressBack = useOnPressBackButton()
|
|
|
|
return (
|
|
<View style={[a.h_full]}>
|
|
<Layout.Header.Outer noBottomBorder>
|
|
<Layout.Header.BackButton onPress={onPressBack} />
|
|
<Layout.Header.Content />
|
|
{context === 'Onboarding' ? (
|
|
<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">
|
|
{context === 'Onboarding' && <OnboardingPosition />}
|
|
<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 {prettyNumber}</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={code => verifyNumber(code)}
|
|
/>
|
|
</View>
|
|
<View style={[a.mt_sm]}>
|
|
<OTPStatus
|
|
error={error}
|
|
isPending={isPending}
|
|
isResendingCode={isResendingCode}
|
|
isSuccess={isSuccess}
|
|
onResend={() => resendCode()}
|
|
onRetry={() => verifyNumber(otpCode)}
|
|
lastCodeSentAt={state.lastSentAt}
|
|
/>
|
|
</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,
|
|
isResendingCode,
|
|
isSuccess,
|
|
onResend,
|
|
onRetry,
|
|
lastCodeSentAt,
|
|
}: {
|
|
error: {
|
|
retryable: boolean
|
|
isResendError: boolean
|
|
message: string
|
|
} | null
|
|
isPending: boolean
|
|
isResendingCode: boolean
|
|
isSuccess: boolean
|
|
onResend: () => void
|
|
onRetry: () => void
|
|
lastCodeSentAt: Date | null
|
|
}) {
|
|
const {_} = useLingui()
|
|
const t = useTheme()
|
|
|
|
const [time, setTime] = useState(Date.now())
|
|
useEffect(() => {
|
|
const interval = setInterval(() => {
|
|
setTime(Date.now())
|
|
}, 1000)
|
|
return () => clearInterval(interval)
|
|
}, [])
|
|
|
|
const timeUntilCanResend = Math.max(
|
|
0,
|
|
30000 - (time - (lastCodeSentAt?.getTime() ?? 0)),
|
|
)
|
|
const isWaiting = timeUntilCanResend > 0
|
|
|
|
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 number verified`)
|
|
textColor = t.palette.positive_500
|
|
} else if (isPending) {
|
|
text = _(msg`Verifying...`)
|
|
} 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.align_center]}>
|
|
{text && (
|
|
<View
|
|
style={[
|
|
a.gap_xs,
|
|
a.flex_row,
|
|
a.align_center,
|
|
(isSuccess || isPending) && a.mt_lg,
|
|
]}>
|
|
{Icon && <Icon size="xs" style={{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}
|
|
style={[a.mt_2xl]}>
|
|
<ButtonIcon icon={RetryIcon} />
|
|
<ButtonText>
|
|
<Trans>Retry</Trans>
|
|
</ButtonText>
|
|
</Button>
|
|
)}
|
|
|
|
{showResendButton && (
|
|
<Button
|
|
size="large"
|
|
color="secondary"
|
|
variant="ghost"
|
|
label={_(msg`Resend code`)}
|
|
disabled={isResendingCode || isWaiting}
|
|
onPress={onResend}
|
|
style={[a.mt_2xl]}>
|
|
{isResendingCode && <ButtonIcon icon={Loader} />}
|
|
<ButtonText>
|
|
{isWaiting ? (
|
|
<Trans>
|
|
Resend code in{' '}
|
|
<NestedText style={{fontVariant: ['tabular-nums']}}>
|
|
00:
|
|
{String(
|
|
clamp(Math.round(timeUntilCanResend / 1000), 0, 30),
|
|
).padStart(2, '0')}
|
|
</NestedText>
|
|
</Trans>
|
|
) : (
|
|
<Trans>Resend code</Trans>
|
|
)}
|
|
</ButtonText>
|
|
</Button>
|
|
)}
|
|
</View>
|
|
)
|
|
}
|