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