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