Rework account creation and login views

zio/stable
Paul Frazee 2023-03-14 13:03:43 -05:00
parent d55780f5c3
commit acf0f80de2
22 changed files with 1266 additions and 66 deletions

View File

@ -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",

View File

@ -801,3 +801,30 @@ export function SquarePlusIcon({
</Svg>
)
}
export function InfoCircleIcon({
style,
size,
strokeWidth = 1.5,
}: {
style?: StyleProp<TextStyle>
size?: string | number
strokeWidth?: number
}) {
return (
<Svg
fill="none"
viewBox="0 0 24 24"
strokeWidth={strokeWidth}
stroke="currentColor"
width={size}
height={size}
style={style}>
<Path
strokeLinecap="round"
strokeLinejoin="round"
d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"
/>
</Svg>
)
}

View File

@ -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},

View File

@ -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

View File

@ -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
}
}

View File

@ -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>(
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 (
<SplashScreen
onPressSignin={() => setScreenState(ScreenState.S_Signin)}
onPressSignin={() => setScreenState(ScreenState.S_Login)}
onPressCreateAccount={() => setScreenState(ScreenState.S_CreateAccount)}
/>
)
@ -46,17 +46,17 @@ export const LoggedOut = observer(() => {
<CenteredView style={[s.hContentRegion, pal.view]}>
<SafeAreaView testID="noSessionView" style={s.hContentRegion}>
<ErrorBoundary>
{screenState === ScreenState.S_Signin ? (
<Signin
{screenState === ScreenState.S_Login ? (
<Login
onPressBack={() =>
setScreenState(ScreenState.S_SigninOrCreateAccount)
setScreenState(ScreenState.S_LoginOrCreateAccount)
}
/>
) : undefined}
{screenState === ScreenState.S_CreateAccount ? (
<CreateAccount
onPressBack={() =>
setScreenState(ScreenState.S_SigninOrCreateAccount)
setScreenState(ScreenState.S_LoginOrCreateAccount)
}
/>
) : undefined}

View File

@ -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 (
<CenteredView style={styles.container}>
<Image source={CLOUD_SPLASH as ImageSource} style={styles.bgImg} />
<CenteredView style={[styles.container, pal.view]}>
<SafeAreaView testID="noSessionView" style={styles.container}>
<ErrorBoundary>
<View style={styles.hero}>
<View style={styles.heroText}>
<Text style={styles.title}>Bluesky</Text>
</View>
<Text style={[styles.title, pal.link]}>Bluesky</Text>
<Text style={[styles.subtitle, pal.textLight]}>
See what's next
</Text>
</View>
<View testID="signinOrCreateAccount" style={styles.btns}>
<TouchableOpacity
testID="createAccountButton"
style={[pal.view, styles.btn]}
style={[styles.btn, {backgroundColor: colors.blue3}]}
onPress={onPressCreateAccount}>
<Text style={[pal.link, styles.btnLabel]}>
<Text style={[s.white, styles.btnLabel]}>
Create a new account
</Text>
</TouchableOpacity>
<TouchableOpacity
testID="signInButton"
style={[pal.view, styles.btn]}
style={[styles.btn, pal.btn]}
onPress={onPressSignin}>
<Text style={[pal.link, styles.btnLabel]}>Sign in</Text>
<Text style={[pal.text, styles.btnLabel]}>Sign in</Text>
</TouchableOpacity>
</View>
</ErrorBoundary>
@ -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,
},
})

View File

@ -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'

View File

@ -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 (
<ScrollView testID="createAccount" style={pal.view}>
<KeyboardAvoidingView behavior="padding">
<View style={styles.stepContainer}>
{model.step === 1 && <Step1 model={model} />}
{model.step === 2 && <Step2 model={model} />}
{model.step === 3 && <Step3 model={model} />}
</View>
<View style={[s.flexRow, s.pl20, s.pr20]}>
<TouchableOpacity onPress={onPressBackInner}>
<Text type="xl" style={pal.link}>
Back
</Text>
</TouchableOpacity>
<View style={s.flex1} />
{model.canNext ? (
<TouchableOpacity
testID="createAccountButton"
onPress={onPressNext}>
{model.isProcessing ? (
<ActivityIndicator />
) : (
<Text type="xl-bold" style={[pal.link, s.pr5]}>
Next
</Text>
)}
</TouchableOpacity>
) : model.didServiceDescriptionFetchFail ? (
<TouchableOpacity
testID="registerRetryButton"
onPress={onPressRetryConnect}>
<Text type="xl-bold" style={[pal.link, s.pr5]}>
Retry
</Text>
</TouchableOpacity>
) : model.isFetchingServiceDescription ? (
<>
<ActivityIndicator color="#fff" />
<Text type="xl" style={[pal.text, s.pr5]}>
Connecting...
</Text>
</>
) : undefined}
</View>
<View style={s.footerSpacer} />
</KeyboardAvoidingView>
</ScrollView>
)
},
)
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,
},
})

