diff options
author | Paul Frazee <pfrazee@gmail.com> | 2023-01-24 09:06:27 -0600 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-01-24 09:06:27 -0600 |
commit | 9027882fb401df2a9df6a89facb2bdb94b8b731b (patch) | |
tree | dc60ca1a2cc1be0838229f06b588f56871f2b91e /src | |
parent | 439305b57e0c20799d87baf92c067ec8e262ea13 (diff) | |
download | voidsky-9027882fb401df2a9df6a89facb2bdb94b8b731b.tar.zst |
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
Diffstat (limited to 'src')
-rw-r--r-- | src/state/index.ts | 4 | ||||
-rw-r--r-- | src/state/models/session.ts | 218 | ||||
-rw-r--r-- | src/view/com/login/CreateAccount.tsx | 327 | ||||
-rw-r--r-- | src/view/com/login/Logo.tsx | 42 | ||||
-rw-r--r-- | src/view/com/login/Signin.tsx | 589 | ||||
-rw-r--r-- | src/view/com/modals/ServerInput.tsx | 4 | ||||
-rw-r--r-- | src/view/com/util/ViewHeader.tsx | 1 | ||||
-rw-r--r-- | src/view/lib/ThemeContext.tsx | 1 | ||||
-rw-r--r-- | src/view/lib/hooks/usePalette.ts | 4 | ||||
-rw-r--r-- | src/view/lib/themes.ts | 7 | ||||
-rw-r--r-- | src/view/screens/Login.tsx | 73 | ||||
-rw-r--r-- | src/view/screens/Settings.tsx | 116 | ||||
-rw-r--r-- | src/view/shell/mobile/Menu.tsx | 2 | ||||
-rw-r--r-- | src/view/shell/mobile/index.tsx | 18 |
14 files changed, 925 insertions, 481 deletions
diff --git a/src/state/index.ts b/src/state/index.ts index 5c8b50ef1..78fba2ecf 100644 --- a/src/state/index.ts +++ b/src/state/index.ts @@ -13,13 +13,13 @@ export const DEFAULT_SERVICE = PROD_SERVICE const ROOT_STATE_STORAGE_KEY = 'root' const STATE_FETCH_INTERVAL = 15e3 -export async function setupState() { +export async function setupState(serviceUri = DEFAULT_SERVICE) { let rootStore: RootStoreModel let data: any libapi.doPolyfill() - const api = AtpApi.service(DEFAULT_SERVICE) as SessionServiceClient + const api = AtpApi.service(serviceUri) as SessionServiceClient rootStore = new RootStoreModel(api) try { data = (await storage.load(ROOT_STATE_STORAGE_KEY)) || {} diff --git a/src/state/models/session.ts b/src/state/models/session.ts index 13e0fcbe0..89347af9a 100644 --- a/src/state/models/session.ts +++ b/src/state/models/session.ts @@ -6,24 +6,44 @@ import { ComAtprotoServerGetAccountsConfig as GetAccountsConfig, } from '@atproto/api' import {isObj, hasProp} from '../lib/type-guards' +import {z} from 'zod' import {RootStoreModel} from './root-store' import {isNetworkError} from '../../lib/errors' export type ServiceDescription = GetAccountsConfig.OutputSchema -interface SessionData { - service: string - refreshJwt: string - accessJwt: string - handle: string - did: string -} +export const sessionData = z.object({ + service: z.string(), + refreshJwt: z.string(), + accessJwt: z.string(), + handle: z.string(), + did: z.string(), +}) +export type SessionData = z.infer<typeof sessionData> + +export const accountData = z.object({ + service: z.string(), + refreshJwt: z.string().optional(), + accessJwt: z.string().optional(), + handle: z.string(), + did: z.string(), + displayName: z.string().optional(), + aviUrl: z.string().optional(), +}) +export type AccountData = z.infer<typeof accountData> export class SessionModel { + /** + * Current session data + */ data: SessionData | null = null + /** + * A listing of the currently & previous sessions, used for account switching + */ + accounts: AccountData[] = [] online = false attemptingConnect = false - private _connectPromise: Promise<void> | undefined + private _connectPromise: Promise<boolean> | undefined constructor(public rootStore: RootStoreModel) { makeAutoObservable(this, { @@ -37,51 +57,32 @@ export class SessionModel { return this.data !== null } + get hasAccounts() { + return this.accounts.length >= 1 + } + + get switchableAccounts() { + return this.accounts.filter(acct => acct.did !== this.data?.did) + } + serialize(): unknown { return { data: this.data, + accounts: this.accounts, } } hydrate(v: unknown) { + this.accounts = [] if (isObj(v)) { - if (hasProp(v, 'data') && isObj(v.data)) { - const data: SessionData = { - service: '', - refreshJwt: '', - accessJwt: '', - handle: '', - did: '', - } - if (hasProp(v.data, 'service') && typeof v.data.service === 'string') { - data.service = v.data.service - } - if ( - hasProp(v.data, 'refreshJwt') && - typeof v.data.refreshJwt === 'string' - ) { - data.refreshJwt = v.data.refreshJwt - } - if ( - hasProp(v.data, 'accessJwt') && - typeof v.data.accessJwt === 'string' - ) { - data.accessJwt = v.data.accessJwt - } - if (hasProp(v.data, 'handle') && typeof v.data.handle === 'string') { - data.handle = v.data.handle - } - if (hasProp(v.data, 'did') && typeof v.data.did === 'string') { - data.did = v.data.did - } - if ( - data.service && - data.refreshJwt && - data.accessJwt && - data.handle && - data.did - ) { - this.data = data + if (hasProp(v, 'data') && sessionData.safeParse(v.data)) { + this.data = v.data as SessionData + } + if (hasProp(v, 'accounts') && Array.isArray(v.accounts)) { + for (const account of v.accounts) { + if (accountData.safeParse(account)) { + this.accounts.push(account as AccountData) + } } } } @@ -113,6 +114,9 @@ export class SessionModel { } } + /** + * Sets up the XRPC API, must be called before connecting to a service + */ private configureApi(): boolean { if (!this.data) { return false @@ -137,19 +141,68 @@ export class SessionModel { return true } - async connect(): Promise<void> { + /** + * Upserts the current session into the accounts + */ + private addSessionToAccounts() { + if (!this.data) { + return + } + const existingAccount = this.accounts.find( + acc => acc.service === this.data?.service && acc.did === this.data.did, + ) + const newAccount = { + service: this.data.service, + refreshJwt: this.data.refreshJwt, + accessJwt: this.data.accessJwt, + handle: this.data.handle, + did: this.data.did, + displayName: this.rootStore.me.displayName, + aviUrl: this.rootStore.me.avatar, + } + if (!existingAccount) { + this.accounts.push(newAccount) + } else { + this.accounts = this.accounts + .filter( + acc => + !(acc.service === this.data?.service && acc.did === this.data.did), + ) + .concat([newAccount]) + } + } + + /** + * Clears any session tokens from the accounts; used on logout. + */ + private clearSessionTokensFromAccounts() { + this.accounts = this.accounts.map(acct => ({ + service: acct.service, + handle: acct.handle, + did: acct.did, + displayName: acct.displayName, + aviUrl: acct.aviUrl, + })) + } + + /** + * Fetches the current session from the service, if possible. + * Requires an existing session (.data) to be populated with access tokens. + */ + async connect(): Promise<boolean> { if (this._connectPromise) { return this._connectPromise } this._connectPromise = this._connect() - await this._connectPromise + const res = await this._connectPromise this._connectPromise = undefined + return res } - private async _connect(): Promise<void> { + private async _connect(): Promise<boolean> { this.attemptingConnect = true if (!this.configureApi()) { - return + return false } try { @@ -159,29 +212,44 @@ export class SessionModel { if (this.rootStore.me.did !== sess.data.did) { this.rootStore.me.clear() } - this.rootStore.me.load().catch(e => { - this.rootStore.log.error('Failed to fetch local user information', e) - }) - return // success + this.rootStore.me + .load() + .catch(e => { + this.rootStore.log.error( + 'Failed to fetch local user information', + e, + ) + }) + .then(() => { + this.addSessionToAccounts() + }) + return true // success } } catch (e: any) { if (isNetworkError(e)) { this.setOnline(false, false) // connection issue - return + return false } else { this.clear() // invalid session cached } } this.setOnline(false, false) + return false } + /** + * Helper to fetch the accounts config settings from an account. + */ async describeService(service: string): Promise<ServiceDescription> { const api = AtpApi.service(service) as SessionServiceClient const res = await api.com.atproto.server.getAccountsConfig({}) return res.data } + /** + * Create a new session. + */ async login({ service, handle, @@ -203,10 +271,33 @@ export class SessionModel { }) this.configureApi() this.setOnline(true, false) - this.rootStore.me.load().catch(e => { - this.rootStore.log.error('Failed to fetch local user information', e) + this.rootStore.me + .load() + .catch(e => { + this.rootStore.log.error('Failed to fetch local user information', e) + }) + .then(() => { + this.addSessionToAccounts() + }) + } + } + + /** + * Attempt to resume a session that we still have access tokens for. + */ + async resumeSession(account: AccountData): Promise<boolean> { + if (account.accessJwt && account.refreshJwt) { + this.setState({ + service: account.service, + accessJwt: account.accessJwt, + refreshJwt: account.refreshJwt, + handle: account.handle, + did: account.did, }) + } else { + return false } + return this.connect() } async createAccount({ @@ -239,12 +330,20 @@ export class SessionModel { }) this.rootStore.onboard.start() this.configureApi() - this.rootStore.me.load().catch(e => { - this.rootStore.log.error('Failed to fetch local user information', e) - }) + this.rootStore.me + .load() + .catch(e => { + this.rootStore.log.error('Failed to fetch local user information', e) + }) + .then(() => { + this.addSessionToAccounts() + }) } } + /** + * Close all sessions across all accounts. + */ async logout() { if (this.hasSession) { this.rootStore.api.com.atproto.session.delete().catch((e: any) => { @@ -254,6 +353,7 @@ export class SessionModel { ) }) } + this.clearSessionTokensFromAccounts() this.rootStore.clearAll() } } diff --git a/src/view/com/login/CreateAccount.tsx b/src/view/com/login/CreateAccount.tsx index 349c48ef7..6c597408f 100644 --- a/src/view/com/login/CreateAccount.tsx +++ b/src/view/com/login/CreateAccount.tsx @@ -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, diff --git a/src/view/com/login/Logo.tsx b/src/view/com/login/Logo.tsx index d1dc9c671..7045e4152 100644 --- a/src/view/com/login/Logo.tsx +++ b/src/view/com/login/Logo.tsx @@ -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, + }, }) diff --git a/src/view/com/login/Signin.tsx b/src/view/com/login/Signin.tsx index e99aaa651..a39ea5e74 100644 --- a/src/view/com/login/Signin.tsx +++ b/src/view/com/login/Signin.tsx @@ -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="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} /> + </View> + </TouchableOpacity> + <View style={[pal.borderDark, styles.groupContent]}> <FontAwesomeIcon - icon="pen" - size={12} - style={styles.textBtnFakeInnerBtnIcon} + 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} /> - <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> + {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> - </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> - </TouchableOpacity> - )} - {!serviceDescription || isProcessing ? ( - <Text style={[s.white, s.f18, s.pl10]}>Processing...</Text> ) : 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', diff --git a/src/view/com/modals/ServerInput.tsx b/src/view/com/modals/ServerInput.tsx index 884fb91e6..c8174f3cd 100644 --- a/src/view/com/modals/ServerInput.tsx +++ b/src/view/com/modals/ServerInput.tsx @@ -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 diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx index d1e9b397b..761553cc5 100644 --- a/src/view/com/util/ViewHeader.tsx +++ b/src/view/com/util/ViewHeader.tsx @@ -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}> diff --git a/src/view/lib/ThemeContext.tsx b/src/view/lib/ThemeContext.tsx index 54ae71277..16a7d9cb3 100644 --- a/src/view/lib/ThemeContext.tsx +++ b/src/view/lib/ThemeContext.tsx @@ -18,6 +18,7 @@ export type PaletteColor = { textInverted: string link: string border: string + borderDark: string icon: string [k: string]: string } diff --git a/src/view/lib/hooks/usePalette.ts b/src/view/lib/hooks/usePalette.ts index 890439f34..5b9929c7d 100644 --- a/src/view/lib/hooks/usePalette.ts +++ b/src/view/lib/hooks/usePalette.ts @@ -6,6 +6,7 @@ export interface UsePaletteValue { view: ViewStyle btn: ViewStyle border: ViewStyle + borderDark: ViewStyle text: TextStyle textLight: TextStyle textInverted: TextStyle @@ -25,6 +26,9 @@ export function usePalette(color: PaletteColorName): UsePaletteValue { border: { borderColor: palette.border, }, + borderDark: { + borderColor: palette.borderDark, + }, text: { color: palette.text, }, diff --git a/src/view/lib/themes.ts b/src/view/lib/themes.ts index b9e2bdacf..84e2b7883 100644 --- a/src/view/lib/themes.ts +++ b/src/view/lib/themes.ts @@ -13,6 +13,7 @@ export const defaultTheme: Theme = { textInverted: colors.white, link: colors.blue3, border: '#f0e9e9', + borderDark: '#e0d9d9', icon: colors.gray3, // non-standard @@ -32,6 +33,7 @@ export const defaultTheme: Theme = { textInverted: colors.blue3, link: colors.blue0, border: colors.blue4, + borderDark: colors.blue5, icon: colors.blue4, }, secondary: { @@ -42,6 +44,7 @@ export const defaultTheme: Theme = { textInverted: colors.green4, link: colors.green1, border: colors.green4, + borderDark: colors.green5, icon: colors.green4, }, inverted: { @@ -52,6 +55,7 @@ export const defaultTheme: Theme = { textInverted: colors.black, link: colors.blue2, border: colors.gray3, + borderDark: colors.gray2, icon: colors.gray5, }, error: { @@ -62,6 +66,7 @@ export const defaultTheme: Theme = { textInverted: colors.red3, link: colors.red1, border: colors.red4, + borderDark: colors.red5, icon: colors.red4, }, }, @@ -257,6 +262,7 @@ export const darkTheme: Theme = { textInverted: colors.black, link: colors.blue3, border: colors.gray6, + borderDark: colors.gray5, icon: colors.gray5, // non-standard @@ -284,6 +290,7 @@ export const darkTheme: Theme = { textInverted: colors.white, link: colors.blue3, border: colors.gray3, + borderDark: colors.gray4, icon: colors.gray1, }, }, diff --git a/src/view/screens/Login.tsx b/src/view/screens/Login.tsx index 8363dbfe0..7d99f1444 100644 --- a/src/view/screens/Login.tsx +++ b/src/view/screens/Login.tsx @@ -1,17 +1,21 @@ import React, {useState} from 'react' import { + SafeAreaView, StyleSheet, TouchableOpacity, View, useWindowDimensions, } from 'react-native' import Svg, {Line} from 'react-native-svg' +import LinearGradient from 'react-native-linear-gradient' import {observer} from 'mobx-react-lite' import {Signin} from '../com/login/Signin' import {Logo} from '../com/login/Logo' import {CreateAccount} from '../com/login/CreateAccount' import {Text} from '../com/util/text/Text' +import {ErrorBoundary} from '../com/util/ErrorBoundary' import {s, colors} from '../lib/styles' +import {usePalette} from '../lib/hooks/usePalette' enum ScreenState { SigninOrCreateAccount, @@ -31,7 +35,7 @@ const SigninOrCreateAccount = ({ return ( <> <View style={styles.hero}> - <Logo /> + <Logo color="white" /> <Text style={styles.title}>Bluesky</Text> <Text style={styles.subtitle}>[ private beta ]</Text> </View> @@ -76,40 +80,61 @@ const SigninOrCreateAccount = ({ export const Login = observer( (/*{navigation}: RootTabsScreenProps<'Login'>*/) => { + const pal = usePalette('default') const [screenState, setScreenState] = useState<ScreenState>( ScreenState.SigninOrCreateAccount, ) + if (screenState === ScreenState.SigninOrCreateAccount) { + return ( + <LinearGradient + colors={['#007CFF', '#00BCFF']} + start={{x: 0, y: 0.8}} + end={{x: 0, y: 1}} + style={styles.container}> + <SafeAreaView testID="noSessionView" style={styles.container}> + <ErrorBoundary> + <SigninOrCreateAccount + onPressSignin={() => setScreenState(ScreenState.Signin)} + onPressCreateAccount={() => + setScreenState(ScreenState.CreateAccount) + } + /> + </ErrorBoundary> + </SafeAreaView> + </LinearGradient> + ) + } + return ( - <View style={styles.outer}> - {screenState === ScreenState.SigninOrCreateAccount ? ( - <SigninOrCreateAccount - onPressSignin={() => setScreenState(ScreenState.Signin)} - onPressCreateAccount={() => - setScreenState(ScreenState.CreateAccount) - } - /> - ) : undefined} - {screenState === ScreenState.Signin ? ( - <Signin - onPressBack={() => - setScreenState(ScreenState.SigninOrCreateAccount) - } - /> - ) : undefined} - {screenState === ScreenState.CreateAccount ? ( - <CreateAccount - onPressBack={() => - setScreenState(ScreenState.SigninOrCreateAccount) - } - /> - ) : undefined} + <View style={[styles.container, pal.view]}> + <SafeAreaView testID="noSessionView" style={styles.container}> + <ErrorBoundary> + {screenState === ScreenState.Signin ? ( + <Signin + onPressBack={() => + setScreenState(ScreenState.SigninOrCreateAccount) + } + /> + ) : undefined} + {screenState === ScreenState.CreateAccount ? ( + <CreateAccount + onPressBack={() => + setScreenState(ScreenState.SigninOrCreateAccount) + } + /> + ) : undefined} + </ErrorBoundary> + </SafeAreaView> </View> ) }, ) const styles = StyleSheet.create({ + container: { + height: '100%', + }, outer: { flex: 1, }, diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index 22230f24c..2c6982685 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -1,5 +1,12 @@ import React, {useEffect} from 'react' -import {StyleSheet, TouchableOpacity, View} from 'react-native' +import { + ActivityIndicator, + ScrollView, + StyleSheet, + TouchableOpacity, + View, +} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {observer} from 'mobx-react-lite' import {useStores} from '../../state' import {ScreenParams} from '../routes' @@ -7,8 +14,10 @@ import {s} from '../lib/styles' import {ViewHeader} from '../com/util/ViewHeader' import {Link} from '../com/util/Link' import {Text} from '../com/util/text/Text' +import * as Toast from '../com/util/Toast' import {UserAvatar} from '../com/util/UserAvatar' import {usePalette} from '../lib/hooks/usePalette' +import {AccountData} from '../../state/models/session' export const Settings = observer(function Settings({ navIdx, @@ -16,6 +25,7 @@ export const Settings = observer(function Settings({ }: ScreenParams) { const pal = usePalette('default') const store = useStores() + const [isSwitching, setIsSwitching] = React.useState(false) useEffect(() => { if (!visible) { @@ -25,45 +35,114 @@ export const Settings = observer(function Settings({ store.nav.setTitle(navIdx, 'Settings') }, [visible, store]) + const onPressSwitchAccount = async (acct: AccountData) => { + setIsSwitching(true) + if (await store.session.resumeSession(acct)) { + setIsSwitching(false) + Toast.show(`Signed in as ${acct.displayName || acct.handle}`) + return + } + setIsSwitching(false) + Toast.show('Sorry! We need you to enter your password.') + store.session.clear() + } + const onPressAddAccount = () => { + store.session.clear() + } const onPressSignout = () => { store.session.logout() } return ( - <View style={[s.flex1]}> + <View style={[s.h100pct]} testID="settingsScreen"> <ViewHeader title="Settings" /> - <View style={[s.mt10, s.pl10, s.pr10, s.flex1]}> + <ScrollView style={[s.mt10, s.pl10, s.pr10, s.h100pct]}> <View style={[s.flexRow]}> - <Text type="xl" style={pal.text}> + <Text type="xl-bold" style={pal.text}> Signed in as </Text> <View style={s.flex1} /> - <TouchableOpacity onPress={onPressSignout}> + <TouchableOpacity + testID="signOutBtn" + onPress={isSwitching ? undefined : onPressSignout}> <Text type="xl-medium" style={pal.link}> Sign out </Text> </TouchableOpacity> </View> - <Link - href={`/profile/${store.me.handle}`} - title="Your profile" - noFeedback> + {isSwitching ? ( <View style={[pal.view, styles.profile]}> + <ActivityIndicator /> + </View> + ) : ( + <Link + href={`/profile/${store.me.handle}`} + title="Your profile" + noFeedback> + <View style={[pal.view, styles.profile]}> + <UserAvatar + size={40} + displayName={store.me.displayName} + handle={store.me.handle || ''} + avatar={store.me.avatar} + /> + <View style={[s.ml10]}> + <Text type="xl-bold" style={pal.text}> + {store.me.displayName || store.me.handle} + </Text> + <Text style={pal.textLight}>@{store.me.handle}</Text> + </View> + </View> + </Link> + )} + <Text type="sm-medium" style={pal.text}> + Switch to: + </Text> + {store.session.switchableAccounts.map(account => ( + <TouchableOpacity + testID={`switchToAccountBtn-${account.handle}`} + key={account.did} + style={[ + pal.view, + styles.profile, + s.mb2, + isSwitching && styles.dimmed, + ]} + onPress={ + isSwitching ? undefined : () => onPressSwitchAccount(account) + }> <UserAvatar size={40} - displayName={store.me.displayName} - handle={store.me.handle || ''} - avatar={store.me.avatar} + displayName={account.displayName} + handle={account.handle || ''} + avatar={account.aviUrl} /> <View style={[s.ml10]}> <Text type="xl-bold" style={pal.text}> - {store.me.displayName || store.me.handle} + {account.displayName || account.handle} </Text> - <Text style={pal.textLight}>@{store.me.handle}</Text> + <Text style={pal.textLight}>@{account.handle}</Text> </View> + </TouchableOpacity> + ))} + <TouchableOpacity + testID="switchToNewAccountBtn" + style={[ + pal.view, + styles.profile, + s.mb2, + {alignItems: 'center'}, + isSwitching && styles.dimmed, + ]} + onPress={isSwitching ? undefined : onPressAddAccount}> + <FontAwesomeIcon icon="plus" /> + <View style={[s.ml5]}> + <Text type="md-medium" style={pal.text}> + Add account + </Text> </View> - </Link> - <View style={s.flex1} /> + </TouchableOpacity> + <View style={{height: 50}} /> <Text type="sm-medium" style={[s.mb5]}> Developer tools </Text> @@ -80,12 +159,15 @@ export const Settings = observer(function Settings({ <Text style={pal.link}>Storybook</Text> </Link> <View style={s.footerSpacer} /> - </View> + </ScrollView> </View> ) }) const styles = StyleSheet.create({ + dimmed: { + opacity: 0.5, + }, title: { fontSize: 32, fontWeight: 'bold', diff --git a/src/view/shell/mobile/Menu.tsx b/src/view/shell/mobile/Menu.tsx index 875bb5a3d..26cb5b9bd 100644 --- a/src/view/shell/mobile/Menu.tsx +++ b/src/view/shell/mobile/Menu.tsx @@ -62,7 +62,7 @@ export const Menu = observer( onPress?: () => void }) => ( <TouchableOpacity - testID="menuItemButton" + testID={`menuItemButton-${label}`} style={styles.menuItem} onPress={onPress ? onPress : () => onNavigate(url || '/')}> <View style={[styles.menuItemIconWrapper]}> diff --git a/src/view/shell/mobile/index.tsx b/src/view/shell/mobile/index.tsx index c4ca7b9f5..b999d05d9 100644 --- a/src/view/shell/mobile/index.tsx +++ b/src/view/shell/mobile/index.tsx @@ -5,7 +5,6 @@ import { Easing, FlatList, GestureResponderEvent, - SafeAreaView, StatusBar, StyleSheet, TouchableOpacity, @@ -16,7 +15,6 @@ import { ViewStyle, } from 'react-native' import {ScreenContainer, Screen} from 'react-native-screens' -import LinearGradient from 'react-native-linear-gradient' import {useSafeAreaInsets} from 'react-native-safe-area-context' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {IconProp} from '@fortawesome/fontawesome-svg-core' @@ -34,7 +32,7 @@ import {Text} from '../../com/util/text/Text' import {ErrorBoundary} from '../../com/util/ErrorBoundary' import {TabsSelector} from './TabsSelector' import {Composer} from './Composer' -import {s, colors} from '../../lib/styles' +import {colors} from '../../lib/styles' import {clamp} from '../../../lib/numbers' import { GridIcon, @@ -323,18 +321,10 @@ export const MobileShell: React.FC = observer(() => { if (!store.session.hasSession) { return ( - <LinearGradient - colors={['#007CFF', '#00BCFF']} - start={{x: 0, y: 0.8}} - end={{x: 0, y: 1}} - style={styles.outerContainer}> - <SafeAreaView testID="noSessionView" style={styles.innerContainer}> - <ErrorBoundary> - <Login /> - </ErrorBoundary> - </SafeAreaView> + <View style={styles.outerContainer}> + <Login /> <Modal /> - </LinearGradient> + </View> ) } if (store.onboard.isOnboarding) { |