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
zio/stable
Paul Frazee 2023-01-24 09:06:27 -06:00 committed by GitHub
parent 439305b57e
commit 9027882fb4
23 changed files with 2406 additions and 658 deletions

View File

@ -0,0 +1,57 @@
import React from 'react'
import {View, ScrollView, Modal, FlatList, TextInput} from 'react-native'
const BottomSheetModalContext = React.createContext(null)
const BottomSheetModalProvider = (props: any) => {
return <BottomSheetModalContext.Provider {...props} value={{}} />
}
class BottomSheet extends React.Component {
snapToIndex() {}
snapToPosition() {}
expand() {}
collapse() {}
close() {
this.props.onClose?.()
}
forceClose() {}
render() {
return <View>{this.props.children}</View>
}
}
const BottomSheetModal = (props: any) => <Modal {...props} />
const BottomSheetBackdrop = (props: any) => <View {...props} />
const BottomSheetHandle = (props: any) => <View {...props} />
const BottomSheetFooter = (props: any) => <View {...props} />
const BottomSheetScrollView = (props: any) => <ScrollView {...props} />
const BottomSheetFlatList = (props: any) => <FlatList {...props} />
const BottomSheetTextInput = (props: any) => <TextInput {...props} />
const useBottomSheet = jest.fn()
const useBottomSheetModal = jest.fn()
const useBottomSheetSpringConfigs = jest.fn()
const useBottomSheetTimingConfigs = jest.fn()
const useBottomSheetInternal = jest.fn()
const useBottomSheetDynamicSnapPoints = jest.fn()
export {useBottomSheet}
export {useBottomSheetModal}
export {useBottomSheetSpringConfigs}
export {useBottomSheetTimingConfigs}
export {useBottomSheetInternal}
export {useBottomSheetDynamicSnapPoints}
export {
BottomSheetModalProvider,
BottomSheetBackdrop,
BottomSheetHandle,
BottomSheetModal,
BottomSheetFooter,
BottomSheetScrollView,
BottomSheetFlatList,
BottomSheetTextInput,
}
export default BottomSheet

View File