View File

@ -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 <View />
}
const tos = validWebLink(serviceDescription.links?.termsOfService)
const pp = validWebLink(serviceDescription.links?.privacyPolicy)
if (!tos && !pp) {
return (
<View style={styles.policies}>
<View style={[styles.errorIcon, {borderColor: pal.colors.text}, s.mt2]}>
<FontAwesomeIcon
icon="exclamation"
style={pal.textLight as FontAwesomeIconStyle}
size={10}
/>
</View>
<Text style={[pal.textLight, s.pl5, s.flex1]}>
This service has not provided terms of service or a privacy policy.
</Text>
</View>
)
}
const els = []
if (tos) {
els.push(
<TextLink
key="tos"
href={tos}
text="Terms of Service"
style={[pal.link, s.underline]}
/>,
)
}
if (pp) {
els.push(
<TextLink
key="pp"
href={pp}
text="Privacy Policy"
style={[pal.link, s.underline]}
/>,
)
}
if (els.length === 2) {
els.splice(
1,
0,
<Text key="and" style={pal.textLight}>
{' '}
and{' '}
</Text>,
)
}
return (
<View style={styles.policies}>
<Text style={pal.textLight}>
By creating an account you agree to the {els}.
</Text>
</View>
)
}
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,
},
})

View File

@ -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 (
<View>
<StepHeader step="1" title="Your hosting provider" />
<Text style={[pal.text, s.mb10]}>
This is the company that keeps you online.
</Text>
<Option
isSelected={isDefaultSelected}
label="Bluesky"
help="&nbsp;(default)"
onPress={onPressDefault}
/>
<Option
isSelected={!isDefaultSelected}
label="Other"
onPress={onPressOther}>
<View style={styles.otherForm}>
<Text style={[pal.text, s.mb5]}>
Enter the address of your provider:
</Text>
<TextInput
icon="globe"
placeholder="Hosting provider address"
value={model.serviceUrl}
editable
onChange={onChangeServiceUrl}
/>
{LOGIN_INCLUDE_DEV_SERVERS && (
<View style={[s.flexRow, s.mt10]}>
<Button
type="default"
style={s.mr5}
label="Staging"
onPress={() => onDebugChangeServiceUrl(STAGING_SERVICE)}
/>
<Button
type="default"
label="Dev Server"
onPress={() => onDebugChangeServiceUrl(LOCAL_DEV_SERVICE)}
/>
</View>
)}
</View>
</Option>
{model.error ? (
<ErrorMessage message={model.error} style={styles.error} />
) : (
<HelpTip text="You can change hosting providers at any time." />
)}
</View>
)
})
function Option({
children,
isSelected,
label,
help,
onPress,
}: React.PropsWithChildren<{
isSelected: boolean
label: string
help?: string
onPress: () => void
}>) {
const theme = useTheme()
const pal = usePalette('default')
const circleFillStyle = React.useMemo(
() => ({
backgroundColor: theme.palette.primary.background,
}),
[theme],
)
return (
<View style={[styles.option, pal.border]}>
<TouchableWithoutFeedback onPress={onPress}>
<View style={styles.optionHeading}>
<View style={[styles.circle, pal.border]}>
{isSelected ? (
<View style={[circleFillStyle, styles.circleFill]} />
) : undefined}
</View>
<Text type="xl" style={pal.text}>
{label}
{help ? (
<Text type="xl" style={pal.textLight}>
{help}
</Text>
) : undefined}
</Text>
</View>
</TouchableWithoutFeedback>
{isSelected && children}
</View>
)
}
const styles = StyleSheet.create({
error: {
borderRadius: 6,
},
option: {
borderWidth: 1,
borderRadius: 6,
marginBottom: 10,
},
optionHeading: {
flexDirection: 'row',
alignItems: 'center',
padding: 10,
},
circle: {
width: 26,
height: 26,
borderRadius: 15,
padding: 4,
borderWidth: 1,
marginRight: 10,
},
circleFill: {
width: 16,
height: 16,
borderRadius: 10,
},
otherForm: {
paddingBottom: 10,
paddingHorizontal: 12,
},
})

