Rework logged out state to preserve routing and work for web

This commit is contained in:
Paul Frazee 2023-03-13 23:30:12 -05:00
parent b5c64a03b6
commit 774fb83719
26 changed files with 1063 additions and 1078 deletions

View file

@ -0,0 +1,584 @@
import React from 'react'
import {
ActivityIndicator,
Keyboard,
KeyboardAvoidingView,
ScrollView,
StyleSheet,
TextInput,
TouchableOpacity,
View,
} from 'react-native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
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 {s, colors} from 'lib/styles'
import {makeValidHandle, createFullHandle} from 'lib/strings/handles'
import {toNiceDomain} from 'lib/strings/url-helpers'
import {useStores, DEFAULT_SERVICE} from 'state/index'
import {ServiceDescription} from 'state/models/session'
import {usePalette} from 'lib/hooks/usePalette'
import {useTheme} from 'lib/ThemeContext'
import {cleanError} from 'lib/strings/errors'
export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
const {track, screen, identify} = useAnalytics()
const pal = usePalette('default')
const theme = useTheme()
const store = useStores()
const [isProcessing, setIsProcessing] = React.useState<boolean>(false)
const [serviceUrl, setServiceUrl] = React.useState<string>(DEFAULT_SERVICE)
const [error, setError] = React.useState<string>('')
const [retryDescribeTrigger, setRetryDescribeTrigger] = React.useState<any>(
{},
)
const [serviceDescription, setServiceDescription] = React.useState<
ServiceDescription | undefined
>(undefined)
const [userDomain, setUserDomain] = React.useState<string>('')
const [inviteCode, setInviteCode] = React.useState<string>('')
const [email, setEmail] = React.useState<string>('')
const [password, setPassword] = React.useState<string>('')
const [handle, setHandle] = React.useState<string>('')
const [is13, setIs13] = React.useState<boolean>(false)
React.useEffect(() => {
screen('CreateAccount')
}, [screen])
React.useEffect(() => {
let aborted = false
setError('')
setServiceDescription(undefined)
store.session.describeService(serviceUrl).then(
desc => {
if (aborted) {
return
}
setServiceDescription(desc)
setUserDomain(desc.availableUserDomains[0])
},
err => {
if (aborted) {
return
}
store.log.warn(
`Failed to fetch service description for ${serviceUrl}`,
err,
)
setError(
'Unable to contact your service. Please check your Internet connection.',
)
},
)
return () => {
aborted = true
}
}, [serviceUrl, store.session, store.log, retryDescribeTrigger])
const onPressRetryConnect = React.useCallback(
() => setRetryDescribeTrigger({}),
[setRetryDescribeTrigger],
)
const onPressSelectService = React.useCallback(() => {
store.shell.openModal({
name: 'server-input',
initialService: serviceUrl,
onSelect: setServiceUrl,
})
Keyboard.dismiss()
}, [store, serviceUrl])
const onBlurInviteCode = React.useCallback(() => {
setInviteCode(inviteCode.trim())
}, [setInviteCode, inviteCode])
const onPressNext = React.useCallback(async () => {
if (!email) {
return setError('Please enter your email.')
}
if (!EmailValidator.validate(email)) {
return setError('Your email appears to be invalid.')
}
if (!password) {
return setError('Please choose your password.')
}
if (!handle) {
return setError('Please choose your username.')
}
setError('')
setIsProcessing(true)
try {
await store.session.createAccount({
service: serviceUrl,
email,
handle: createFullHandle(handle, userDomain),
password,
inviteCode,
})
const email_hashed = sha256(email)
identify(email_hashed, {email_hashed})
track('Create Account')
} 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.'
}
store.log.error('Failed to create account', e)
setIsProcessing(false)
setError(cleanError(errMsg))
}
}, [
serviceUrl,
userDomain,
inviteCode,
email,
password,
handle,
setError,
setIsProcessing,
store,
track,
identify,
])
const isReady = !!email && !!password && !!handle && is13
return (
<ScrollView testID="createAccount" style={pal.view}>
<KeyboardAvoidingView behavior="padding">
<LogoTextHero />
{error ? (
<View style={[styles.error, styles.errorFloating]}>
<View style={[styles.errorIcon]}>
<FontAwesomeIcon icon="exclamation" style={s.white} size={10} />
</View>
<View style={s.flex1}>
<Text style={[s.white, s.bold]}>{error}</Text>
</View>
</View>
) : undefined}
<View style={styles.groupLabel}>
<Text type="sm-bold" style={pal.text}>
Service provider
</Text>
</View>
<View style={[pal.borderDark, styles.group]}>
<View
style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
<FontAwesomeIcon
icon="globe"
style={[pal.textLight, styles.groupContentIcon]}
/>
<TouchableOpacity
testID="registerSelectServiceButton"
style={styles.textBtn}
onPress={onPressSelectService}>
<Text type="xl" style={[pal.text, styles.textBtnLabel]}>
{toNiceDomain(serviceUrl)}
</Text>
<View style={[pal.btn, styles.textBtnFakeInnerBtn]}>
<FontAwesomeIcon
icon="pen"
size={12}
style={[pal.textLight, styles.textBtnFakeInnerBtnIcon]}
/>
<Text style={[pal.textLight]}>Change</Text>
</View>
</TouchableOpacity>
</View>
</View>
{serviceDescription ? (
<>
<View style={styles.groupLabel}>
<Text type="sm-bold" style={pal.text}>
Account details
</Text>
</View>
<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>
<Policies serviceDescription={serviceDescription} />
</>
) : undefined}
<View style={[s.flexRow, s.pl20, s.pr20]}>
<TouchableOpacity onPress={onPressBack}>
<Text type="xl" style={pal.link}>
Back
</Text>
</TouchableOpacity>
<View style={s.flex1} />
{isReady ? (
<TouchableOpacity
testID="createAccountButton"
onPress={onPressNext}>
{isProcessing ? (
<ActivityIndicator />
) : (
<Text type="xl-bold" style={[pal.link, s.pr5]}>
Next
</Text>
)}
</TouchableOpacity>
) : !serviceDescription && error ? (
<TouchableOpacity
testID="registerRetryButton"
onPress={onPressRetryConnect}>
<Text type="xl-bold" style={[pal.link, s.pr5]}>
Retry
</Text>
</TouchableOpacity>
) : !serviceDescription ? (
<>
<ActivityIndicator color="#fff" />
<Text type="xl-bold" style={[pal.link, s.pr5]}>
Connecting...
</Text>
</>
) : undefined}
</View>
<View style={s.footerSpacer} />
</KeyboardAvoidingView>
</ScrollView>
)
}
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({
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,67 @@
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 {ErrorBoundary} from 'view/com/util/ErrorBoundary'
import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {useStores} from 'state/index'
import {useAnalytics} from 'lib/analytics'
import {SplashScreen} from './SplashScreen'
import {CenteredView} from '../util/Views'
enum ScreenState {
S_SigninOrCreateAccount,
S_Signin,
S_CreateAccount,
}
export const LoggedOut = observer(() => {
const pal = usePalette('default')
const store = useStores()
const {screen} = useAnalytics()
const [screenState, setScreenState] = React.useState<ScreenState>(
ScreenState.S_SigninOrCreateAccount,
)
React.useEffect(() => {
screen('Login')
store.shell.setMinimalShellMode(true)
}, [store, screen])
if (
store.session.isResumingSession ||
screenState === ScreenState.S_SigninOrCreateAccount
) {
return (
<SplashScreen
onPressSignin={() => setScreenState(ScreenState.S_Signin)}
onPressCreateAccount={() => setScreenState(ScreenState.S_CreateAccount)}
/>
)
}
return (
<CenteredView style={[s.hContentRegion, pal.view]}>
<SafeAreaView testID="noSessionView" style={s.hContentRegion}>
<ErrorBoundary>
{screenState === ScreenState.S_Signin ? (
<Signin
onPressBack={() =>
setScreenState(ScreenState.S_SigninOrCreateAccount)
}
/>
) : undefined}
{screenState === ScreenState.S_CreateAccount ? (
<CreateAccount
onPressBack={() =>
setScreenState(ScreenState.S_SigninOrCreateAccount)
}
/>
) : undefined}
</ErrorBoundary>
</SafeAreaView>
</CenteredView>
)
})

View file

@ -0,0 +1,26 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {s, colors} from 'lib/styles'
import {Text} from '../util/text/Text'
export const LogoTextHero = () => {
return (
<View style={[styles.textHero]}>
<Text type="title-lg" style={[s.white, s.bold]}>
Bluesky
</Text>
</View>
)
}
const styles = StyleSheet.create({
textHero: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingRight: 20,
paddingVertical: 15,
marginBottom: 20,
backgroundColor: colors.blue3,
},
})

View file

@ -0,0 +1,895 @@
import React, {useState, useEffect} from 'react'
import {
ActivityIndicator,
Keyboard,
KeyboardAvoidingView,
StyleSheet,
TextInput,
TouchableOpacity,
View,
} from 'react-native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
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 {s, colors} from 'lib/styles'
import {createFullHandle} from 'lib/strings/handles'
import {toNiceDomain} from 'lib/strings/url-helpers'
import {useStores, RootStoreModel, DEFAULT_SERVICE} from 'state/index'
import {ServiceDescription} from 'state/models/session'
import {AccountData} from 'state/models/session'
import {isNetworkError} from 'lib/strings/errors'
import {usePalette} from 'lib/hooks/usePalette'
import {useTheme} from 'lib/ThemeContext'
import {cleanError} from 'lib/strings/errors'
enum Forms {
Login,
ChooseAccount,
ForgotPassword,
SetNewPassword,
PasswordUpdated,
}
export const Signin = ({onPressBack}: {onPressBack: () => void}) => {
const pal = usePalette('default')
const store = useStores()
const {track} = useAnalytics()
const [error, setError] = useState<string>('')
const [retryDescribeTrigger, setRetryDescribeTrigger] = useState<any>({})
const [serviceUrl, setServiceUrl] = useState<string>(DEFAULT_SERVICE)
const [serviceDescription, setServiceDescription] = useState<
ServiceDescription | undefined
>(undefined)
const [initialHandle, setInitialHandle] = useState<string>('')
const [currentForm, setCurrentForm] = useState<Forms>(
store.session.hasAccounts ? Forms.ChooseAccount : Forms.Login,
)
const onSelectAccount = (account?: AccountData) => {
if (account?.service) {
setServiceUrl(account.service)
}
setInitialHandle(account?.handle || '')
setCurrentForm(Forms.Login)
}
const gotoForm = (form: Forms) => () => {
setError('')
setCurrentForm(form)
}
useEffect(() => {
let aborted = false
setError('')
store.session.describeService(serviceUrl).then(
desc => {
if (aborted) {
return
}
setServiceDescription(desc)
},
err => {
if (aborted) {
return
}
store.log.warn(
`Failed to fetch service description for ${serviceUrl}`,
err,
)
setError(
'Unable to contact your service. Please check your Internet connection.',
)
},
)
return () => {
aborted = true
}
}, [store.session, store.log, serviceUrl, retryDescribeTrigger])
const onPressRetryConnect = () => setRetryDescribeTrigger({})
const onPressForgotPassword = () => {
track('Signin:PressedForgotPassword')
setCurrentForm(Forms.ForgotPassword)
}
return (
<KeyboardAvoidingView testID="signIn" behavior="padding" style={[pal.view]}>
{currentForm === Forms.Login ? (
<LoginForm
store={store}
error={error}
serviceUrl={serviceUrl}
serviceDescription={serviceDescription}
initialHandle={initialHandle}
setError={setError}
setServiceUrl={setServiceUrl}
onPressBack={onPressBack}
onPressForgotPassword={onPressForgotPassword}
onPressRetryConnect={onPressRetryConnect}
/>
) : undefined}
{currentForm === Forms.ChooseAccount ? (
<ChooseAccountForm
store={store}
onSelectAccount={onSelectAccount}
onPressBack={onPressBack}
/>
) : undefined}
{currentForm === Forms.ForgotPassword ? (
<ForgotPasswordForm
store={store}
error={error}
serviceUrl={serviceUrl}
serviceDescription={serviceDescription}
setError={setError}
setServiceUrl={setServiceUrl}
onPressBack={gotoForm(Forms.Login)}
onEmailSent={gotoForm(Forms.SetNewPassword)}
/>
) : undefined}
{currentForm === Forms.SetNewPassword ? (
<SetNewPasswordForm
store={store}
error={error}
serviceUrl={serviceUrl}
setError={setError}
onPressBack={gotoForm(Forms.ForgotPassword)}
onPasswordSet={gotoForm(Forms.PasswordUpdated)}
/>
) : undefined}
{currentForm === Forms.PasswordUpdated ? (
<PasswordUpdatedForm onPressNext={gotoForm(Forms.Login)} />
) : undefined}
</KeyboardAvoidingView>
)
}
const ChooseAccountForm = ({
store,
onSelectAccount,
onPressBack,
}: {
store: RootStoreModel
onSelectAccount: (account?: AccountData) => void
onPressBack: () => void
}) => {
const {track, screen} = useAnalytics()
const pal = usePalette('default')
const [isProcessing, setIsProcessing] = React.useState(false)
// React.useEffect(() => {
screen('Choose Account')
// }, [screen])
const onTryAccount = async (account: AccountData) => {
if (account.accessJwt && account.refreshJwt) {
setIsProcessing(true)
if (await store.session.resumeSession(account)) {
track('Sign In', {resumedSession: true})
setIsProcessing(false)
return
}
setIsProcessing(false)
}
onSelectAccount(account)
}
return (
<View testID="chooseAccountForm">
<LogoTextHero />
<Text type="sm-bold" style={[pal.text, styles.groupLabel]}>
Sign in as...
</Text>
{store.session.accounts.map(account => (
<TouchableOpacity
testID={`chooseAccountBtn-${account.handle}`}
key={account.did}
style={[pal.borderDark, styles.group, s.mb5]}
onPress={() => onTryAccount(account)}>
<View
style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
<View style={s.p10}>
<UserAvatar avatar={account.aviUrl} size={30} />
</View>
<Text style={styles.accountText}>
<Text type="lg-bold" style={pal.text}>
{account.displayName || account.handle}{' '}
</Text>
<Text type="lg" style={[pal.textLight]}>
{account.handle}
</Text>
</Text>
<FontAwesomeIcon
icon="angle-right"
size={16}
style={[pal.text, s.mr10]}
/>
</View>
</TouchableOpacity>
))}
<TouchableOpacity
testID="chooseNewAccountBtn"
style={[pal.borderDark, styles.group]}
onPress={() => onSelectAccount(undefined)}>
<View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
<Text style={[styles.accountText, styles.accountTextOther]}>
<Text type="lg" style={pal.text}>
Other account
</Text>
</Text>
<FontAwesomeIcon
icon="angle-right"
size={16}
style={[pal.text, s.mr10]}
/>
</View>
</TouchableOpacity>
<View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
<TouchableOpacity onPress={onPressBack}>
<Text type="xl" style={[pal.link, s.pl5]}>
Back
</Text>
</TouchableOpacity>
<View style={s.flex1} />
{isProcessing && <ActivityIndicator />}
</View>
</View>
)
}
const LoginForm = ({
store,
error,
serviceUrl,
serviceDescription,
initialHandle,
setError,
setServiceUrl,
onPressRetryConnect,
onPressBack,
onPressForgotPassword,
}: {
store: RootStoreModel
error: string
serviceUrl: string
serviceDescription: ServiceDescription | undefined
initialHandle: string
setError: (v: string) => void
setServiceUrl: (v: string) => void
onPressRetryConnect: () => void
onPressBack: () => void
onPressForgotPassword: () => void
}) => {
const {track} = useAnalytics()
const pal = usePalette('default')
const theme = useTheme()
const [isProcessing, setIsProcessing] = useState<boolean>(false)
const [identifier, setIdentifier] = useState<string>(initialHandle)
const [password, setPassword] = useState<string>('')
const onPressSelectService = () => {
store.shell.openModal({
name: 'server-input',
initialService: serviceUrl,
onSelect: setServiceUrl,
})
Keyboard.dismiss()
track('Signin:PressedSelectService')
}
const onPressNext = async () => {
setError('')
setIsProcessing(true)
try {
// try to guess the handle if the user just gave their own username
let fullIdent = identifier
if (
!identifier.includes('@') && // not an email
!identifier.includes('.') && // not a domain
serviceDescription &&
serviceDescription.availableUserDomains.length > 0
) {
let matched = false
for (const domain of serviceDescription.availableUserDomains) {
if (fullIdent.endsWith(domain)) {
matched = true
}
}
if (!matched) {
fullIdent = createFullHandle(
identifier,
serviceDescription.availableUserDomains[0],
)
}
}
await store.session.login({
service: serviceUrl,
identifier: fullIdent,
password,
})
track('Sign In', {resumedSession: false})
} catch (e: any) {
const errMsg = e.toString()
store.log.warn('Failed to login', e)
setIsProcessing(false)
if (errMsg.includes('Authentication Required')) {
setError('Invalid username or password')
} else if (isNetworkError(e)) {
setError(
'Unable to contact your service. Please check your Internet connection.',
)
} else {
setError(cleanError(errMsg))
}
}
}
const isReady = !!serviceDescription && !!identifier && !!password
return (
<View testID="loginForm">
<LogoTextHero />
<Text type="sm-bold" style={[pal.text, styles.groupLabel]}>
Sign into
</Text>
<View style={[pal.borderDark, styles.group]}>
<View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
<FontAwesomeIcon
icon="globe"
style={[pal.textLight, styles.groupContentIcon]}
/>
<TouchableOpacity
testID="loginSelectServiceButton"
style={styles.textBtn}
onPress={onPressSelectService}>
<Text type="xl" style={[pal.text, styles.textBtnLabel]}>
{toNiceDomain(serviceUrl)}
</Text>
<View style={[pal.btn, styles.textBtnFakeInnerBtn]}>
<FontAwesomeIcon
icon="pen"
size={12}
style={pal.textLight as FontAwesomeIconStyle}
/>
</View>
</TouchableOpacity>
</View>
</View>
<Text type="sm-bold" style={[pal.text, styles.groupLabel]}>
Account
</Text>
<View style={[pal.borderDark, styles.group]}>
<View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
<FontAwesomeIcon
icon="at"
style={[pal.textLight, styles.groupContentIcon]}
/>
<TextInput
testID="loginUsernameInput"
style={[pal.text, styles.textInput]}
placeholder="Username or email address"
placeholderTextColor={pal.colors.textLight}
autoCapitalize="none"
autoFocus
autoCorrect={false}
keyboardAppearance={theme.colorScheme}
value={identifier}
onChangeText={str => setIdentifier((str || '').toLowerCase())}
editable={!isProcessing}
/>
</View>
<View style={[pal.borderDark, styles.groupContent]}>
<FontAwesomeIcon
icon="lock"
style={[pal.textLight, styles.groupContentIcon]}
/>
<TextInput
testID="loginPasswordInput"
style={[pal.text, styles.textInput]}
placeholder="Password"
placeholderTextColor={pal.colors.textLight}
autoCapitalize="none"
autoCorrect={false}
keyboardAppearance={theme.colorScheme}
secureTextEntry
value={password}
onChangeText={setPassword}
editable={!isProcessing}
/>
<TouchableOpacity
testID="forgotPasswordButton"
style={styles.textInputInnerBtn}
onPress={onPressForgotPassword}>
<Text style={pal.link}>Forgot</Text>
</TouchableOpacity>
</View>
</View>
{error ? (
<View style={styles.error}>
<View style={styles.errorIcon}>
<FontAwesomeIcon icon="exclamation" style={s.white} size={10} />
</View>
<View style={s.flex1}>
<Text style={[s.white, s.bold]}>{error}</Text>
</View>
</View>
) : undefined}
<View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
<TouchableOpacity onPress={onPressBack}>
<Text type="xl" style={[pal.link, s.pl5]}>
Back
</Text>
</TouchableOpacity>
<View style={s.flex1} />
{!serviceDescription && error ? (
<TouchableOpacity
testID="loginRetryButton"
onPress={onPressRetryConnect}>
<Text type="xl-bold" style={[pal.link, s.pr5]}>
Retry
</Text>
</TouchableOpacity>
) : !serviceDescription ? (
<>
<ActivityIndicator />
<Text type="xl" style={[pal.textLight, s.pl10]}>
Connecting...
</Text>
</>
) : isProcessing ? (
<ActivityIndicator />
) : isReady ? (
<TouchableOpacity testID="loginNextButton" onPress={onPressNext}>
<Text type="xl-bold" style={[pal.link, s.pr5]}>
Next
</Text>
</TouchableOpacity>
) : undefined}
</View>
</View>
)
}
const ForgotPasswordForm = ({
store,
error,
serviceUrl,
serviceDescription,
setError,
setServiceUrl,
onPressBack,
onEmailSent,
}: {
store: RootStoreModel
error: string
serviceUrl: string
serviceDescription: ServiceDescription | undefined
setError: (v: string) => void
setServiceUrl: (v: string) => void
onPressBack: () => void
onEmailSent: () => void
}) => {
const pal = usePalette('default')
const theme = useTheme()
const [isProcessing, setIsProcessing] = useState<boolean>(false)
const [email, setEmail] = useState<string>('')
const {screen} = useAnalytics()
useEffect(() => {
screen('Signin:ForgotPassword')
}, [screen])
const onPressSelectService = () => {
store.shell.openModal({
name: 'server-input',
initialService: serviceUrl,
onSelect: setServiceUrl,
})
}
const onPressNext = async () => {
if (!EmailValidator.validate(email)) {
return setError('Your email appears to be invalid.')
}
setError('')
setIsProcessing(true)
try {
const agent = new AtpAgent({service: serviceUrl})
await agent.api.com.atproto.account.requestPasswordReset({email})
onEmailSent()
} catch (e: any) {
const errMsg = e.toString()
store.log.warn('Failed to request password reset', e)
setIsProcessing(false)
if (isNetworkError(e)) {
setError(
'Unable to contact your service. Please check your Internet connection.',
)
} else {
setError(cleanError(errMsg))
}
}
}
return (
<>
<LogoTextHero />
<View>
<Text type="title-lg" style={[pal.text, styles.screenTitle]}>
Reset password
</Text>
<Text type="md" style={[pal.text, styles.instructions]}>
Enter the email you used to create your account. We'll send you a
"reset code" so you can set a new password.
</Text>
<View
testID="forgotPasswordView"
style={[pal.borderDark, pal.view, styles.group]}>
<TouchableOpacity
testID="forgotPasswordSelectServiceButton"
style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}
onPress={onPressSelectService}>
<FontAwesomeIcon
icon="globe"
style={[pal.textLight, styles.groupContentIcon]}
/>
<Text style={[pal.text, styles.textInput]} numberOfLines={1}>
{toNiceDomain(serviceUrl)}
</Text>
<View style={[pal.btn, styles.textBtnFakeInnerBtn]}>
<FontAwesomeIcon
icon="pen"
size={12}
style={pal.text as FontAwesomeIconStyle}
/>
</View>
</TouchableOpacity>
<View style={[pal.borderDark, styles.groupContent]}>
<FontAwesomeIcon
icon="envelope"
style={[pal.textLight, styles.groupContentIcon]}
/>
<TextInput
testID="forgotPasswordEmail"
style={[pal.text, styles.textInput]}
placeholder="Email address"
placeholderTextColor={pal.colors.textLight}
autoCapitalize="none"
autoFocus
autoCorrect={false}
keyboardAppearance={theme.colorScheme}
value={email}
onChangeText={setEmail}
editable={!isProcessing}
/>
</View>
</View>
{error ? (
<View style={styles.error}>
<View style={styles.errorIcon}>
<FontAwesomeIcon icon="exclamation" style={s.white} size={10} />
</View>
<View style={s.flex1}>
<Text style={[s.white, s.bold]}>{error}</Text>
</View>
</View>
) : undefined}
<View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
<TouchableOpacity onPress={onPressBack}>
<Text type="xl" style={[pal.link, s.pl5]}>
Back
</Text>
</TouchableOpacity>
<View style={s.flex1} />
{!serviceDescription || isProcessing ? (
<ActivityIndicator />
) : !email ? (
<Text type="xl-bold" style={[pal.link, s.pr5, styles.dimmed]}>
Next
</Text>
) : (
<TouchableOpacity testID="newPasswordButton" onPress={onPressNext}>
<Text type="xl-bold" style={[pal.link, s.pr5]}>
Next
</Text>
</TouchableOpacity>
)}
{!serviceDescription || isProcessing ? (
<Text type="xl" style={[pal.textLight, s.pl10]}>
Processing...
</Text>
) : undefined}
</View>
</View>
</>
)
}
const SetNewPasswordForm = ({
store,
error,
serviceUrl,
setError,
onPressBack,
onPasswordSet,
}: {
store: RootStoreModel
error: string
serviceUrl: string
setError: (v: string) => void
onPressBack: () => void
onPasswordSet: () => void
}) => {
const pal = usePalette('default')
const theme = useTheme()
const {screen} = useAnalytics()
useEffect(() => {
screen('Signin:SetNewPasswordForm')
}, [screen])
const [isProcessing, setIsProcessing] = useState<boolean>(false)
const [resetCode, setResetCode] = useState<string>('')
const [password, setPassword] = useState<string>('')
const onPressNext = async () => {
setError('')
setIsProcessing(true)
try {
const agent = new AtpAgent({service: serviceUrl})
await agent.api.com.atproto.account.resetPassword({
token: resetCode,
password,
})
onPasswordSet()
} catch (e: any) {
const errMsg = e.toString()
store.log.warn('Failed to set new password', e)
setIsProcessing(false)
if (isNetworkError(e)) {
setError(
'Unable to contact your service. Please check your Internet connection.',
)
} else {
setError(cleanError(errMsg))
}
}
}
return (
<>
<LogoTextHero />
<View>
<Text type="title-lg" style={[pal.text, styles.screenTitle]}>
Set new password
</Text>
<Text type="lg" style={[pal.text, styles.instructions]}>
You will receive an email with a "reset code." Enter that code here,
then enter your new password.
</Text>
<View
testID="newPasswordView"
style={[pal.view, pal.borderDark, styles.group]}>
<View
style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
<FontAwesomeIcon
icon="ticket"
style={[pal.textLight, styles.groupContentIcon]}
/>
<TextInput
testID="resetCodeInput"
style={[pal.text, styles.textInput]}
placeholder="Reset code"
placeholderTextColor={pal.colors.textLight}
autoCapitalize="none"
autoCorrect={false}
keyboardAppearance={theme.colorScheme}
autoFocus
value={resetCode}
onChangeText={setResetCode}
editable={!isProcessing}
/>
</View>
<View style={[pal.borderDark, styles.groupContent]}>
<FontAwesomeIcon
icon="lock"
style={[pal.textLight, styles.groupContentIcon]}
/>
<TextInput
testID="newPasswordInput"
style={[pal.text, styles.textInput]}
placeholder="New password"
placeholderTextColor={pal.colors.textLight}
autoCapitalize="none"
autoCorrect={false}
keyboardAppearance={theme.colorScheme}
secureTextEntry
value={password}
onChangeText={setPassword}
editable={!isProcessing}
/>
</View>
</View>
{error ? (
<View style={styles.error}>
<View style={styles.errorIcon}>
<FontAwesomeIcon icon="exclamation" style={s.white} size={10} />
</View>
<View style={s.flex1}>
<Text style={[s.white, s.bold]}>{error}</Text>
</View>
</View>
) : undefined}
<View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
<TouchableOpacity onPress={onPressBack}>
<Text type="xl" style={[pal.link, s.pl5]}>
Back
</Text>
</TouchableOpacity>
<View style={s.flex1} />
{isProcessing ? (
<ActivityIndicator />
) : !resetCode || !password ? (
<Text type="xl-bold" style={[pal.link, s.pr5, styles.dimmed]}>
Next
</Text>
) : (
<TouchableOpacity
testID="setNewPasswordButton"
onPress={onPressNext}>
<Text type="xl-bold" style={[pal.link, s.pr5]}>
Next
</Text>
</TouchableOpacity>
)}
{isProcessing ? (
<Text type="xl" style={[pal.textLight, s.pl10]}>
Updating...
</Text>
) : undefined}
</View>
</View>
</>
)
}
const PasswordUpdatedForm = ({onPressNext}: {onPressNext: () => void}) => {
const {screen} = useAnalytics()
// useEffect(() => {
screen('Signin:PasswordUpdatedForm')
// }, [screen])
const pal = usePalette('default')
return (
<>
<LogoTextHero />
<View>
<Text type="title-lg" style={[pal.text, styles.screenTitle]}>
Password updated!
</Text>
<Text type="lg" style={[pal.text, styles.instructions]}>
You can now sign in with your new password.
</Text>
<View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
<View style={s.flex1} />
<TouchableOpacity onPress={onPressNext}>
<Text type="xl-bold" style={[pal.link, s.pr5]}>
Okay
</Text>
</TouchableOpacity>
</View>
</View>
</>
)
}
const styles = StyleSheet.create({
screenTitle: {
marginBottom: 10,
marginHorizontal: 20,
},
instructions: {
marginBottom: 20,
marginHorizontal: 20,
},
group: {
borderWidth: 1,
borderRadius: 10,
marginBottom: 20,
marginHorizontal: 20,
},
groupLabel: {
paddingHorizontal: 20,
paddingBottom: 5,
},
groupContent: {
borderTopWidth: 1,
flexDirection: 'row',
alignItems: 'center',
},
noTopBorder: {
borderTopWidth: 0,
},
groupContentIcon: {
marginLeft: 10,
},
textInput: {
flex: 1,
width: '100%',
paddingVertical: 10,
paddingHorizontal: 12,
fontSize: 17,
letterSpacing: 0.25,
fontWeight: '400',
borderRadius: 10,
},
textInputInnerBtn: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 6,
paddingHorizontal: 8,
marginHorizontal: 6,
},
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,
},
accountText: {
flex: 1,
flexDirection: 'row',
alignItems: 'baseline',
paddingVertical: 10,
},
accountTextOther: {
paddingLeft: 12,
},
error: {
backgroundColor: colors.red4,
flexDirection: 'row',
alignItems: 'center',
marginTop: -5,
marginHorizontal: 20,
marginBottom: 15,
borderRadius: 8,
paddingHorizontal: 8,
paddingVertical: 8,
},
errorIcon: {
borderWidth: 1,
borderColor: colors.white,
color: colors.white,
borderRadius: 30,
width: 16,
height: 16,
alignItems: 'center',
justifyContent: 'center',
marginRight: 5,
},
dimmed: {opacity: 0.5},
})

