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

Configure Feed

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

at readme-update 388 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, 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}