forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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}