View file

@ -0,0 +1,92 @@
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 {usePalette} from 'lib/hooks/usePalette'
import {CLOUD_SPLASH} from 'lib/assets'
import {CenteredView} from '../util/Views'
export const SplashScreen = ({
onPressSignin,
onPressCreateAccount,
}: {
onPressSignin: () => void
onPressCreateAccount: () => void
}) => {
const pal = usePalette('default')
return (
<CenteredView style={styles.container}>
<Image source={CLOUD_SPLASH as ImageSource} style={styles.bgImg} />
<SafeAreaView testID="noSessionView" style={styles.container}>
<ErrorBoundary>
<View style={styles.hero}>
<View style={styles.heroText}>
<Text style={styles.title}>Bluesky</Text>
</View>
</View>
<View testID="signinOrCreateAccount" style={styles.btns}>
<TouchableOpacity
testID="createAccountButton"
style={[pal.view, styles.btn]}
onPress={onPressCreateAccount}>
<Text style={[pal.link, styles.btnLabel]}>
Create a new account
</Text>
</TouchableOpacity>
<TouchableOpacity
testID="signInButton"
style={[pal.view, styles.btn]}
onPress={onPressSignin}>
<Text style={[pal.link, styles.btnLabel]}>Sign in</Text>
</TouchableOpacity>
</View>
</ErrorBoundary>
</SafeAreaView>
</CenteredView>
)
}
const styles = StyleSheet.create({
container: {
height: '100%',
},
hero: {
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',
},
btn: {
borderRadius: 4,
paddingVertical: 16,
marginBottom: 20,
marginHorizontal: 20,
backgroundColor: colors.blue3,
},
btnLabel: {
textAlign: 'center',
fontSize: 21,
color: colors.white,
},
})

