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", () => {
|
it("presses notifications menu item' button", () => {
|
||||||
const {getAllByTestId} = render(<Menu {...mockedProps} />)
|
const {getByTestId} = render(<Menu {...mockedProps} />)
|
||||||
|
|
||||||
const menuItemButton = getAllByTestId('menuItemButton')
|
const menuItemButton = getByTestId('menuItemButton-Notifications')
|
||||||
fireEvent.press(menuItemButton[1])
|
fireEvent.press(menuItemButton)
|
||||||
|
|
||||||
expect(onCloseMock).toHaveBeenCalled()
|
expect(onCloseMock).toHaveBeenCalled()
|
||||||
expect(mockedNavigationStore.switchTo).toHaveBeenCalledWith(1, true)
|
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', () => ({
|
jest.mock('rn-fetch-blob', () => ({
|
||||||
config: jest.fn().mockReturnThis(),
|
config: jest.fn().mockReturnThis(),
|
||||||
cancel: jest.fn(),
|
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 {RootSiblingParent} from 'react-native-root-siblings'
|
||||||
import {SafeAreaProvider} from 'react-native-safe-area-context'
|
import {SafeAreaProvider} from 'react-native-safe-area-context'
|
||||||
import {RootStoreProvider} from '../src/state'
|
import {RootStoreProvider} from '../src/state'
|
||||||
|
import {ThemeProvider} from '../src/view/lib/ThemeContext'
|
||||||
import {mockedRootStore} from '../__mocks__/state-mock'
|
import {mockedRootStore} from '../__mocks__/state-mock'
|
||||||
|
|
||||||
const customRender = (ui: any, storeMock?: any) =>
|
const customRender = (ui: any, rootStore?: any) =>
|
||||||
render(
|
render(
|
||||||
// eslint-disable-next-line react-native/no-inline-styles
|
// eslint-disable-next-line react-native/no-inline-styles
|
||||||
<GestureHandlerRootView style={{flex: 1}}>
|
<GestureHandlerRootView style={{flex: 1}}>
|
||||||
<RootSiblingParent>
|
<RootSiblingParent>
|
||||||
<RootStoreProvider
|
<RootStoreProvider
|
||||||
value={
|
value={rootStore != null ? rootStore : mockedRootStore}>
|
||||||
storeMock != null
|
<ThemeProvider theme="light">
|
||||||
? {...mockedRootStore, ...storeMock}
|
<SafeAreaProvider>{ui}</SafeAreaProvider>
|
||||||
: mockedRootStore
|
</ThemeProvider>
|
||||||
}>
|
|
||||||
<SafeAreaProvider>{ui}</SafeAreaProvider>
|
|
||||||
</RootStoreProvider>
|
</RootStoreProvider>
|
||||||
</RootSiblingParent>
|
</RootSiblingParent>
|
||||||
</GestureHandlerRootView>,
|
</GestureHandlerRootView>,
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
"web": "react-scripts start",
|
"web": "react-scripts start",
|
||||||
"start": "react-native start",
|
"start": "react-native start",
|
||||||
"clean-cache": "rm -rf node_modules/.cache/babel-loader/*",
|
"clean-cache": "rm -rf node_modules/.cache/babel-loader/*",
|
||||||
"test": "jest",
|
"test": "jest --forceExit",
|
||||||
"test-watch": "jest --watchAll",
|
"test-watch": "jest --watchAll",
|
||||||
"test-ci": "jest --ci --forceExit --reporters=default --reporters=jest-junit",
|
"test-ci": "jest --ci --forceExit --reporters=default --reporters=jest-junit",
|
||||||
"test-coverage": "jest --coverage",
|
"test-coverage": "jest --coverage",
|
||||||
|
@ -64,9 +64,11 @@
|
||||||
"react-native-version-number": "^0.3.6",
|
"react-native-version-number": "^0.3.6",
|
||||||
"react-native-web": "^0.17.7",
|
"react-native-web": "^0.17.7",
|
||||||
"rn-fetch-blob": "^0.12.0",
|
"rn-fetch-blob": "^0.12.0",
|
||||||
"tlds": "^1.234.0"
|
"tlds": "^1.234.0",
|
||||||
|
"zod": "^3.20.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@atproto/pds": "^0.0.1",
|
||||||
"@babel/core": "^7.12.9",
|
"@babel/core": "^7.12.9",
|
||||||
"@babel/preset-env": "^7.14.0",
|
"@babel/preset-env": "^7.14.0",
|
||||||
"@babel/runtime": "^7.12.5",
|
"@babel/runtime": "^7.12.5",
|
||||||
|
|
|
@ -13,13 +13,13 @@ export const DEFAULT_SERVICE = PROD_SERVICE
|
||||||
const ROOT_STATE_STORAGE_KEY = 'root'
|
const ROOT_STATE_STORAGE_KEY = 'root'
|
||||||
const STATE_FETCH_INTERVAL = 15e3
|
const STATE_FETCH_INTERVAL = 15e3
|
||||||
|
|
||||||
export async function setupState() {
|
export async function setupState(serviceUri = DEFAULT_SERVICE) {
|
||||||
let rootStore: RootStoreModel
|
let rootStore: RootStoreModel
|
||||||
let data: any
|
let data: any
|
||||||
|
|
||||||
libapi.doPolyfill()
|
libapi.doPolyfill()
|
||||||
|
|
||||||
const api = AtpApi.service(DEFAULT_SERVICE) as SessionServiceClient
|
const api = AtpApi.service(serviceUri) as SessionServiceClient
|
||||||
rootStore = new RootStoreModel(api)
|
rootStore = new RootStoreModel(api)
|
||||||
try {
|
try {
|
||||||
data = (await storage.load(ROOT_STATE_STORAGE_KEY)) || {}
|
data = (await storage.load(ROOT_STATE_STORAGE_KEY)) || {}
|
||||||
|
|
|
@ -6,24 +6,44 @@ import {
|
||||||
ComAtprotoServerGetAccountsConfig as GetAccountsConfig,
|
ComAtprotoServerGetAccountsConfig as GetAccountsConfig,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {isObj, hasProp} from '../lib/type-guards'
|
import {isObj, hasProp} from '../lib/type-guards'
|
||||||
|
import {z} from 'zod'
|
||||||
import {RootStoreModel} from './root-store'
|
import {RootStoreModel} from './root-store'
|
||||||
import {isNetworkError} from '../../lib/errors'
|
import {isNetworkError} from '../../lib/errors'
|
||||||
|
|
||||||
export type ServiceDescription = GetAccountsConfig.OutputSchema
|
export type ServiceDescription = GetAccountsConfig.OutputSchema
|
||||||
|
|
||||||
interface SessionData {
|
export const sessionData = z.object({
|
||||||
service: string
|
service: z.string(),
|
||||||
refreshJwt: string
|
refreshJwt: z.string(),
|
||||||
accessJwt: string
|
accessJwt: z.string(),
|
||||||
handle: string
|
handle: z.string(),
|
||||||
did: 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 {
|
export class SessionModel {
|
||||||
|
/**
|
||||||
|
* Current session data
|
||||||
|
*/
|
||||||
data: SessionData | null = null
|
data: SessionData | null = null
|
||||||
|
/**
|
||||||
|
* A listing of the currently & previous sessions, used for account switching
|
||||||
|
*/
|
||||||
|
accounts: AccountData[] = []
|
||||||
online = false
|
online = false
|
||||||
attemptingConnect = false
|
attemptingConnect = false
|
||||||
private _connectPromise: Promise<void> | undefined
|
private _connectPromise: Promise<boolean> | undefined
|
||||||
|
|
||||||
constructor(public rootStore: RootStoreModel) {
|
constructor(public rootStore: RootStoreModel) {
|
||||||
makeAutoObservable(this, {
|
makeAutoObservable(this, {
|
||||||
|
@ -37,51 +57,32 @@ export class SessionModel {
|
||||||
return this.data !== null
|
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 {
|
serialize(): unknown {
|
||||||
return {
|
return {
|
||||||
data: this.data,
|
data: this.data,
|
||||||
|
accounts: this.accounts,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hydrate(v: unknown) {
|
hydrate(v: unknown) {
|
||||||
|
this.accounts = []
|
||||||
if (isObj(v)) {
|
if (isObj(v)) {
|
||||||
if (hasProp(v, 'data') && isObj(v.data)) {
|
if (hasProp(v, 'data') && sessionData.safeParse(v.data)) {
|
||||||
const data: SessionData = {
|
this.data = v.data as SessionData
|
||||||
service: '',
|
}
|
||||||
refreshJwt: '',
|
if (hasProp(v, 'accounts') && Array.isArray(v.accounts)) {
|
||||||
accessJwt: '',
|
for (const account of v.accounts) {
|
||||||
handle: '',
|
if (accountData.safeParse(account)) {
|
||||||
did: '',
|
this.accounts.push(account as AccountData)
|
||||||
}
|
}
|
||||||
if (hasProp(v.data, 'service') && typeof v.data.service === 'string') {
|
|
||||||
data.service = v.data.service
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
hasProp(v.data, 'refreshJwt') &&
|
|
||||||
typeof v.data.refreshJwt === 'string'
|
|
||||||
) {
|
|
||||||
data.refreshJwt = v.data.refreshJwt
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
hasProp(v.data, 'accessJwt') &&
|
|
||||||
typeof v.data.accessJwt === 'string'
|
|
||||||
) {
|
|
||||||
data.accessJwt = v.data.accessJwt
|
|
||||||
}
|
|
||||||
if (hasProp(v.data, 'handle') && typeof v.data.handle === 'string') {
|
|
||||||
data.handle = v.data.handle
|
|
||||||
}
|
|
||||||
if (hasProp(v.data, 'did') && typeof v.data.did === 'string') {
|
|
||||||
data.did = v.data.did
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
data.service &&
|
|
||||||
data.refreshJwt &&
|
|
||||||
data.accessJwt &&
|
|
||||||
data.handle &&
|
|
||||||
data.did
|
|
||||||
) {
|
|
||||||
this.data = data
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -113,6 +114,9 @@ export class SessionModel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets up the XRPC API, must be called before connecting to a service
|
||||||
|
*/
|
||||||
private configureApi(): boolean {
|
private configureApi(): boolean {
|
||||||
if (!this.data) {
|
if (!this.data) {
|
||||||
return false
|
return false
|
||||||
|
@ -137,19 +141,68 @@ export class SessionModel {
|
||||||
return true
|
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) {
|
if (this._connectPromise) {
|
||||||
return this._connectPromise
|
return this._connectPromise
|
||||||
}
|
}
|
||||||
this._connectPromise = this._connect()
|
this._connectPromise = this._connect()
|
||||||
await this._connectPromise
|
const res = await this._connectPromise
|
||||||
this._connectPromise = undefined
|
this._connectPromise = undefined
|
||||||
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _connect(): Promise<void> {
|
private async _connect(): Promise<boolean> {
|
||||||
this.attemptingConnect = true
|
this.attemptingConnect = true
|
||||||
if (!this.configureApi()) {
|
if (!this.configureApi()) {
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -159,29 +212,44 @@ export class SessionModel {
|
||||||
if (this.rootStore.me.did !== sess.data.did) {
|
if (this.rootStore.me.did !== sess.data.did) {
|
||||||
this.rootStore.me.clear()
|
this.rootStore.me.clear()
|
||||||
}
|
}
|
||||||
this.rootStore.me.load().catch(e => {
|
this.rootStore.me
|
||||||
this.rootStore.log.error('Failed to fetch local user information', e)
|
.load()
|
||||||
})
|
.catch(e => {
|
||||||
return // success
|
this.rootStore.log.error(
|
||||||
|
'Failed to fetch local user information',
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
this.addSessionToAccounts()
|
||||||
|
})
|
||||||
|
return true // success
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (isNetworkError(e)) {
|
if (isNetworkError(e)) {
|
||||||
this.setOnline(false, false) // connection issue
|
this.setOnline(false, false) // connection issue
|
||||||
return
|
return false
|
||||||
} else {
|
} else {
|
||||||
this.clear() // invalid session cached
|
this.clear() // invalid session cached
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setOnline(false, false)
|
this.setOnline(false, false)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to fetch the accounts config settings from an account.
|
||||||
|
*/
|
||||||
async describeService(service: string): Promise<ServiceDescription> {
|
async describeService(service: string): Promise<ServiceDescription> {
|
||||||
const api = AtpApi.service(service) as SessionServiceClient
|
const api = AtpApi.service(service) as SessionServiceClient
|
||||||
const res = await api.com.atproto.server.getAccountsConfig({})
|
const res = await api.com.atproto.server.getAccountsConfig({})
|
||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new session.
|
||||||
|
*/
|
||||||
async login({
|
async login({
|
||||||
service,
|
service,
|
||||||
handle,
|
handle,
|
||||||
|
@ -203,12 +271,35 @@ export class SessionModel {
|
||||||
})
|
})
|
||||||
this.configureApi()
|
this.configureApi()
|
||||||
this.setOnline(true, false)
|
this.setOnline(true, false)
|
||||||
this.rootStore.me.load().catch(e => {
|
this.rootStore.me
|
||||||
this.rootStore.log.error('Failed to fetch local user information', e)
|
.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({
|
async createAccount({
|
||||||
service,
|
service,
|
||||||
email,
|
email,
|
||||||
|
@ -239,12 +330,20 @@ export class SessionModel {
|
||||||
})
|
})
|
||||||
this.rootStore.onboard.start()
|
this.rootStore.onboard.start()
|
||||||
this.configureApi()
|
this.configureApi()
|
||||||
this.rootStore.me.load().catch(e => {
|
this.rootStore.me
|
||||||
this.rootStore.log.error('Failed to fetch local user information', e)
|
.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() {
|
async logout() {
|
||||||
if (this.hasSession) {
|
if (this.hasSession) {
|
||||||
this.rootStore.api.com.atproto.session.delete().catch((e: any) => {
|
this.rootStore.api.com.atproto.session.delete().catch((e: any) => {
|
||||||
|
@ -254,6 +353,7 @@ export class SessionModel {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
this.clearSessionTokensFromAccounts()
|
||||||
this.rootStore.clearAll()
|
this.rootStore.clearAll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,7 +12,7 @@ import {
|
||||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
import {ComAtprotoAccountCreate} from '@atproto/api'
|
import {ComAtprotoAccountCreate} from '@atproto/api'
|
||||||
import * as EmailValidator from 'email-validator'
|
import * as EmailValidator from 'email-validator'
|
||||||
import {Logo} from './Logo'
|
import {LogoTextHero} from './Logo'
|
||||||
import {Picker} from '../util/Picker'
|
import {Picker} from '../util/Picker'
|
||||||
import {TextLink} from '../util/Link'
|
import {TextLink} from '../util/Link'
|
||||||
import {Text} from '../util/text/Text'
|
import {Text} from '../util/text/Text'
|
||||||
|
@ -25,8 +25,10 @@ import {
|
||||||
import {useStores, DEFAULT_SERVICE} from '../../../state'
|
import {useStores, DEFAULT_SERVICE} from '../../../state'
|
||||||
import {ServiceDescription} from '../../../state/models/session'
|
import {ServiceDescription} from '../../../state/models/session'
|
||||||
import {ServerInputModal} from '../../../state/models/shell-ui'
|
import {ServerInputModal} from '../../../state/models/shell-ui'
|
||||||
|
import {usePalette} from '../../lib/hooks/usePalette'
|
||||||
|
|
||||||
export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
|
export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
|
||||||
|
const pal = usePalette('default')
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const [isProcessing, setIsProcessing] = useState<boolean>(false)
|
const [isProcessing, setIsProcessing] = useState<boolean>(false)
|
||||||
const [serviceUrl, setServiceUrl] = useState<string>(DEFAULT_SERVICE)
|
const [serviceUrl, setServiceUrl] = useState<string>(DEFAULT_SERVICE)
|
||||||
|
@ -114,74 +116,14 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const Policies = () => {
|
|
||||||
if (!serviceDescription) {
|
|
||||||
return <View />
|
|
||||||
}
|
|
||||||
const tos = validWebLink(serviceDescription.links?.termsOfService)
|
|
||||||
const pp = validWebLink(serviceDescription.links?.privacyPolicy)
|
|
||||||
if (!tos && !pp) {
|
|
||||||
return (
|
|
||||||
<View style={styles.policies}>
|
|
||||||
<View style={[styles.errorIcon, s.mt2]}>
|
|
||||||
<FontAwesomeIcon icon="exclamation" style={s.white} size={10} />
|
|
||||||
</View>
|
|
||||||
<Text style={[s.white, s.pl5, s.flex1]}>
|
|
||||||
This service has not provided terms of service or a privacy policy.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const els = []
|
|
||||||
if (tos) {
|
|
||||||
els.push(
|
|
||||||
<TextLink
|
|
||||||
key="tos"
|
|
||||||
href={tos}
|
|
||||||
text="Terms of Service"
|
|
||||||
style={[s.white, s.underline]}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (pp) {
|
|
||||||
els.push(
|
|
||||||
<TextLink
|
|
||||||
key="pp"
|
|
||||||
href={pp}
|
|
||||||
text="Privacy Policy"
|
|
||||||
style={[s.white, s.underline]}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (els.length === 2) {
|
|
||||||
els.splice(
|
|
||||||
1,
|
|
||||||
0,
|
|
||||||
<Text key="and" style={s.white}>
|
|
||||||
{' '}
|
|
||||||
and{' '}
|
|
||||||
</Text>,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<View style={styles.policies}>
|
|
||||||
<Text style={s.white}>
|
|
||||||
By creating an account you agree to the {els}.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isReady = !!email && !!password && !!handle && is13
|
const isReady = !!email && !!password && !!handle && is13
|
||||||
return (
|
return (
|
||||||
<ScrollView testID="createAccount" style={{flex: 1}}>
|
<ScrollView testID="createAccount" style={pal.view}>
|
||||||
<KeyboardAvoidingView behavior="padding" style={{flex: 1}}>
|
<KeyboardAvoidingView behavior="padding">
|
||||||
<View style={styles.logoHero}>
|
<LogoTextHero />
|
||||||
<Logo />
|
|
||||||
</View>
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<View style={[styles.error, styles.errorFloating]}>
|
<View style={[styles.error, styles.errorFloating]}>
|
||||||
<View style={styles.errorIcon}>
|
<View style={[styles.errorIcon]}>
|
||||||
<FontAwesomeIcon icon="exclamation" style={s.white} size={10} />
|
<FontAwesomeIcon icon="exclamation" style={s.white} size={10} />
|
||||||
</View>
|
</View>
|
||||||
<View style={s.flex1}>
|
<View style={s.flex1}>
|
||||||
|
@ -189,41 +131,55 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
<View style={[styles.group]}>
|
<View style={styles.groupLabel}>
|
||||||
<View style={styles.groupTitle}>
|
<Text type="sm-bold" style={pal.text}>
|
||||||
<Text style={[s.white, s.f18, s.bold]}>Create a new account</Text>
|
Service provider
|
||||||
</View>
|
</Text>
|
||||||
<View style={styles.groupContent}>
|
</View>
|
||||||
<FontAwesomeIcon icon="globe" style={styles.groupContentIcon} />
|
<View style={[pal.borderDark, styles.group]}>
|
||||||
|
<View
|
||||||
|
style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon="globe"
|
||||||
|
style={[pal.textLight, styles.groupContentIcon]}
|
||||||
|
/>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
testID="registerSelectServiceButton"
|
testID="registerSelectServiceButton"
|
||||||
style={styles.textBtn}
|
style={styles.textBtn}
|
||||||
onPress={onPressSelectService}>
|
onPress={onPressSelectService}>
|
||||||
<Text style={styles.textBtnLabel}>
|
<Text type="xl" style={[pal.text, styles.textBtnLabel]}>
|
||||||
{toNiceDomain(serviceUrl)}
|
{toNiceDomain(serviceUrl)}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={styles.textBtnFakeInnerBtn}>
|
<View style={[pal.btn, styles.textBtnFakeInnerBtn]}>
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon="pen"
|
icon="pen"
|
||||||
size={12}
|
size={12}
|
||||||
style={styles.textBtnFakeInnerBtnIcon}
|
style={[pal.textLight, styles.textBtnFakeInnerBtnIcon]}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.textBtnFakeInnerBtnLabel}>Change</Text>
|
<Text style={[pal.textLight]}>Change</Text>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
{serviceDescription ? (
|
</View>
|
||||||
<>
|
{serviceDescription ? (
|
||||||
|
<>
|
||||||
|
<View style={styles.groupLabel}>
|
||||||
|
<Text type="sm-bold" style={pal.text}>
|
||||||
|
Account details
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[pal.borderDark, styles.group]}>
|
||||||
{serviceDescription?.inviteCodeRequired ? (
|
{serviceDescription?.inviteCodeRequired ? (
|
||||||
<View style={styles.groupContent}>
|
<View
|
||||||
|
style={[pal.border, styles.groupContent, styles.noTopBorder]}>
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon="ticket"
|
icon="ticket"
|
||||||
style={styles.groupContentIcon}
|
style={[pal.textLight, styles.groupContentIcon]}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[styles.textInput]}
|
style={[pal.text, styles.textInput]}
|
||||||
placeholder="Invite code"
|
placeholder="Invite code"
|
||||||
placeholderTextColor={colors.blue0}
|
placeholderTextColor={pal.colors.textLight}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
autoFocus
|
autoFocus
|
||||||
|
@ -233,16 +189,16 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
<View style={styles.groupContent}>
|
<View style={[pal.border, styles.groupContent]}>
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon="envelope"
|
icon="envelope"
|
||||||
style={styles.groupContentIcon}
|
style={[pal.textLight, styles.groupContentIcon]}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
testID="registerEmailInput"
|
testID="registerEmailInput"
|
||||||
style={[styles.textInput]}
|
style={[pal.text, styles.textInput]}
|
||||||
placeholder="Email address"
|
placeholder="Email address"
|
||||||
placeholderTextColor={colors.blue0}
|
placeholderTextColor={pal.colors.textLight}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
value={email}
|
value={email}
|
||||||
|
@ -250,13 +206,16 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
|
||||||
editable={!isProcessing}
|
editable={!isProcessing}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.groupContent}>
|
<View style={[pal.border, styles.groupContent]}>
|
||||||
<FontAwesomeIcon icon="lock" style={styles.groupContentIcon} />
|
<FontAwesomeIcon
|
||||||
|
icon="lock"
|
||||||
|
style={[pal.textLight, styles.groupContentIcon]}
|
||||||
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
testID="registerPasswordInput"
|
testID="registerPasswordInput"
|
||||||
style={[styles.textInput]}
|
style={[pal.text, styles.textInput]}
|
||||||
placeholder="Choose your password"
|
placeholder="Choose your password"
|
||||||
placeholderTextColor={colors.blue0}
|
placeholderTextColor={pal.colors.textLight}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
secureTextEntry
|
secureTextEntry
|
||||||
|
@ -265,24 +224,28 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
|
||||||
editable={!isProcessing}
|
editable={!isProcessing}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</>
|
</View>
|
||||||
) : undefined}
|
</>
|
||||||
</View>
|
) : undefined}
|
||||||
{serviceDescription ? (
|
{serviceDescription ? (
|
||||||
<>
|
<>
|
||||||
<View style={styles.group}>
|
<View style={styles.groupLabel}>
|
||||||
<View style={styles.groupTitle}>
|
<Text type="sm-bold" style={pal.text}>
|
||||||
<Text style={[s.white, s.f18, s.bold]}>
|
Choose your username
|
||||||
Choose your username
|
</Text>
|
||||||
</Text>
|
</View>
|
||||||
</View>
|
<View style={[pal.border, styles.group]}>
|
||||||
<View style={styles.groupContent}>
|
<View
|
||||||
<FontAwesomeIcon icon="at" style={styles.groupContentIcon} />
|
style={[pal.border, styles.groupContent, styles.noTopBorder]}>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon="at"
|
||||||
|
style={[pal.textLight, styles.groupContentIcon]}
|
||||||
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
testID="registerHandleInput"
|
testID="registerHandleInput"
|
||||||
style={[styles.textInput]}
|
style={[pal.text, styles.textInput]}
|
||||||
placeholder="eg alice"
|
placeholder="eg alice"
|
||||||
placeholderTextColor={colors.blue0}
|
placeholderTextColor={pal.colors.textLight}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
value={handle}
|
value={handle}
|
||||||
onChangeText={v => setHandle(makeValidHandle(v))}
|
onChangeText={v => setHandle(makeValidHandle(v))}
|
||||||
|
@ -290,15 +253,15 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
{serviceDescription.availableUserDomains.length > 1 && (
|
{serviceDescription.availableUserDomains.length > 1 && (
|
||||||
<View style={styles.groupContent}>
|
<View style={[pal.border, styles.groupContent]}>
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon="globe"
|
icon="globe"
|
||||||
style={styles.groupContentIcon}
|
style={styles.groupContentIcon}
|
||||||
/>
|
/>
|
||||||
<Picker
|
<Picker
|
||||||
style={styles.picker}
|
style={[pal.text, styles.picker]}
|
||||||
labelStyle={styles.pickerLabel}
|
labelStyle={styles.pickerLabel}
|
||||||
iconStyle={styles.pickerIcon}
|
iconStyle={pal.textLight}
|
||||||
value={userDomain}
|
value={userDomain}
|
||||||
items={serviceDescription.availableUserDomains.map(d => ({
|
items={serviceDescription.availableUserDomains.map(d => ({
|
||||||
label: `.${d}`,
|
label: `.${d}`,
|
||||||
|
@ -309,41 +272,50 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<View style={styles.groupContent}>
|
<View style={[pal.border, styles.groupContent]}>
|
||||||
<Text style={[s.white, s.p10]}>
|
<Text style={[pal.textLight, s.p10]}>
|
||||||
Your full username will be{' '}
|
Your full username will be{' '}
|
||||||
<Text style={[s.white, s.bold]}>
|
<Text type="md-bold" style={pal.textLight}>
|
||||||
@{createFullHandle(handle, userDomain)}
|
@{createFullHandle(handle, userDomain)}
|
||||||
</Text>
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View style={[styles.group]}>
|
<View style={styles.groupLabel}>
|
||||||
<View style={styles.groupTitle}>
|
<Text type="sm-bold" style={pal.text}>
|
||||||
<Text style={[s.white, s.f18, s.bold]}>Legal</Text>
|
Legal
|
||||||
</View>
|
</Text>
|
||||||
<View style={styles.groupContent}>
|
</View>
|
||||||
|
<View style={[pal.border, styles.group]}>
|
||||||
|
<View
|
||||||
|
style={[pal.border, styles.groupContent, styles.noTopBorder]}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
testID="registerIs13Input"
|
testID="registerIs13Input"
|
||||||
style={styles.textBtn}
|
style={styles.textBtn}
|
||||||
onPress={() => setIs13(!is13)}>
|
onPress={() => setIs13(!is13)}>
|
||||||
<View style={is13 ? styles.checkboxFilled : styles.checkbox}>
|
<View
|
||||||
|
style={[
|
||||||
|
pal.border,
|
||||||
|
is13 ? styles.checkboxFilled : styles.checkbox,
|
||||||
|
]}>
|
||||||
{is13 && (
|
{is13 && (
|
||||||
<FontAwesomeIcon icon="check" style={s.blue3} size={14} />
|
<FontAwesomeIcon icon="check" style={s.blue3} size={14} />
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<Text style={[styles.textBtnLabel, s.f16]}>
|
<Text style={[pal.text, styles.textBtnLabel]}>
|
||||||
I am 13 years old or older
|
I am 13 years old or older
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Policies />
|
<Policies serviceDescription={serviceDescription} />
|
||||||
</>
|
</>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
<View style={[s.flexRow, s.pl20, s.pr20]}>
|
<View style={[s.flexRow, s.pl20, s.pr20]}>
|
||||||
<TouchableOpacity onPress={onPressBack}>
|
<TouchableOpacity onPress={onPressBack}>
|
||||||
<Text style={[s.white, s.f18, s.pl5]}>Back</Text>
|
<Text type="xl" style={pal.link}>
|
||||||
|
Back
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<View style={s.flex1} />
|
<View style={s.flex1} />
|
||||||
{isReady ? (
|
{isReady ? (
|
||||||
|
@ -351,21 +323,27 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
|
||||||
testID="createAccountButton"
|
testID="createAccountButton"
|
||||||
onPress={onPressNext}>
|
onPress={onPressNext}>
|
||||||
{isProcessing ? (
|
{isProcessing ? (
|
||||||
<ActivityIndicator color="#fff" />
|
<ActivityIndicator />
|
||||||
) : (
|
) : (
|
||||||
<Text style={[s.white, s.f18, s.bold, s.pr5]}>Next</Text>
|
<Text type="xl-bold" style={[pal.link, s.pr5]}>
|
||||||
|
Next
|
||||||
|
</Text>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : !serviceDescription && error ? (
|
) : !serviceDescription && error ? (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
testID="registerRetryButton"
|
testID="registerRetryButton"
|
||||||
onPress={onPressRetryConnect}>
|
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>
|
</TouchableOpacity>
|
||||||
) : !serviceDescription ? (
|
) : !serviceDescription ? (
|
||||||
<>
|
<>
|
||||||
<ActivityIndicator color="#fff" />
|
<ActivityIndicator color="#fff" />
|
||||||
<Text style={[s.white, s.f18, s.pl5, s.pr5]}>Connecting...</Text>
|
<Text type="xl-bold" style={[pal.link, s.pr5]}>
|
||||||
|
Connecting...
|
||||||
|
</Text>
|
||||||
</>
|
</>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
</View>
|
</View>
|
||||||
|
@ -375,6 +353,69 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const Policies = ({
|
||||||
|
serviceDescription,
|
||||||
|
}: {
|
||||||
|
serviceDescription: ServiceDescription
|
||||||
|
}) => {
|
||||||
|
const pal = usePalette('default')
|
||||||
|
if (!serviceDescription) {
|
||||||
|
return <View />
|
||||||
|
}
|
||||||
|
const tos = validWebLink(serviceDescription.links?.termsOfService)
|
||||||
|
const pp = validWebLink(serviceDescription.links?.privacyPolicy)
|
||||||
|
if (!tos && !pp) {
|
||||||
|
return (
|
||||||
|
<View style={styles.policies}>
|
||||||
|
<View style={[styles.errorIcon, {borderColor: pal.colors.text}, s.mt2]}>
|
||||||
|
<FontAwesomeIcon icon="exclamation" style={pal.textLight} size={10} />
|
||||||
|
</View>
|
||||||
|
<Text style={[pal.textLight, s.pl5, s.flex1]}>
|
||||||
|
This service has not provided terms of service or a privacy policy.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const els = []
|
||||||
|
if (tos) {
|
||||||
|
els.push(
|
||||||
|
<TextLink
|
||||||
|
key="tos"
|
||||||
|
href={tos}
|
||||||
|
text="Terms of Service"
|
||||||
|
style={[pal.link, s.underline]}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (pp) {
|
||||||
|
els.push(
|
||||||
|
<TextLink
|
||||||
|
key="pp"
|
||||||
|
href={pp}
|
||||||
|
text="Privacy Policy"
|
||||||
|
style={[pal.link, s.underline]}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (els.length === 2) {
|
||||||
|
els.splice(
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
<Text key="and" style={pal.textLight}>
|
||||||
|
{' '}
|
||||||
|
and{' '}
|
||||||
|
</Text>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<View style={styles.policies}>
|
||||||
|
<Text style={pal.textLight}>
|
||||||
|
By creating an account you agree to the {els}.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function validWebLink(url?: string): string | undefined {
|
function validWebLink(url?: string): string | undefined {
|
||||||
return url && (url.startsWith('http://') || url.startsWith('https://'))
|
return url && (url.startsWith('http://') || url.startsWith('https://'))
|
||||||
? url
|
? url
|
||||||
|
@ -382,42 +423,39 @@ function validWebLink(url?: string): string | undefined {
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
|
noTopBorder: {
|
||||||
|
borderTopWidth: 0,
|
||||||
|
},
|
||||||
logoHero: {
|
logoHero: {
|
||||||
paddingTop: 30,
|
paddingTop: 30,
|
||||||
paddingBottom: 40,
|
paddingBottom: 40,
|
||||||
},
|
},
|
||||||
group: {
|
group: {
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.white,
|
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
marginBottom: 20,
|
marginBottom: 20,
|
||||||
marginHorizontal: 20,
|
marginHorizontal: 20,
|
||||||
backgroundColor: colors.blue3,
|
|
||||||
},
|
},
|
||||||
groupTitle: {
|
groupLabel: {
|
||||||
flexDirection: 'row',
|
paddingHorizontal: 20,
|
||||||
alignItems: 'center',
|
paddingBottom: 5,
|
||||||
paddingVertical: 8,
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
},
|
},
|
||||||
groupContent: {
|
groupContent: {
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
borderTopColor: colors.blue1,
|
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
groupContentIcon: {
|
groupContentIcon: {
|
||||||
color: 'white',
|
|
||||||
marginLeft: 10,
|
marginLeft: 10,
|
||||||
},
|
},
|
||||||
textInput: {
|
textInput: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
backgroundColor: colors.blue3,
|
|
||||||
color: colors.white,
|
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
fontSize: 18,
|
fontSize: 17,
|
||||||
|
letterSpacing: 0.25,
|
||||||
|
fontWeight: '400',
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
},
|
},
|
||||||
textBtn: {
|
textBtn: {
|
||||||
|
@ -427,47 +465,33 @@ const styles = StyleSheet.create({
|
||||||
},
|
},
|
||||||
textBtnLabel: {
|
textBtnLabel: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
color: colors.white,
|
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
fontSize: 18,
|
|
||||||
},
|
},
|
||||||
textBtnFakeInnerBtn: {
|
textBtnFakeInnerBtn: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
backgroundColor: colors.blue2,
|
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
paddingHorizontal: 8,
|
paddingHorizontal: 8,
|
||||||
marginHorizontal: 6,
|
marginHorizontal: 6,
|
||||||
},
|
},
|
||||||
textBtnFakeInnerBtnIcon: {
|
textBtnFakeInnerBtnIcon: {
|
||||||
color: colors.white,
|
|
||||||
marginRight: 4,
|
marginRight: 4,
|
||||||
},
|
},
|
||||||
textBtnFakeInnerBtnLabel: {
|
|
||||||
color: colors.white,
|
|
||||||
},
|
|
||||||
picker: {
|
picker: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
backgroundColor: colors.blue3,
|
|
||||||
color: colors.white,
|
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
fontSize: 18,
|
fontSize: 17,
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
},
|
},
|
||||||
pickerLabel: {
|
pickerLabel: {
|
||||||
color: colors.white,
|
fontSize: 17,
|
||||||
fontSize: 18,
|
|
||||||
},
|
|
||||||
pickerIcon: {
|
|
||||||
color: colors.white,
|
|
||||||
},
|
},
|
||||||
checkbox: {
|
checkbox: {
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.white,
|
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
width: 16,
|
width: 16,
|
||||||
height: 16,
|
height: 16,
|
||||||
|
@ -475,8 +499,6 @@ const styles = StyleSheet.create({
|
||||||
},
|
},
|
||||||
checkboxFilled: {
|
checkboxFilled: {
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.white,
|
|
||||||
backgroundColor: colors.white,
|
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
width: 16,
|
width: 16,
|
||||||
height: 16,
|
height: 16,
|
||||||
|
@ -489,8 +511,6 @@ const styles = StyleSheet.create({
|
||||||
paddingBottom: 20,
|
paddingBottom: 20,
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.red5,
|
|
||||||
backgroundColor: colors.red4,
|
backgroundColor: colors.red4,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
@ -509,7 +529,6 @@ const styles = StyleSheet.create({
|
||||||
errorIcon: {
|
errorIcon: {
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.white,
|
borderColor: colors.white,
|
||||||
color: colors.white,
|
|
||||||
borderRadius: 30,
|
borderRadius: 30,
|
||||||
width: 16,
|
width: 16,
|
||||||
height: 16,
|
height: 16,
|
||||||
|
|
|
@ -1,26 +1,29 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {StyleSheet, View} from 'react-native'
|
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 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 (
|
return (
|
||||||
<View style={styles.logo}>
|
<View style={styles.logo}>
|
||||||
<Svg width="100" height="100">
|
<Svg width={size} height={size} viewBox="0 0 100 100">
|
||||||
<Circle
|
<Circle
|
||||||
cx="50"
|
cx="50"
|
||||||
cy="50"
|
cy="50"
|
||||||
r="46"
|
r="46"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="white"
|
stroke={color}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
/>
|
/>
|
||||||
<Line stroke="white" strokeWidth={1} x1="30" x2="30" y1="0" y2="100" />
|
<Line stroke={color} 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={color} 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={color} 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="0" x2="100" y1="74" y2="74" />
|
||||||
<SvgText
|
<SvgText
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="white"
|
stroke={color}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
fontSize="60"
|
fontSize="60"
|
||||||
fontWeight="bold"
|
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({
|
const styles = StyleSheet.create({
|
||||||
logo: {
|
logo: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'center',
|
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 {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
import * as EmailValidator from 'email-validator'
|
import * as EmailValidator from 'email-validator'
|
||||||
import {sessionClient as AtpApi, SessionServiceClient} from '@atproto/api'
|
import {sessionClient as AtpApi, SessionServiceClient} from '@atproto/api'
|
||||||
import {Logo} from './Logo'
|
import {LogoTextHero} from './Logo'
|
||||||
import {Text} from '../util/text/Text'
|
import {Text} from '../util/text/Text'
|
||||||
|
import {UserAvatar} from '../util/UserAvatar'
|
||||||
import {s, colors} from '../../lib/styles'
|
import {s, colors} from '../../lib/styles'
|
||||||
import {createFullHandle, toNiceDomain} from '../../../lib/strings'
|
import {createFullHandle, toNiceDomain} from '../../../lib/strings'
|
||||||
import {useStores, RootStoreModel, DEFAULT_SERVICE} from '../../../state'
|
import {useStores, RootStoreModel, DEFAULT_SERVICE} from '../../../state'
|
||||||
import {ServiceDescription} from '../../../state/models/session'
|
import {ServiceDescription} from '../../../state/models/session'
|
||||||
import {ServerInputModal} from '../../../state/models/shell-ui'
|
import {ServerInputModal} from '../../../state/models/shell-ui'
|
||||||
|
import {AccountData} from '../../../state/models/session'
|
||||||
import {isNetworkError} from '../../../lib/errors'
|
import {isNetworkError} from '../../../lib/errors'
|
||||||
|
import {usePalette} from '../../lib/hooks/usePalette'
|
||||||
|
|
||||||
enum Forms {
|
enum Forms {
|
||||||
Login,
|
Login,
|
||||||
|
ChooseAccount,
|
||||||
ForgotPassword,
|
ForgotPassword,
|
||||||
SetNewPassword,
|
SetNewPassword,
|
||||||
PasswordUpdated,
|
PasswordUpdated,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Signin = ({onPressBack}: {onPressBack: () => void}) => {
|
export const Signin = ({onPressBack}: {onPressBack: () => void}) => {
|
||||||
|
const pal = usePalette('default')
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const [error, setError] = useState<string>('')
|
const [error, setError] = useState<string>('')
|
||||||
const [retryDescribeTrigger, setRetryDescribeTrigger] = useState<any>({})
|
const [retryDescribeTrigger, setRetryDescribeTrigger] = useState<any>({})
|
||||||
|
@ -35,7 +40,18 @@ export const Signin = ({onPressBack}: {onPressBack: () => void}) => {
|
||||||
const [serviceDescription, setServiceDescription] = useState<
|
const [serviceDescription, setServiceDescription] = useState<
|
||||||
ServiceDescription | undefined
|
ServiceDescription | undefined
|
||||||
>(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) => () => {
|
const gotoForm = (form: Forms) => () => {
|
||||||
setError('')
|
setError('')
|
||||||
|
@ -73,16 +89,14 @@ export const Signin = ({onPressBack}: {onPressBack: () => void}) => {
|
||||||
const onPressRetryConnect = () => setRetryDescribeTrigger({})
|
const onPressRetryConnect = () => setRetryDescribeTrigger({})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView testID="signIn" behavior="padding" style={{flex: 1}}>
|
<KeyboardAvoidingView testID="signIn" behavior="padding" style={[pal.view]}>
|
||||||
<View style={styles.logoHero}>
|
|
||||||
<Logo />
|
|
||||||
</View>
|
|
||||||
{currentForm === Forms.Login ? (
|
{currentForm === Forms.Login ? (
|
||||||
<LoginForm
|
<LoginForm
|
||||||
store={store}
|
store={store}
|
||||||
error={error}
|
error={error}
|
||||||
serviceUrl={serviceUrl}
|
serviceUrl={serviceUrl}
|
||||||
serviceDescription={serviceDescription}
|
serviceDescription={serviceDescription}
|
||||||
|
initialHandle={initialHandle}
|
||||||
setError={setError}
|
setError={setError}
|
||||||
setServiceUrl={setServiceUrl}
|
setServiceUrl={setServiceUrl}
|
||||||
onPressBack={onPressBack}
|
onPressBack={onPressBack}
|
||||||
|
@ -90,6 +104,13 @@ export const Signin = ({onPressBack}: {onPressBack: () => void}) => {
|
||||||
onPressRetryConnect={onPressRetryConnect}
|
onPressRetryConnect={onPressRetryConnect}
|
||||||
/>
|
/>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
{currentForm === Forms.ChooseAccount ? (
|
||||||
|
<ChooseAccountForm
|
||||||
|
store={store}
|
||||||
|
onSelectAccount={onSelectAccount}
|
||||||
|
onPressBack={onPressBack}
|
||||||
|
/>
|
||||||
|
) : undefined}
|
||||||
{currentForm === Forms.ForgotPassword ? (
|
{currentForm === Forms.ForgotPassword ? (
|
||||||
<ForgotPasswordForm
|
<ForgotPasswordForm
|
||||||
store={store}
|
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 = ({
|
const LoginForm = ({
|
||||||
store,
|
store,
|
||||||
error,
|
error,
|
||||||
serviceUrl,
|
serviceUrl,
|
||||||
serviceDescription,
|
serviceDescription,
|
||||||
|
initialHandle,
|
||||||
setError,
|
setError,
|
||||||
setServiceUrl,
|
setServiceUrl,
|
||||||
onPressRetryConnect,
|
onPressRetryConnect,
|
||||||
|
@ -134,14 +253,16 @@ const LoginForm = ({
|
||||||
error: string
|
error: string
|
||||||
serviceUrl: string
|
serviceUrl: string
|
||||||
serviceDescription: ServiceDescription | undefined
|
serviceDescription: ServiceDescription | undefined
|
||||||
|
initialHandle: string
|
||||||
setError: (v: string) => void
|
setError: (v: string) => void
|
||||||
setServiceUrl: (v: string) => void
|
setServiceUrl: (v: string) => void
|
||||||
onPressRetryConnect: () => void
|
onPressRetryConnect: () => void
|
||||||
onPressBack: () => void
|
onPressBack: () => void
|
||||||
onPressForgotPassword: () => void
|
onPressForgotPassword: () => void
|
||||||
}) => {
|
}) => {
|
||||||
|
const pal = usePalette('default')
|
||||||
const [isProcessing, setIsProcessing] = useState<boolean>(false)
|
const [isProcessing, setIsProcessing] = useState<boolean>(false)
|
||||||
const [handle, setHandle] = useState<string>('')
|
const [handle, setHandle] = useState<string>(initialHandle)
|
||||||
const [password, setPassword] = useState<string>('')
|
const [password, setPassword] = useState<string>('')
|
||||||
|
|
||||||
const onPressSelectService = () => {
|
const onPressSelectService = () => {
|
||||||
|
@ -197,31 +318,44 @@ const LoginForm = ({
|
||||||
|
|
||||||
const isReady = !!serviceDescription && !!handle && !!password
|
const isReady = !!serviceDescription && !!handle && !!password
|
||||||
return (
|
return (
|
||||||
<>
|
<View testID="loginForm">
|
||||||
<View testID="loginFormView" style={styles.group}>
|
<LogoTextHero />
|
||||||
<TouchableOpacity
|
<Text type="sm-bold" style={[pal.text, styles.groupLabel]}>
|
||||||
testID="loginSelectServiceButton"
|
Sign into
|
||||||
style={[styles.groupTitle, {paddingRight: 0, paddingVertical: 6}]}
|
</Text>
|
||||||
onPress={onPressSelectService}>
|
<View style={[pal.borderDark, styles.group]}>
|
||||||
<Text style={[s.flex1, s.white, s.f18, s.bold]} numberOfLines={1}>
|
<View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
|
||||||
Sign in to {toNiceDomain(serviceUrl)}
|
<FontAwesomeIcon
|
||||||
</Text>
|
icon="globe"
|
||||||
<View style={styles.textBtnFakeInnerBtn}>
|
style={[pal.textLight, styles.groupContentIcon]}
|
||||||
<FontAwesomeIcon
|
/>
|
||||||
icon="pen"
|
<TouchableOpacity
|
||||||
size={12}
|
testID="loginSelectServiceButton"
|
||||||
style={styles.textBtnFakeInnerBtnIcon}
|
style={styles.textBtn}
|
||||||
/>
|
onPress={onPressSelectService}>
|
||||||
<Text style={styles.textBtnFakeInnerBtnLabel}>Change</Text>
|
<Text type="xl" style={[pal.text, styles.textBtnLabel]}>
|
||||||
</View>
|
{toNiceDomain(serviceUrl)}
|
||||||
</TouchableOpacity>
|
</Text>
|
||||||
<View style={styles.groupContent}>
|
<View style={[pal.btn, styles.textBtnFakeInnerBtn]}>
|
||||||
<FontAwesomeIcon icon="at" style={styles.groupContentIcon} />
|
<FontAwesomeIcon icon="pen" size={12} style={pal.textLight} />
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text type="sm-bold" style={[pal.text, styles.groupLabel]}>
|
||||||
|
Account
|
||||||
|
</Text>
|
||||||
|
<View style={[pal.borderDark, styles.group]}>
|
||||||
|
<View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon="at"
|
||||||
|
style={[pal.textLight, styles.groupContentIcon]}
|
||||||
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
testID="loginUsernameInput"
|
testID="loginUsernameInput"
|
||||||
style={styles.textInput}
|
style={[pal.text, styles.textInput]}
|
||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
placeholderTextColor={colors.blue0}
|
placeholderTextColor={pal.colors.textLight}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoFocus
|
autoFocus
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
|
@ -230,13 +364,16 @@ const LoginForm = ({
|
||||||
editable={!isProcessing}
|
editable={!isProcessing}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.groupContent}>
|
<View style={[pal.borderDark, styles.groupContent]}>
|
||||||
<FontAwesomeIcon icon="lock" style={styles.groupContentIcon} />
|
<FontAwesomeIcon
|
||||||
|
icon="lock"
|
||||||
|
style={[pal.textLight, styles.groupContentIcon]}
|
||||||
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
testID="loginPasswordInput"
|
testID="loginPasswordInput"
|
||||||
style={styles.textInput}
|
style={[pal.text, styles.textInput]}
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
placeholderTextColor={colors.blue0}
|
placeholderTextColor={pal.colors.textLight}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
secureTextEntry
|
secureTextEntry
|
||||||
|
@ -248,7 +385,7 @@ const LoginForm = ({
|
||||||
testID="forgotPasswordButton"
|
testID="forgotPasswordButton"
|
||||||
style={styles.textInputInnerBtn}
|
style={styles.textInputInnerBtn}
|
||||||
onPress={onPressForgotPassword}>
|
onPress={onPressForgotPassword}>
|
||||||
<Text style={styles.textInputInnerBtnLabel}>Forgot</Text>
|
<Text style={pal.link}>Forgot</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
@ -264,29 +401,37 @@ const LoginForm = ({
|
||||||
) : undefined}
|
) : undefined}
|
||||||
<View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
|
<View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
|
||||||
<TouchableOpacity onPress={onPressBack}>
|
<TouchableOpacity onPress={onPressBack}>
|
||||||
<Text style={[s.white, s.f18, s.pl5]}>Back</Text>
|
<Text type="xl" style={[pal.link, s.pl5]}>
|
||||||
|
Back
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<View style={s.flex1} />
|
<View style={s.flex1} />
|
||||||
{!serviceDescription && error ? (
|
{!serviceDescription && error ? (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
testID="loginRetryButton"
|
testID="loginRetryButton"
|
||||||
onPress={onPressRetryConnect}>
|
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>
|
</TouchableOpacity>
|
||||||
) : !serviceDescription ? (
|
) : !serviceDescription ? (
|
||||||
<>
|
<>
|
||||||
<ActivityIndicator color="#fff" />
|
<ActivityIndicator />
|
||||||
<Text style={[s.white, s.f18, s.pl10]}>Connecting...</Text>
|
<Text type="xl" style={[pal.textLight, s.pl10]}>
|
||||||
|
Connecting...
|
||||||
|
</Text>
|
||||||
</>
|
</>
|
||||||
) : isProcessing ? (
|
) : isProcessing ? (
|
||||||
<ActivityIndicator color="#fff" />
|
<ActivityIndicator />
|
||||||
) : isReady ? (
|
) : isReady ? (
|
||||||
<TouchableOpacity testID="loginNextButton" onPress={onPressNext}>
|
<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>
|
</TouchableOpacity>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
</View>
|
</View>
|
||||||
</>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -309,6 +454,7 @@ const ForgotPasswordForm = ({
|
||||||
onPressBack: () => void
|
onPressBack: () => void
|
||||||
onEmailSent: () => void
|
onEmailSent: () => void
|
||||||
}) => {
|
}) => {
|
||||||
|
const pal = usePalette('default')
|
||||||
const [isProcessing, setIsProcessing] = useState<boolean>(false)
|
const [isProcessing, setIsProcessing] = useState<boolean>(false)
|
||||||
const [email, setEmail] = useState<string>('')
|
const [email, setEmail] = useState<string>('')
|
||||||
|
|
||||||
|
@ -344,72 +490,88 @@ const ForgotPasswordForm = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Text style={styles.screenTitle}>Reset password</Text>
|
<LogoTextHero />
|
||||||
<Text style={styles.instructions}>
|
<View>
|
||||||
Enter the email you used to create your account. We'll send you a "reset
|
<Text type="title-lg" style={[pal.text, styles.screenTitle]}>
|
||||||
code" so you can set a new password.
|
Reset password
|
||||||
</Text>
|
</Text>
|
||||||
<View testID="forgotPasswordView" style={styles.group}>
|
<Text type="md" style={[pal.text, styles.instructions]}>
|
||||||
<TouchableOpacity
|
Enter the email you used to create your account. We'll send you a
|
||||||
testID="forgotPasswordSelectServiceButton"
|
"reset code" so you can set a new password.
|
||||||
style={[styles.groupContent, {borderTopWidth: 0}]}
|
</Text>
|
||||||
onPress={onPressSelectService}>
|
<View
|
||||||
<FontAwesomeIcon icon="globe" style={styles.groupContentIcon} />
|
testID="forgotPasswordView"
|
||||||
<Text style={styles.textInput} numberOfLines={1}>
|
style={[pal.borderDark, pal.view, styles.group]}>
|
||||||
{toNiceDomain(serviceUrl)}
|
<TouchableOpacity
|
||||||
</Text>
|
testID="forgotPasswordSelectServiceButton"
|
||||||
<View style={styles.textBtnFakeInnerBtn}>
|
style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}
|
||||||
|
onPress={onPressSelectService}>
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon="pen"
|
icon="globe"
|
||||||
size={12}
|
style={[pal.textLight, styles.groupContentIcon]}
|
||||||
style={styles.textBtnFakeInnerBtnIcon}
|
|
||||||
/>
|
/>
|
||||||
<Text style={styles.textBtnFakeInnerBtnLabel}>Change</Text>
|
<Text style={[pal.text, styles.textInput]} numberOfLines={1}>
|
||||||
</View>
|
{toNiceDomain(serviceUrl)}
|
||||||
</TouchableOpacity>
|
</Text>
|
||||||
<View style={styles.groupContent}>
|
<View style={[pal.btn, styles.textBtnFakeInnerBtn]}>
|
||||||
<FontAwesomeIcon icon="envelope" style={styles.groupContentIcon} />
|
<FontAwesomeIcon icon="pen" size={12} style={pal.text} />
|
||||||
<TextInput
|
</View>
|
||||||
testID="forgotPasswordEmail"
|
|
||||||
style={styles.textInput}
|
|
||||||
placeholder="Email address"
|
|
||||||
placeholderTextColor={colors.blue0}
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoFocus
|
|
||||||
autoCorrect={false}
|
|
||||||
value={email}
|
|
||||||
onChangeText={setEmail}
|
|
||||||
editable={!isProcessing}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
{error ? (
|
|
||||||
<View style={styles.error}>
|
|
||||||
<View style={styles.errorIcon}>
|
|
||||||
<FontAwesomeIcon icon="exclamation" style={s.white} size={10} />
|
|
||||||
</View>
|
|
||||||
<View style={s.flex1}>
|
|
||||||
<Text style={[s.white, s.bold]}>{error}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
) : undefined}
|
|
||||||
<View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
|
|
||||||
<TouchableOpacity onPress={onPressBack}>
|
|
||||||
<Text style={[s.white, s.f18, s.pl5]}>Back</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<View style={s.flex1} />
|
|
||||||
{!serviceDescription || isProcessing ? (
|
|
||||||
<ActivityIndicator color="#fff" />
|
|
||||||
) : !email ? (
|
|
||||||
<Text style={[s.blue1, s.f18, s.bold, s.pr5]}>Next</Text>
|
|
||||||
) : (
|
|
||||||
<TouchableOpacity testID="newPasswordButton" onPress={onPressNext}>
|
|
||||||
<Text style={[s.white, s.f18, s.bold, s.pr5]}>Next</Text>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
<View style={[pal.borderDark, styles.groupContent]}>
|
||||||
{!serviceDescription || isProcessing ? (
|
<FontAwesomeIcon
|
||||||
<Text style={[s.white, s.f18, s.pl10]}>Processing...</Text>
|
icon="envelope"
|
||||||
|
style={[pal.textLight, styles.groupContentIcon]}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
testID="forgotPasswordEmail"
|
||||||
|
style={[pal.text, styles.textInput]}
|
||||||
|
placeholder="Email address"
|
||||||
|
placeholderTextColor={pal.colors.textLight}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoFocus
|
||||||
|
autoCorrect={false}
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
editable={!isProcessing}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{error ? (
|
||||||
|
<View style={styles.error}>
|
||||||
|
<View style={styles.errorIcon}>
|
||||||
|
<FontAwesomeIcon icon="exclamation" style={s.white} size={10} />
|
||||||
|
</View>
|
||||||
|
<View style={s.flex1}>
|
||||||
|
<Text style={[s.white, s.bold]}>{error}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
<View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
|
||||||
|
<TouchableOpacity onPress={onPressBack}>
|
||||||
|
<Text type="xl" style={[pal.link, s.pl5]}>
|
||||||
|
Back
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View style={s.flex1} />
|
||||||
|
{!serviceDescription || isProcessing ? (
|
||||||
|
<ActivityIndicator />
|
||||||
|
) : !email ? (
|
||||||
|
<Text type="xl-bold" style={[pal.link, s.pr5, {opacity: 0.5}]}>
|
||||||
|
Next
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<TouchableOpacity testID="newPasswordButton" onPress={onPressNext}>
|
||||||
|
<Text type="xl-bold" style={[pal.link, s.pr5]}>
|
||||||
|
Next
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
{!serviceDescription || isProcessing ? (
|
||||||
|
<Text type="xl" style={[pal.textLight, s.pl10]}>
|
||||||
|
Processing...
|
||||||
|
</Text>
|
||||||
|
) : undefined}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
@ -430,6 +592,7 @@ const SetNewPasswordForm = ({
|
||||||
onPressBack: () => void
|
onPressBack: () => void
|
||||||
onPasswordSet: () => void
|
onPasswordSet: () => void
|
||||||
}) => {
|
}) => {
|
||||||
|
const pal = usePalette('default')
|
||||||
const [isProcessing, setIsProcessing] = useState<boolean>(false)
|
const [isProcessing, setIsProcessing] = useState<boolean>(false)
|
||||||
const [resetCode, setResetCode] = useState<string>('')
|
const [resetCode, setResetCode] = useState<string>('')
|
||||||
const [password, setPassword] = useState<string>('')
|
const [password, setPassword] = useState<string>('')
|
||||||
|
@ -458,87 +621,119 @@ const SetNewPasswordForm = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Text style={styles.screenTitle}>Set new password</Text>
|
<LogoTextHero />
|
||||||
<Text style={styles.instructions}>
|
<View>
|
||||||
You will receive an email with a "reset code." Enter that code here,
|
<Text type="title-lg" style={[pal.text, styles.screenTitle]}>
|
||||||
then enter your new password.
|
Set new password
|
||||||
</Text>
|
</Text>
|
||||||
<View testID="newPasswordView" style={styles.group}>
|
<Text type="lg" style={[pal.text, styles.instructions]}>
|
||||||
<View style={[styles.groupContent, {borderTopWidth: 0}]}>
|
You will receive an email with a "reset code." Enter that code here,
|
||||||
<FontAwesomeIcon icon="ticket" style={styles.groupContentIcon} />
|
then enter your new password.
|
||||||
<TextInput
|
</Text>
|
||||||
testID="resetCodeInput"
|
<View
|
||||||
style={[styles.textInput]}
|
testID="newPasswordView"
|
||||||
placeholder="Reset code"
|
style={[pal.view, pal.borderDark, styles.group]}>
|
||||||
placeholderTextColor={colors.blue0}
|
<View
|
||||||
autoCapitalize="none"
|
style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
|
||||||
autoCorrect={false}
|
<FontAwesomeIcon
|
||||||
autoFocus
|
icon="ticket"
|
||||||
value={resetCode}
|
style={[pal.textLight, styles.groupContentIcon]}
|
||||||
onChangeText={setResetCode}
|
/>
|
||||||
editable={!isProcessing}
|
<TextInput
|
||||||
/>
|
testID="resetCodeInput"
|
||||||
</View>
|
style={[pal.text, styles.textInput]}
|
||||||
<View style={styles.groupContent}>
|
placeholder="Reset code"
|
||||||
<FontAwesomeIcon icon="lock" style={styles.groupContentIcon} />
|
placeholderTextColor={pal.colors.textLight}
|
||||||
<TextInput
|
autoCapitalize="none"
|
||||||
testID="newPasswordInput"
|
autoCorrect={false}
|
||||||
style={styles.textInput}
|
autoFocus
|
||||||
placeholder="New password"
|
value={resetCode}
|
||||||
placeholderTextColor={colors.blue0}
|
onChangeText={setResetCode}
|
||||||
autoCapitalize="none"
|
editable={!isProcessing}
|
||||||
autoCorrect={false}
|
/>
|
||||||
secureTextEntry
|
|
||||||
value={password}
|
|
||||||
onChangeText={setPassword}
|
|
||||||
editable={!isProcessing}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
{error ? (
|
|
||||||
<View style={styles.error}>
|
|
||||||
<View style={styles.errorIcon}>
|
|
||||||
<FontAwesomeIcon icon="exclamation" style={s.white} size={10} />
|
|
||||||
</View>
|
</View>
|
||||||
<View style={s.flex1}>
|
<View style={[pal.borderDark, styles.groupContent]}>
|
||||||
<Text style={[s.white, s.bold]}>{error}</Text>
|
<FontAwesomeIcon
|
||||||
|
icon="lock"
|
||||||
|
style={[pal.textLight, styles.groupContentIcon]}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
testID="newPasswordInput"
|
||||||
|
style={[pal.text, styles.textInput]}
|
||||||
|
placeholder="New password"
|
||||||
|
placeholderTextColor={pal.colors.textLight}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
secureTextEntry
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
editable={!isProcessing}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
) : undefined}
|
{error ? (
|
||||||
<View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
|
<View style={styles.error}>
|
||||||
<TouchableOpacity onPress={onPressBack}>
|
<View style={styles.errorIcon}>
|
||||||
<Text style={[s.white, s.f18, s.pl5]}>Back</Text>
|
<FontAwesomeIcon icon="exclamation" style={s.white} size={10} />
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
<View style={s.flex1} />
|
<View style={s.flex1}>
|
||||||
{isProcessing ? (
|
<Text style={[s.white, s.bold]}>{error}</Text>
|
||||||
<ActivityIndicator color="#fff" />
|
</View>
|
||||||
) : !resetCode || !password ? (
|
</View>
|
||||||
<Text style={[s.blue1, s.f18, s.bold, s.pr5]}>Next</Text>
|
|
||||||
) : (
|
|
||||||
<TouchableOpacity testID="setNewPasswordButton" onPress={onPressNext}>
|
|
||||||
<Text style={[s.white, s.f18, s.bold, s.pr5]}>Next</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
{isProcessing ? (
|
|
||||||
<Text style={[s.white, s.f18, s.pl10]}>Updating...</Text>
|
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
<View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
|
||||||
|
<TouchableOpacity onPress={onPressBack}>
|
||||||
|
<Text type="xl" style={[pal.link, s.pl5]}>
|
||||||
|
Back
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View style={s.flex1} />
|
||||||
|
{isProcessing ? (
|
||||||
|
<ActivityIndicator />
|
||||||
|
) : !resetCode || !password ? (
|
||||||
|
<Text type="xl-bold" style={[pal.link, s.pr5, {opacity: 0.5}]}>
|
||||||
|
Next
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<TouchableOpacity
|
||||||
|
testID="setNewPasswordButton"
|
||||||
|
onPress={onPressNext}>
|
||||||
|
<Text type="xl-bold" style={[pal.link, s.pr5]}>
|
||||||
|
Next
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
{isProcessing ? (
|
||||||
|
<Text type="xl" style={[pal.textLight, s.pl10]}>
|
||||||
|
Updating...
|
||||||
|
</Text>
|
||||||
|
) : undefined}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const PasswordUpdatedForm = ({onPressNext}: {onPressNext: () => void}) => {
|
const PasswordUpdatedForm = ({onPressNext}: {onPressNext: () => void}) => {
|
||||||
|
const pal = usePalette('default')
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Text style={styles.screenTitle}>Password updated!</Text>
|
<LogoTextHero />
|
||||||
<Text style={styles.instructions}>
|
<View>
|
||||||
You can now sign in with your new password.
|
<Text type="title-lg" style={[pal.text, styles.screenTitle]}>
|
||||||
</Text>
|
Password updated!
|
||||||
<View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
|
</Text>
|
||||||
<View style={s.flex1} />
|
<Text type="lg" style={[pal.text, styles.instructions]}>
|
||||||
<TouchableOpacity onPress={onPressNext}>
|
You can now sign in with your new password.
|
||||||
<Text style={[s.white, s.f18, s.bold, s.pr5]}>Okay</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
<View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
|
||||||
|
<View style={s.flex1} />
|
||||||
|
<TouchableOpacity onPress={onPressNext}>
|
||||||
|
<Text type="xl-bold" style={[pal.link, s.pr5]}>
|
||||||
|
Okay
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
@ -546,53 +741,42 @@ const PasswordUpdatedForm = ({onPressNext}: {onPressNext: () => void}) => {
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
screenTitle: {
|
screenTitle: {
|
||||||
color: colors.white,
|
|
||||||
fontSize: 26,
|
|
||||||
marginBottom: 10,
|
marginBottom: 10,
|
||||||
marginHorizontal: 20,
|
marginHorizontal: 20,
|
||||||
},
|
},
|
||||||
instructions: {
|
instructions: {
|
||||||
color: colors.white,
|
|
||||||
fontSize: 16,
|
|
||||||
marginBottom: 20,
|
marginBottom: 20,
|
||||||
marginHorizontal: 20,
|
marginHorizontal: 20,
|
||||||
},
|
},
|
||||||
logoHero: {
|
|
||||||
paddingTop: 30,
|
|
||||||
paddingBottom: 40,
|
|
||||||
},
|
|
||||||
group: {
|
group: {
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.white,
|
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
marginBottom: 20,
|
marginBottom: 20,
|
||||||
marginHorizontal: 20,
|
marginHorizontal: 20,
|
||||||
backgroundColor: colors.blue3,
|
|
||||||
},
|
},
|
||||||
groupTitle: {
|
groupLabel: {
|
||||||
flexDirection: 'row',
|
paddingHorizontal: 20,
|
||||||
alignItems: 'center',
|
paddingBottom: 5,
|
||||||
paddingVertical: 8,
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
},
|
},
|
||||||
groupContent: {
|
groupContent: {
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
borderTopColor: colors.blue1,
|
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
|
noTopBorder: {
|
||||||
|
borderTopWidth: 0,
|
||||||
|
},
|
||||||
groupContentIcon: {
|
groupContentIcon: {
|
||||||
color: 'white',
|
|
||||||
marginLeft: 10,
|
marginLeft: 10,
|
||||||
},
|
},
|
||||||
textInput: {
|
textInput: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
backgroundColor: colors.blue3,
|
|
||||||
color: colors.white,
|
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
fontSize: 18,
|
fontSize: 17,
|
||||||
|
letterSpacing: 0.25,
|
||||||
|
fontWeight: '400',
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
},
|
},
|
||||||
textInputInnerBtn: {
|
textInputInnerBtn: {
|
||||||
|
@ -602,28 +786,31 @@ const styles = StyleSheet.create({
|
||||||
paddingHorizontal: 8,
|
paddingHorizontal: 8,
|
||||||
marginHorizontal: 6,
|
marginHorizontal: 6,
|
||||||
},
|
},
|
||||||
textInputInnerBtnLabel: {
|
textBtn: {
|
||||||
color: colors.white,
|
flexDirection: 'row',
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
textBtnLabel: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
},
|
},
|
||||||
textBtnFakeInnerBtn: {
|
textBtnFakeInnerBtn: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
backgroundColor: colors.blue2,
|
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
paddingHorizontal: 8,
|
paddingHorizontal: 8,
|
||||||
marginHorizontal: 6,
|
marginHorizontal: 6,
|
||||||
},
|
},
|
||||||
textBtnFakeInnerBtnIcon: {
|
accountText: {
|
||||||
color: colors.white,
|
flex: 1,
|
||||||
marginRight: 4,
|
flexDirection: 'row',
|
||||||
},
|
alignItems: 'baseline',
|
||||||
textBtnFakeInnerBtnLabel: {
|
paddingVertical: 10,
|
||||||
color: colors.white,
|
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.red5,
|
|
||||||
backgroundColor: colors.red4,
|
backgroundColor: colors.red4,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|
|
@ -33,7 +33,7 @@ export function Component({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={s.flex1}>
|
<View style={s.flex1} testID="serverInputModal">
|
||||||
<Text style={[s.textCenter, s.bold, s.f18]}>Choose Service</Text>
|
<Text style={[s.textCenter, s.bold, s.f18]}>Choose Service</Text>
|
||||||
<BottomSheetScrollView style={styles.inner}>
|
<BottomSheetScrollView style={styles.inner}>
|
||||||
<View style={styles.group}>
|
<View style={styles.group}>
|
||||||
|
@ -64,6 +64,7 @@ export function Component({
|
||||||
<Text style={styles.label}>Other service</Text>
|
<Text style={styles.label}>Other service</Text>
|
||||||
<View style={{flexDirection: 'row'}}>
|
<View style={{flexDirection: 'row'}}>
|
||||||
<BottomSheetTextInput
|
<BottomSheetTextInput
|
||||||
|
testID="customServerTextInput"
|
||||||
style={styles.textInput}
|
style={styles.textInput}
|
||||||
placeholder="e.g. https://bsky.app"
|
placeholder="e.g. https://bsky.app"
|
||||||
placeholderTextColor={colors.gray4}
|
placeholderTextColor={colors.gray4}
|
||||||
|
@ -74,6 +75,7 @@ export function Component({
|
||||||
onChangeText={setCustomUrl}
|
onChangeText={setCustomUrl}
|
||||||
/>
|
/>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
testID="customServerSelectBtn"
|
||||||
style={styles.textInputBtn}
|
style={styles.textInputBtn}
|
||||||
onPress={() => doSelect(customUrl)}>
|
onPress={() => doSelect(customUrl)}>
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
|
|
|
@ -49,6 +49,7 @@ export const ViewHeader = observer(function ViewHeader({
|
||||||
return (
|
return (
|
||||||
<View style={[styles.header, pal.view]}>
|
<View style={[styles.header, pal.view]}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
testID="viewHeaderBackOrMenuBtn"
|
||||||
onPress={canGoBack ? onPressBack : onPressMenu}
|
onPress={canGoBack ? onPressBack : onPressMenu}
|
||||||
hitSlop={BACK_HITSLOP}
|
hitSlop={BACK_HITSLOP}
|
||||||
style={canGoBack ? styles.backIcon : styles.backIconWide}>
|
style={canGoBack ? styles.backIcon : styles.backIconWide}>
|
||||||
|
|
|
@ -18,6 +18,7 @@ export type PaletteColor = {
|
||||||
textInverted: string
|
textInverted: string
|
||||||
link: string
|
link: string
|
||||||
border: string
|
border: string
|
||||||
|
borderDark: string
|
||||||
icon: string
|
icon: string
|
||||||
[k: string]: string
|
[k: string]: string
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ export interface UsePaletteValue {
|
||||||
view: ViewStyle
|
view: ViewStyle
|
||||||
btn: ViewStyle
|
btn: ViewStyle
|
||||||
border: ViewStyle
|
border: ViewStyle
|
||||||
|
borderDark: ViewStyle
|
||||||
text: TextStyle
|
text: TextStyle
|
||||||
textLight: TextStyle
|
textLight: TextStyle
|
||||||
textInverted: TextStyle
|
textInverted: TextStyle
|
||||||
|
@ -25,6 +26,9 @@ export function usePalette(color: PaletteColorName): UsePaletteValue {
|
||||||
border: {
|
border: {
|
||||||
borderColor: palette.border,
|
borderColor: palette.border,
|
||||||
},
|
},
|
||||||
|
borderDark: {
|
||||||
|
borderColor: palette.borderDark,
|
||||||
|
},
|
||||||
text: {
|
text: {
|
||||||
color: palette.text,
|
color: palette.text,
|
||||||
},
|
},
|
||||||
|
|
|
@ -13,6 +13,7 @@ export const defaultTheme: Theme = {
|
||||||
textInverted: colors.white,
|
textInverted: colors.white,
|
||||||
link: colors.blue3,
|
link: colors.blue3,
|
||||||
border: '#f0e9e9',
|
border: '#f0e9e9',
|
||||||
|
borderDark: '#e0d9d9',
|
||||||
icon: colors.gray3,
|
icon: colors.gray3,
|
||||||
|
|
||||||
// non-standard
|
// non-standard
|
||||||
|
@ -32,6 +33,7 @@ export const defaultTheme: Theme = {
|
||||||
textInverted: colors.blue3,
|
textInverted: colors.blue3,
|
||||||
link: colors.blue0,
|
link: colors.blue0,
|
||||||
border: colors.blue4,
|
border: colors.blue4,
|
||||||
|
borderDark: colors.blue5,
|
||||||
icon: colors.blue4,
|
icon: colors.blue4,
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
|
@ -42,6 +44,7 @@ export const defaultTheme: Theme = {
|
||||||
textInverted: colors.green4,
|
textInverted: colors.green4,
|
||||||
link: colors.green1,
|
link: colors.green1,
|
||||||
border: colors.green4,
|
border: colors.green4,
|
||||||
|
borderDark: colors.green5,
|
||||||
icon: colors.green4,
|
icon: colors.green4,
|
||||||
},
|
},
|
||||||
inverted: {
|
inverted: {
|
||||||
|
@ -52,6 +55,7 @@ export const defaultTheme: Theme = {
|
||||||
textInverted: colors.black,
|
textInverted: colors.black,
|
||||||
link: colors.blue2,
|
link: colors.blue2,
|
||||||
border: colors.gray3,
|
border: colors.gray3,
|
||||||
|
borderDark: colors.gray2,
|
||||||
icon: colors.gray5,
|
icon: colors.gray5,
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
|
@ -62,6 +66,7 @@ export const defaultTheme: Theme = {
|
||||||
textInverted: colors.red3,
|
textInverted: colors.red3,
|
||||||
link: colors.red1,
|
link: colors.red1,
|
||||||
border: colors.red4,
|
border: colors.red4,
|
||||||
|
borderDark: colors.red5,
|
||||||
icon: colors.red4,
|
icon: colors.red4,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -257,6 +262,7 @@ export const darkTheme: Theme = {
|
||||||
textInverted: colors.black,
|
textInverted: colors.black,
|
||||||
link: colors.blue3,
|
link: colors.blue3,
|
||||||
border: colors.gray6,
|
border: colors.gray6,
|
||||||
|
borderDark: colors.gray5,
|
||||||
icon: colors.gray5,
|
icon: colors.gray5,
|
||||||
|
|
||||||
// non-standard
|
// non-standard
|
||||||
|
@ -284,6 +290,7 @@ export const darkTheme: Theme = {
|
||||||
textInverted: colors.white,
|
textInverted: colors.white,
|
||||||
link: colors.blue3,
|
link: colors.blue3,
|
||||||
border: colors.gray3,
|
border: colors.gray3,
|
||||||
|
borderDark: colors.gray4,
|
||||||
icon: colors.gray1,
|
icon: colors.gray1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,17 +1,21 @@
|
||||||
import React, {useState} from 'react'
|
import React, {useState} from 'react'
|
||||||
import {
|
import {
|
||||||
|
SafeAreaView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
useWindowDimensions,
|
useWindowDimensions,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import Svg, {Line} from 'react-native-svg'
|
import Svg, {Line} from 'react-native-svg'
|
||||||
|
import LinearGradient from 'react-native-linear-gradient'
|
||||||
import {observer} from 'mobx-react-lite'
|
import {observer} from 'mobx-react-lite'
|
||||||
import {Signin} from '../com/login/Signin'
|
import {Signin} from '../com/login/Signin'
|
||||||
import {Logo} from '../com/login/Logo'
|
import {Logo} from '../com/login/Logo'
|
||||||
import {CreateAccount} from '../com/login/CreateAccount'
|
import {CreateAccount} from '../com/login/CreateAccount'
|
||||||
import {Text} from '../com/util/text/Text'
|
import {Text} from '../com/util/text/Text'
|
||||||
|
import {ErrorBoundary} from '../com/util/ErrorBoundary'
|
||||||
import {s, colors} from '../lib/styles'
|
import {s, colors} from '../lib/styles'
|
||||||
|
import {usePalette} from '../lib/hooks/usePalette'
|
||||||
|
|
||||||
enum ScreenState {
|
enum ScreenState {
|
||||||
SigninOrCreateAccount,
|
SigninOrCreateAccount,
|
||||||
|
@ -31,7 +35,7 @@ const SigninOrCreateAccount = ({
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<View style={styles.hero}>
|
<View style={styles.hero}>
|
||||||
<Logo />
|
<Logo color="white" />
|
||||||
<Text style={styles.title}>Bluesky</Text>
|
<Text style={styles.title}>Bluesky</Text>
|
||||||
<Text style={styles.subtitle}>[ private beta ]</Text>
|
<Text style={styles.subtitle}>[ private beta ]</Text>
|
||||||
</View>
|
</View>
|
||||||
|
@ -76,40 +80,61 @@ const SigninOrCreateAccount = ({
|
||||||
|
|
||||||
export const Login = observer(
|
export const Login = observer(
|
||||||
(/*{navigation}: RootTabsScreenProps<'Login'>*/) => {
|
(/*{navigation}: RootTabsScreenProps<'Login'>*/) => {
|
||||||
|
const pal = usePalette('default')
|
||||||
const [screenState, setScreenState] = useState<ScreenState>(
|
const [screenState, setScreenState] = useState<ScreenState>(
|
||||||
ScreenState.SigninOrCreateAccount,
|
ScreenState.SigninOrCreateAccount,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (screenState === ScreenState.SigninOrCreateAccount) {
|
||||||
|
return (
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#007CFF', '#00BCFF']}
|
||||||
|
start={{x: 0, y: 0.8}}
|
||||||
|
end={{x: 0, y: 1}}
|
||||||
|
style={styles.container}>
|
||||||
|
<SafeAreaView testID="noSessionView" style={styles.container}>
|
||||||
|
<ErrorBoundary>
|
||||||
|
<SigninOrCreateAccount
|
||||||
|
onPressSignin={() => setScreenState(ScreenState.Signin)}
|
||||||
|
onPressCreateAccount={() =>
|
||||||
|
setScreenState(ScreenState.CreateAccount)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</SafeAreaView>
|
||||||
|
</LinearGradient>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.outer}>
|
<View style={[styles.container, pal.view]}>
|
||||||
{screenState === ScreenState.SigninOrCreateAccount ? (
|
<SafeAreaView testID="noSessionView" style={styles.container}>
|
||||||
<SigninOrCreateAccount
|
<ErrorBoundary>
|
||||||
onPressSignin={() => setScreenState(ScreenState.Signin)}
|
{screenState === ScreenState.Signin ? (
|
||||||
onPressCreateAccount={() =>
|
<Signin
|
||||||
setScreenState(ScreenState.CreateAccount)
|
onPressBack={() =>
|
||||||
}
|
setScreenState(ScreenState.SigninOrCreateAccount)
|
||||||
/>
|
}
|
||||||
) : undefined}
|
/>
|
||||||
{screenState === ScreenState.Signin ? (
|
) : undefined}
|
||||||
<Signin
|
{screenState === ScreenState.CreateAccount ? (
|
||||||
onPressBack={() =>
|
<CreateAccount
|
||||||
setScreenState(ScreenState.SigninOrCreateAccount)
|
onPressBack={() =>
|
||||||
}
|
setScreenState(ScreenState.SigninOrCreateAccount)
|
||||||
/>
|
}
|
||||||
) : undefined}
|
/>
|
||||||
{screenState === ScreenState.CreateAccount ? (
|
) : undefined}
|
||||||
<CreateAccount
|
</ErrorBoundary>
|
||||||
onPressBack={() =>
|
</SafeAreaView>
|
||||||
setScreenState(ScreenState.SigninOrCreateAccount)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : undefined}
|
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
outer: {
|
outer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,5 +1,12 @@
|
||||||
import React, {useEffect} from 'react'
|
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 {observer} from 'mobx-react-lite'
|
||||||
import {useStores} from '../../state'
|
import {useStores} from '../../state'
|
||||||
import {ScreenParams} from '../routes'
|
import {ScreenParams} from '../routes'
|
||||||
|
@ -7,8 +14,10 @@ import {s} from '../lib/styles'
|
||||||
import {ViewHeader} from '../com/util/ViewHeader'
|
import {ViewHeader} from '../com/util/ViewHeader'
|
||||||
import {Link} from '../com/util/Link'
|
import {Link} from '../com/util/Link'
|
||||||
import {Text} from '../com/util/text/Text'
|
import {Text} from '../com/util/text/Text'
|
||||||
|
import * as Toast from '../com/util/Toast'
|
||||||
import {UserAvatar} from '../com/util/UserAvatar'
|
import {UserAvatar} from '../com/util/UserAvatar'
|
||||||
import {usePalette} from '../lib/hooks/usePalette'
|
import {usePalette} from '../lib/hooks/usePalette'
|
||||||
|
import {AccountData} from '../../state/models/session'
|
||||||
|
|
||||||
export const Settings = observer(function Settings({
|
export const Settings = observer(function Settings({
|
||||||
navIdx,
|
navIdx,
|
||||||
|
@ -16,6 +25,7 @@ export const Settings = observer(function Settings({
|
||||||
}: ScreenParams) {
|
}: ScreenParams) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
|
const [isSwitching, setIsSwitching] = React.useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
|
@ -25,45 +35,114 @@ export const Settings = observer(function Settings({
|
||||||
store.nav.setTitle(navIdx, 'Settings')
|
store.nav.setTitle(navIdx, 'Settings')
|
||||||
}, [visible, store])
|
}, [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 = () => {
|
const onPressSignout = () => {
|
||||||
store.session.logout()
|
store.session.logout()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[s.flex1]}>
|
<View style={[s.h100pct]} testID="settingsScreen">
|
||||||
<ViewHeader title="Settings" />
|
<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]}>
|
<View style={[s.flexRow]}>
|
||||||
<Text type="xl" style={pal.text}>
|
<Text type="xl-bold" style={pal.text}>
|
||||||
Signed in as
|
Signed in as
|
||||||
</Text>
|
</Text>
|
||||||
<View style={s.flex1} />
|
<View style={s.flex1} />
|
||||||
<TouchableOpacity onPress={onPressSignout}>
|
<TouchableOpacity
|
||||||
|
testID="signOutBtn"
|
||||||
|
onPress={isSwitching ? undefined : onPressSignout}>
|
||||||
<Text type="xl-medium" style={pal.link}>
|
<Text type="xl-medium" style={pal.link}>
|
||||||
Sign out
|
Sign out
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
<Link
|
{isSwitching ? (
|
||||||
href={`/profile/${store.me.handle}`}
|
|
||||||
title="Your profile"
|
|
||||||
noFeedback>
|
|
||||||
<View style={[pal.view, styles.profile]}>
|
<View style={[pal.view, styles.profile]}>
|
||||||
|
<ActivityIndicator />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href={`/profile/${store.me.handle}`}
|
||||||
|
title="Your profile"
|
||||||
|
noFeedback>
|
||||||
|
<View style={[pal.view, styles.profile]}>
|
||||||
|
<UserAvatar
|
||||||
|
size={40}
|
||||||
|
displayName={store.me.displayName}
|
||||||
|
handle={store.me.handle || ''}
|
||||||
|
avatar={store.me.avatar}
|
||||||
|
/>
|
||||||
|
<View style={[s.ml10]}>
|
||||||
|
<Text type="xl-bold" style={pal.text}>
|
||||||
|
{store.me.displayName || store.me.handle}
|
||||||
|
</Text>
|
||||||
|
<Text style={pal.textLight}>@{store.me.handle}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<Text type="sm-medium" style={pal.text}>
|
||||||
|
Switch to:
|
||||||
|
</Text>
|
||||||
|
{store.session.switchableAccounts.map(account => (
|
||||||
|
<TouchableOpacity
|
||||||
|
testID={`switchToAccountBtn-${account.handle}`}
|
||||||
|
key={account.did}
|
||||||
|
style={[
|
||||||
|
pal.view,
|
||||||
|
styles.profile,
|
||||||
|
s.mb2,
|
||||||
|
isSwitching && styles.dimmed,
|
||||||
|
]}
|
||||||
|
onPress={
|
||||||
|
isSwitching ? undefined : () => onPressSwitchAccount(account)
|
||||||
|
}>
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
size={40}
|
size={40}
|
||||||
displayName={store.me.displayName}
|
displayName={account.displayName}
|
||||||
handle={store.me.handle || ''}
|
handle={account.handle || ''}
|
||||||
avatar={store.me.avatar}
|
avatar={account.aviUrl}
|
||||||
/>
|
/>
|
||||||
<View style={[s.ml10]}>
|
<View style={[s.ml10]}>
|
||||||
<Text type="xl-bold" style={pal.text}>
|
<Text type="xl-bold" style={pal.text}>
|
||||||
{store.me.displayName || store.me.handle}
|
{account.displayName || account.handle}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={pal.textLight}>@{store.me.handle}</Text>
|
<Text style={pal.textLight}>@{account.handle}</Text>
|
||||||
</View>
|
</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>
|
</View>
|
||||||
</Link>
|
</TouchableOpacity>
|
||||||
<View style={s.flex1} />
|
<View style={{height: 50}} />
|
||||||
<Text type="sm-medium" style={[s.mb5]}>
|
<Text type="sm-medium" style={[s.mb5]}>
|
||||||
Developer tools
|
Developer tools
|
||||||
</Text>
|
</Text>
|
||||||
|
@ -80,12 +159,15 @@ export const Settings = observer(function Settings({
|
||||||
<Text style={pal.link}>Storybook</Text>
|
<Text style={pal.link}>Storybook</Text>
|
||||||
</Link>
|
</Link>
|
||||||
<View style={s.footerSpacer} />
|
<View style={s.footerSpacer} />
|
||||||
</View>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
|
dimmed: {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
title: {
|
title: {
|
||||||
fontSize: 32,
|
fontSize: 32,
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
|
|
|
@ -62,7 +62,7 @@ export const Menu = observer(
|
||||||
onPress?: () => void
|
onPress?: () => void
|
||||||
}) => (
|
}) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
testID="menuItemButton"
|
testID={`menuItemButton-${label}`}
|
||||||
style={styles.menuItem}
|
style={styles.menuItem}
|
||||||
onPress={onPress ? onPress : () => onNavigate(url || '/')}>
|
onPress={onPress ? onPress : () => onNavigate(url || '/')}>
|
||||||
<View style={[styles.menuItemIconWrapper]}>
|
<View style={[styles.menuItemIconWrapper]}>
|
||||||
|
|
|
@ -5,7 +5,6 @@ import {
|
||||||
Easing,
|
Easing,
|
||||||
FlatList,
|
FlatList,
|
||||||
GestureResponderEvent,
|
GestureResponderEvent,
|
||||||
SafeAreaView,
|
|
||||||
StatusBar,
|
StatusBar,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
|
@ -16,7 +15,6 @@ import {
|
||||||
ViewStyle,
|
ViewStyle,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import {ScreenContainer, Screen} from 'react-native-screens'
|
import {ScreenContainer, Screen} from 'react-native-screens'
|
||||||
import LinearGradient from 'react-native-linear-gradient'
|
|
||||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
import {IconProp} from '@fortawesome/fontawesome-svg-core'
|
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 {ErrorBoundary} from '../../com/util/ErrorBoundary'
|
||||||
import {TabsSelector} from './TabsSelector'
|
import {TabsSelector} from './TabsSelector'
|
||||||
import {Composer} from './Composer'
|
import {Composer} from './Composer'
|
||||||
import {s, colors} from '../../lib/styles'
|
import {colors} from '../../lib/styles'
|
||||||
import {clamp} from '../../../lib/numbers'
|
import {clamp} from '../../../lib/numbers'
|
||||||
import {
|
import {
|
||||||
GridIcon,
|
GridIcon,
|
||||||
|
@ -323,18 +321,10 @@ export const MobileShell: React.FC = observer(() => {
|
||||||
|
|
||||||
if (!store.session.hasSession) {
|
if (!store.session.hasSession) {
|
||||||
return (
|
return (
|
||||||
<LinearGradient
|
<View style={styles.outerContainer}>
|
||||||
colors={['#007CFF', '#00BCFF']}
|
<Login />
|
||||||
start={{x: 0, y: 0.8}}
|
|
||||||
end={{x: 0, y: 1}}
|
|
||||||
style={styles.outerContainer}>
|
|
||||||
<SafeAreaView testID="noSessionView" style={styles.innerContainer}>
|
|
||||||
<ErrorBoundary>
|
|
||||||
<Login />
|
|
||||||
</ErrorBoundary>
|
|
||||||
</SafeAreaView>
|
|
||||||
<Modal />
|
<Modal />
|
||||||
</LinearGradient>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (store.onboard.isOnboarding) {
|
if (store.onboard.isOnboarding) {
|
||||||
|
|
Loading…
Reference in New Issue