Bluesky app fork with some witchin' additions 馃挮
at main 371 lines 9.8 kB view raw
1import React, {useCallback} from 'react' 2import {LayoutAnimation} from 'react-native' 3import { 4 ComAtprotoServerCreateAccount, 5 type ComAtprotoServerDescribeServer, 6} from '@atproto/api' 7import {msg} from '@lingui/macro' 8import {useLingui} from '@lingui/react' 9import * as EmailValidator from 'email-validator' 10 11import {DEFAULT_SERVICE} from '#/lib/constants' 12import {cleanError} from '#/lib/strings/errors' 13import {createFullHandle} from '#/lib/strings/handles' 14import {getAge} from '#/lib/strings/time' 15import {logger} from '#/logger' 16import {useSessionApi} from '#/state/session' 17import {useOnboardingDispatch} from '#/state/shell' 18 19export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema 20 21const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago 22 23export enum SignupStep { 24 INFO, 25 HANDLE, 26 CAPTCHA, 27} 28 29type SubmitTask = { 30 verificationCode: string | undefined 31 mutableProcessed: boolean // OK to mutate assuming it's never read in render. 32} 33 34type ErrorField = 35 | 'invite-code' 36 | 'email' 37 | 'handle' 38 | 'password' 39 | 'date-of-birth' 40 41export type SignupState = { 42 hasPrev: boolean 43 activeStep: SignupStep 44 screenTransitionDirection: 'Forward' | 'Backward' 45 46 serviceUrl: string 47 serviceDescription?: ServiceDescription 48 userDomain: string 49 dateOfBirth: Date 50 email: string 51 password: string 52 inviteCode: string 53 handle: string 54 55 error: string 56 errorField?: ErrorField 57 isLoading: boolean 58 59 pendingSubmit: null | SubmitTask 60 61 // Tracking 62 signupStartTime: number 63 fieldErrors: Record<ErrorField, number> 64 backgroundCount: number 65} 66 67export type SignupAction = 68 | {type: 'prev'} 69 | {type: 'next'} 70 | {type: 'finish'} 71 | {type: 'setStep'; value: SignupStep} 72 | {type: 'setServiceUrl'; value: string} 73 | {type: 'setServiceDescription'; value: ServiceDescription | undefined} 74 | {type: 'setEmail'; value: string} 75 | {type: 'setPassword'; value: string} 76 | {type: 'setDateOfBirth'; value: Date} 77 | {type: 'setInviteCode'; value: string} 78 | {type: 'setHandle'; value: string} 79 | {type: 'setError'; value: string; field?: ErrorField} 80 | {type: 'clearError'} 81 | {type: 'setIsLoading'; value: boolean} 82 | {type: 'submit'; task: SubmitTask} 83 | {type: 'incrementBackgroundCount'} 84 85export const initialState: SignupState = { 86 hasPrev: false, 87 activeStep: SignupStep.INFO, 88 screenTransitionDirection: 'Forward', 89 90 serviceUrl: DEFAULT_SERVICE, 91 serviceDescription: undefined, 92 userDomain: '', 93 dateOfBirth: DEFAULT_DATE, 94 email: '', 95 password: '', 96 handle: '', 97 inviteCode: '', 98 99 error: '', 100 errorField: undefined, 101 isLoading: false, 102 103 pendingSubmit: null, 104 105 // Tracking 106 signupStartTime: Date.now(), 107 fieldErrors: { 108 'invite-code': 0, 109 email: 0, 110 handle: 0, 111 password: 0, 112 'date-of-birth': 0, 113 }, 114 backgroundCount: 0, 115} 116 117export function is13(date: Date) { 118 return getAge(date) >= 13 119} 120 121export function is18(date: Date) { 122 return getAge(date) >= 18 123} 124 125export function reducer(s: SignupState, a: SignupAction): SignupState { 126 let next = {...s} 127 128 switch (a.type) { 129 case 'prev': { 130 if (s.activeStep !== SignupStep.INFO) { 131 next.screenTransitionDirection = 'Backward' 132 next.activeStep-- 133 next.error = '' 134 next.errorField = undefined 135 } 136 break 137 } 138 case 'next': { 139 if (s.activeStep !== SignupStep.CAPTCHA) { 140 next.screenTransitionDirection = 'Forward' 141 next.activeStep++ 142 next.error = '' 143 next.errorField = undefined 144 } 145 break 146 } 147 case 'setStep': { 148 next.activeStep = a.value 149 break 150 } 151 case 'setServiceUrl': { 152 next.serviceUrl = a.value 153 break 154 } 155 case 'setServiceDescription': { 156 LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 157 158 next.serviceDescription = a.value 159 next.userDomain = a.value?.availableUserDomains[0] ?? '' 160 next.isLoading = false 161 break 162 } 163 164 case 'setEmail': { 165 next.email = a.value 166 break 167 } 168 case 'setPassword': { 169 next.password = a.value 170 break 171 } 172 case 'setDateOfBirth': { 173 next.dateOfBirth = a.value 174 break 175 } 176 case 'setInviteCode': { 177 next.inviteCode = a.value 178 break 179 } 180 case 'setHandle': { 181 next.handle = a.value 182 break 183 } 184 case 'setIsLoading': { 185 next.isLoading = a.value 186 break 187 } 188 case 'setError': { 189 next.error = a.value 190 next.errorField = a.field 191 192 // Track field errors 193 if (a.field) { 194 next.fieldErrors[a.field] = (next.fieldErrors[a.field] || 0) + 1 195 196 // Log the field error 197 logger.metric( 198 'signup:fieldError', 199 { 200 field: a.field, 201 errorCount: next.fieldErrors[a.field], 202 errorMessage: a.value, 203 activeStep: next.activeStep, 204 }, 205 {statsig: true}, 206 ) 207 } 208 break 209 } 210 case 'clearError': { 211 next.error = '' 212 next.errorField = undefined 213 break 214 } 215 case 'submit': { 216 next.pendingSubmit = a.task 217 break 218 } 219 case 'incrementBackgroundCount': { 220 next.backgroundCount = s.backgroundCount + 1 221 222 // Log background/foreground event during signup 223 logger.metric( 224 'signup:backgrounded', 225 { 226 activeStep: next.activeStep, 227 backgroundCount: next.backgroundCount, 228 }, 229 {statsig: true}, 230 ) 231 break 232 } 233 } 234 235 next.hasPrev = next.activeStep !== SignupStep.INFO 236 237 logger.debug('signup', next) 238 239 if (s.activeStep !== next.activeStep) { 240 logger.debug('signup: step changed', {activeStep: next.activeStep}) 241 } 242 243 return next 244} 245 246interface IContext { 247 state: SignupState 248 dispatch: React.Dispatch<SignupAction> 249} 250export const SignupContext = React.createContext<IContext>({} as IContext) 251SignupContext.displayName = 'SignupContext' 252export const useSignupContext = () => React.useContext(SignupContext) 253 254export function useSubmitSignup() { 255 const {_} = useLingui() 256 const {createAccount} = useSessionApi() 257 const onboardingDispatch = useOnboardingDispatch() 258 259 return useCallback( 260 async (state: SignupState, dispatch: (action: SignupAction) => void) => { 261 if (!state.email) { 262 dispatch({type: 'setStep', value: SignupStep.INFO}) 263 return dispatch({ 264 type: 'setError', 265 value: _(msg`Please enter your email.`), 266 field: 'email', 267 }) 268 } 269 if (!EmailValidator.validate(state.email)) { 270 dispatch({type: 'setStep', value: SignupStep.INFO}) 271 return dispatch({ 272 type: 'setError', 273 value: _(msg`Your email appears to be invalid.`), 274 field: 'email', 275 }) 276 } 277 if (!state.password) { 278 dispatch({type: 'setStep', value: SignupStep.INFO}) 279 return dispatch({ 280 type: 'setError', 281 value: _(msg`Please choose your password.`), 282 field: 'password', 283 }) 284 } 285 if (!state.handle) { 286 dispatch({type: 'setStep', value: SignupStep.HANDLE}) 287 return dispatch({ 288 type: 'setError', 289 value: _(msg`Please choose your handle.`), 290 field: 'handle', 291 }) 292 } 293 if ( 294 state.serviceDescription?.phoneVerificationRequired && 295 !state.pendingSubmit?.verificationCode 296 ) { 297 dispatch({type: 'setStep', value: SignupStep.CAPTCHA}) 298 logger.error('Signup Flow Error', { 299 errorMessage: 'Verification captcha code was not set.', 300 registrationHandle: state.handle, 301 }) 302 return dispatch({ 303 type: 'setError', 304 value: _(msg`Please complete the verification captcha.`), 305 }) 306 } 307 dispatch({type: 'setError', value: ''}) 308 dispatch({type: 'setIsLoading', value: true}) 309 310 try { 311 await createAccount( 312 { 313 service: state.serviceUrl, 314 email: state.email, 315 handle: createFullHandle(state.handle, state.userDomain), 316 password: state.password, 317 birthDate: state.dateOfBirth, 318 inviteCode: state.inviteCode.trim(), 319 verificationCode: state.pendingSubmit?.verificationCode, 320 }, 321 { 322 signupDuration: Date.now() - state.signupStartTime, 323 fieldErrorsTotal: Object.values(state.fieldErrors).reduce( 324 (a, b) => a + b, 325 0, 326 ), 327 backgroundCount: state.backgroundCount, 328 }, 329 ) 330 331 /* 332 * Must happen last so that if the user has multiple tabs open and 333 * createAccount fails, one tab is not stuck in onboarding 鈥斅燛ric 334 */ 335 onboardingDispatch({type: 'start'}) 336 } catch (e: any) { 337 let errMsg = e.toString() 338 if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) { 339 dispatch({ 340 type: 'setError', 341 value: _( 342 msg`Invite code not accepted. Check that you input it correctly and try again.`, 343 ), 344 field: 'invite-code', 345 }) 346 dispatch({type: 'setStep', value: SignupStep.INFO}) 347 return 348 } 349 350 const error = cleanError(errMsg) 351 const isHandleError = error.toLowerCase().includes('handle') 352 353 dispatch({type: 'setIsLoading', value: false}) 354 dispatch({ 355 type: 'setError', 356 value: error, 357 field: isHandleError ? 'handle' : undefined, 358 }) 359 dispatch({type: 'setStep', value: isHandleError ? 2 : 1}) 360 361 logger.error('Signup Flow Error', { 362 errorMessage: error, 363 registrationHandle: state.handle, 364 }) 365 } finally { 366 dispatch({type: 'setIsLoading', value: false}) 367 } 368 }, 369 [_, onboardingDispatch, createAccount], 370 ) 371}