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