Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 389 lines 11 kB view raw
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}