Account switcher (#85)

* Update the account-create and signin views to use the design system.

Also:
- Add borderDark to the theme
- Start to an account selector in the signin flow

* Dark mode fixes in signin ui

* Track multiple active accounts and provide account-switching UI

* Add test tooling for an in-memory pds

* Add complete integration tests for login and the account switcher
This commit is contained in:
Paul Frazee 2023-01-24 09:06:27 -06:00 committed by GitHub
parent 439305b57e
commit 9027882fb4
23 changed files with 2406 additions and 658 deletions

View file

@ -12,7 +12,7 @@ import {
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {ComAtprotoAccountCreate} from '@atproto/api'
import * as EmailValidator from 'email-validator'
import {Logo} from './Logo'
import {LogoTextHero} from './Logo'
import {Picker} from '../util/Picker'
import {TextLink} from '../util/Link'
import {Text} from '../util/text/Text'
@ -25,8 +25,10 @@ import {
import {useStores, DEFAULT_SERVICE} from '../../../state'
import {ServiceDescription} from '../../../state/models/session'
import {ServerInputModal} from '../../../state/models/shell-ui'
import {usePalette} from '../../lib/hooks/usePalette'
export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
const pal = usePalette('default')
const store = useStores()
const [isProcessing, setIsProcessing] = useState<boolean>(false)
const [serviceUrl, setServiceUrl] = useState<string>(DEFAULT_SERVICE)
@ -114,74 +116,14 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
}
}
const Policies = () => {
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, s.mt2]}>
<FontAwesomeIcon icon="exclamation" style={s.white} size={10} />
</View>
<Text style={[s.white, 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={[s.white, s.underline]}
/>,
)
}
if (pp) {
els.push(
<TextLink
key="pp"
href={pp}
text="Privacy Policy"
style={[s.white, s.underline]}
/>,
)
}
if (els.length === 2) {
els.splice(
1,
0,
<Text key="and" style={s.white}>
{' '}
and{' '}
</Text>,
)
}
return (
<View style={styles.policies}>
<Text style={s.white}>
By creating an account you agree to the {els}.
</Text>
</View>
)
}
const isReady = !!email && !!password && !!handle && is13
return (
<ScrollView testID="createAccount" style={{flex: 1}}>
<KeyboardAvoidingView behavior="padding" style={{flex: 1}}>
<View style={styles.logoHero}>
<Logo />
</View>
<ScrollView testID="createAccount" style={pal.view}>
<KeyboardAvoidingView behavior="padding">
<LogoTextHero />
{error ? (
<View style={[styles.error, styles.errorFloating]}>
<View style={styles.errorIcon}>
<View style={[styles.errorIcon]}>
<FontAwesomeIcon icon="exclamation" style={s.white} size={10} />
</View>
<View style={s.flex1}>
@ -189,41 +131,55 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
</View>
</View>
) : undefined}
<View style={[styles.group]}>
<View style={styles.groupTitle}>
<Text style={[s.white, s.f18, s.bold]}>Create a new account</Text>
</View>
<View style={styles.groupContent}>
<FontAwesomeIcon icon="globe" style={styles.groupContentIcon} />
<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 style={styles.textBtnLabel}>
<Text type="xl" style={[pal.text, styles.textBtnLabel]}>
{toNiceDomain(serviceUrl)}
</Text>
<View style={styles.textBtnFakeInnerBtn}>
<View style={[pal.btn, styles.textBtnFakeInnerBtn]}>
<FontAwesomeIcon
icon="pen"
size={12}
style={styles.textBtnFakeInnerBtnIcon}
style={[pal.textLight, styles.textBtnFakeInnerBtnIcon]}
/>
<Text style={styles.textBtnFakeInnerBtnLabel}>Change</Text>
<Text style={[pal.textLight]}>Change</Text>
</View>
</TouchableOpacity>
</View>
{serviceDescription ? (
<>
</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={styles.groupContent}>
<View
style={[pal.border, styles.groupContent, styles.noTopBorder]}>
<FontAwesomeIcon
icon="ticket"
style={styles.groupContentIcon}
style={[pal.textLight, styles.groupContentIcon]}
/>
<TextInput
style={[styles.textInput]}
style={[pal.text, styles.textInput]}
placeholder="Invite code"
placeholderTextColor={colors.blue0}
placeholderTextColor={pal.colors.textLight}
autoCapitalize="none"
autoCorrect={false}
autoFocus
@ -233,16 +189,16 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
/>
</View>
) : undefined}
<View style={styles.groupContent}>
<View style={[pal.border, styles.groupContent]}>
<FontAwesomeIcon
icon="envelope"
style={styles.groupContentIcon}
style={[pal.textLight, styles.groupContentIcon]}
/>
<TextInput
testID="registerEmailInput"
style={[styles.textInput]}
style={[pal.text, styles.textInput]}
placeholder="Email address"
placeholderTextColor={colors.blue0}
placeholderTextColor={pal.colors.textLight}
autoCapitalize="none"
autoCorrect={false}
value={email}
@ -250,13 +206,16 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
editable={!isProcessing}
/>
</View>
<View style={styles.groupContent}>
<FontAwesomeIcon icon="lock" style={styles.groupContentIcon} />
<View style={[pal.border, styles.groupContent]}>
<FontAwesomeIcon
icon="lock"
style={[pal.textLight, styles.groupContentIcon]}
/>
<TextInput
testID="registerPasswordInput"
style={[styles.textInput]}
style={[pal.text, styles.textInput]}
placeholder="Choose your password"
placeholderTextColor={colors.blue0}
placeholderTextColor={pal.colors.textLight}
autoCapitalize="none"
autoCorrect={false}
secureTextEntry
@ -265,24 +224,28 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
editable={!isProcessing}
/>
</View>
</>
) : undefined}
</View>
</View>
</>
) : undefined}
{serviceDescription ? (
<>
<View style={styles.group}>
<View style={styles.groupTitle}>
<Text style={[s.white, s.f18, s.bold]}>
Choose your username
</Text>
</View>
<View style={styles.groupContent}>
<FontAwesomeIcon icon="at" style={styles.groupContentIcon} />
<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={[styles.textInput]}
style={[pal.text, styles.textInput]}
placeholder="eg alice"
placeholderTextColor={colors.blue0}
placeholderTextColor={pal.colors.textLight}
autoCapitalize="none"
value={handle}
onChangeText={v => setHandle(makeValidHandle(v))}
@ -290,15 +253,15 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
/>
</View>
{serviceDescription.availableUserDomains.length > 1 && (
<View style={styles.groupContent}>
<View style={[pal.border, styles.groupContent]}>
<FontAwesomeIcon
icon="globe"
style={styles.groupContentIcon}
/>
<Picker
style={styles.picker}
style={[pal.text, styles.picker]}
labelStyle={styles.pickerLabel}
iconStyle={styles.pickerIcon}
iconStyle={pal.textLight}
value={userDomain}
items={serviceDescription.availableUserDomains.map(d => ({
label: `.${d}`,
@ -309,41 +272,50 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
/>
</View>
)}
<View style={styles.groupContent}>
<Text style={[s.white, s.p10]}>
<View style={[pal.border, styles.groupContent]}>
<Text style={[pal.textLight, s.p10]}>
Your full username will be{' '}
<Text style={[s.white, s.bold]}>
<Text type="md-bold" style={pal.textLight}>
@{createFullHandle(handle, userDomain)}
</Text>
</Text>
</View>
</View>
<View style={[styles.group]}>
<View style={styles.groupTitle}>
<Text style={[s.white, s.f18, s.bold]}>Legal</Text>
</View>
<View style={styles.groupContent}>
<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={is13 ? styles.checkboxFilled : styles.checkbox}>
<View
style={[
pal.border,
is13 ? styles.checkboxFilled : styles.checkbox,
]}>
{is13 && (
<FontAwesomeIcon icon="check" style={s.blue3} size={14} />
)}
</View>
<Text style={[styles.textBtnLabel, s.f16]}>
<Text style={[pal.text, styles.textBtnLabel]}>
I am 13 years old or older
</Text>
</TouchableOpacity>
</View>
</View>
<Policies />
<Policies serviceDescription={serviceDescription} />
</>
) : undefined}
<View style={[s.flexRow, s.pl20, s.pr20]}>
<TouchableOpacity onPress={onPressBack}>
<Text style={[s.white, s.f18, s.pl5]}>Back</Text>
<Text type="xl" style={pal.link}>
Back
</Text>
</TouchableOpacity>
<View style={s.flex1} />
{isReady ? (
@ -351,21 +323,27 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
testID="createAccountButton"
onPress={onPressNext}>
{isProcessing ? (
<ActivityIndicator color="#fff" />
<ActivityIndicator />
) : (
<Text style={[s.white, s.f18, s.bold, s.pr5]}>Next</Text>
<Text type="xl-bold" style={[pal.link, s.pr5]}>
Next
</Text>
)}
</TouchableOpacity>
) : !serviceDescription && error ? (
<TouchableOpacity
testID="registerRetryButton"
onPress={onPressRetryConnect}>
<Text style={[s.white, s.f18, s.bold, s.pr5]}>Retry</Text>
<Text type="xl-bold" style={[pal.link, s.pr5]}>
Retry
</Text>
</TouchableOpacity>
) : !serviceDescription ? (
<>
<ActivityIndicator color="#fff" />
<Text style={[s.white, s.f18, s.pl5, s.pr5]}>Connecting...</Text>
<Text type="xl-bold" style={[pal.link, s.pr5]}>
Connecting...
</Text>
</>
) : undefined}
</View>
@ -375,6 +353,69 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
)
}
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} 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
@ -382,42 +423,39 @@ function validWebLink(url?: string): string | undefined {
}
const styles = StyleSheet.create({
noTopBorder: {
borderTopWidth: 0,
},
logoHero: {
paddingTop: 30,
paddingBottom: 40,
},
group: {
borderWidth: 1,
borderColor: colors.white,
borderRadius: 10,
marginBottom: 20,
marginHorizontal: 20,
backgroundColor: colors.blue3,
},
groupTitle: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 8,
paddingHorizontal: 12,
groupLabel: {
paddingHorizontal: 20,
paddingBottom: 5,
},
groupContent: {
borderTopWidth: 1,
borderTopColor: colors.blue1,
flexDirection: 'row',
alignItems: 'center',
},
groupContentIcon: {
color: 'white',
marginLeft: 10,
},
textInput: {
flex: 1,
width: '100%',
backgroundColor: colors.blue3,
color: colors.white,
paddingVertical: 10,
paddingHorizontal: 12,
fontSize: 18,
fontSize: 17,
letterSpacing: 0.25,
fontWeight: '400',
borderRadius: 10,
},
textBtn: {
@ -427,47 +465,33 @@ const styles = StyleSheet.create({
},
textBtnLabel: {
flex: 1,
color: colors.white,
paddingVertical: 10,
paddingHorizontal: 12,
fontSize: 18,
},
textBtnFakeInnerBtn: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.blue2,
borderRadius: 6,
paddingVertical: 6,
paddingHorizontal: 8,
marginHorizontal: 6,
},
textBtnFakeInnerBtnIcon: {
color: colors.white,
marginRight: 4,
},
textBtnFakeInnerBtnLabel: {
color: colors.white,
},
picker: {
flex: 1,
width: '100%',
backgroundColor: colors.blue3,
color: colors.white,
paddingVertical: 10,
paddingHorizontal: 12,
fontSize: 18,
fontSize: 17,
borderRadius: 10,
},
pickerLabel: {
color: colors.white,
fontSize: 18,
},
pickerIcon: {
color: colors.white,
fontSize: 17,
},
checkbox: {
borderWidth: 1,
borderColor: colors.white,
borderRadius: 2,
width: 16,
height: 16,
@ -475,8 +499,6 @@ const styles = StyleSheet.create({
},
checkboxFilled: {
borderWidth: 1,
borderColor: colors.white,
backgroundColor: colors.white,
borderRadius: 2,
width: 16,
height: 16,
@ -489,8 +511,6 @@ const styles = StyleSheet.create({
paddingBottom: 20,
},
error: {
borderWidth: 1,
borderColor: colors.red5,
backgroundColor: colors.red4,
flexDirection: 'row',
alignItems: 'center',
@ -509,7 +529,6 @@ const styles = StyleSheet.create({
errorIcon: {
borderWidth: 1,
borderColor: colors.white,
color: colors.white,
borderRadius: 30,
width: 16,
height: 16,

View file

@ -1,26 +1,29 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import LinearGradient from 'react-native-linear-gradient'
import Svg, {Circle, Line, Text as SvgText} from 'react-native-svg'
import {s, gradients} from '../../lib/styles'
import {Text} from '../util/text/Text'
export const Logo = () => {
export const Logo = ({color, size = 100}: {color: string; size?: number}) => {
return (
<View style={styles.logo}>
<Svg width="100" height="100">
<Svg width={size} height={size} viewBox="0 0 100 100">
<Circle
cx="50"
cy="50"
r="46"
fill="none"
stroke="white"
stroke={color}
strokeWidth={2}
/>
<Line stroke="white" strokeWidth={1} x1="30" x2="30" y1="0" y2="100" />
<Line stroke="white" strokeWidth={1} x1="74" x2="74" y1="0" y2="100" />
<Line stroke="white" strokeWidth={1} x1="0" x2="100" y1="22" y2="22" />
<Line stroke="white" strokeWidth={1} x1="0" x2="100" y1="74" y2="74" />
<Line stroke={color} strokeWidth={1} x1="30" x2="30" y1="0" y2="100" />
<Line stroke={color} strokeWidth={1} x1="74" x2="74" y1="0" y2="100" />
<Line stroke={color} strokeWidth={1} x1="0" x2="100" y1="22" y2="22" />
<Line stroke={color} strokeWidth={1} x1="0" x2="100" y1="74" y2="74" />
<SvgText
fill="none"
stroke="white"
stroke={color}
strokeWidth={2}
fontSize="60"
fontWeight="bold"
@ -34,9 +37,32 @@ export const Logo = () => {
)
}
export const LogoTextHero = () => {
return (
<LinearGradient
colors={[gradients.blue.start, gradients.blue.end]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[styles.textHero]}>
<Logo color="white" size={40} />
<Text type="title-lg" style={[s.white, s.pl10]}>
Bluesky
</Text>
</LinearGradient>
)
}
const styles = StyleSheet.create({
logo: {
flexDirection: 'row',
justifyContent: 'center',
},
textHero: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingRight: 20,
paddingVertical: 15,
marginBottom: 20,
},
})

View file

@ -11,23 +11,28 @@ import {
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import * as EmailValidator from 'email-validator'
import {sessionClient as AtpApi, SessionServiceClient} from '@atproto/api'
import {Logo} from './Logo'
import {LogoTextHero} from './Logo'
import {Text} from '../util/text/Text'
import {UserAvatar} from '../util/UserAvatar'
import {s, colors} from '../../lib/styles'
import {createFullHandle, toNiceDomain} from '../../../lib/strings'
import {useStores, RootStoreModel, DEFAULT_SERVICE} from '../../../state'
import {ServiceDescription} from '../../../state/models/session'
import {ServerInputModal} from '../../../state/models/shell-ui'
import {AccountData} from '../../../state/models/session'
import {isNetworkError} from '../../../lib/errors'
import {usePalette} from '../../lib/hooks/usePalette'
enum Forms {
Login,
ChooseAccount,
ForgotPassword,
SetNewPassword,
PasswordUpdated,
}
export const Signin = ({onPressBack}: {onPressBack: () => void}) => {
const pal = usePalette('default')
const store = useStores()
const [error, setError] = useState<string>('')
const [retryDescribeTrigger, setRetryDescribeTrigger] = useState<any>({})
@ -35,7 +40,18 @@ export const Signin = ({onPressBack}: {onPressBack: () => void}) => {
const [serviceDescription, setServiceDescription] = useState<
ServiceDescription | undefined
>(undefined)
const [currentForm, setCurrentForm] = useState<Forms>(Forms.Login)
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('')
@ -73,16 +89,14 @@ export const Signin = ({onPressBack}: {onPressBack: () => void}) => {
const onPressRetryConnect = () => setRetryDescribeTrigger({})
return (
<KeyboardAvoidingView testID="signIn" behavior="padding" style={{flex: 1}}>
<View style={styles.logoHero}>
<Logo />
</View>
<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}
@ -90,6 +104,13 @@ export const Signin = ({onPressBack}: {onPressBack: () => void}) => {
onPressRetryConnect={onPressRetryConnect}
/>
) : undefined}
{currentForm === Forms.ChooseAccount ? (
<ChooseAccountForm
store={store}
onSelectAccount={onSelectAccount}
onPressBack={onPressBack}
/>
) : undefined}
{currentForm === Forms.ForgotPassword ? (
<ForgotPasswordForm
store={store}
@ -119,11 +140,109 @@ export const Signin = ({onPressBack}: {onPressBack: () => void}) => {
)
}
const ChooseAccountForm = ({
store,
onSelectAccount,
onPressBack,
}: {
store: RootStoreModel
onSelectAccount: (account?: AccountData) => void
onPressBack: () => void
}) => {
const pal = usePalette('default')
const [isProcessing, setIsProcessing] = React.useState(false)
const onTryAccount = async (account: AccountData) => {
if (account.accessJwt && account.refreshJwt) {
setIsProcessing(true)
if (await store.session.resumeSession(account)) {
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
displayName={account.displayName}
handle={account.handle}
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]}>
<View style={s.p10}>
<View
style={[pal.btn, {width: 30, height: 30, borderRadius: 15}]}
/>
</View>
<Text style={styles.accountText}>
<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,
@ -134,14 +253,16 @@ const LoginForm = ({
error: string
serviceUrl: string
serviceDescription: ServiceDescription | undefined
initialHandle: string
setError: (v: string) => void
setServiceUrl: (v: string) => void
onPressRetryConnect: () => void
onPressBack: () => void
onPressForgotPassword: () => void
}) => {
const pal = usePalette('default')
const [isProcessing, setIsProcessing] = useState<boolean>(false)
const [handle, setHandle] = useState<string>('')
const [handle, setHandle] = useState<string>(initialHandle)
const [password, setPassword] = useState<string>('')
const onPressSelectService = () => {
@ -197,31 +318,44 @@ const LoginForm = ({
const isReady = !!serviceDescription && !!handle && !!password
return (
<>
<View testID="loginFormView" style={styles.group}>
<TouchableOpacity
testID="loginSelectServiceButton"
style={[styles.groupTitle, {paddingRight: 0, paddingVertical: 6}]}
onPress={onPressSelectService}>
<Text style={[s.flex1, s.white, s.f18, s.bold]} numberOfLines={1}>
Sign in to {toNiceDomain(serviceUrl)}
</Text>
<View style={styles.textBtnFakeInnerBtn}>
<FontAwesomeIcon
icon="pen"
size={12}
style={styles.textBtnFakeInnerBtnIcon}
/>
<Text style={styles.textBtnFakeInnerBtnLabel}>Change</Text>
</View>
</TouchableOpacity>
<View style={styles.groupContent}>
<FontAwesomeIcon icon="at" style={styles.groupContentIcon} />
<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} />
</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={styles.textInput}
style={[pal.text, styles.textInput]}
placeholder="Username"
placeholderTextColor={colors.blue0}
placeholderTextColor={pal.colors.textLight}
autoCapitalize="none"
autoFocus
autoCorrect={false}
@ -230,13 +364,16 @@ const LoginForm = ({
editable={!isProcessing}
/>
</View>
<View style={styles.groupContent}>
<FontAwesomeIcon icon="lock" style={styles.groupContentIcon} />
<View style={[pal.borderDark, styles.groupContent]}>
<FontAwesomeIcon
icon="lock"
style={[pal.textLight, styles.groupContentIcon]}
/>
<TextInput
testID="loginPasswordInput"
style={styles.textInput}
style={[pal.text, styles.textInput]}
placeholder="Password"
placeholderTextColor={colors.blue0}
placeholderTextColor={pal.colors.textLight}
autoCapitalize="none"
autoCorrect={false}
secureTextEntry
@ -248,7 +385,7 @@ const LoginForm = ({
testID="forgotPasswordButton"
style={styles.textInputInnerBtn}
onPress={onPressForgotPassword}>
<Text style={styles.textInputInnerBtnLabel}>Forgot</Text>
<Text style={pal.link}>Forgot</Text>
</TouchableOpacity>
</View>
</View>
@ -264,29 +401,37 @@ const LoginForm = ({
) : undefined}
<View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
<TouchableOpacity onPress={onPressBack}>
<Text style={[s.white, s.f18, s.pl5]}>Back</Text>
<Text type="xl" style={[pal.link, s.pl5]}>
Back
</Text>
</TouchableOpacity>
<View style={s.flex1} />
{!serviceDescription && error ? (
<TouchableOpacity
testID="loginRetryButton"
onPress={onPressRetryConnect}>
<Text style={[s.white, s.f18, s.bold, s.pr5]}>Retry</Text>
<Text type="xl-bold" style={[pal.link, s.pr5]}>
Retry
</Text>
</TouchableOpacity>
) : !serviceDescription ? (
<>
<ActivityIndicator color="#fff" />
<Text style={[s.white, s.f18, s.pl10]}>Connecting...</Text>
<ActivityIndicator />
<Text type="xl" style={[pal.textLight, s.pl10]}>
Connecting...
</Text>
</>
) : isProcessing ? (
<ActivityIndicator color="#fff" />
<ActivityIndicator />
) : isReady ? (
<TouchableOpacity testID="loginNextButton" onPress={onPressNext}>
<Text style={[s.white, s.f18, s.bold, s.pr5]}>Next</Text>
<Text type="xl-bold" style={[pal.link, s.pr5]}>
Next
</Text>
</TouchableOpacity>
) : undefined}
</View>
</>
</View>
)
}
@ -309,6 +454,7 @@ const ForgotPasswordForm = ({
onPressBack: () => void
onEmailSent: () => void
}) => {
const pal = usePalette('default')
const [isProcessing, setIsProcessing] = useState<boolean>(false)
const [email, setEmail] = useState<string>('')
@ -344,72 +490,88 @@ const ForgotPasswordForm = ({
return (
<>
<Text style={styles.screenTitle}>Reset password</Text>
<Text style={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={styles.group}>
<TouchableOpacity
testID="forgotPasswordSelectServiceButton"
style={[styles.groupContent, {borderTopWidth: 0}]}
onPress={onPressSelectService}>
<FontAwesomeIcon icon="globe" style={styles.groupContentIcon} />
<Text style={styles.textInput} numberOfLines={1}>
{toNiceDomain(serviceUrl)}
</Text>
<View style={styles.textBtnFakeInnerBtn}>
<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="pen"
size={12}
style={styles.textBtnFakeInnerBtnIcon}
icon="globe"
style={[pal.textLight, styles.groupContentIcon]}
/>
<Text style={styles.textBtnFakeInnerBtnLabel}>Change</Text>
</View>
</TouchableOpacity>
<View style={styles.groupContent}>
<FontAwesomeIcon icon="envelope" style={styles.groupContentIcon} />
<TextInput
testID="forgotPasswordEmail"
style={styles.textInput}
placeholder="Email address"
placeholderTextColor={colors.blue0}
autoCapitalize="none"
autoFocus
autoCorrect={false}
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 style={[s.white, s.f18, s.pl5]}>Back</Text>
</TouchableOpacity>
<View style={s.flex1} />
{!serviceDescription || isProcessing ? (
<ActivityIndicator color="#fff" />
) : !email ? (
<Text style={[s.blue1, s.f18, s.bold, s.pr5]}>Next</Text>
) : (
<TouchableOpacity testID="newPasswordButton" onPress={onPressNext}>
<Text style={[s.white, s.f18, s.bold, s.pr5]}>Next</Text>
<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} />
</View>
</TouchableOpacity>
)}
{!serviceDescription || isProcessing ? (
<Text style={[s.white, s.f18, s.pl10]}>Processing...</Text>
<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}
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, {opacity: 0.5}]}>
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>
</>
)
@ -430,6 +592,7 @@ const SetNewPasswordForm = ({
onPressBack: () => void
onPasswordSet: () => void
}) => {
const pal = usePalette('default')
const [isProcessing, setIsProcessing] = useState<boolean>(false)
const [resetCode, setResetCode] = useState<string>('')
const [password, setPassword] = useState<string>('')
@ -458,87 +621,119 @@ const SetNewPasswordForm = ({
return (
<>
<Text style={styles.screenTitle}>Set new password</Text>
<Text style={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={styles.group}>
<View style={[styles.groupContent, {borderTopWidth: 0}]}>
<FontAwesomeIcon icon="ticket" style={styles.groupContentIcon} />
<TextInput
testID="resetCodeInput"
style={[styles.textInput]}
placeholder="Reset code"
placeholderTextColor={colors.blue0}
autoCapitalize="none"
autoCorrect={false}
autoFocus
value={resetCode}
onChangeText={setResetCode}
editable={!isProcessing}
/>
</View>
<View style={styles.groupContent}>
<FontAwesomeIcon icon="lock" style={styles.groupContentIcon} />
<TextInput
testID="newPasswordInput"
style={styles.textInput}
placeholder="New password"
placeholderTextColor={colors.blue0}
autoCapitalize="none"
autoCorrect={false}
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} />
<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}
autoFocus
value={resetCode}
onChangeText={setResetCode}
editable={!isProcessing}
/>
</View>
<View style={s.flex1}>
<Text style={[s.white, s.bold]}>{error}</Text>
<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}
secureTextEntry
value={password}
onChangeText={setPassword}
editable={!isProcessing}
/>
</View>
</View>
) : undefined}
<View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
<TouchableOpacity onPress={onPressBack}>
<Text style={[s.white, s.f18, s.pl5]}>Back</Text>
</TouchableOpacity>
<View style={s.flex1} />
{isProcessing ? (
<ActivityIndicator color="#fff" />
) : !resetCode || !password ? (
<Text style={[s.blue1, s.f18, s.bold, s.pr5]}>Next</Text>
) : (
<TouchableOpacity testID="setNewPasswordButton" onPress={onPressNext}>
<Text style={[s.white, s.f18, s.bold, s.pr5]}>Next</Text>
</TouchableOpacity>
)}
{isProcessing ? (
<Text style={[s.white, s.f18, s.pl10]}>Updating...</Text>
{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, {opacity: 0.5}]}>
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 pal = usePalette('default')
return (
<>
<Text style={styles.screenTitle}>Password updated!</Text>
<Text style={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 style={[s.white, s.f18, s.bold, s.pr5]}>Okay</Text>
</TouchableOpacity>
<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>
</>
)
@ -546,53 +741,42 @@ const PasswordUpdatedForm = ({onPressNext}: {onPressNext: () => void}) => {
const styles = StyleSheet.create({
screenTitle: {
color: colors.white,
fontSize: 26,
marginBottom: 10,
marginHorizontal: 20,
},
instructions: {
color: colors.white,
fontSize: 16,
marginBottom: 20,
marginHorizontal: 20,
},
logoHero: {
paddingTop: 30,
paddingBottom: 40,
},
group: {
borderWidth: 1,
borderColor: colors.white,
borderRadius: 10,
marginBottom: 20,
marginHorizontal: 20,
backgroundColor: colors.blue3,
},
groupTitle: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 8,
paddingHorizontal: 12,
groupLabel: {
paddingHorizontal: 20,
paddingBottom: 5,
},
groupContent: {
borderTopWidth: 1,
borderTopColor: colors.blue1,
flexDirection: 'row',
alignItems: 'center',
},
noTopBorder: {
borderTopWidth: 0,
},
groupContentIcon: {
color: 'white',
marginLeft: 10,
},
textInput: {
flex: 1,
width: '100%',
backgroundColor: colors.blue3,
color: colors.white,
paddingVertical: 10,
paddingHorizontal: 12,
fontSize: 18,
fontSize: 17,
letterSpacing: 0.25,
fontWeight: '400',
borderRadius: 10,
},
textInputInnerBtn: {
@ -602,28 +786,31 @@ const styles = StyleSheet.create({
paddingHorizontal: 8,
marginHorizontal: 6,
},
textInputInnerBtnLabel: {
color: colors.white,
textBtn: {
flexDirection: 'row',
flex: 1,
alignItems: 'center',
},
textBtnLabel: {
flex: 1,
paddingVertical: 10,
paddingHorizontal: 12,
},
textBtnFakeInnerBtn: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.blue2,
borderRadius: 6,
paddingVertical: 6,
paddingHorizontal: 8,
marginHorizontal: 6,
},
textBtnFakeInnerBtnIcon: {
color: colors.white,
marginRight: 4,
},
textBtnFakeInnerBtnLabel: {
color: colors.white,
accountText: {
flex: 1,
flexDirection: 'row',
alignItems: 'baseline',
paddingVertical: 10,
},
error: {
borderWidth: 1,
borderColor: colors.red5,
backgroundColor: colors.red4,
flexDirection: 'row',
alignItems: 'center',

View file

@ -33,7 +33,7 @@ export function Component({
}
return (
<View style={s.flex1}>
<View style={s.flex1} testID="serverInputModal">
<Text style={[s.textCenter, s.bold, s.f18]}>Choose Service</Text>
<BottomSheetScrollView style={styles.inner}>
<View style={styles.group}>
@ -64,6 +64,7 @@ export function Component({
<Text style={styles.label}>Other service</Text>
<View style={{flexDirection: 'row'}}>
<BottomSheetTextInput
testID="customServerTextInput"
style={styles.textInput}
placeholder="e.g. https://bsky.app"
placeholderTextColor={colors.gray4}
@ -74,6 +75,7 @@ export function Component({
onChangeText={setCustomUrl}
/>
<TouchableOpacity
testID="customServerSelectBtn"
style={styles.textInputBtn}
onPress={() => doSelect(customUrl)}>
<FontAwesomeIcon

View file

@ -49,6 +49,7 @@ export const ViewHeader = observer(function ViewHeader({
return (
<View style={[styles.header, pal.view]}>
<TouchableOpacity
testID="viewHeaderBackOrMenuBtn"
onPress={canGoBack ? onPressBack : onPressMenu}
hitSlop={BACK_HITSLOP}
style={canGoBack ? styles.backIcon : styles.backIconWide}>