View File

@ -0,0 +1,275 @@
import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {CreateAccountModel} from 'state/models/ui/create-account'
import {Text} from 'view/com/util/text/Text'
import {TextLink} from 'view/com/util/Link'
import {StepHeader} from './StepHeader'
import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {TextInput} from '../util/TextInput'
import {Policies} from './Policies'
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
export const Step2 = observer(({model}: {model: CreateAccountModel}) => {
const pal = usePalette('default')
return (
<View>
<StepHeader step="2" title="Your account" />
{model.isInviteCodeRequired && (
<View style={s.pb20}>
<Text type="md-medium" style={[pal.text, s.mb2]}>
Invite code
</Text>
<TextInput
icon="ticket"
placeholder="Required for this provider"
value={model.inviteCode}
editable
onChange={model.setInviteCode}
/>
</View>
)}
{!model.inviteCode && model.isInviteCodeRequired ? (
<Text>
Don't have an invite code?{' '}
<TextLink text="Join the waitlist" href="#" style={pal.link} /> to try
the beta before it's publicly available.
</Text>
) : (
<>
<View style={s.pb20}>
<Text type="md-medium" style={[pal.text, s.mb2]}>
Email address
</Text>
<TextInput
icon="envelope"
placeholder="Enter your email address"
value={model.email}
editable
onChange={model.setEmail}
/>
</View>
<View style={s.pb20}>
<Text type="md-medium" style={[pal.text, s.mb2]}>
Password
</Text>
<TextInput
icon="lock"
placeholder="Choose your password"
value={model.password}
editable
secureTextEntry
onChange={model.setPassword}
/>
</View>
<View style={s.pb20}>
<Text type="md-medium" style={[pal.text, s.mb2]}>
Legal check
</Text>
<TouchableOpacity
testID="registerIs13Input"
style={[styles.toggleBtn, pal.border]}
onPress={() => model.setIs13(!model.is13)}>
<View style={[pal.borderDark, styles.checkbox]}>
{model.is13 && (
<FontAwesomeIcon icon="check" style={s.blue3} size={16} />
)}
</View>
<Text type="md" style={[pal.text, styles.toggleBtnLabel]}>
I am 13 years old or older
</Text>
</TouchableOpacity>
</View>
{model.serviceDescription && (
<Policies serviceDescription={model.serviceDescription} />
)}
</>
)}
{model.error ? (
<ErrorMessage message={model.error} style={styles.error} />
) : undefined}
</View>
)
})
const styles = StyleSheet.create({
error: {
borderRadius: 6,
marginTop: 10,
},
toggleBtn: {
flexDirection: 'row',
flex: 1,
alignItems: 'center',
borderWidth: 1,
paddingHorizontal: 10,
paddingVertical: 10,
borderRadius: 6,
},
toggleBtnLabel: {
flex: 1,
paddingHorizontal: 10,
},
checkbox: {
borderWidth: 1,
borderRadius: 2,
width: 24,
height: 24,
alignItems: 'center',
justifyContent: 'center',
},
})
/*
<View style={[pal.borderDark, styles.group]}>
{serviceDescription?.inviteCodeRequired ? (
<View
style={[pal.border, styles.groupContent, styles.noTopBorder]}>
<FontAwesomeIcon
icon="ticket"
style={[pal.textLight, styles.groupContentIcon]}
/>
<TextInput
style={[pal.text, styles.textInput]}
placeholder="Invite code"
placeholderTextColor={pal.colors.textLight}
autoCapitalize="none"
autoCorrect={false}
autoFocus
keyboardAppearance={theme.colorScheme}
value={inviteCode}
onChangeText={setInviteCode}
onBlur={onBlurInviteCode}
editable={!isProcessing}
/>
</View>
) : undefined}
<View style={[pal.border, styles.groupContent]}>
<FontAwesomeIcon
icon="envelope"
style={[pal.textLight, styles.groupContentIcon]}
/>
<TextInput
testID="registerEmailInput"
style={[pal.text, styles.textInput]}
placeholder="Email address"
placeholderTextColor={pal.colors.textLight}
autoCapitalize="none"
autoCorrect={false}
value={email}
onChangeText={setEmail}
editable={!isProcessing}
/>
</View>
<View style={[pal.border, styles.groupContent]}>
<FontAwesomeIcon
icon="lock"
style={[pal.textLight, styles.groupContentIcon]}
/>
<TextInput
testID="registerPasswordInput"
style={[pal.text, styles.textInput]}
placeholder="Choose your password"
placeholderTextColor={pal.colors.textLight}
autoCapitalize="none"
autoCorrect={false}
secureTextEntry
value={password}
onChangeText={setPassword}
editable={!isProcessing}
/>
</View>
</View>
</>
) : undefined}
{serviceDescription ? (
<>
<View style={styles.groupLabel}>
<Text type="sm-bold" style={pal.text}>
Choose your username
</Text>
</View>
<View style={[pal.border, styles.group]}>
<View
style={[pal.border, styles.groupContent, styles.noTopBorder]}>
<FontAwesomeIcon
icon="at"
style={[pal.textLight, styles.groupContentIcon]}
/>
<TextInput
testID="registerHandleInput"
style={[pal.text, styles.textInput]}
placeholder="eg alice"
placeholderTextColor={pal.colors.textLight}
autoCapitalize="none"
value={handle}
onChangeText={v => setHandle(makeValidHandle(v))}
editable={!isProcessing}
/>
</View>
{serviceDescription.availableUserDomains.length > 1 && (
<View style={[pal.border, styles.groupContent]}>
<FontAwesomeIcon
icon="globe"
style={styles.groupContentIcon}
/>
<Picker
style={[pal.text, styles.picker]}
labelStyle={styles.pickerLabel}
iconStyle={pal.textLight as FontAwesomeIconStyle}
value={userDomain}
items={serviceDescription.availableUserDomains.map(d => ({
label: `.${d}`,
value: d,
}))}
onChange={itemValue => setUserDomain(itemValue)}
enabled={!isProcessing}
/>
</View>
)}
<View style={[pal.border, styles.groupContent]}>
<Text style={[pal.textLight, s.p10]}>
Your full username will be{' '}
<Text type="md-bold" style={pal.textLight}>
@{createFullHandle(handle, userDomain)}
</Text>
</Text>
</View>
</View>
<View style={styles.groupLabel}>
<Text type="sm-bold" style={pal.text}>
Legal
</Text>
</View>
<View style={[pal.border, styles.group]}>
<View
style={[pal.border, styles.groupContent, styles.noTopBorder]}>
<TouchableOpacity
testID="registerIs13Input"
style={styles.textBtn}
onPress={() => setIs13(!is13)}>
<View
style={[
pal.border,
is13 ? styles.checkboxFilled : styles.checkbox,
]}>
{is13 && (
<FontAwesomeIcon icon="check" style={s.blue3} size={14} />
)}
</View>
<Text style={[pal.text, styles.textBtnLabel]}>
I am 13 years old or older
</Text>
</TouchableOpacity>
</View>
</View>*/

