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