forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 💫
1import React, {useRef} from 'react'
2import {type TextInput, View} from 'react-native'
3import {msg, Trans} from '@lingui/macro'
4import {useLingui} from '@lingui/react'
5import * as EmailValidator from 'email-validator'
6import type tldts from 'tldts'
7
8import {DEFAULT_SERVICE} from '#/lib/constants'
9import {isEmailMaybeInvalid} from '#/lib/strings/email'
10import {logger} from '#/logger'
11import {useSignupContext} from '#/screens/Signup/state'
12import {Policies} from '#/screens/Signup/StepInfo/Policies'
13import {atoms as a, native} from '#/alf'
14import * as Admonition from '#/components/Admonition'
15import {Button, ButtonText} from '#/components/Button'
16import * as Dialog from '#/components/Dialog'
17import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRequestDialog'
18import {Divider} from '#/components/Divider'
19import * as DateField from '#/components/forms/DateField'
20import {type DateFieldRef} from '#/components/forms/DateField/types'
21import {FormError} from '#/components/forms/FormError'
22import {HostingProvider} from '#/components/forms/HostingProvider'
23import * as TextField from '#/components/forms/TextField'
24import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope'
25import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock'
26import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket'
27import {SimpleInlineLinkText as InlineLinkText} from '#/components/Link'
28import {createStaticClick, SimpleInlineLinkText} from '#/components/Link'
29import {Loader} from '#/components/Loader'
30import {usePreemptivelyCompleteActivePolicyUpdate} from '#/components/PolicyUpdateOverlay/usePreemptivelyCompleteActivePolicyUpdate'
31import {ScreenTransition} from '#/components/ScreenTransition'
32import * as Toast from '#/components/Toast'
33import {Text} from '#/components/Typography'
34import {
35 isUnderAge,
36 MIN_ACCESS_AGE,
37 useAgeAssuranceRegionConfigWithFallback,
38} from '#/ageAssurance/util'
39import {useAnalytics} from '#/analytics'
40import {IS_NATIVE, IS_WEB} from '#/env'
41import {
42 useDeviceGeolocationApi,
43 useIsDeviceGeolocationGranted,
44} from '#/geolocation'
45import {BackNextButtons} from '../BackNextButtons'
46
47function sanitizeDate(date: Date): Date {
48 if (!date || date.toString() === 'Invalid Date') {
49 logger.error(`Create account: handled invalid date for birthDate`, {
50 hasDate: !!date,
51 })
52 return new Date()
53 }
54 return date
55}
56
57export function StepInfo({
58 onPressBack,
59 onPressSignIn,
60 isServerError,
61 refetchServer,
62 isLoadingStarterPack,
63}: {
64 onPressBack: () => void
65 onPressSignIn: () => void
66 isServerError: boolean
67 refetchServer: () => void
68 isLoadingStarterPack: boolean
69}) {
70 const {_} = useLingui()
71 const ax = useAnalytics()
72 const {state, dispatch} = useSignupContext()
73 const preemptivelyCompleteActivePolicyUpdate =
74 usePreemptivelyCompleteActivePolicyUpdate()
75
76 const inviteCodeValueRef = useRef<string>(state.inviteCode)
77 const emailValueRef = useRef<string>(state.email)
78 const prevEmailValueRef = useRef<string>(state.email)
79 const passwordValueRef = useRef<string>(state.password)
80
81 const emailInputRef = useRef<TextInput>(null)
82 const passwordInputRef = useRef<TextInput>(null)
83 const birthdateInputRef = useRef<DateFieldRef>(null)
84
85 const aaRegionConfig = useAgeAssuranceRegionConfigWithFallback()
86 const {setDeviceGeolocation} = useDeviceGeolocationApi()
87 const locationControl = Dialog.useDialogControl()
88 const isOverRegionMinAccessAge = state.dateOfBirth
89 ? !isUnderAge(state.dateOfBirth.toISOString(), aaRegionConfig.minAccessAge)
90 : true
91 const isOverAppMinAccessAge = state.dateOfBirth
92 ? !isUnderAge(state.dateOfBirth.toISOString(), MIN_ACCESS_AGE)
93 : true
94 const isOverMinAdultAge = state.dateOfBirth
95 ? !isUnderAge(state.dateOfBirth.toISOString(), 18)
96 : true
97 const isDeviceGeolocationGranted = useIsDeviceGeolocationGranted()
98
99 const [hasWarnedEmail, setHasWarnedEmail] = React.useState<boolean>(false)
100
101 const tldtsRef = React.useRef<typeof tldts>(undefined)
102 React.useEffect(() => {
103 // @ts-expect-error - valid path
104 import('tldts/dist/index.cjs.min.js').then(tldts => {
105 tldtsRef.current = tldts
106 })
107 // This will get used in the avatar creator a few steps later, so lets preload it now
108 // @ts-expect-error - valid path
109 import('react-native-view-shot/src/index')
110 }, [])
111
112 const onNextPress = () => {
113 const inviteCode = inviteCodeValueRef.current
114 const email = emailValueRef.current
115 const emailChanged = prevEmailValueRef.current !== email
116 const password = passwordValueRef.current
117
118 if (!isOverRegionMinAccessAge) {
119 return
120 }
121
122 if (state.serviceUrl === DEFAULT_SERVICE) {
123 return dispatch({
124 type: 'setError',
125 value: _(
126 msg`Please choose a 3rd party service host, or sign up on bsky.app.`,
127 ),
128 })
129 }
130
131 if (state.serviceDescription?.inviteCodeRequired && !inviteCode) {
132 return dispatch({
133 type: 'setError',
134 value: _(msg`Please enter your invite code.`),
135 field: 'invite-code',
136 })
137 }
138 if (!email) {
139 return dispatch({
140 type: 'setError',
141 value: _(msg`Please enter your email.`),
142 field: 'email',
143 })
144 }
145 if (!EmailValidator.validate(email)) {
146 return dispatch({
147 type: 'setError',
148 value: _(msg`Your email appears to be invalid.`),
149 field: 'email',
150 })
151 }
152 if (emailChanged && tldtsRef.current) {
153 if (isEmailMaybeInvalid(email, tldtsRef.current)) {
154 prevEmailValueRef.current = email
155 setHasWarnedEmail(true)
156 return dispatch({
157 type: 'setError',
158 value: _(
159 msg`Please double-check that you have entered your email address correctly.`,
160 ),
161 })
162 }
163 } else if (hasWarnedEmail) {
164 setHasWarnedEmail(false)
165 }
166 prevEmailValueRef.current = email
167 if (!password) {
168 return dispatch({
169 type: 'setError',
170 value: _(msg`Please choose your password.`),
171 field: 'password',
172 })
173 }
174 if (password.length < 8) {
175 return dispatch({
176 type: 'setError',
177 value: _(msg`Your password must be at least 8 characters long.`),
178 field: 'password',
179 })
180 }
181
182 preemptivelyCompleteActivePolicyUpdate()
183 dispatch({type: 'setInviteCode', value: inviteCode})
184 dispatch({type: 'setEmail', value: email})
185 dispatch({type: 'setPassword', value: password})
186 dispatch({type: 'next'})
187 ax.metric('signup:nextPressed', {
188 activeStep: state.activeStep,
189 })
190 }
191
192 return (
193 <ScreenTransition direction={state.screenTransitionDirection}>
194 <View style={[a.gap_md]}>
195 {state.serviceUrl === DEFAULT_SERVICE && (
196 <View style={[a.gap_xl]}>
197 <Text style={[a.gap_md, a.leading_normal]}>
198 <Trans>
199 Witchsky is part of the{' '}
200 {
201 <InlineLinkText
202 label={_(msg`Atmosphere`)}
203 to="https://atproto.com/">
204 <Trans>Atmosphere</Trans>
205 </InlineLinkText>
206 }
207 —the network of apps, services, and accounts built on the AT
208 Protocol.
209 </Trans>
210 </Text>
211 <Text style={[a.gap_md, a.leading_normal]}>
212 <Trans>
213 If you have one, sign in with an existing Bluesky account.
214 </Trans>
215 </Text>
216 <View style={IS_WEB && [a.flex_row, a.justify_center]}>
217 <Button
218 testID="signInButton"
219 onPress={onPressSignIn}
220 label={_(msg`Sign in with an Atmosphere account`)}
221 accessibilityHint={_(
222 msg`Opens flow to sign in to your existing Atmosphere account`,
223 )}
224 size="large"
225 variant="solid"
226 color="primary">
227 <ButtonText>
228 <Trans>Sign in with an Atmosphere account</Trans>
229 </ButtonText>
230 </Button>
231 </View>
232 <Divider style={[a.mb_xl]} />
233 </View>
234 )}
235 <FormError error={state.error} />
236 <HostingProvider
237 serviceUrl={state.serviceUrl}
238 onSelectServiceUrl={v => dispatch({type: 'setServiceUrl', value: v})}
239 />
240 {state.serviceUrl === DEFAULT_SERVICE && (
241 <Text style={[a.gap_md, a.leading_normal, a.mt_md]}>
242 <Trans>
243 Don't have an account provider or an existing Bluesky account? To
244 create a new account on a Bluesky-hosted PDS, sign up through{' '}
245 {/* TODO: Xan: change to say sign up for a Witchsky account */}
246 {
247 <InlineLinkText label={_(msg`bsky.app`)} to="https://bsky.app">
248 <Trans>bsky.app</Trans>
249 </InlineLinkText>
250 }{' '}
251 first, then return to Witchsky and log in with the account you
252 created.
253 </Trans>
254 </Text>
255 )}
256 {state.isLoading || isLoadingStarterPack ? (
257 <View style={[a.align_center]}>
258 <Loader size="xl" />
259 </View>
260 ) : state.serviceDescription && state.serviceUrl !== DEFAULT_SERVICE ? (
261 <>
262 {state.serviceDescription.inviteCodeRequired && (
263 <View>
264 <TextField.LabelText>
265 <Trans>Invite code</Trans>
266 </TextField.LabelText>
267 <TextField.Root isInvalid={state.errorField === 'invite-code'}>
268 <TextField.Icon icon={Ticket} />
269 <TextField.Input
270 onChangeText={value => {
271 inviteCodeValueRef.current = value.trim()
272 if (
273 state.errorField === 'invite-code' &&
274 value.trim().length > 0
275 ) {
276 dispatch({type: 'clearError'})
277 }
278 }}
279 label={_(msg`Required for this provider`)}
280 defaultValue={state.inviteCode}
281 autoCapitalize="none"
282 autoComplete="email"
283 keyboardType="email-address"
284 returnKeyType="next"
285 submitBehavior={native('submit')}
286 onSubmitEditing={native(() =>
287 emailInputRef.current?.focus(),
288 )}
289 />
290 </TextField.Root>
291 </View>
292 )}
293 <View>
294 <TextField.LabelText>
295 <Trans>Email</Trans>
296 </TextField.LabelText>
297 <TextField.Root isInvalid={state.errorField === 'email'}>
298 <TextField.Icon icon={Envelope} />
299 <TextField.Input
300 testID="emailInput"
301 inputRef={emailInputRef}
302 onChangeText={value => {
303 emailValueRef.current = value.trim()
304 if (hasWarnedEmail) {
305 setHasWarnedEmail(false)
306 }
307 if (
308 state.errorField === 'email' &&
309 value.trim().length > 0 &&
310 EmailValidator.validate(value.trim())
311 ) {
312 dispatch({type: 'clearError'})
313 }
314 }}
315 label={_(msg`Enter your email address`)}
316 defaultValue={state.email}
317 autoCapitalize="none"
318 autoComplete="email"
319 keyboardType="email-address"
320 returnKeyType="next"
321 submitBehavior={native('submit')}
322 onSubmitEditing={native(() =>
323 passwordInputRef.current?.focus(),
324 )}
325 />
326 </TextField.Root>
327 </View>
328 <View>
329 <TextField.LabelText>
330 <Trans>Password</Trans>
331 </TextField.LabelText>
332 <TextField.Root isInvalid={state.errorField === 'password'}>
333 <TextField.Icon icon={Lock} />
334 <TextField.Input
335 testID="passwordInput"
336 inputRef={passwordInputRef}
337 onChangeText={value => {
338 passwordValueRef.current = value
339 if (state.errorField === 'password' && value.length >= 8) {
340 dispatch({type: 'clearError'})
341 }
342 }}
343 label={_(msg`Choose your password`)}
344 defaultValue={state.password}
345 secureTextEntry
346 autoComplete="new-password"
347 autoCapitalize="none"
348 returnKeyType="next"
349 submitBehavior={native('blurAndSubmit')}
350 onSubmitEditing={native(() =>
351 birthdateInputRef.current?.focus(),
352 )}
353 passwordRules="minlength: 8;"
354 />
355 </TextField.Root>
356 </View>
357 <View>
358 <DateField.LabelText>
359 <Trans>Your birth date</Trans>
360 </DateField.LabelText>
361 <DateField.DateField
362 testID="date"
363 inputRef={birthdateInputRef}
364 value={state.dateOfBirth}
365 onChangeDate={date => {
366 dispatch({
367 type: 'setDateOfBirth',
368 value: sanitizeDate(new Date(date)),
369 })
370 }}
371 label={_(msg`Date of birth`)}
372 accessibilityHint={_(msg`Select your date of birth`)}
373 maximumDate={new Date()}
374 />
375 </View>
376
377 <View style={[a.gap_sm]}>
378 <Policies serviceDescription={state.serviceDescription} />
379
380 {!isOverRegionMinAccessAge || !isOverAppMinAccessAge ? (
381 <Admonition.Outer type="error">
382 <Admonition.Row>
383 <Admonition.Icon />
384 <Admonition.Content>
385 <Admonition.Text>
386 {!isOverAppMinAccessAge ? (
387 <Trans>
388 You must be {MIN_ACCESS_AGE} years of age or older
389 to create an account.
390 </Trans>
391 ) : (
392 <Trans>
393 You must be {aaRegionConfig.minAccessAge} years of
394 age or older to create an account in your region.
395 </Trans>
396 )}
397 </Admonition.Text>
398 {IS_NATIVE &&
399 !isDeviceGeolocationGranted &&
400 isOverAppMinAccessAge && (
401 <Admonition.Text>
402 <Trans>
403 Have we got your location wrong?{' '}
404 <SimpleInlineLinkText
405 label={_(
406 msg`Tap here to confirm your location with GPS.`,
407 )}
408 {...createStaticClick(() => {
409 locationControl.open()
410 })}>
411 Tap here to confirm your location with GPS.
412 </SimpleInlineLinkText>
413 </Trans>
414 </Admonition.Text>
415 )}
416 </Admonition.Content>
417 </Admonition.Row>
418 </Admonition.Outer>
419 ) : !isOverMinAdultAge ? (
420 <Admonition.Admonition type="warning">
421 <Trans>
422 If you are not yet an adult according to the laws of your
423 country, your parent or legal guardian must read these Terms
424 on your behalf.
425 </Trans>
426 </Admonition.Admonition>
427 ) : undefined}
428 </View>
429
430 {IS_NATIVE && (
431 <DeviceLocationRequestDialog
432 control={locationControl}
433 onLocationAcquired={props => {
434 props.closeDialog(() => {
435 // set this after close!
436 setDeviceGeolocation(props.geolocation)
437 Toast.show(_(msg`Your location has been updated.`), {
438 type: 'success',
439 })
440 })
441 }}
442 />
443 )}
444 </>
445 ) : undefined}
446 </View>
447 <BackNextButtons
448 hideNext={!isOverRegionMinAccessAge}
449 showRetry={isServerError}
450 isLoading={state.isLoading}
451 onBackPress={onPressBack}
452 onNextPress={onNextPress}
453 onRetryPress={refetchServer}
454 overrideNextText={hasWarnedEmail ? _(msg`It's correct`) : undefined}
455 />
456 </ScreenTransition>
457 )
458}