View File

@ -0,0 +1,44 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {CreateAccountModel} from 'state/models/ui/create-account'
import {Text} from 'view/com/util/text/Text'
import {StepHeader} from './StepHeader'
import {s} from 'lib/styles'
import {TextInput} from '../util/TextInput'
import {createFullHandle} from 'lib/strings/handles'
import {usePalette} from 'lib/hooks/usePalette'
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
export const Step3 = observer(({model}: {model: CreateAccountModel}) => {
const pal = usePalette('default')
return (
<View>
<StepHeader step="3" title="Your user handle" />
<View style={s.pb10}>
<TextInput
icon="at"
placeholder="eg alice"
value={model.handle}
editable
onChange={model.setHandle}
/>
<Text type="lg" style={[pal.text, s.pl5, s.pt10]}>
Your full handle will be{' '}
<Text type="lg-bold" style={pal.text}>
@{createFullHandle(model.handle, model.userDomain)}
</Text>
</Text>
</View>
{model.error ? (
<ErrorMessage message={model.error} style={styles.error} />
) : undefined}
</View>
)
})
const styles = StyleSheet.create({
error: {
borderRadius: 6,
},
})

View File

@ -0,0 +1,22 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {Text} from 'view/com/util/text/Text'
import {usePalette} from 'lib/hooks/usePalette'
export function StepHeader({step, title}: {step: string; title: string}) {
const pal = usePalette('default')
return (
<View style={styles.container}>
<Text type="lg" style={pal.textLight}>
{step === '3' ? 'Last step!' : <>Step {step} of 3</>}
</Text>
<Text type="title-xl">{title}</Text>
</View>
)
}
const styles = StyleSheet.create({
container: {
marginBottom: 20,
},
})