@ -0,0 +1,241 @@
import React from 'react'
import {MobileShell} from '../src/view/shell/mobile'
import {cleanup, fireEvent, render, waitFor} from '../jest/test-utils'
import {createServer, TestPDS} from '../jest/test-pds'
import {RootStoreModel, setupState} from '../src/state'
const WAIT_OPTS = {timeout: 5e3}
describe('Account flows', () => {
let pds: TestPDS | undefined
let rootStore: RootStoreModel | undefined
beforeAll(async () => {
jest.useFakeTimers()
pds = await createServer()
rootStore = await setupState(pds.pdsUrl)
})
afterAll(async () => {
jest.clearAllMocks()
cleanup()
await pds?.close()
})
it('renders initial screen', () => {
const {getByTestId} = render(<MobileShell />, rootStore)
const signUpScreen = getByTestId('signinOrCreateAccount')
expect(signUpScreen).toBeTruthy()
})
it('completes signin to the server', async () => {
const {getByTestId} = render(<MobileShell />, rootStore)
// move to signin view
fireEvent.press(getByTestId('signInButton'))
expect(getByTestId('signIn')).toBeTruthy()
expect(getByTestId('loginForm')).toBeTruthy()
// input the target server
expect(getByTestId('loginSelectServiceButton')).toBeTruthy()
fireEvent.press(getByTestId('loginSelectServiceButton'))
expect(getByTestId('serverInputModal')).toBeTruthy()
fireEvent.changeText(
getByTestId('customServerTextInput'),
pds?.pdsUrl || '',
)
fireEvent.press(getByTestId('customServerSelectBtn'))
await waitFor(() => {
expect(getByTestId('loginUsernameInput')).toBeTruthy()
}, WAIT_OPTS)
// enter username & pass
fireEvent.changeText(getByTestId('loginUsernameInput'), 'alice')
fireEvent.changeText(getByTestId('loginPasswordInput'), 'hunter2')
await waitFor(() => {
expect(getByTestId('loginNextButton')).toBeTruthy()
}, WAIT_OPTS)
fireEvent.press(getByTestId('loginNextButton'))
// signed in
await waitFor(() => {
expect(getByTestId('homeFeed')).toBeTruthy()
expect(rootStore?.me?.displayName).toBe('Alice')
expect(rootStore?.me?.handle).toBe('alice.test')
expect(rootStore?.session.accounts.length).toBe(1)
}, WAIT_OPTS)
expect(rootStore?.me?.displayName).toBe('Alice')
expect(rootStore?.me?.handle).toBe('alice.test')
expect(rootStore?.session.accounts.length).toBe(1)
})
it('opens the login screen when "add account" is pressed', async () => {
const {getByTestId, getAllByTestId} = render(<MobileShell />, rootStore)
await waitFor(() => expect(getByTestId('homeFeed')).toBeTruthy(), WAIT_OPTS)
// open side menu
fireEvent.press(getAllByTestId('viewHeaderBackOrMenuBtn')[0])
await waitFor(() => expect(getByTestId('menuView')).toBeTruthy(), WAIT_OPTS)
// nav to settings
fireEvent.press(getByTestId('menuItemButton-Settings'))
await waitFor(
() => expect(getByTestId('settingsScreen')).toBeTruthy(),
WAIT_OPTS,
)
// press '+ new account' in switcher
fireEvent.press(getByTestId('switchToNewAccountBtn'))
await waitFor(
() => expect(getByTestId('signinOrCreateAccount')).toBeTruthy(),
WAIT_OPTS,
)
})
it('shows the "choose account" form when a previous session has been created', async () => {
const {getByTestId} = render(<MobileShell />, rootStore)
// move to signin view
fireEvent.press(getByTestId('signInButton'))
expect(getByTestId('signIn')).toBeTruthy()
expect(getByTestId('chooseAccountForm')).toBeTruthy()
})
it('logs directly into the account due to still possessing session tokens', async () => {
const {getByTestId} = render(<MobileShell />, rootStore)
// move to signin view
fireEvent.press(getByTestId('signInButton'))
expect(getByTestId('signIn')).toBeTruthy()
expect(getByTestId('chooseAccountForm')).toBeTruthy()
// select the previous account
fireEvent.press(getByTestId('chooseAccountBtn-alice.test'))
// signs in immediately
await waitFor(() => {
expect(getByTestId('homeFeed')).toBeTruthy()
expect(rootStore?.me?.displayName).toBe('Alice')
expect(rootStore?.me?.handle).toBe('alice.test')
expect(rootStore?.session.accounts.length).toBe(1)
}, WAIT_OPTS)
expect(rootStore?.me?.displayName).toBe('Alice')
expect(rootStore?.me?.handle).toBe('alice.test')
expect(rootStore?.session.accounts.length).toBe(1)
})
it('logs into a second account via the switcher', async () => {
const {getByTestId, getAllByTestId} = render(<MobileShell />, rootStore)
await waitFor(() => expect(getByTestId('homeFeed')).toBeTruthy(), WAIT_OPTS)
// open side menu
fireEvent.press(getAllByTestId('viewHeaderBackOrMenuBtn')[0])
await waitFor(() => expect(getByTestId('menuView')).toBeTruthy(), WAIT_OPTS)
// nav to settings
fireEvent.press(getByTestId('menuItemButton-Settings'))
await waitFor(
() => expect(getByTestId('settingsScreen')).toBeTruthy(),
WAIT_OPTS,
)
// press '+ new account' in switcher
fireEvent.press(getByTestId('switchToNewAccountBtn'))
await waitFor(
() => expect(getByTestId('signinOrCreateAccount')).toBeTruthy(),
WAIT_OPTS,
)
// move to signin view
fireEvent.press(getByTestId('signInButton'))
expect(getByTestId('signIn')).toBeTruthy()
expect(getByTestId('chooseAccountForm')).toBeTruthy()
// select a new account
fireEvent.press(getByTestId('chooseNewAccountBtn'))
expect(getByTestId('loginForm')).toBeTruthy()
// input the target server
expect(getByTestId('loginSelectServiceButton')).toBeTruthy()
fireEvent.press(getByTestId('loginSelectServiceButton'))
expect(getByTestId('serverInputModal')).toBeTruthy()
fireEvent.changeText(
getByTestId('customServerTextInput'),
pds?.pdsUrl || '',
)
fireEvent.press(getByTestId('customServerSelectBtn'))
await waitFor(
() => expect(getByTestId('loginUsernameInput')).toBeTruthy(),
WAIT_OPTS,
)
// enter username & pass
fireEvent.changeText(getByTestId('loginUsernameInput'), 'bob')
fireEvent.changeText(getByTestId('loginPasswordInput'), 'hunter2')
await waitFor(
() => expect(getByTestId('loginNextButton')).toBeTruthy(),
WAIT_OPTS,
)
fireEvent.press(getByTestId('loginNextButton'))
// signed in
await waitFor(() => {
expect(getByTestId('settingsScreen')).toBeTruthy() // we go back to settings in this situation
expect(rootStore?.me?.displayName).toBe('Bob')
expect(rootStore?.me?.handle).toBe('bob.test')
expect(rootStore?.session.accounts.length).toBe(2)
}, WAIT_OPTS)
expect(rootStore?.me?.displayName).toBe('Bob')
expect(rootStore?.me?.handle).toBe('bob.test')
expect(rootStore?.session.accounts.length).toBe(2)
})
it('can instantly switch between accounts', async () => {
const {getByTestId} = render(<MobileShell />, rootStore)
await waitFor(
() => expect(getByTestId('settingsScreen')).toBeTruthy(),
WAIT_OPTS,
)
// select the alice account
fireEvent.press(getByTestId('switchToAccountBtn-alice.test'))
// swapped account
await waitFor(() => {
expect(rootStore?.me?.displayName).toBe('Alice')
expect(rootStore?.me?.handle).toBe('alice.test')
expect(rootStore?.session.accounts.length).toBe(2)
}, WAIT_OPTS)
expect(rootStore?.me?.displayName).toBe('Alice')
expect(rootStore?.me?.handle).toBe('alice.test')
expect(rootStore?.session.accounts.length).toBe(2)
})
it('will prompt for a password if you sign out', async () => {
const {getByTestId} = render(<MobileShell />, rootStore)
await waitFor(
() => expect(getByTestId('settingsScreen')).toBeTruthy(),
WAIT_OPTS,
)
// press the sign out button
fireEvent.press(getByTestId('signOutBtn'))
// in the logged out state
await waitFor(
() => expect(getByTestId('signinOrCreateAccount')).toBeTruthy(),
WAIT_OPTS,
)
// move to signin view
fireEvent.press(getByTestId('signInButton'))
expect(getByTestId('signIn')).toBeTruthy()
expect(getByTestId('chooseAccountForm')).toBeTruthy()
// select an existing account
fireEvent.press(getByTestId('chooseAccountBtn-alice.test'))
// goes to login screen instead of straight back to settings
expect(getByTestId('loginForm')).toBeTruthy()
})
})

