Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 453 lines 17 kB view raw
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}