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