View File

@ -1,126 +0,0 @@
import React from 'react'
import {Signin} from '../../../../src/view/com/login/Signin'
import {cleanup, fireEvent, render} from '../../../../jest/test-utils'
import {SessionServiceClient, sessionClient as AtpApi} from '@atproto/api'
import {
mockedSessionStore,
mockedShellStore,
} from '../../../../__mocks__/state-mock'
import {Keyboard} from 'react-native'
describe('Signin', () => {
const requestPasswordResetMock = jest.fn()
const resetPasswordMock = jest.fn()
jest.spyOn(AtpApi, 'service').mockReturnValue({
com: {
atproto: {
account: {
requestPasswordReset: requestPasswordResetMock,
resetPassword: resetPasswordMock,
},
},
},
} as unknown as SessionServiceClient)
const mockedProps = {
onPressBack: jest.fn(),
}
afterAll(() => {
jest.clearAllMocks()
cleanup()
})
it('renders logs in form', async () => {
const {findByTestId} = render(<Signin {...mockedProps} />)
const loginFormView = await findByTestId('loginFormView')
expect(loginFormView).toBeTruthy()
const loginUsernameInput = await findByTestId('loginUsernameInput')
expect(loginUsernameInput).toBeTruthy()
fireEvent.changeText(loginUsernameInput, 'testusername')
const loginPasswordInput = await findByTestId('loginPasswordInput')
expect(loginPasswordInput).toBeTruthy()
fireEvent.changeText(loginPasswordInput, 'test pass')
const loginNextButton = await findByTestId('loginNextButton')
expect(loginNextButton).toBeTruthy()
fireEvent.press(loginNextButton)
expect(mockedSessionStore.login).toHaveBeenCalled()
})
it('renders selects service from login form', async () => {
const keyboardSpy = jest.spyOn(Keyboard, 'dismiss')
const {findByTestId} = render(<Signin {...mockedProps} />)
const loginSelectServiceButton = await findByTestId(
'loginSelectServiceButton',
)
expect(loginSelectServiceButton).toBeTruthy()
fireEvent.press(loginSelectServiceButton)
expect(mockedShellStore.openModal).toHaveBeenCalled()
expect(keyboardSpy).toHaveBeenCalled()
})
it('renders new password form', async () => {
const {findByTestId} = render(<Signin {...mockedProps} />)
const forgotPasswordButton = await findByTestId('forgotPasswordButton')
expect(forgotPasswordButton).toBeTruthy()
fireEvent.press(forgotPasswordButton)
const forgotPasswordView = await findByTestId('forgotPasswordView')
expect(forgotPasswordView).toBeTruthy()
const forgotPasswordEmail = await findByTestId('forgotPasswordEmail')
expect(forgotPasswordEmail).toBeTruthy()
fireEvent.changeText(forgotPasswordEmail, 'test@email.com')
const newPasswordButton = await findByTestId('newPasswordButton')
expect(newPasswordButton).toBeTruthy()
fireEvent.press(newPasswordButton)
expect(requestPasswordResetMock).toHaveBeenCalled()
const newPasswordView = await findByTestId('newPasswordView')
expect(newPasswordView).toBeTruthy()
const newPasswordInput = await findByTestId('newPasswordInput')
expect(newPasswordInput).toBeTruthy()
const resetCodeInput = await findByTestId('resetCodeInput')
expect(resetCodeInput).toBeTruthy()
fireEvent.changeText(newPasswordInput, 'test pass')
fireEvent.changeText(resetCodeInput, 'test reset code')
const setNewPasswordButton = await findByTestId('setNewPasswordButton')
expect(setNewPasswordButton).toBeTruthy()
fireEvent.press(setNewPasswordButton)
expect(resetPasswordMock).toHaveBeenCalled()
})
it('renders forgot password form', async () => {
const {findByTestId} = render(<Signin {...mockedProps} />)
const forgotPasswordButton = await findByTestId('forgotPasswordButton')
expect(forgotPasswordButton).toBeTruthy()
fireEvent.press(forgotPasswordButton)
const forgotPasswordSelectServiceButton = await findByTestId(
'forgotPasswordSelectServiceButton',
)
expect(forgotPasswordSelectServiceButton).toBeTruthy()
fireEvent.press(forgotPasswordSelectServiceButton)
expect(mockedShellStore.openModal).toHaveBeenCalled()
})
})

