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