forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {useEffect, useMemo, useState} from 'react'
2import {Text as NestedText, View} from 'react-native'
3import {
4 AppBskyContactStartPhoneVerification,
5 AppBskyContactVerifyPhone,
6} from '@atproto/api'
7import {msg, Trans} from '@lingui/macro'
8import {useLingui} from '@lingui/react'
9import {useMutation} from '@tanstack/react-query'
10
11import {clamp} from '#/lib/numbers'
12import {cleanError, isNetworkError} from '#/lib/strings/errors'
13import {logger} from '#/logger'
14import {useAgent} from '#/state/session'
15import {OnboardingPosition} from '#/screens/Onboarding/Layout'
16import {atoms as a, useGutters, useTheme} from '#/alf'
17import {Button, ButtonIcon, ButtonText} from '#/components/Button'
18import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate'
19import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheckIcon} from '#/components/icons/CircleCheck'
20import {type Props as SVGIconProps} from '#/components/icons/common'
21import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
22import * as Layout from '#/components/Layout'
23import {Loader} from '#/components/Loader'
24import * as Toast from '#/components/Toast'
25import {Text} from '#/components/Typography'
26import {OTPInput} from '../components/OTPInput'
27import {constructFullPhoneNumber, prettyPhoneNumber} from '../phone-number'
28import {type Action, type State, useOnPressBackButton} from '../state'
29
30export function VerifyNumber({
31 state,
32 dispatch,
33 context,
34 onSkip,
35}: {
36 state: Extract<State, {step: '2: verify number'}>
37 dispatch: React.ActionDispatch<[Action]>
38 context: 'Onboarding' | 'Standalone'
39 onSkip: () => void
40}) {
41 const t = useTheme()
42 const {_} = useLingui()
43 const agent = useAgent()
44 const gutters = useGutters([0, 'wide'])
45
46 const [otpCode, setOtpCode] = useState('')
47 const [error, setError] = useState<{
48 retryable: boolean
49 isResendError: boolean
50 message: string
51 } | null>(null)
52
53 const [prevOtpCode, setPrevOtpCode] = useState(otpCode)
54 if (otpCode !== prevOtpCode) {
55 setPrevOtpCode(otpCode)
56 setError(null)
57 }
58
59 const phone = useMemo(
60 () => constructFullPhoneNumber(state.phoneCountryCode, state.phoneNumber),
61 [state.phoneCountryCode, state.phoneNumber],
62 )
63
64 const prettyNumber = useMemo(() => prettyPhoneNumber(phone), [phone])
65
66 const {
67 mutate: verifyNumber,
68 isPending,
69 isSuccess,
70 } = useMutation({
71 mutationFn: async (code: string) => {
72 const res = await agent.app.bsky.contact.verifyPhone({code, phone})
73 return res.data.token
74 },
75 onSuccess: async token => {
76 // let the success state show for a moment
77 setTimeout(() => {
78 dispatch({
79 type: 'VERIFY_PHONE_NUMBER_SUCCESS',
80 payload: {
81 token,
82 },
83 })
84 }, 1000)
85
86 logger.metric('contacts:phone:phoneVerified', {entryPoint: context})
87 },
88 onMutate: () => setError(null),
89 onError: err => {
90 setOtpCode('')
91 if (isNetworkError(err)) {
92 setError({
93 retryable: true,
94 isResendError: false,
95 message: _(
96 msg`A network error occurred. Please check your internet connection.`,
97 ),
98 })
99 } else if (err instanceof AppBskyContactVerifyPhone.InvalidCodeError) {
100 setError({
101 retryable: true,
102 isResendError: true,
103 message: _(msg`This code is invalid. Resend to get a new code.`),
104 })
105 } else if (err instanceof AppBskyContactVerifyPhone.InvalidPhoneError) {
106 setError({
107 retryable: false,
108 isResendError: false,
109 message: _(
110 msg`The verification provider was unable to send a code to your phone number. Please check your phone number and try again.`,
111 ),
112 })
113 } else if (
114 err instanceof AppBskyContactVerifyPhone.RateLimitExceededError
115 ) {
116 setError({
117 retryable: true,
118 isResendError: false,
119 message: _(
120 msg`Too many attempts. Please wait a few minutes and try again.`,
121 ),
122 })
123 } else {
124 logger.error('Verify phone number failed', {safeMessage: err})
125 setError({
126 retryable: true,
127 isResendError: false,
128 message: _(msg`An error occurred. ${cleanError(err)}`),
129 })
130 }
131 },
132 })
133
134 const {mutate: resendCode, isPending: isResendingCode} = useMutation({
135 mutationFn: async () => {
136 await agent.app.bsky.contact.startPhoneVerification({phone: phone})
137 },
138 onSuccess: () => {
139 dispatch({type: 'RESEND_VERIFICATION_CODE'})
140 Toast.show(_(msg`A new code has been sent`))
141 },
142 onMutate: () => {
143 setOtpCode('')
144 setError(null)
145 },
146 onError: err => {
147 if (isNetworkError(err)) {
148 setError({
149 retryable: true,
150 isResendError: true,
151 message: _(
152 msg`A network error occurred. Please check your internet connection.`,
153 ),
154 })
155 } else if (
156 err instanceof AppBskyContactStartPhoneVerification.InvalidPhoneError
157 ) {
158 setError({
159 retryable: false,
160 isResendError: true,
161 message: _(
162 msg`The verification provider was unable to send a code to your phone number. Please check your phone number and try again.`,
163 ),
164 })
165 } else if (
166 err instanceof
167 AppBskyContactStartPhoneVerification.RateLimitExceededError
168 ) {
169 setError({
170 retryable: true,
171 isResendError: true,
172 message: _(
173 msg`Too many codes sent. Please wait a few minutes and try again.`,
174 ),
175 })
176 } else {
177 logger.error('Resend failed', {safeMessage: err})
178 setError({
179 retryable: true,
180 isResendError: true,
181 message: _(msg`An error occurred. ${cleanError(err)}`),
182 })
183 }
184 },
185 })
186
187 const onPressBack = useOnPressBackButton()
188
189 return (
190 <View style={[a.h_full]}>
191 <Layout.Header.Outer noBottomBorder>
192 <Layout.Header.BackButton onPress={onPressBack} />
193 <Layout.Header.Content />
194 {context === 'Onboarding' ? (
195 <Button
196 size="small"
197 color="secondary"
198 variant="ghost"
199 label={_(msg`Skip contact sharing and continue to the app`)}
200 onPress={onSkip}>
201 <ButtonText>
202 <Trans>Skip</Trans>
203 </ButtonText>
204 </Button>
205 ) : (
206 <Layout.Header.Slot />
207 )}
208 </Layout.Header.Outer>
209 <Layout.Content
210 contentContainerStyle={[gutters, a.pt_sm, a.flex_1]}
211 keyboardShouldPersistTaps="always">
212 {context === 'Onboarding' && <OnboardingPosition />}
213 <Text style={[a.font_bold, a.text_3xl]}>
214 <Trans>Verify phone number</Trans>
215 </Text>
216 <Text
217 style={[
218 a.text_md,
219 t.atoms.text_contrast_medium,
220 a.leading_snug,
221 a.mt_sm,
222 ]}>
223 <Trans>Enter the 6-digit code sent to {prettyNumber}</Trans>
224 </Text>
225 <View style={[a.mt_2xl]}>
226 <OTPInput
227 label={_(
228 msg`Enter 6-digit code that was sent to your phone number`,
229 )}
230 value={otpCode}
231 onChange={setOtpCode}
232 onComplete={code => verifyNumber(code)}
233 />
234 </View>
235 <View style={[a.mt_sm]}>
236 <OTPStatus
237 error={error}
238 isPending={isPending}
239 isResendingCode={isResendingCode}
240 isSuccess={isSuccess}
241 onResend={() => resendCode()}
242 onRetry={() => verifyNumber(otpCode)}
243 lastCodeSentAt={state.lastSentAt}
244 />
245 </View>
246 </Layout.Content>
247 </View>
248 )
249}
250
251/**
252 * Horrible component that takes all the state above and figures out what messages
253 * and buttons to display.
254 */
255function OTPStatus({
256 error,
257 isPending,
258 isResendingCode,
259 isSuccess,
260 onResend,
261 onRetry,
262 lastCodeSentAt,
263}: {
264 error: {
265 retryable: boolean
266 isResendError: boolean
267 message: string
268 } | null
269 isPending: boolean
270 isResendingCode: boolean
271 isSuccess: boolean
272 onResend: () => void
273 onRetry: () => void
274 lastCodeSentAt: Date | null
275}) {
276 const {_} = useLingui()
277 const t = useTheme()
278
279 const [time, setTime] = useState(Date.now())
280 useEffect(() => {
281 const interval = setInterval(() => {
282 setTime(Date.now())
283 }, 1000)
284 return () => clearInterval(interval)
285 }, [])
286
287 const timeUntilCanResend = Math.max(
288 0,
289 30000 - (time - (lastCodeSentAt?.getTime() ?? 0)),
290 )
291 const isWaiting = timeUntilCanResend > 0
292
293 let Icon: React.ComponentType<SVGIconProps> | null = null
294 let text = ''
295 let textColor = t.atoms.text_contrast_medium.color
296 let showResendButton = false
297 let showRetryButton = false
298
299 if (isSuccess) {
300 Icon = CircleCheckIcon
301 text = _(msg`Phone number verified`)
302 textColor = t.palette.positive_500
303 } else if (isPending) {
304 text = _(msg`Verifying...`)
305 } else if (error) {
306 Icon = WarningIcon
307 text = error.message
308 textColor = t.palette.negative_500
309 if (error.retryable) {
310 if (error.isResendError) {
311 showResendButton = true
312 } else {
313 showRetryButton = true
314 }
315 }
316 } else {
317 showResendButton = true
318 }
319
320 return (
321 <View style={[a.w_full, a.align_center]}>
322 {text && (
323 <View
324 style={[
325 a.gap_xs,
326 a.flex_row,
327 a.align_center,
328 (isSuccess || isPending) && a.mt_lg,
329 ]}>
330 {Icon && <Icon size="xs" style={{color: textColor}} />}
331 <Text
332 style={[
333 {color: textColor},
334 a.text_sm,
335 a.leading_snug,
336 a.text_center,
337 ]}>
338 {text}
339 </Text>
340 </View>
341 )}
342
343 {showRetryButton && (
344 <Button
345 size="small"
346 color="secondary_inverted"
347 label={_(msg`Retry`)}
348 onPress={onRetry}
349 style={[a.mt_2xl]}>
350 <ButtonIcon icon={RetryIcon} />
351 <ButtonText>
352 <Trans>Retry</Trans>
353 </ButtonText>
354 </Button>
355 )}
356
357 {showResendButton && (
358 <Button
359 size="large"
360 color="secondary"
361 variant="ghost"
362 label={_(msg`Resend code`)}
363 disabled={isResendingCode || isWaiting}
364 onPress={onResend}
365 style={[a.mt_2xl]}>
366 {isResendingCode && <ButtonIcon icon={Loader} />}
367 <ButtonText>
368 {isWaiting ? (
369 <Trans>
370 Resend code in{' '}
371 <NestedText style={{fontVariant: ['tabular-nums']}}>
372 00:
373 {String(
374 clamp(Math.round(timeUntilCanResend / 1000), 0, 30),
375 ).padStart(2, '0')}
376 </NestedText>
377 </Trans>
378 ) : (
379 <Trans>Resend code</Trans>
380 )}
381 </ButtonText>
382 </Button>
383 )}
384 </View>
385 )
386}