mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
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}