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