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 switcherzio/stable
parent
439305b57e
commit
9027882fb4
|
@ -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
|
|
@ -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()
|
||||
})
|
||||
})
|
|
@ -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()
|
||||
})
|
||||
})
|
|
@ -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)
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}>
|
||||
value={rootStore != null ? rootStore : mockedRootStore}>
|
||||
<ThemeProvider theme="light">
|
||||
<SafeAreaProvider>{ui}</SafeAreaProvider>
|
||||
</ThemeProvider>
|
||||
</RootStoreProvider>
|
||||
</RootSiblingParent>
|
||||
</GestureHandlerRootView>,
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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)) || {}
|
||||
|
|
|
@ -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') && sessionData.safeParse(v.data)) {
|
||||
this.data = v.data as SessionData
|
||||
}
|
||||
if (hasProp(v.data, 'service') && typeof v.data.service === 'string') {
|
||||
data.service = v.data.service
|
||||
if (hasProp(v, 'accounts') && Array.isArray(v.accounts)) {
|
||||
for (const account of v.accounts) {
|
||||
if (accountData.safeParse(account)) {
|
||||
this.accounts.push(account as AccountData)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
this.rootStore.me
|
||||
.load()
|
||||
.catch(e => {
|
||||
this.rootStore.log.error(
|
||||
'Failed to fetch local user information',
|
||||
e,
|
||||
)
|
||||
})
|
||||
return // success
|
||||
.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.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.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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,7 +116,249 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
|
|||
}
|
||||
}
|
||||
|
||||
const Policies = () => {
|
||||
const isReady = !!email && !!password && !!handle && is13
|
||||
return (
|
||||
<ScrollView testID="createAccount" style={pal.view}>
|
||||
<KeyboardAvoidingView behavior="padding">
|
||||
<LogoTextHero />
|
||||
{error ? (
|
||||
<View style={[styles.error, styles.errorFloating]}>
|
||||
<View style={[styles.errorIcon]}>
|
||||
<FontAwesomeIcon icon="exclamation" style={s.white} size={10} />
|
||||
</View>
|
||||
<View style={s.flex1}>
|
||||
<Text style={[s.white, s.bold]}>{error}</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : undefined}
|
||||
<View style={styles.groupLabel}>
|
||||
<Text type="sm-bold" style={pal.text}>
|
||||
Service provider
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[pal.borderDark, styles.group]}>
|
||||
<View
|
||||
style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
|
||||
<FontAwesomeIcon
|
||||
icon="globe"
|
||||
style={[pal.textLight, styles.groupContentIcon]}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
testID="registerSelectServiceButton"
|
||||
style={styles.textBtn}
|
||||
onPress={onPressSelectService}>
|
||||
<Text type="xl" style={[pal.text, styles.textBtnLabel]}>
|
||||
{toNiceDomain(serviceUrl)}
|
||||
</Text>
|
||||
<View style={[pal.btn, styles.textBtnFakeInnerBtn]}>
|
||||
<FontAwesomeIcon
|
||||
icon="pen"
|
||||
size={12}
|
||||
style={[pal.textLight, styles.textBtnFakeInnerBtnIcon]}
|
||||
/>
|
||||
<Text style={[pal.textLight]}>Change</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
{serviceDescription ? (
|
||||
<>
|
||||
<View style={styles.groupLabel}>
|
||||
<Text type="sm-bold" style={pal.text}>
|
||||
Account details
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[pal.borderDark, styles.group]}>
|
||||
{serviceDescription?.inviteCodeRequired ? (
|
||||
<View
|
||||
style={[pal.border, styles.groupContent, styles.noTopBorder]}>
|
||||
<FontAwesomeIcon
|
||||
icon="ticket"
|
||||
style={[pal.textLight, styles.groupContentIcon]}
|
||||
/>
|
||||
<TextInput
|
||||
style={[pal.text, styles.textInput]}
|
||||
placeholder="Invite code"
|
||||
placeholderTextColor={pal.colors.textLight}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
value={inviteCode}
|
||||
onChangeText={setInviteCode}
|
||||
editable={!isProcessing}
|
||||
/>
|
||||
</View>
|
||||
) : undefined}
|
||||
<View style={[pal.border, styles.groupContent]}>
|
||||
<FontAwesomeIcon
|
||||
icon="envelope"
|
||||
style={[pal.textLight, styles.groupContentIcon]}
|
||||
/>
|
||||
<TextInput
|
||||
testID="registerEmailInput"
|
||||
style={[pal.text, styles.textInput]}
|
||||
placeholder="Email address"
|
||||
placeholderTextColor={pal.colors.textLight}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
editable={!isProcessing}
|
||||
/>
|
||||
</View>
|
||||
<View style={[pal.border, styles.groupContent]}>
|
||||
<FontAwesomeIcon
|
||||
icon="lock"
|
||||
style={[pal.textLight, styles.groupContentIcon]}
|
||||
/>
|
||||
<TextInput
|
||||
testID="registerPasswordInput"
|
||||
style={[pal.text, styles.textInput]}
|
||||
placeholder="Choose your password"
|
||||
placeholderTextColor={pal.colors.textLight}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
secureTextEntry
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
editable={!isProcessing}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
) : undefined}
|
||||
{serviceDescription ? (
|
||||
<>
|
||||
<View style={styles.groupLabel}>
|
||||
<Text type="sm-bold" style={pal.text}>
|
||||
Choose your username
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[pal.border, styles.group]}>
|
||||
<View
|
||||
style={[pal.border, styles.groupContent, styles.noTopBorder]}>
|
||||
<FontAwesomeIcon
|
||||
icon="at"
|
||||
style={[pal.textLight, styles.groupContentIcon]}
|
||||
/>
|
||||
<TextInput
|
||||
testID="registerHandleInput"
|
||||
style={[pal.text, styles.textInput]}
|
||||
placeholder="eg alice"
|
||||
placeholderTextColor={pal.colors.textLight}
|
||||
autoCapitalize="none"
|
||||
value={handle}
|
||||
onChangeText={v => setHandle(makeValidHandle(v))}
|
||||
editable={!isProcessing}
|
||||
/>
|
||||
</View>
|
||||
{serviceDescription.availableUserDomains.length > 1 && (
|
||||
<View style={[pal.border, styles.groupContent]}>
|
||||
<FontAwesomeIcon
|
||||
icon="globe"
|
||||
style={styles.groupContentIcon}
|
||||
/>
|
||||
<Picker
|
||||
style={[pal.text, styles.picker]}
|
||||
labelStyle={styles.pickerLabel}
|
||||
iconStyle={pal.textLight}
|
||||
value={userDomain}
|
||||
items={serviceDescription.availableUserDomains.map(d => ({
|
||||
label: `.${d}`,
|
||||
value: d,
|
||||
}))}
|
||||
onChange={itemValue => setUserDomain(itemValue)}
|
||||
enabled={!isProcessing}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<View style={[pal.border, styles.groupContent]}>
|
||||
<Text style={[pal.textLight, s.p10]}>
|
||||
Your full username will be{' '}
|
||||
<Text type="md-bold" style={pal.textLight}>
|
||||
@{createFullHandle(handle, userDomain)}
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.groupLabel}>
|
||||
<Text type="sm-bold" style={pal.text}>
|
||||
Legal
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[pal.border, styles.group]}>
|
||||
<View
|
||||
style={[pal.border, styles.groupContent, styles.noTopBorder]}>
|
||||
<TouchableOpacity
|
||||
testID="registerIs13Input"
|
||||
style={styles.textBtn}
|
||||
onPress={() => setIs13(!is13)}>
|
||||
<View
|
||||
style={[
|
||||
pal.border,
|
||||
is13 ? styles.checkboxFilled : styles.checkbox,
|
||||
]}>
|
||||
{is13 && (
|
||||
<FontAwesomeIcon icon="check" style={s.blue3} size={14} />
|
||||
)}
|
||||
</View>
|
||||
<Text style={[pal.text, styles.textBtnLabel]}>
|
||||
I am 13 years old or older
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
<Policies serviceDescription={serviceDescription} />
|
||||
</>
|
||||
) : undefined}
|
||||
<View style={[s.flexRow, s.pl20, s.pr20]}>
|
||||
<TouchableOpacity onPress={onPressBack}>
|
||||
<Text type="xl" style={pal.link}>
|
||||
Back
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={s.flex1} />
|
||||
{isReady ? (
|
||||
<TouchableOpacity
|
||||
testID="createAccountButton"
|
||||
onPress={onPressNext}>
|
||||
{isProcessing ? (
|
||||
<ActivityIndicator />
|
||||
) : (
|
||||
<Text type="xl-bold" style={[pal.link, s.pr5]}>
|
||||
Next
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
) : !serviceDescription && error ? (
|
||||
<TouchableOpacity
|
||||
testID="registerRetryButton"
|
||||
onPress={onPressRetryConnect}>
|
||||
<Text type="xl-bold" style={[pal.link, s.pr5]}>
|
||||
Retry
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
) : !serviceDescription ? (
|
||||
<>
|
||||
<ActivityIndicator color="#fff" />
|
||||
<Text type="xl-bold" style={[pal.link, s.pr5]}>
|
||||
Connecting...
|
||||
</Text>
|
||||
</>
|
||||
) : undefined}
|
||||
</View>
|
||||
<View style={s.footerSpacer} />
|
||||
</KeyboardAvoidingView>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
const Policies = ({
|
||||
serviceDescription,
|
||||
}: {
|
||||
serviceDescription: ServiceDescription
|
||||
}) => {
|
||||
const pal = usePalette('default')
|
||||
if (!serviceDescription) {
|
||||
return <View />
|
||||
}
|
||||
|
@ -123,10 +367,10 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
|
|||
if (!tos && !pp) {
|
||||
return (
|
||||
<View style={styles.policies}>
|
||||
<View style={[styles.errorIcon, s.mt2]}>
|
||||
<FontAwesomeIcon icon="exclamation" style={s.white} size={10} />
|
||||
<View style={[styles.errorIcon, {borderColor: pal.colors.text}, s.mt2]}>
|
||||
<FontAwesomeIcon icon="exclamation" style={pal.textLight} size={10} />
|
||||
</View>
|
||||
<Text style={[s.white, s.pl5, s.flex1]}>
|
||||
<Text style={[pal.textLight, s.pl5, s.flex1]}>
|
||||
This service has not provided terms of service or a privacy policy.
|
||||
</Text>
|
||||
</View>
|
||||
|
@ -139,7 +383,7 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
|
|||
key="tos"
|
||||
href={tos}
|
||||
text="Terms of Service"
|
||||
style={[s.white, s.underline]}
|
||||
style={[pal.link, s.underline]}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
@ -149,7 +393,7 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
|
|||
key="pp"
|
||||
href={pp}
|
||||
text="Privacy Policy"
|
||||
style={[s.white, s.underline]}
|
||||
style={[pal.link, s.underline]}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
@ -157,7 +401,7 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
|
|||
els.splice(
|
||||
1,
|
||||
0,
|
||||
<Text key="and" style={s.white}>
|
||||
<Text key="and" style={pal.textLight}>
|
||||
{' '}
|
||||
and{' '}
|
||||
</Text>,
|
||||
|
@ -165,214 +409,11 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
|
|||
}
|
||||
return (
|
||||
<View style={styles.policies}>
|
||||
<Text style={s.white}>
|
||||
<Text style={pal.textLight}>
|
||||
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>
|
||||
{error ? (
|
||||
<View style={[styles.error, styles.errorFloating]}>
|
||||
<View style={styles.errorIcon}>
|
||||
<FontAwesomeIcon icon="exclamation" style={s.white} size={10} />
|
||||
</View>
|
||||
<View style={s.flex1}>
|
||||
<Text style={[s.white, s.bold]}>{error}</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : undefined}
|
||||
<View style={[styles.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} />
|
||||
<TouchableOpacity
|
||||
testID="registerSelectServiceButton"
|
||||
style={styles.textBtn}
|
||||
onPress={onPressSelectService}>
|
||||
<Text style={styles.textBtnLabel}>
|
||||
{toNiceDomain(serviceUrl)}
|
||||
</Text>
|
||||
<View style={styles.textBtnFakeInnerBtn}>
|
||||
<FontAwesomeIcon
|
||||
icon="pen"
|
||||
size={12}
|
||||
style={styles.textBtnFakeInnerBtnIcon}
|
||||
/>
|
||||
<Text style={styles.textBtnFakeInnerBtnLabel}>Change</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{serviceDescription ? (
|
||||
<>
|
||||
{serviceDescription?.inviteCodeRequired ? (
|
||||
<View style={styles.groupContent}>
|
||||
<FontAwesomeIcon
|
||||
icon="ticket"
|
||||
style={styles.groupContentIcon}
|
||||
/>
|
||||
<TextInput
|
||||
style={[styles.textInput]}
|
||||
placeholder="Invite code"
|
||||
placeholderTextColor={colors.blue0}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
value={inviteCode}
|
||||
onChangeText={setInviteCode}
|
||||
editable={!isProcessing}
|
||||
/>
|
||||
</View>
|
||||
) : undefined}
|
||||
<View style={styles.groupContent}>
|
||||
<FontAwesomeIcon
|
||||
icon="envelope"
|
||||
style={styles.groupContentIcon}
|
||||
/>
|
||||
<TextInput
|
||||
testID="registerEmailInput"
|
||||
style={[styles.textInput]}
|
||||
placeholder="Email address"
|
||||
placeholderTextColor={colors.blue0}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
editable={!isProcessing}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.groupContent}>
|
||||
<FontAwesomeIcon icon="lock" style={styles.groupContentIcon} />
|
||||
<TextInput
|
||||
testID="registerPasswordInput"
|
||||
style={[styles.textInput]}
|
||||
placeholder="Choose your password"
|
||||
placeholderTextColor={colors.blue0}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
secureTextEntry
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
editable={!isProcessing}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
) : undefined}
|
||||
</View>
|
||||
{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} />
|
||||
<TextInput
|
||||
testID="registerHandleInput"
|
||||
style={[styles.textInput]}
|
||||
placeholder="eg alice"
|
||||
placeholderTextColor={colors.blue0}
|
||||
autoCapitalize="none"
|
||||
value={handle}
|
||||
onChangeText={v => setHandle(makeValidHandle(v))}
|
||||
editable={!isProcessing}
|
||||
/>
|
||||
</View>
|
||||
{serviceDescription.availableUserDomains.length > 1 && (
|
||||
<View style={styles.groupContent}>
|
||||
<FontAwesomeIcon
|
||||
icon="globe"
|
||||
style={styles.groupContentIcon}
|
||||
/>
|
||||
<Picker
|
||||
style={styles.picker}
|
||||
labelStyle={styles.pickerLabel}
|
||||
iconStyle={styles.pickerIcon}
|
||||
value={userDomain}
|
||||
items={serviceDescription.availableUserDomains.map(d => ({
|
||||
label: `.${d}`,
|
||||
value: d,
|
||||
}))}
|
||||
onChange={itemValue => setUserDomain(itemValue)}
|
||||
enabled={!isProcessing}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.groupContent}>
|
||||
<Text style={[s.white, s.p10]}>
|
||||
Your full username will be{' '}
|
||||
<Text style={[s.white, s.bold]}>
|
||||
@{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}>
|
||||
<TouchableOpacity
|
||||
testID="registerIs13Input"
|
||||
style={styles.textBtn}
|
||||
onPress={() => setIs13(!is13)}>
|
||||
<View style={is13 ? styles.checkboxFilled : styles.checkbox}>
|
||||
{is13 && (
|
||||
<FontAwesomeIcon icon="check" style={s.blue3} size={14} />
|
||||
)}
|
||||
</View>
|
||||
<Text style={[styles.textBtnLabel, s.f16]}>
|
||||
I am 13 years old or older
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
<Policies />
|
||||
</>
|
||||
) : undefined}
|
||||
<View style={[s.flexRow, s.pl20, s.pr20]}>
|
||||
<TouchableOpacity onPress={onPressBack}>
|
||||
<Text style={[s.white, s.f18, s.pl5]}>Back</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={s.flex1} />
|
||||
{isReady ? (
|
||||
<TouchableOpacity
|
||||
testID="createAccountButton"
|
||||
onPress={onPressNext}>
|
||||
{isProcessing ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={[s.white, s.f18, s.bold, s.pr5]}>Next</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
) : !serviceDescription && error ? (
|
||||
<TouchableOpacity
|
||||
testID="registerRetryButton"
|
||||
onPress={onPressRetryConnect}>
|
||||
<Text style={[s.white, s.f18, s.bold, s.pr5]}>Retry</Text>
|
||||
</TouchableOpacity>
|
||||
) : !serviceDescription ? (
|
||||
<>
|
||||
<ActivityIndicator color="#fff" />
|
||||
<Text style={[s.white, s.f18, s.pl5, s.pr5]}>Connecting...</Text>
|
||||
</>
|
||||
) : undefined}
|
||||
</View>
|
||||
<View style={s.footerSpacer} />
|
||||
</KeyboardAvoidingView>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
function validWebLink(url?: string): string | undefined {
|
||||
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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}>
|
||||
<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.groupTitle, {paddingRight: 0, paddingVertical: 6}]}
|
||||
style={styles.textBtn}
|
||||
onPress={onPressSelectService}>
|
||||
<Text style={[s.flex1, s.white, s.f18, s.bold]} numberOfLines={1}>
|
||||
Sign in to {toNiceDomain(serviceUrl)}
|
||||
<Text type="xl" style={[pal.text, styles.textBtnLabel]}>
|
||||
{toNiceDomain(serviceUrl)}
|
||||
</Text>
|
||||
<View style={styles.textBtnFakeInnerBtn}>
|
||||
<FontAwesomeIcon
|
||||
icon="pen"
|
||||
size={12}
|
||||
style={styles.textBtnFakeInnerBtnIcon}
|
||||
/>
|
||||
<Text style={styles.textBtnFakeInnerBtnLabel}>Change</Text>
|
||||
<View style={[pal.btn, styles.textBtnFakeInnerBtn]}>
|
||||
<FontAwesomeIcon icon="pen" size={12} style={pal.textLight} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.groupContent}>
|
||||
<FontAwesomeIcon icon="at" style={styles.groupContentIcon} />
|
||||
</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,36 +490,43 @@ 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.
|
||||
<LogoTextHero />
|
||||
<View>
|
||||
<Text type="title-lg" style={[pal.text, styles.screenTitle]}>
|
||||
Reset password
|
||||
</Text>
|
||||
<View testID="forgotPasswordView" style={styles.group}>
|
||||
<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={[styles.groupContent, {borderTopWidth: 0}]}
|
||||
style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}
|
||||
onPress={onPressSelectService}>
|
||||
<FontAwesomeIcon icon="globe" style={styles.groupContentIcon} />
|
||||
<Text style={styles.textInput} numberOfLines={1}>
|
||||
<FontAwesomeIcon
|
||||
icon="globe"
|
||||
style={[pal.textLight, styles.groupContentIcon]}
|
||||
/>
|
||||
<Text style={[pal.text, styles.textInput]} numberOfLines={1}>
|
||||
{toNiceDomain(serviceUrl)}
|
||||
</Text>
|
||||
<View style={styles.textBtnFakeInnerBtn}>
|
||||
<FontAwesomeIcon
|
||||
icon="pen"
|
||||
size={12}
|
||||
style={styles.textBtnFakeInnerBtnIcon}
|
||||
/>
|
||||
<Text style={styles.textBtnFakeInnerBtnLabel}>Change</Text>
|
||||
<View style={[pal.btn, styles.textBtnFakeInnerBtn]}>
|
||||
<FontAwesomeIcon icon="pen" size={12} style={pal.text} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.groupContent}>
|
||||
<FontAwesomeIcon icon="envelope" style={styles.groupContentIcon} />
|
||||
<View style={[pal.borderDark, styles.groupContent]}>
|
||||
<FontAwesomeIcon
|
||||
icon="envelope"
|
||||
style={[pal.textLight, styles.groupContentIcon]}
|
||||
/>
|
||||
<TextInput
|
||||
testID="forgotPasswordEmail"
|
||||
style={styles.textInput}
|
||||
style={[pal.text, styles.textInput]}
|
||||
placeholder="Email address"
|
||||
placeholderTextColor={colors.blue0}
|
||||
placeholderTextColor={pal.colors.textLight}
|
||||
autoCapitalize="none"
|
||||
autoFocus
|
||||
autoCorrect={false}
|
||||
|
@ -395,22 +548,31 @@ const ForgotPasswordForm = ({
|
|||
) : 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 || isProcessing ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
<ActivityIndicator />
|
||||
) : !email ? (
|
||||
<Text style={[s.blue1, s.f18, s.bold, s.pr5]}>Next</Text>
|
||||
<Text type="xl-bold" style={[pal.link, s.pr5, {opacity: 0.5}]}>
|
||||
Next
|
||||
</Text>
|
||||
) : (
|
||||
<TouchableOpacity testID="newPasswordButton" 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>
|
||||
)}
|
||||
{!serviceDescription || isProcessing ? (
|
||||
<Text style={[s.white, s.f18, s.pl10]}>Processing...</Text>
|
||||
<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,19 +621,29 @@ const SetNewPasswordForm = ({
|
|||
|
||||
return (
|
||||
<>
|
||||
<Text style={styles.screenTitle}>Set new password</Text>
|
||||
<Text style={styles.instructions}>
|
||||
<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={styles.group}>
|
||||
<View style={[styles.groupContent, {borderTopWidth: 0}]}>
|
||||
<FontAwesomeIcon icon="ticket" style={styles.groupContentIcon} />
|
||||
<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={[styles.textInput]}
|
||||
style={[pal.text, styles.textInput]}
|
||||
placeholder="Reset code"
|
||||
placeholderTextColor={colors.blue0}
|
||||
placeholderTextColor={pal.colors.textLight}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
|
@ -479,13 +652,16 @@ const SetNewPasswordForm = ({
|
|||
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="newPasswordInput"
|
||||
style={styles.textInput}
|
||||
style={[pal.text, styles.textInput]}
|
||||
placeholder="New password"
|
||||
placeholderTextColor={colors.blue0}
|
||||
placeholderTextColor={pal.colors.textLight}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
secureTextEntry
|
||||
|
@ -507,92 +683,100 @@ const SetNewPasswordForm = ({
|
|||
) : 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} />
|
||||
{isProcessing ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
<ActivityIndicator />
|
||||
) : !resetCode || !password ? (
|
||||
<Text style={[s.blue1, s.f18, s.bold, s.pr5]}>Next</Text>
|
||||
<Text type="xl-bold" style={[pal.link, s.pr5, {opacity: 0.5}]}>
|
||||
Next
|
||||
</Text>
|
||||
) : (
|
||||
<TouchableOpacity testID="setNewPasswordButton" onPress={onPressNext}>
|
||||
<Text style={[s.white, s.f18, s.bold, s.pr5]}>Next</Text>
|
||||
<TouchableOpacity
|
||||
testID="setNewPasswordButton"
|
||||
onPress={onPressNext}>
|
||||
<Text type="xl-bold" style={[pal.link, s.pr5]}>
|
||||
Next
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{isProcessing ? (
|
||||
<Text style={[s.white, s.f18, s.pl10]}>Updating...</Text>
|
||||
<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}>
|
||||
<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 style={[s.white, s.f18, s.bold, s.pr5]}>Okay</Text>
|
||||
<Text type="xl-bold" style={[pal.link, s.pr5]}>
|
||||
Okay
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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',
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -18,6 +18,7 @@ export type PaletteColor = {
|
|||
textInverted: string
|
||||
link: string
|
||||
border: string
|
||||
borderDark: string
|
||||
icon: string
|
||||
[k: string]: string
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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,20 +80,36 @@ 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 (
|
||||
<View style={styles.outer}>
|
||||
{screenState === ScreenState.SigninOrCreateAccount ? (
|
||||
<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)
|
||||
}
|
||||
/>
|
||||
) : undefined}
|
||||
</ErrorBoundary>
|
||||
</SafeAreaView>
|
||||
</LinearGradient>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, pal.view]}>
|
||||
<SafeAreaView testID="noSessionView" style={styles.container}>
|
||||
<ErrorBoundary>
|
||||
{screenState === ScreenState.Signin ? (
|
||||
<Signin
|
||||
onPressBack={() =>
|
||||
|
@ -104,12 +124,17 @@ export const Login = observer(
|
|||
}
|
||||
/>
|
||||
) : undefined}
|
||||
</ErrorBoundary>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
height: '100%',
|
||||
},
|
||||
outer: {
|
||||
flex: 1,
|
||||
},
|
||||
|
|
|
@ -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,25 +35,46 @@ 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>
|
||||
{isSwitching ? (
|
||||
<View style={[pal.view, styles.profile]}>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
) : (
|
||||
<Link
|
||||
href={`/profile/${store.me.handle}`}
|
||||
title="Your profile"
|
||||
|
@ -63,7 +94,55 @@ export const Settings = observer(function Settings({
|
|||
</View>
|
||||
</View>
|
||||
</Link>
|
||||
<View style={s.flex1} />
|
||||
)}
|
||||
<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={account.displayName}
|
||||
handle={account.handle || ''}
|
||||
avatar={account.aviUrl}
|
||||
/>
|
||||
<View style={[s.ml10]}>
|
||||
<Text type="xl-bold" style={pal.text}>
|
||||
{account.displayName || account.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>
|
||||
</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',
|
||||
|
|
|
@ -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]}>
|
||||
|
|
|
@ -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>
|
||||
<View style={styles.outerContainer}>
|
||||
<Login />
|
||||
</ErrorBoundary>
|
||||
</SafeAreaView>
|
||||
<Modal />
|
||||
</LinearGradient>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
if (store.onboard.isOnboarding) {
|
||||
|
|
Loading…
Reference in New Issue