View File

@ -15,9 +15,8 @@ import {
import * as EmailValidator from 'email-validator'
import AtpAgent from '@atproto/api'
import {useAnalytics} from 'lib/analytics'
import {LogoTextHero} from './Logo'
import {Text} from '../util/text/Text'
import {UserAvatar} from '../util/UserAvatar'
import {Text} from '../../util/text/Text'
import {UserAvatar} from '../../util/UserAvatar'
import {s, colors} from 'lib/styles'
import {createFullHandle} from 'lib/strings/handles'
import {toNiceDomain} from 'lib/strings/url-helpers'
@ -37,7 +36,7 @@ enum Forms {
PasswordUpdated,
}
export const Signin = ({onPressBack}: {onPressBack: () => void}) => {
export const Login = ({onPressBack}: {onPressBack: () => void}) => {
const pal = usePalette('default')
const store = useStores()
const {track} = useAnalytics()
@ -100,7 +99,10 @@ export const Signin = ({onPressBack}: {onPressBack: () => void}) => {
}
return (
<KeyboardAvoidingView testID="signIn" behavior="padding" style={[pal.view]}>
<KeyboardAvoidingView
testID="signIn"
behavior="padding"
style={[pal.view, s.pt10]}>
{currentForm === Forms.Login ? (
<LoginForm
store={store}
@ -164,9 +166,9 @@ const ChooseAccountForm = ({
const pal = usePalette('default')
const [isProcessing, setIsProcessing] = React.useState(false)
// React.useEffect(() => {
screen('Choose Account')
// }, [screen])
React.useEffect(() => {
screen('Choose Account')
}, [screen])
const onTryAccount = async (account: AccountData) => {
if (account.accessJwt && account.refreshJwt) {
@ -183,15 +185,16 @@ const ChooseAccountForm = ({
return (
<View testID="chooseAccountForm">
<LogoTextHero />
<Text type="sm-bold" style={[pal.text, styles.groupLabel]}>
<Text
type="2xl-medium"
style={[pal.text, styles.groupLabel, s.mt5, s.mb10]}>
Sign in as...
</Text>
{store.session.accounts.map(account => (
<TouchableOpacity
testID={`chooseAccountBtn-${account.handle}`}
key={account.did}
style={[pal.borderDark, styles.group, s.mb5]}
style={[pal.view, pal.border, styles.account]}
onPress={() => onTryAccount(account)}>
<View
style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
@ -216,7 +219,7 @@ const ChooseAccountForm = ({
))}
<TouchableOpacity
testID="chooseNewAccountBtn"
style={[pal.borderDark, styles.group]}
style={[pal.view, pal.border, styles.account, styles.accountLast]}
onPress={() => onSelectAccount(undefined)}>
<View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
<Text style={[styles.accountText, styles.accountTextOther]}>
@ -336,7 +339,6 @@ const LoginForm = ({
const isReady = !!serviceDescription && !!identifier && !!password
return (
<View testID="loginForm">
<LogoTextHero />
<Text type="sm-bold" style={[pal.text, styles.groupLabel]}>
Sign into
</Text>
@ -523,7 +525,6 @@ const ForgotPasswordForm = ({
return (
<>
<LogoTextHero />
<View>
<Text type="title-lg" style={[pal.text, styles.screenTitle]}>
Reset password
@ -669,7 +670,6 @@ const SetNewPasswordForm = ({
return (
<>
<LogoTextHero />
<View>
<Text type="title-lg" style={[pal.text, styles.screenTitle]}>
Set new password
@ -774,7 +774,6 @@ const PasswordUpdatedForm = ({onPressNext}: {onPressNext: () => void}) => {
const pal = usePalette('default')
return (
<>
<LogoTextHero />
<View>
<Text type="title-lg" style={[pal.text, styles.screenTitle]}>
Password updated!
@ -825,6 +824,16 @@ const styles = StyleSheet.create({
groupContentIcon: {
marginLeft: 10,
},
account: {
borderTopWidth: 1,
paddingHorizontal: 20,
paddingVertical: 4,
},
accountLast: {
borderBottomWidth: 1,
marginBottom: 20,
paddingVertical: 8,
},
textInput: {
flex: 1,
width: '100%',

View File

@ -0,0 +1,32 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {Text} from 'view/com/util/text/Text'
import {InfoCircleIcon} from 'lib/icons'
import {s, colors} from 'lib/styles'
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
export function HelpTip({text}: {text: string}) {
const bg = useColorSchemeStyle(
{backgroundColor: colors.gray1},
{backgroundColor: colors.gray8},
)
const fg = useColorSchemeStyle({color: colors.gray5}, {color: colors.gray4})
return (
<View style={[styles.helptip, bg]}>
<InfoCircleIcon size={18} style={fg} strokeWidth={1.5} />
<Text type="xs-medium" style={[fg, s.ml5]}>
{text}
</Text>
</View>
)
}
const styles = StyleSheet.create({
helptip: {
flexDirection: 'row',
alignItems: 'center',
borderRadius: 6,
paddingHorizontal: 10,
paddingVertical: 8,
},
})

View File

@ -0,0 +1,68 @@
import React from 'react'
import {StyleSheet, TextInput as RNTextInput, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {IconProp} from '@fortawesome/fontawesome-svg-core'
import {usePalette} from 'lib/hooks/usePalette'
import {useTheme} from 'lib/ThemeContext'
export function TextInput({
testID,
icon,
value,
placeholder,
editable,
secureTextEntry,
onChange,
}: {
testID?: string
icon: IconProp
value: string
placeholder: string
editable: boolean
secureTextEntry?: boolean
onChange: (v: string) => void
}) {
const theme = useTheme()
const pal = usePalette('default')
return (
<View style={[pal.border, styles.container]}>
<FontAwesomeIcon icon={icon} style={[pal.textLight, styles.icon]} />
<RNTextInput
testID={testID}
style={[pal.text, styles.textInput]}
placeholder={placeholder}
placeholderTextColor={pal.colors.textLight}
autoCapitalize="none"
autoCorrect={false}
keyboardAppearance={theme.colorScheme}
secureTextEntry={secureTextEntry}
value={value}
onChangeText={v => onChange(v)}
editable={editable}
/>
</View>
)
}
const styles = StyleSheet.create({
container: {
borderWidth: 1,
borderRadius: 6,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 4,
},
icon: {
marginLeft: 10,
},
textInput: {
flex: 1,
width: '100%',
paddingVertical: 10,
paddingHorizontal: 10,
fontSize: 17,
letterSpacing: 0.25,
fontWeight: '400',
borderRadius: 10,
},
})

View File

@ -5,7 +5,7 @@ import {
TouchableOpacity,
View,
} from 'react-native'
import {BottomSheetTextInput} from '@gorhom/bottom-sheet'
import {TextInput} from './util'
import LinearGradient from 'react-native-linear-gradient'
import * as Toast from '../util/Toast'
import {Text} from '../util/text/Text'
@ -116,7 +116,7 @@ export function Component({}: {}) {
Check your inbox for an email with the confirmation code to enter
below:
</Text>
<BottomSheetTextInput
<TextInput
style={[styles.textInput, pal.borderDark, pal.text, styles.mb20]}
placeholder="Confirmation code"
placeholderTextColor={pal.textLight.color}
@ -127,7 +127,7 @@ export function Component({}: {}) {
<Text type="lg" style={styles.description}>
Please enter your password as well:
</Text>
<BottomSheetTextInput
<TextInput
style={[styles.textInput, pal.borderDark, pal.text]}
placeholder="Password"
placeholderTextColor={pal.textLight.color}

View File

@ -10,6 +10,7 @@ import * as EditProfileModal from './EditProfile'
import * as ServerInputModal from './ServerInput'
import * as ReportPostModal from './ReportPost'
import * as ReportAccountModal from './ReportAccount'
import * as DeleteAccountModal from './DeleteAccount'
import * as RepostModal from './Repost'
import * as CropImageModal from './crop-image/CropImage.web'
import * as ChangeHandleModal from './ChangeHandle'
@ -61,6 +62,8 @@ function Modal({modal}: {modal: ModalIface}) {
element = <ReportAccountModal.Component {...modal} />
} else if (modal.name === 'crop-image') {
element = <CropImageModal.Component {...modal} />
} else if (modal.name === 'delete-account') {
element = <DeleteAccountModal.Component />
} else if (modal.name === 'repost') {
element = <RepostModal.Component {...modal} />
} else if (modal.name === 'change-handle') {

View File

@ -10,6 +10,7 @@ import {useStores} from 'state/index'
import {SUGGESTED_FOLLOWS} from 'lib/constants'
// @ts-ignore no type definition -prf
import ProgressBar from 'react-native-progress/Bar'
import {CenteredView} from './Views'
export const WelcomeBanner = observer(() => {
const pal = usePalette('default')
@ -39,7 +40,7 @@ export const WelcomeBanner = observer(() => {
}, [store])
return (
<View
<CenteredView
testID="welcomeBanner"
style={[pal.view, styles.container, pal.border]}>
<Text
@ -76,7 +77,7 @@ export const WelcomeBanner = observer(() => {
</View>
</>
)}
</View>
</CenteredView>
)
})

View File

@ -38,7 +38,7 @@ export function ErrorMessage({
/>
</View>
<Text
type="sm"
type="sm-medium"
style={[styles.message, pal.text]}
numberOfLines={numberOfLines}>
{message}

View File

@ -3508,6 +3508,13 @@
dependencies:
"@types/lodash" "*"
"@types/lodash.debounce@^4.0.7":
version "4.0.7"
resolved "https://registry.yarnpkg.com/@types/lodash.debounce/-/lodash.debounce-4.0.7.tgz#0285879defb7cdb156ae633cecd62d5680eded9f"
integrity sha512-X1T4wMZ+gT000M2/91SYj0d/7JfeNZ9PeeOldSNoE/lunLeQXKvkmIumI29IaKMotU/ln/McOIvgzZcQ/3TrSA==
dependencies:
"@types/lodash" "*"
"@types/lodash.isequal@^4.5.6":
version "4.5.6"
resolved "https://registry.yarnpkg.com/@types/lodash.isequal/-/lodash.isequal-4.5.6.tgz#ff42a1b8e20caa59a97e446a77dc57db923bc02b"