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