From 9027882fb401df2a9df6a89facb2bdb94b8b731b Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Tue, 24 Jan 2023 09:06:27 -0600 Subject: 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 --- src/state/index.ts | 4 +- src/state/models/session.ts | 218 +++++++++---- src/view/com/login/CreateAccount.tsx | 327 ++++++++++--------- src/view/com/login/Logo.tsx | 42 ++- src/view/com/login/Signin.tsx | 589 +++++++++++++++++++++++------------ src/view/com/modals/ServerInput.tsx | 4 +- src/view/com/util/ViewHeader.tsx | 1 + src/view/lib/ThemeContext.tsx | 1 + src/view/lib/hooks/usePalette.ts | 4 + src/view/lib/themes.ts | 7 + src/view/screens/Login.tsx | 73 +++-- src/view/screens/Settings.tsx | 116 ++++++- src/view/shell/mobile/Menu.tsx | 2 +- src/view/shell/mobile/index.tsx | 18 +- 14 files changed, 925 insertions(+), 481 deletions(-) (limited to 'src') 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 + +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 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 | undefined + private _connectPromise: Promise | 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 { + /** + * 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 { 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 { + private async _connect(): Promise { 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 { 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 { + 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(false) const [serviceUrl, setServiceUrl] = useState(DEFAULT_SERVICE) @@ -114,74 +116,14 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => { } } - const Policies = () => { - if (!serviceDescription) { - return - } - const tos = validWebLink(serviceDescription.links?.termsOfService) - const pp = validWebLink(serviceDescription.links?.privacyPolicy) - if (!tos && !pp) { - return ( - - - - - - This service has not provided terms of service or a privacy policy. - - - ) - } - const els = [] - if (tos) { - els.push( - , - ) - } - if (pp) { - els.push( - , - ) - } - if (els.length === 2) { - els.splice( - 1, - 0, - - {' '} - and{' '} - , - ) - } - return ( - - - By creating an account you agree to the {els}. - - - ) - } - const isReady = !!email && !!password && !!handle && is13 return ( - - - - - + + + {error ? ( - + @@ -189,41 +131,55 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => { ) : undefined} - - - Create a new account - - - + + + Service provider + + + + + - + {toNiceDomain(serviceUrl)} - + - Change + Change - {serviceDescription ? ( - <> + + {serviceDescription ? ( + <> + + + Account details + + + {serviceDescription?.inviteCodeRequired ? ( - + void}) => { /> ) : undefined} - + void}) => { editable={!isProcessing} /> - - + + void}) => { editable={!isProcessing} /> - - ) : undefined} - + + + ) : undefined} {serviceDescription ? ( <> - - - - Choose your username - - - - + + + Choose your username + + + + + setHandle(makeValidHandle(v))} @@ -290,15 +253,15 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => { /> {serviceDescription.availableUserDomains.length > 1 && ( - + ({ label: `.${d}`, @@ -309,41 +272,50 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => { /> )} - - + + Your full username will be{' '} - + @{createFullHandle(handle, userDomain)} - - - Legal - - + + + Legal + + + + setIs13(!is13)}> - + {is13 && ( )} - + I am 13 years old or older - + ) : undefined} - Back + + Back + {isReady ? ( @@ -351,21 +323,27 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => { testID="createAccountButton" onPress={onPressNext}> {isProcessing ? ( - + ) : ( - Next + + Next + )} ) : !serviceDescription && error ? ( - Retry + + Retry + ) : !serviceDescription ? ( <> - Connecting... + + Connecting... + ) : undefined} @@ -375,6 +353,69 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => { ) } +const Policies = ({ + serviceDescription, +}: { + serviceDescription: ServiceDescription +}) => { + const pal = usePalette('default') + if (!serviceDescription) { + return + } + const tos = validWebLink(serviceDescription.links?.termsOfService) + const pp = validWebLink(serviceDescription.links?.privacyPolicy) + if (!tos && !pp) { + return ( + + + + + + This service has not provided terms of service or a privacy policy. + + + ) + } + const els = [] + if (tos) { + els.push( + , + ) + } + if (pp) { + els.push( + , + ) + } + if (els.length === 2) { + els.splice( + 1, + 0, + + {' '} + and{' '} + , + ) + } + return ( + + + By creating an account you agree to the {els}. + + + ) +} + function validWebLink(url?: string): string | undefined { return url && (url.startsWith('http://') || url.startsWith('https://')) ? url @@ -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 ( - + - - - - + + + + { ) } +export const LogoTextHero = () => { + return ( + + + + Bluesky + + + ) +} + 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('') const [retryDescribeTrigger, setRetryDescribeTrigger] = useState({}) @@ -35,7 +40,18 @@ export const Signin = ({onPressBack}: {onPressBack: () => void}) => { const [serviceDescription, setServiceDescription] = useState< ServiceDescription | undefined >(undefined) - const [currentForm, setCurrentForm] = useState(Forms.Login) + const [initialHandle, setInitialHandle] = useState('') + const [currentForm, setCurrentForm] = useState( + 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 ( - - - - + {currentForm === Forms.Login ? ( void}) => { onPressRetryConnect={onPressRetryConnect} /> ) : undefined} + {currentForm === Forms.ChooseAccount ? ( + + ) : undefined} {currentForm === Forms.ForgotPassword ? ( 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 ( + + + + Sign in as... + + {store.session.accounts.map(account => ( + onTryAccount(account)}> + + + + + + + {account.displayName || account.handle}{' '} + + + {account.handle} + + + + + + ))} + onSelectAccount(undefined)}> + + + + + + + Other account + + + + + + + + + Back + + + + {isProcessing && } + + + ) +} + 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(false) - const [handle, setHandle] = useState('') + const [handle, setHandle] = useState(initialHandle) const [password, setPassword] = useState('') const onPressSelectService = () => { @@ -197,31 +318,44 @@ const LoginForm = ({ const isReady = !!serviceDescription && !!handle && !!password return ( - <> - - - - Sign in to {toNiceDomain(serviceUrl)} - - - - Change - - - - + + + + Sign into + + + + + + + {toNiceDomain(serviceUrl)} + + + + + + + + + Account + + + + - - + + - Forgot + Forgot @@ -264,29 +401,37 @@ const LoginForm = ({ ) : undefined} - Back + + Back + {!serviceDescription && error ? ( - Retry + + Retry + ) : !serviceDescription ? ( <> - - Connecting... + + + Connecting... + ) : isProcessing ? ( - + ) : isReady ? ( - Next + + Next + ) : undefined} - + ) } @@ -309,6 +454,7 @@ const ForgotPasswordForm = ({ onPressBack: () => void onEmailSent: () => void }) => { + const pal = usePalette('default') const [isProcessing, setIsProcessing] = useState(false) const [email, setEmail] = useState('') @@ -344,72 +490,88 @@ const ForgotPasswordForm = ({ return ( <> - Reset password - - Enter the email you used to create your account. We'll send you a "reset - code" so you can set a new password. - - - - - - {toNiceDomain(serviceUrl)} - - + + + + Reset password + + + Enter the email you used to create your account. We'll send you a + "reset code" so you can set a new password. + + + + + + {toNiceDomain(serviceUrl)} + + + + + + + - Change - - - - - - {error ? ( - - - - - - {error} + {error ? ( + + + + + + {error} + - - ) : undefined} - - - Back - - - {!serviceDescription || isProcessing ? ( - - ) : !email ? ( - Next - ) : ( - - Next - - )} - {!serviceDescription || isProcessing ? ( - Processing... ) : undefined} + + + + Back + + + + {!serviceDescription || isProcessing ? ( + + ) : !email ? ( + + Next + + ) : ( + + + Next + + + )} + {!serviceDescription || isProcessing ? ( + + Processing... + + ) : undefined} + ) @@ -430,6 +592,7 @@ const SetNewPasswordForm = ({ onPressBack: () => void onPasswordSet: () => void }) => { + const pal = usePalette('default') const [isProcessing, setIsProcessing] = useState(false) const [resetCode, setResetCode] = useState('') const [password, setPassword] = useState('') @@ -458,87 +621,119 @@ const SetNewPasswordForm = ({ return ( <> - Set new password - - You will receive an email with a "reset code." Enter that code here, - then enter your new password. - - - - - - - - - - - - {error ? ( - - - + + + + Set new password + + + You will receive an email with a "reset code." Enter that code here, + then enter your new password. + + + + + - - {error} + + + - ) : undefined} - - - Back - - - {isProcessing ? ( - - ) : !resetCode || !password ? ( - Next - ) : ( - - Next - - )} - {isProcessing ? ( - Updating... + {error ? ( + + + + + + {error} + + ) : undefined} + + + + Back + + + + {isProcessing ? ( + + ) : !resetCode || !password ? ( + + Next + + ) : ( + + + Next + + + )} + {isProcessing ? ( + + Updating... + + ) : undefined} + ) } const PasswordUpdatedForm = ({onPressNext}: {onPressNext: () => void}) => { + const pal = usePalette('default') return ( <> - Password updated! - - You can now sign in with your new password. - - - - - Okay - + + + + Password updated! + + + You can now sign in with your new password. + + + + + + Okay + + + ) @@ -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 ( - + Choose Service @@ -64,6 +64,7 @@ export function Component({ Other service doSelect(customUrl)}> 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 ( <> - + Bluesky [ private beta ] @@ -76,40 +80,61 @@ const SigninOrCreateAccount = ({ export const Login = observer( (/*{navigation}: RootTabsScreenProps<'Login'>*/) => { + const pal = usePalette('default') const [screenState, setScreenState] = useState( ScreenState.SigninOrCreateAccount, ) + if (screenState === ScreenState.SigninOrCreateAccount) { + return ( + + + + setScreenState(ScreenState.Signin)} + onPressCreateAccount={() => + setScreenState(ScreenState.CreateAccount) + } + /> + + + + ) + } + return ( - - {screenState === ScreenState.SigninOrCreateAccount ? ( - setScreenState(ScreenState.Signin)} - onPressCreateAccount={() => - setScreenState(ScreenState.CreateAccount) - } - /> - ) : undefined} - {screenState === ScreenState.Signin ? ( - - setScreenState(ScreenState.SigninOrCreateAccount) - } - /> - ) : undefined} - {screenState === ScreenState.CreateAccount ? ( - - setScreenState(ScreenState.SigninOrCreateAccount) - } - /> - ) : undefined} + + + + {screenState === ScreenState.Signin ? ( + + setScreenState(ScreenState.SigninOrCreateAccount) + } + /> + ) : undefined} + {screenState === ScreenState.CreateAccount ? ( + + setScreenState(ScreenState.SigninOrCreateAccount) + } + /> + ) : undefined} + + ) }, ) 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 ( - + - + - + Signed in as - + Sign out - + {isSwitching ? ( + + + ) : ( + + + + + + {store.me.displayName || store.me.handle} + + @{store.me.handle} + + + + )} + + Switch to: + + {store.session.switchableAccounts.map(account => ( + onPressSwitchAccount(account) + }> - {store.me.displayName || store.me.handle} + {account.displayName || account.handle} - @{store.me.handle} + @{account.handle} + + ))} + + + + + Add account + - - + + Developer tools @@ -80,12 +159,15 @@ export const Settings = observer(function Settings({ Storybook - + ) }) 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 }) => ( onNavigate(url || '/')}> 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 ( - - - - - - + + - + ) } if (store.onboard.isOnboarding) { -- cgit 1.4.1