View file

@ -0,0 +1,102 @@
import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {Text} from 'view/com/util/text/Text'
import {TextLink} from '../util/Link'
import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
import {s, colors} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {CenteredView} from '../util/Views'
export const SplashScreen = ({
onPressSignin,
onPressCreateAccount,
}: {
onPressSignin: () => void
onPressCreateAccount: () => void
}) => {
const pal = usePalette('default')
return (
<CenteredView style={styles.container}>
<View testID="noSessionView" style={styles.containerInner}>
<ErrorBoundary>
<Text style={styles.title}>Bluesky</Text>
<Text style={styles.subtitle}>See what's next</Text>
<View testID="signinOrCreateAccount" style={styles.btns}>
<TouchableOpacity
testID="createAccountButton"
style={[styles.btn, {backgroundColor: colors.blue3}]}
onPress={onPressCreateAccount}>
<Text style={[s.white, styles.btnLabel]}>
Create a new account
</Text>
</TouchableOpacity>
<TouchableOpacity
testID="signInButton"
style={[styles.btn, pal.btn]}
onPress={onPressSignin}>
<Text style={[pal.link, styles.btnLabel]}>Sign in</Text>
</TouchableOpacity>
</View>
<Text
type="xl"
style={[styles.notice, pal.textLight]}
lineHeight={1.3}>
Bluesky will launch soon.{' '}
<TextLink
type="xl"
text="Join the waitlist"
href="#"
style={pal.link}
/>{' '}
to try the beta before it's publicly available.
</Text>
</ErrorBoundary>
</View>
</CenteredView>
)
}
const styles = StyleSheet.create({
container: {
height: '100%',
backgroundColor: colors.gray1,
},
containerInner: {
backgroundColor: colors.white,
paddingVertical: 40,
paddingBottom: 50,
paddingHorizontal: 20,
},
title: {
textAlign: 'center',
color: colors.blue3,
fontSize: 68,
fontWeight: 'bold',
paddingBottom: 10,
},
subtitle: {
textAlign: 'center',
color: colors.gray5,
fontSize: 52,
fontWeight: 'bold',
paddingBottom: 30,
},
btns: {
flexDirection: 'row',
paddingBottom: 40,
},
btn: {
flex: 1,
borderRadius: 30,
paddingVertical: 12,
marginHorizontal: 10,
},
btnLabel: {
textAlign: 'center',
fontSize: 18,
},
notice: {
paddingHorizontal: 40,
textAlign: 'center',
},
})

View file

@ -0,0 +1,47 @@
import React from 'react'
import {ActivityIndicator, StyleSheet, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {useStores} from 'state/index'
import {LoggedOut} from './LoggedOut'
import {Text} from '../util/text/Text'
import {usePalette} from 'lib/hooks/usePalette'
export const withAuthRequired = <P extends object>(
Component: React.ComponentType<P>,
): React.FC<P> =>
observer((props: P) => {
const store = useStores()
if (store.session.isResumingSession) {
return <Loading />
}
if (!store.session.hasSession) {
return <LoggedOut />
}
return <Component {...props} />
})
function Loading() {
const pal = usePalette('default')
return (
<View style={[styles.loading, pal.view]}>
<ActivityIndicator size="large" />
<Text type="2xl" style={[styles.loadingText, pal.textLight]}>
Firing up the grill...
</Text>
</View>
)
}
const styles = StyleSheet.create({
loading: {
height: '100%',
alignContent: 'center',
justifyContent: 'center',
paddingBottom: 100,
},
loadingText: {
paddingVertical: 20,
paddingHorizontal: 20,
textAlign: 'center',
},
})