Bluesky app fork with some witchin' additions 💫
at linkat-integration 458 lines 17 kB view raw
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}