diff --git a/package.json b/package.json index 41f25192..f7264d32 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "js-sha256": "^0.9.0", "lodash.chunk": "^4.2.0", "lodash.clonedeep": "^4.5.0", + "lodash.debounce": "^4.0.8", "lodash.isequal": "^4.5.0", "lodash.omit": "^4.5.0", "lodash.samplesize": "^4.2.0", @@ -122,6 +123,7 @@ "@types/jest": "^29.4.0", "@types/lodash.chunk": "^4.2.7", "@types/lodash.clonedeep": "^4.5.7", + "@types/lodash.debounce": "^4.0.7", "@types/lodash.isequal": "^4.5.6", "@types/lodash.omit": "^4.5.7", "@types/lodash.samplesize": "^4.2.7", diff --git a/src/lib/icons.tsx b/src/lib/icons.tsx index e194e7a8..fd233f99 100644 --- a/src/lib/icons.tsx +++ b/src/lib/icons.tsx @@ -801,3 +801,30 @@ export function SquarePlusIcon({ ) } + +export function InfoCircleIcon({ + style, + size, + strokeWidth = 1.5, +}: { + style?: StyleProp + size?: string | number + strokeWidth?: number +}) { + return ( + + + + ) +} diff --git a/src/lib/styles.ts b/src/lib/styles.ts index 328229f4..5d7f7f82 100644 --- a/src/lib/styles.ts +++ b/src/lib/styles.ts @@ -64,6 +64,7 @@ export const s = StyleSheet.create({ footerSpacer: {height: 100}, contentContainer: {paddingBottom: 200}, contentContainerExtra: {paddingBottom: 300}, + border0: {borderWidth: 0}, border1: {borderWidth: 1}, borderTop1: {borderTopWidth: 1}, borderRight1: {borderRightWidth: 1}, diff --git a/src/state/index.ts b/src/state/index.ts index 61b85e51..f0713efe 100644 --- a/src/state/index.ts +++ b/src/state/index.ts @@ -6,7 +6,7 @@ import * as apiPolyfill from 'lib/api/api-polyfill' import * as storage from 'lib/storage' export const LOCAL_DEV_SERVICE = - Platform.OS === 'ios' ? 'http://localhost:2583' : 'http://10.0.2.2:2583' + Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583' export const STAGING_SERVICE = 'https://pds.staging.bsky.dev' export const PROD_SERVICE = 'https://bsky.social' export const DEFAULT_SERVICE = PROD_SERVICE diff --git a/src/state/models/ui/create-account.ts b/src/state/models/ui/create-account.ts new file mode 100644 index 00000000..a212fe05 --- /dev/null +++ b/src/state/models/ui/create-account.ts @@ -0,0 +1,192 @@ +import {makeAutoObservable} from 'mobx' +import {RootStoreModel} from '../root-store' +import {ServiceDescription} from '../session' +import {DEFAULT_SERVICE} from 'state/index' +import {ComAtprotoAccountCreate} from '@atproto/api' +import * as EmailValidator from 'email-validator' +import {createFullHandle} from 'lib/strings/handles' +import {cleanError} from 'lib/strings/errors' + +export class CreateAccountModel { + step: number = 1 + isProcessing = false + isFetchingServiceDescription = false + didServiceDescriptionFetchFail = false + error = '' + + serviceUrl = DEFAULT_SERVICE + serviceDescription: ServiceDescription | undefined = undefined + userDomain = '' + inviteCode = '' + email = '' + password = '' + handle = '' + is13 = false + + constructor(public rootStore: RootStoreModel) { + makeAutoObservable(this, {}, {autoBind: true}) + } + + // form state controls + // = + + next() { + this.error = '' + this.step++ + } + + back() { + this.error = '' + this.step-- + } + + setStep(v: number) { + this.step = v + } + + async fetchServiceDescription() { + this.setError('') + this.setIsFetchingServiceDescription(true) + this.setDidServiceDescriptionFetchFail(false) + this.setServiceDescription(undefined) + if (!this.serviceUrl) { + return + } + try { + const desc = await this.rootStore.session.describeService(this.serviceUrl) + this.setServiceDescription(desc) + this.setUserDomain(desc.availableUserDomains[0]) + } catch (err: any) { + this.rootStore.log.warn( + `Failed to fetch service description for ${this.serviceUrl}`, + err, + ) + this.setError( + 'Unable to contact your service. Please check your Internet connection.', + ) + this.setDidServiceDescriptionFetchFail(true) + } finally { + this.setIsFetchingServiceDescription(false) + } + } + + async submit() { + if (!this.email) { + this.setStep(2) + return this.setError('Please enter your email.') + } + if (!EmailValidator.validate(this.email)) { + this.setStep(2) + return this.setError('Your email appears to be invalid.') + } + if (!this.password) { + this.setStep(2) + return this.setError('Please choose your password.') + } + if (!this.handle) { + this.setStep(3) + return this.setError('Please choose your handle.') + } + this.setError('') + this.setIsProcessing(true) + try { + await this.rootStore.session.createAccount({ + service: this.serviceUrl, + email: this.email, + handle: createFullHandle(this.handle, this.userDomain), + password: this.password, + inviteCode: this.inviteCode, + }) + } catch (e: any) { + let errMsg = e.toString() + if (e instanceof ComAtprotoAccountCreate.InvalidInviteCodeError) { + errMsg = + 'Invite code not accepted. Check that you input it correctly and try again.' + } + this.rootStore.log.error('Failed to create account', e) + this.setIsProcessing(false) + this.setError(cleanError(errMsg)) + throw e + } + } + + // form state accessors + // = + + get canBack() { + return this.step > 1 + } + + get canNext() { + if (this.step === 1) { + return !!this.serviceDescription + } else if (this.step === 2) { + return ( + (!this.isInviteCodeRequired || this.inviteCode) && + !!this.email && + !!this.password && + this.is13 + ) + } + return !!this.handle + } + + get isServiceDescribed() { + return !!this.serviceDescription + } + + get isInviteCodeRequired() { + return this.serviceDescription?.inviteCodeRequired + } + + // setters + // = + + setIsProcessing(v: boolean) { + this.isProcessing = v + } + + setIsFetchingServiceDescription(v: boolean) { + this.isFetchingServiceDescription = v + } + + setDidServiceDescriptionFetchFail(v: boolean) { + this.didServiceDescriptionFetchFail = v + } + + setError(v: string) { + this.error = v + } + + setServiceUrl(v: string) { + this.serviceUrl = v + } + + setServiceDescription(v: ServiceDescription | undefined) { + this.serviceDescription = v + } + + setUserDomain(v: string) { + this.userDomain = v + } + + setInviteCode(v: string) { + this.inviteCode = v + } + + setEmail(v: string) { + this.email = v + } + + setPassword(v: string) { + this.password = v + } + + setHandle(v: string) { + this.handle = v + } + + setIs13(v: boolean) { + this.is13 = v + } +} diff --git a/src/view/com/auth/LoggedOut.tsx b/src/view/com/auth/LoggedOut.tsx index 47dd51d9..5d4b9451 100644 --- a/src/view/com/auth/LoggedOut.tsx +++ b/src/view/com/auth/LoggedOut.tsx @@ -1,8 +1,8 @@ import React from 'react' import {SafeAreaView} from 'react-native' import {observer} from 'mobx-react-lite' -import {Signin} from 'view/com/auth/Signin' -import {CreateAccount} from 'view/com/auth/CreateAccount' +import {Login} from 'view/com/auth/login/Login' +import {CreateAccount} from 'view/com/auth/create/CreateAccount' import {ErrorBoundary} from 'view/com/util/ErrorBoundary' import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' @@ -12,8 +12,8 @@ import {SplashScreen} from './SplashScreen' import {CenteredView} from '../util/Views' enum ScreenState { - S_SigninOrCreateAccount, - S_Signin, + S_LoginOrCreateAccount, + S_Login, S_CreateAccount, } @@ -22,7 +22,7 @@ export const LoggedOut = observer(() => { const store = useStores() const {screen} = useAnalytics() const [screenState, setScreenState] = React.useState( - ScreenState.S_SigninOrCreateAccount, + ScreenState.S_LoginOrCreateAccount, ) React.useEffect(() => { @@ -32,11 +32,11 @@ export const LoggedOut = observer(() => { if ( store.session.isResumingSession || - screenState === ScreenState.S_SigninOrCreateAccount + screenState === ScreenState.S_LoginOrCreateAccount ) { return ( setScreenState(ScreenState.S_Signin)} + onPressSignin={() => setScreenState(ScreenState.S_Login)} onPressCreateAccount={() => setScreenState(ScreenState.S_CreateAccount)} /> ) @@ -46,17 +46,17 @@ export const LoggedOut = observer(() => { - {screenState === ScreenState.S_Signin ? ( - - setScreenState(ScreenState.S_SigninOrCreateAccount) + setScreenState(ScreenState.S_LoginOrCreateAccount) } /> ) : undefined} {screenState === ScreenState.S_CreateAccount ? ( - setScreenState(ScreenState.S_SigninOrCreateAccount) + setScreenState(ScreenState.S_LoginOrCreateAccount) } /> ) : undefined} diff --git a/src/view/com/auth/SplashScreen.tsx b/src/view/com/auth/SplashScreen.tsx index 27943f64..f98bed12 100644 --- a/src/view/com/auth/SplashScreen.tsx +++ b/src/view/com/auth/SplashScreen.tsx @@ -1,11 +1,9 @@ import React from 'react' import {SafeAreaView, StyleSheet, TouchableOpacity, View} from 'react-native' -import Image, {Source as ImageSource} from 'view/com/util/images/Image' import {Text} from 'view/com/util/text/Text' import {ErrorBoundary} from 'view/com/util/ErrorBoundary' -import {colors} from 'lib/styles' +import {s, colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' -import {CLOUD_SPLASH} from 'lib/assets' import {CenteredView} from '../util/Views' export const SplashScreen = ({ @@ -17,29 +15,29 @@ export const SplashScreen = ({ }) => { const pal = usePalette('default') return ( - - + - - Bluesky - + Bluesky + + See what's next + - + Create a new account - Sign in + Sign in @@ -56,37 +54,27 @@ const styles = StyleSheet.create({ flex: 2, justifyContent: 'center', }, - bgImg: { - position: 'absolute', - top: 0, - left: 0, - width: '100%', - height: '100%', - }, - heroText: { - backgroundColor: colors.white, - paddingTop: 10, - paddingBottom: 20, - }, btns: { paddingBottom: 40, }, title: { textAlign: 'center', - color: colors.blue3, fontSize: 68, fontWeight: 'bold', }, + subtitle: { + textAlign: 'center', + fontSize: 42, + fontWeight: 'bold', + }, btn: { - borderRadius: 4, + borderRadius: 32, paddingVertical: 16, marginBottom: 20, marginHorizontal: 20, - backgroundColor: colors.blue3, }, btnLabel: { textAlign: 'center', fontSize: 21, - color: colors.white, }, }) diff --git a/src/view/com/auth/CreateAccount.tsx b/src/view/com/auth/create/Backup.tsx similarity index 99% rename from src/view/com/auth/CreateAccount.tsx rename to src/view/com/auth/create/Backup.tsx index a24dc4e3..c0693605 100644 --- a/src/view/com/auth/CreateAccount.tsx +++ b/src/view/com/auth/create/Backup.tsx @@ -17,10 +17,10 @@ import {ComAtprotoAccountCreate} from '@atproto/api' import * as EmailValidator from 'email-validator' import {sha256} from 'js-sha256' import {useAnalytics} from 'lib/analytics' -import {LogoTextHero} from './Logo' -import {Picker} from '../util/Picker' -import {TextLink} from '../util/Link' -import {Text} from '../util/text/Text' +import {LogoTextHero} from '../Logo' +import {Picker} from '../../util/Picker' +import {TextLink} from '../../util/Link' +import {Text} from '../../util/text/Text' import {s, colors} from 'lib/styles' import {makeValidHandle, createFullHandle} from 'lib/strings/handles' import {toNiceDomain} from 'lib/strings/url-helpers' diff --git a/src/view/com/auth/create/CreateAccount.tsx b/src/view/com/auth/create/CreateAccount.tsx new file mode 100644 index 00000000..93773665 --- /dev/null +++ b/src/view/com/auth/create/CreateAccount.tsx @@ -0,0 +1,241 @@ +import React from 'react' +import { + ActivityIndicator, + KeyboardAvoidingView, + ScrollView, + StyleSheet, + TouchableOpacity, + View, +} from 'react-native' +import {observer} from 'mobx-react-lite' +import {sha256} from 'js-sha256' +import {useAnalytics} from 'lib/analytics' +import {Text} from '../../util/text/Text' +import {s, colors} from 'lib/styles' +import {useStores} from 'state/index' +import {CreateAccountModel} from 'state/models/ui/create-account' +import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' + +import {Step1} from './Step1' +import {Step2} from './Step2' +import {Step3} from './Step3' + +export const CreateAccount = observer( + ({onPressBack}: {onPressBack: () => void}) => { + const {track, screen, identify} = useAnalytics() + const pal = usePalette('default') + const store = useStores() + const model = React.useMemo(() => new CreateAccountModel(store), [store]) + + React.useEffect(() => { + screen('CreateAccount') + }, [screen]) + + React.useEffect(() => { + model.fetchServiceDescription() + }, [model]) + + const onPressRetryConnect = React.useCallback( + () => model.fetchServiceDescription(), + [model], + ) + + const onPressBackInner = React.useCallback(() => { + if (model.canBack) { + console.log('?') + model.back() + } else { + onPressBack() + } + }, [model, onPressBack]) + + const onPressNext = React.useCallback(async () => { + if (!model.canNext) { + return + } + if (model.step < 3) { + model.next() + } else { + try { + await model.submit() + const email_hashed = sha256(model.email) + identify(email_hashed, {email_hashed}) + track('Create Account') + } catch { + // dont need to handle here + } + } + }, [model, identify, track]) + + return ( + + + + {model.step === 1 && } + {model.step === 2 && } + {model.step === 3 && } + + + + + Back + + + + {model.canNext ? ( + + {model.isProcessing ? ( + + ) : ( + + Next + + )} + + ) : model.didServiceDescriptionFetchFail ? ( + + + Retry + + + ) : model.isFetchingServiceDescription ? ( + <> + + + Connecting... + + + ) : undefined} + + + + + ) + }, +) + +const styles = StyleSheet.create({ + stepContainer: { + paddingHorizontal: 20, + paddingVertical: 20, + }, + + noTopBorder: { + borderTopWidth: 0, + }, + logoHero: { + paddingTop: 30, + paddingBottom: 40, + }, + group: { + borderWidth: 1, + borderRadius: 10, + marginBottom: 20, + marginHorizontal: 20, + }, + groupLabel: { + paddingHorizontal: 20, + paddingBottom: 5, + }, + groupContent: { + borderTopWidth: 1, + flexDirection: 'row', + alignItems: 'center', + }, + groupContentIcon: { + marginLeft: 10, + }, + textInput: { + flex: 1, + width: '100%', + paddingVertical: 10, + paddingHorizontal: 12, + fontSize: 17, + letterSpacing: 0.25, + fontWeight: '400', + borderRadius: 10, + }, + textBtn: { + flexDirection: 'row', + flex: 1, + alignItems: 'center', + }, + textBtnLabel: { + flex: 1, + paddingVertical: 10, + paddingHorizontal: 12, + }, + textBtnFakeInnerBtn: { + flexDirection: 'row', + alignItems: 'center', + borderRadius: 6, + paddingVertical: 6, + paddingHorizontal: 8, + marginHorizontal: 6, + }, + textBtnFakeInnerBtnIcon: { + marginRight: 4, + }, + picker: { + flex: 1, + width: '100%', + paddingVertical: 10, + paddingHorizontal: 12, + fontSize: 17, + borderRadius: 10, + }, + pickerLabel: { + fontSize: 17, + }, + checkbox: { + borderWidth: 1, + borderRadius: 2, + width: 16, + height: 16, + marginLeft: 16, + }, + checkboxFilled: { + borderWidth: 1, + borderRadius: 2, + width: 16, + height: 16, + marginLeft: 16, + }, + policies: { + flexDirection: 'row', + alignItems: 'flex-start', + paddingHorizontal: 20, + paddingBottom: 20, + }, + error: { + backgroundColor: colors.red4, + flexDirection: 'row', + alignItems: 'center', + marginTop: -5, + marginHorizontal: 20, + marginBottom: 15, + borderRadius: 8, + paddingHorizontal: 8, + paddingVertical: 8, + }, + errorFloating: { + marginBottom: 20, + marginHorizontal: 20, + borderRadius: 8, + }, + errorIcon: { + borderWidth: 1, + borderColor: colors.white, + borderRadius: 30, + width: 16, + height: 16, + alignItems: 'center', + justifyContent: 'center', + marginRight: 5, + }, +}) diff --git a/src/view/com/auth/create/Policies.tsx b/src/view/com/auth/create/Policies.tsx new file mode 100644 index 00000000..4ba6a540 --- /dev/null +++ b/src/view/com/auth/create/Policies.tsx @@ -0,0 +1,101 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {TextLink} from '../../util/Link' +import {Text} from '../../util/text/Text' +import {s, colors} from 'lib/styles' +import {ServiceDescription} from 'state/models/session' +import {usePalette} from 'lib/hooks/usePalette' + +export const Policies = ({ + serviceDescription, +}: { + serviceDescription: ServiceDescription +}) => { + const pal = usePalette('default') + if (!serviceDescription) { + return + } + const tos = validWebLink(serviceDescription.links?.termsOfService) + const pp = validWebLink(serviceDescription.links?.privacyPolicy) + if (!tos && !pp) { + return ( + + + + + + This service has not provided terms of service or a privacy policy. + + + ) + } + const els = [] + if (tos) { + els.push( + , + ) + } + if (pp) { + els.push( + , + ) + } + if (els.length === 2) { + els.splice( + 1, + 0, + + {' '} + and{' '} + , + ) + } + return ( + + + By creating an account you agree to the {els}. + + + ) +} + +function validWebLink(url?: string): string | undefined { + return url && (url.startsWith('http://') || url.startsWith('https://')) + ? url + : undefined +} + +const styles = StyleSheet.create({ + policies: { + flexDirection: 'row', + alignItems: 'flex-start', + }, + errorIcon: { + borderWidth: 1, + borderColor: colors.white, + borderRadius: 30, + width: 16, + height: 16, + alignItems: 'center', + justifyContent: 'center', + marginRight: 5, + }, +}) diff --git a/src/view/com/auth/create/Step1.tsx b/src/view/com/auth/create/Step1.tsx new file mode 100644 index 00000000..0a628f9d --- /dev/null +++ b/src/view/com/auth/create/Step1.tsx @@ -0,0 +1,187 @@ +import React from 'react' +import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native' +import {observer} from 'mobx-react-lite' +import debounce from 'lodash.debounce' +import {Text} from 'view/com/util/text/Text' +import {StepHeader} from './StepHeader' +import {CreateAccountModel} from 'state/models/ui/create-account' +import {useTheme} from 'lib/ThemeContext' +import {usePalette} from 'lib/hooks/usePalette' +import {s} from 'lib/styles' +import {HelpTip} from '../util/HelpTip' +import {TextInput} from '../util/TextInput' +import {Button} from 'view/com/util/forms/Button' +import {ErrorMessage} from 'view/com/util/error/ErrorMessage' + +import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'state/index' +import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags' + +export const Step1 = observer(({model}: {model: CreateAccountModel}) => { + const pal = usePalette('default') + const [isDefaultSelected, setIsDefaultSelected] = React.useState(true) + + const onPressDefault = React.useCallback(() => { + setIsDefaultSelected(true) + model.setServiceUrl(PROD_SERVICE) + model.fetchServiceDescription() + }, [setIsDefaultSelected, model]) + + const onPressOther = React.useCallback(() => { + setIsDefaultSelected(false) + model.setServiceUrl('https://') + model.setServiceDescription(undefined) + }, [setIsDefaultSelected, model]) + + const fetchServiceDesription = React.useMemo( + () => debounce(() => model.fetchServiceDescription(), 1e3), + [model], + ) + + const onChangeServiceUrl = React.useCallback( + (v: string) => { + model.setServiceUrl(v) + fetchServiceDesription() + }, + [model, fetchServiceDesription], + ) + + const onDebugChangeServiceUrl = React.useCallback( + (v: string) => { + model.setServiceUrl(v) + model.fetchServiceDescription() + }, + [model], + ) + + return ( + + + + This is the company that keeps you online. + +