View File

@ -46,10 +46,10 @@ describe('Menu', () => {
})
it("presses notifications menu item' button", () => {
const {getAllByTestId} = render(<Menu {...mockedProps} />)
const {getByTestId} = render(<Menu {...mockedProps} />)
const menuItemButton = getAllByTestId('menuItemButton')
fireEvent.press(menuItemButton[1])
const menuItemButton = getByTestId('menuItemButton-Notifications')
fireEvent.press(menuItemButton)
expect(onCloseMock).toHaveBeenCalled()
expect(mockedNavigationStore.switchTo).toHaveBeenCalledWith(1, true)

View File

@ -25,19 +25,6 @@ jest.mock('react-native-safe-area-context', () => {
}
})
jest.mock('@gorhom/bottom-sheet', () => {
const react = require('react-native')
return {
__esModule: true,
default: react.View,
namedExport: {
...require('react-native-reanimated/mock'),
...jest.requireActual('@gorhom/bottom-sheet'),
BottomSheetFlatList: react.FlatList,
},
}
})
jest.mock('rn-fetch-blob', () => ({
config: jest.fn().mockReturnThis(),
cancel: jest.fn(),

199
jest/test-pds.ts 100644
View File

@ -0,0 +1,199 @@
import {AddressInfo} from 'net'
import os from 'os'
import path from 'path'
import * as crypto from '@atproto/crypto'
import PDSServer, {
Database as PDSDatabase,
MemoryBlobStore,
ServerConfig as PDSServerConfig,
} from '@atproto/pds'
import * as plc from '@atproto/plc'
import AtpApi, {ServiceClient} from '@atproto/api'
export interface TestUser {
email: string
did: string
declarationCid: string
handle: string
password: string
api: ServiceClient
}
export interface TestUsers {
alice: TestUser
bob: TestUser
carla: TestUser
}
export interface TestPDS {
pdsUrl: string
users: TestUsers
close: () => Promise<void>
}
// NOTE
// deterministic date generator
// we use this to ensure the mock dataset is always the same
// which is very useful when testing
function* dateGen() {
let start = 1657846031914
while (true) {
yield new Date(start).toISOString()
start += 1e3
}
return ''
}
export async function createServer(): Promise<TestPDS> {
const keypair = await crypto.EcdsaKeypair.create()
// run plc server
const plcDb = plc.Database.memory()
await plcDb.migrateToLatestOrThrow()
const plcServer = plc.PlcServer.create({db: plcDb})
const plcListener = await plcServer.start()
const plcPort = (plcListener.address() as AddressInfo).port
const plcUrl = `http://localhost:${plcPort}`
const recoveryKey = (await crypto.EcdsaKeypair.create()).did()
const plcClient = new plc.PlcClient(plcUrl)
const serverDid = await plcClient.createDid(
keypair,
recoveryKey,
'localhost',
'https://pds.public.url',
)
const blobstoreLoc = path.join(os.tmpdir(), crypto.randomStr(5, 'base32'))
const cfg = new PDSServerConfig({
debugMode: true,
version: '0.0.0',
scheme: 'http',
hostname: 'localhost',
serverDid,
recoveryKey,
adminPassword: 'admin-pass',
inviteRequired: false,
didPlcUrl: plcUrl,
jwtSecret: 'jwt-secret',
availableUserDomains: ['.test'],
appUrlPasswordReset: 'app://forgot-password',
emailNoReplyAddress: 'noreply@blueskyweb.xyz',
publicUrl: 'https://pds.public.url',
imgUriSalt: '9dd04221f5755bce5f55f47464c27e1e',
imgUriKey:
'f23ecd142835025f42c3db2cf25dd813956c178392760256211f9d315f8ab4d8',
dbPostgresUrl: process.env.DB_POSTGRES_URL,
blobstoreLocation: `${blobstoreLoc}/blobs`,
blobstoreTmp: `${blobstoreLoc}/tmp`,
})
const db = PDSDatabase.memory()
await db.migrateToLatestOrThrow()
const blobstore = new MemoryBlobStore()
const pds = PDSServer.create({db, blobstore, keypair, config: cfg})
const pdsServer = await pds.start()
const pdsPort = (pdsServer.address() as AddressInfo).port
const pdsUrl = `http://localhost:${pdsPort}`
const testUsers = await genMockData(pdsUrl)
return {
pdsUrl,
users: testUsers,
async close() {
await pds.destroy()
await plcServer.destroy()
},
}
}
async function genMockData(pdsUrl: string): Promise<TestUsers> {
const date = dateGen()
const clients = {
loggedout: AtpApi.service(pdsUrl),
alice: AtpApi.service(pdsUrl),
bob: AtpApi.service(pdsUrl),
carla: AtpApi.service(pdsUrl),
}
const users: TestUser[] = [
{
email: 'alice@test.com',
did: '',
declarationCid: '',
handle: 'alice.test',
password: 'hunter2',
api: clients.alice,
},
{
email: 'bob@test.com',
did: '',
declarationCid: '',
handle: 'bob.test',
password: 'hunter2',
api: clients.bob,
},
{
email: 'carla@test.com',
did: '',
declarationCid: '',
handle: 'carla.test',
password: 'hunter2',
api: clients.carla,
},
]
const alice = users[0]
const bob = users[1]
const carla = users[2]
let _i = 1
for (const user of users) {
const res = await clients.loggedout.com.atproto.account.create({
email: user.email,
handle: user.handle,
password: user.password,
})
user.api.setHeader('Authorization', `Bearer ${res.data.accessJwt}`)
const {data: profile} = await user.api.app.bsky.actor.getProfile({
actor: user.handle,
})
user.did = res.data.did
user.declarationCid = profile.declaration.cid
await user.api.app.bsky.actor.profile.create(
{did: user.did},
{
displayName: ucfirst(user.handle).slice(0, -5),
description: `Test user ${_i++}`,
},
)
}
// everybody follows everybody
const follow = async (author: TestUser, subject: TestUser) => {
await author.api.app.bsky.graph.follow.create(
{did: author.did},
{
subject: {
did: subject.did,
declarationCid: subject.declarationCid,
},
createdAt: date.next().value,
},
)
}
await follow(alice, bob)
await follow(alice, carla)
await follow(bob, alice)
await follow(bob, carla)
await follow(carla, alice)
await follow(carla, bob)
return {alice, bob, carla}
}
function ucfirst(str: string): string {
return str.at(0)?.toUpperCase() + str.slice(1)
}

View File

@ -4,20 +4,19 @@ import {GestureHandlerRootView} from 'react-native-gesture-handler'
import {RootSiblingParent} from 'react-native-root-siblings'
import {SafeAreaProvider} from 'react-native-safe-area-context'
import {RootStoreProvider} from '../src/state'
import {ThemeProvider} from '../src/view/lib/ThemeContext'
import {mockedRootStore} from '../__mocks__/state-mock'
const customRender = (ui: any, storeMock?: any) =>
const customRender = (ui: any, rootStore?: any) =>
render(
// eslint-disable-next-line react-native/no-inline-styles
<GestureHandlerRootView style={{flex: 1}}>
<RootSiblingParent>
<RootStoreProvider
value={
storeMock != null
? {...mockedRootStore, ...storeMock}
: mockedRootStore
}>
<SafeAreaProvider>{ui}</SafeAreaProvider>
value={rootStore != null ? rootStore : mockedRootStore}>
<ThemeProvider theme="light">
<SafeAreaProvider>{ui}</SafeAreaProvider>
</ThemeProvider>
</RootStoreProvider>
</RootSiblingParent>
</GestureHandlerRootView>,

View File

@ -8,7 +8,7 @@
"web": "react-scripts start",
"start": "react-native start",
"clean-cache": "rm -rf node_modules/.cache/babel-loader/*",
"test": "jest",
"test": "jest --forceExit",
"test-watch": "jest --watchAll",
"test-ci": "jest --ci --forceExit --reporters=default --reporters=jest-junit",
"test-coverage": "jest --coverage",
@ -64,9 +64,11 @@
"react-native-version-number": "^0.3.6",
"react-native-web": "^0.17.7",
"rn-fetch-blob": "^0.12.0",
"tlds": "^1.234.0"
"tlds": "^1.234.0",
"zod": "^3.20.2"
},
"devDependencies": {
"@atproto/pds": "^0.0.1",
"@babel/core": "^7.12.9",
"@babel/preset-env": "^7.14.0",
"@babel/runtime": "^7.12.5",

View File

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

View File

@ -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,12 +271,35 @@ 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({
service,
email,
@ -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()
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -18,6 +18,7 @@ export type PaletteColor = {
textInverted: string
link: string
border: string
borderDark: string
icon: string
[k: string]: string
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

991
yarn.lock

File diff suppressed because it is too large Load Diff