diff --git a/__mocks__/@gorhom/bottom-sheet.tsx b/__mocks__/@gorhom/bottom-sheet.tsx
new file mode 100644
index 0000000000..d6f907a34d
--- /dev/null
+++ b/__mocks__/@gorhom/bottom-sheet.tsx
@@ -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
+}
+class BottomSheet extends React.Component {
+ snapToIndex() {}
+ snapToPosition() {}
+ expand() {}
+ collapse() {}
+ close() {
+ this.props.onClose?.()
+ }
+ forceClose() {}
+
+ render() {
+ return {this.props.children}
+ }
+}
+const BottomSheetModal = (props: any) =>
+
+const BottomSheetBackdrop = (props: any) =>
+const BottomSheetHandle = (props: any) =>
+const BottomSheetFooter = (props: any) =>
+const BottomSheetScrollView = (props: any) =>
+const BottomSheetFlatList = (props: any) =>
+const BottomSheetTextInput = (props: any) =>
+
+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
diff --git a/__tests__/accounts.test.tsx b/__tests__/accounts.test.tsx
new file mode 100644
index 0000000000..f3ecb6af43
--- /dev/null
+++ b/__tests__/accounts.test.tsx
@@ -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(, rootStore)
+ const signUpScreen = getByTestId('signinOrCreateAccount')
+
+ expect(signUpScreen).toBeTruthy()
+ })
+
+ it('completes signin to the server', async () => {
+ const {getByTestId} = render(, 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(, 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(, 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(, 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(, 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(, 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(, 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()
+ })
+})
diff --git a/__tests__/view/com/login/Signin.test.tsx b/__tests__/view/com/login/Signin.test.tsx
deleted file mode 100644
index e5b6bdbc6a..0000000000
--- a/__tests__/view/com/login/Signin.test.tsx
+++ /dev/null
@@ -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()
-
- 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()
-
- 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()
-
- 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()
-
- 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()
- })
-})
diff --git a/__tests__/view/shell/mobile/Menu.test.tsx b/__tests__/view/shell/mobile/Menu.test.tsx
index 0bffaff7f5..313259041d 100644
--- a/__tests__/view/shell/mobile/Menu.test.tsx
+++ b/__tests__/view/shell/mobile/Menu.test.tsx
@@ -46,10 +46,10 @@ describe('Menu', () => {
})
it("presses notifications menu item' button", () => {
- const {getAllByTestId} = render(
)
+ const {getByTestId} = render()
- const menuItemButton = getAllByTestId('menuItemButton')
- fireEvent.press(menuItemButton[1])
+ const menuItemButton = getByTestId('menuItemButton-Notifications')
+ fireEvent.press(menuItemButton)
expect(onCloseMock).toHaveBeenCalled()
expect(mockedNavigationStore.switchTo).toHaveBeenCalledWith(1, true)
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index 16acc15efa..9ae498034f 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -573,13 +573,13 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon/yoga"
SPEC CHECKSUMS:
- boost: a7c83b31436843459a1961bfd74b96033dc77234
+ boost: 57d2868c099736d80fcd648bf211b4431e51a558
BVLinearGradient: 34a999fda29036898a09c6a6b728b0b4189e1a44
- DoubleConversion: 831926d9b8bf8166fd87886c4abab286c2422662
+ DoubleConversion: 5189b271737e1565bdce30deb4a08d647e3f5f54
FBLazyVector: 61839cba7a48c570b7ac3e1cd8a4d0948382202f
FBReactNativeSpec: 5a14398ccf5e27c1ca2d7109eb920594ce93c10d
fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9
- glog: 476ee3e89abb49e07f822b48323c51c57124b572
+ glog: 04b94705f318337d7ead9e6d17c019bd9b1f6b1b
hermes-engine: f6e715aa6c8bd38de6c13bc85e07b0a337edaa89
libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913
RCT-Folly: 424b8c9a7a0b9ab2886ffe9c3b041ef628fd4fb1
diff --git a/jest/jestSetup.js b/jest/jestSetup.js
index b1f110a537..8f95062a9b 100644
--- a/jest/jestSetup.js
+++ b/jest/jestSetup.js
@@ -25,19 +25,6 @@ jest.mock('react-native-safe-area-context', () => {
}
})
-jest.mock('@gorhom/bottom-sheet', () => {
- const react = require('react-native')
- return {
- __esModule: true,
- default: react.View,
- namedExport: {
- ...require('react-native-reanimated/mock'),
- ...jest.requireActual('@gorhom/bottom-sheet'),
- BottomSheetFlatList: react.FlatList,
- },
- }
-})
-
jest.mock('rn-fetch-blob', () => ({
config: jest.fn().mockReturnThis(),
cancel: jest.fn(),
diff --git a/jest/test-pds.ts b/jest/test-pds.ts
new file mode 100644
index 0000000000..4915b58e36
--- /dev/null
+++ b/jest/test-pds.ts
@@ -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
+}
+
+// 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 {
+ 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 {
+ 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)
+}
diff --git a/jest/test-utils.tsx b/jest/test-utils.tsx
index c84ee637e2..5a74a6ef6f 100644
--- a/jest/test-utils.tsx
+++ b/jest/test-utils.tsx
@@ -4,20 +4,19 @@ import {GestureHandlerRootView} from 'react-native-gesture-handler'
import {RootSiblingParent} from 'react-native-root-siblings'
import {SafeAreaProvider} from 'react-native-safe-area-context'
import {RootStoreProvider} from '../src/state'
+import {ThemeProvider} from '../src/view/lib/ThemeContext'
import {mockedRootStore} from '../__mocks__/state-mock'
-const customRender = (ui: any, storeMock?: any) =>
+const customRender = (ui: any, rootStore?: any) =>
render(
// eslint-disable-next-line react-native/no-inline-styles
- {ui}
+ value={rootStore != null ? rootStore : mockedRootStore}>
+
+ {ui}
+
,
diff --git a/package.json b/package.json
index 05b6825385..1d69cfe1e2 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,7 @@
"start": "react-native start",
"postinstall": "patch-package",
"clean-cache": "rm -rf node_modules/.cache/babel-loader/*",
- "test": "jest",
+ "test": "jest --forceExit",
"test-watch": "jest --watchAll",
"test-ci": "jest --ci --forceExit --reporters=default --reporters=jest-junit",
"test-coverage": "jest --coverage",
@@ -67,9 +67,11 @@
"react-native-version-number": "^0.3.6",
"react-native-web": "^0.17.7",
"rn-fetch-blob": "^0.12.0",
- "tlds": "^1.234.0"
+ "tlds": "^1.234.0",
+ "zod": "^3.20.2"
},
"devDependencies": {
+ "@atproto/pds": "^0.0.1",
"@babel/core": "^7.12.9",
"@babel/preset-env": "^7.14.0",
"@babel/runtime": "^7.12.5",
diff --git a/src/state/index.ts b/src/state/index.ts
index 5c8b50ef1f..78fba2ecf6 100644
--- a/src/state/index.ts
+++ b/src/state/index.ts
@@ -13,13 +13,13 @@ export const DEFAULT_SERVICE = PROD_SERVICE
const ROOT_STATE_STORAGE_KEY = 'root'
const STATE_FETCH_INTERVAL = 15e3
-export async function setupState() {
+export async function setupState(serviceUri = DEFAULT_SERVICE) {
let rootStore: RootStoreModel
let data: any
libapi.doPolyfill()
- const api = AtpApi.service(DEFAULT_SERVICE) as SessionServiceClient
+ const api = AtpApi.service(serviceUri) as SessionServiceClient
rootStore = new RootStoreModel(api)
try {
data = (await storage.load(ROOT_STATE_STORAGE_KEY)) || {}
diff --git a/src/state/models/session.ts b/src/state/models/session.ts
index 13e0fcbe0f..89347af9ae 100644
--- a/src/state/models/session.ts
+++ b/src/state/models/session.ts
@@ -6,24 +6,44 @@ import {
ComAtprotoServerGetAccountsConfig as GetAccountsConfig,
} from '@atproto/api'
import {isObj, hasProp} from '../lib/type-guards'
+import {z} from 'zod'
import {RootStoreModel} from './root-store'
import {isNetworkError} from '../../lib/errors'
export type ServiceDescription = GetAccountsConfig.OutputSchema
-interface SessionData {
- service: string
- refreshJwt: string
- accessJwt: string
- handle: string
- did: string
-}
+export const sessionData = z.object({
+ service: z.string(),
+ refreshJwt: z.string(),
+ accessJwt: z.string(),
+ handle: z.string(),
+ did: z.string(),
+})
+export type SessionData = z.infer
+
+export const accountData = z.object({
+ service: z.string(),
+ refreshJwt: z.string().optional(),
+ accessJwt: z.string().optional(),
+ handle: z.string(),
+ did: z.string(),
+ displayName: z.string().optional(),
+ aviUrl: z.string().optional(),
+})
+export type AccountData = z.infer
export class SessionModel {
+ /**
+ * Current session data
+ */
data: SessionData | null = null
+ /**
+ * A listing of the currently & previous sessions, used for account switching
+ */
+ accounts: AccountData[] = []
online = false
attemptingConnect = false
- private _connectPromise: Promise | undefined
+ private _connectPromise: Promise | undefined
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(this, {
@@ -37,51 +57,32 @@ export class SessionModel {
return this.data !== null
}
+ get hasAccounts() {
+ return this.accounts.length >= 1
+ }
+
+ get switchableAccounts() {
+ return this.accounts.filter(acct => acct.did !== this.data?.did)
+ }
+
serialize(): unknown {
return {
data: this.data,
+ accounts: this.accounts,
}
}
hydrate(v: unknown) {
+ this.accounts = []
if (isObj(v)) {
- if (hasProp(v, 'data') && isObj(v.data)) {
- const data: SessionData = {
- service: '',
- refreshJwt: '',
- accessJwt: '',
- handle: '',
- did: '',
- }
- if (hasProp(v.data, 'service') && typeof v.data.service === 'string') {
- data.service = v.data.service
- }
- if (
- hasProp(v.data, 'refreshJwt') &&
- typeof v.data.refreshJwt === 'string'
- ) {
- data.refreshJwt = v.data.refreshJwt
- }
- if (
- hasProp(v.data, 'accessJwt') &&
- typeof v.data.accessJwt === 'string'
- ) {
- data.accessJwt = v.data.accessJwt
- }
- if (hasProp(v.data, 'handle') && typeof v.data.handle === 'string') {
- data.handle = v.data.handle
- }
- if (hasProp(v.data, 'did') && typeof v.data.did === 'string') {
- data.did = v.data.did
- }
- if (
- data.service &&
- data.refreshJwt &&
- data.accessJwt &&
- data.handle &&
- data.did
- ) {
- this.data = data
+ if (hasProp(v, 'data') && sessionData.safeParse(v.data)) {
+ this.data = v.data as SessionData
+ }
+ if (hasProp(v, 'accounts') && Array.isArray(v.accounts)) {
+ for (const account of v.accounts) {
+ if (accountData.safeParse(account)) {
+ this.accounts.push(account as AccountData)
+ }
}
}
}
@@ -113,6 +114,9 @@ export class SessionModel {
}
}
+ /**
+ * Sets up the XRPC API, must be called before connecting to a service
+ */
private configureApi(): boolean {
if (!this.data) {
return false
@@ -137,19 +141,68 @@ export class SessionModel {
return true
}
- async connect(): Promise {
+ /**
+ * Upserts the current session into the accounts
+ */
+ private addSessionToAccounts() {
+ if (!this.data) {
+ return
+ }
+ const existingAccount = this.accounts.find(
+ acc => acc.service === this.data?.service && acc.did === this.data.did,
+ )
+ const newAccount = {
+ service: this.data.service,
+ refreshJwt: this.data.refreshJwt,
+ accessJwt: this.data.accessJwt,
+ handle: this.data.handle,
+ did: this.data.did,
+ displayName: this.rootStore.me.displayName,
+ aviUrl: this.rootStore.me.avatar,
+ }
+ if (!existingAccount) {
+ this.accounts.push(newAccount)
+ } else {
+ this.accounts = this.accounts
+ .filter(
+ acc =>
+ !(acc.service === this.data?.service && acc.did === this.data.did),
+ )
+ .concat([newAccount])
+ }
+ }
+
+ /**
+ * Clears any session tokens from the accounts; used on logout.
+ */
+ private clearSessionTokensFromAccounts() {
+ this.accounts = this.accounts.map(acct => ({
+ service: acct.service,
+ handle: acct.handle,
+ did: acct.did,
+ displayName: acct.displayName,
+ aviUrl: acct.aviUrl,
+ }))
+ }
+
+ /**
+ * Fetches the current session from the service, if possible.
+ * Requires an existing session (.data) to be populated with access tokens.
+ */
+ async connect(): Promise {
if (this._connectPromise) {
return this._connectPromise
}
this._connectPromise = this._connect()
- await this._connectPromise
+ const res = await this._connectPromise
this._connectPromise = undefined
+ return res
}
- private async _connect(): Promise {
+ private async _connect(): Promise {
this.attemptingConnect = true
if (!this.configureApi()) {
- return
+ return false
}
try {
@@ -159,29 +212,44 @@ export class SessionModel {
if (this.rootStore.me.did !== sess.data.did) {
this.rootStore.me.clear()
}
- this.rootStore.me.load().catch(e => {
- this.rootStore.log.error('Failed to fetch local user information', e)
- })
- return // success
+ this.rootStore.me
+ .load()
+ .catch(e => {
+ this.rootStore.log.error(
+ 'Failed to fetch local user information',
+ e,
+ )
+ })
+ .then(() => {
+ this.addSessionToAccounts()
+ })
+ return true // success
}
} catch (e: any) {
if (isNetworkError(e)) {
this.setOnline(false, false) // connection issue
- return
+ return false
} else {
this.clear() // invalid session cached
}
}
this.setOnline(false, false)
+ return false
}
+ /**
+ * Helper to fetch the accounts config settings from an account.
+ */
async describeService(service: string): Promise {
const api = AtpApi.service(service) as SessionServiceClient
const res = await api.com.atproto.server.getAccountsConfig({})
return res.data
}
+ /**
+ * Create a new session.
+ */
async login({
service,
handle,
@@ -203,12 +271,35 @@ export class SessionModel {
})
this.configureApi()
this.setOnline(true, false)
- this.rootStore.me.load().catch(e => {
- this.rootStore.log.error('Failed to fetch local user information', e)
- })
+ this.rootStore.me
+ .load()
+ .catch(e => {
+ this.rootStore.log.error('Failed to fetch local user information', e)
+ })
+ .then(() => {
+ this.addSessionToAccounts()
+ })
}
}
+ /**
+ * Attempt to resume a session that we still have access tokens for.
+ */
+ async resumeSession(account: AccountData): Promise {
+ if (account.accessJwt && account.refreshJwt) {
+ this.setState({
+ service: account.service,
+ accessJwt: account.accessJwt,
+ refreshJwt: account.refreshJwt,
+ handle: account.handle,
+ did: account.did,
+ })
+ } else {
+ return false
+ }
+ return this.connect()
+ }
+
async createAccount({
service,
email,
@@ -239,12 +330,20 @@ export class SessionModel {
})
this.rootStore.onboard.start()
this.configureApi()
- this.rootStore.me.load().catch(e => {
- this.rootStore.log.error('Failed to fetch local user information', e)
- })
+ this.rootStore.me
+ .load()
+ .catch(e => {
+ this.rootStore.log.error('Failed to fetch local user information', e)
+ })
+ .then(() => {
+ this.addSessionToAccounts()
+ })
}
}
+ /**
+ * Close all sessions across all accounts.
+ */
async logout() {
if (this.hasSession) {
this.rootStore.api.com.atproto.session.delete().catch((e: any) => {
@@ -254,6 +353,7 @@ export class SessionModel {
)
})
}
+ this.clearSessionTokensFromAccounts()
this.rootStore.clearAll()
}
}
diff --git a/src/view/com/composer/SelectedPhoto.tsx b/src/view/com/composer/SelectedPhoto.tsx
index 393c0b573b..dd508fe1f0 100644
--- a/src/view/com/composer/SelectedPhoto.tsx
+++ b/src/view/com/composer/SelectedPhoto.tsx
@@ -25,12 +25,12 @@ export const SelectedPhoto = ({
)
return selectedPhotos.length !== 0 ? (
-
+
{selectedPhotos.length !== 0 &&
selectedPhotos.map((item, index) => (
+ style={[styles.imageContainer, imageStyle]}>
handleRemovePhoto(item)}
@@ -54,16 +54,17 @@ export const SelectedPhoto = ({
}
const styles = StyleSheet.create({
- imageContainer: {
+ gallery: {
flex: 1,
flexDirection: 'row',
marginTop: 16,
},
- image: {
- resizeMode: 'contain',
- borderRadius: 8,
+ imageContainer: {
margin: 2,
- backgroundColor: colors.gray1,
+ },
+ image: {
+ resizeMode: 'cover',
+ borderRadius: 8,
},
image250: {
width: 250,
@@ -88,5 +89,7 @@ const styles = StyleSheet.create({
justifyContent: 'center',
backgroundColor: colors.black,
zIndex: 1,
+ borderColor: colors.gray4,
+ borderWidth: 0.5,
},
})
diff --git a/src/view/com/login/CreateAccount.tsx b/src/view/com/login/CreateAccount.tsx
index 349c48ef71..6c597408f4 100644
--- a/src/view/com/login/CreateAccount.tsx
+++ b/src/view/com/login/CreateAccount.tsx
@@ -12,7 +12,7 @@ import {
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {ComAtprotoAccountCreate} from '@atproto/api'
import * as EmailValidator from 'email-validator'
-import {Logo} from './Logo'
+import {LogoTextHero} from './Logo'
import {Picker} from '../util/Picker'
import {TextLink} from '../util/Link'
import {Text} from '../util/text/Text'
@@ -25,8 +25,10 @@ import {
import {useStores, DEFAULT_SERVICE} from '../../../state'
import {ServiceDescription} from '../../../state/models/session'
import {ServerInputModal} from '../../../state/models/shell-ui'
+import {usePalette} from '../../lib/hooks/usePalette'
export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
+ const pal = usePalette('default')
const store = useStores()
const [isProcessing, setIsProcessing] = useState(false)
const [serviceUrl, setServiceUrl] = useState(DEFAULT_SERVICE)
@@ -114,74 +116,14 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
}
}
- const Policies = () => {
- if (!serviceDescription) {
- return
- }
- const tos = validWebLink(serviceDescription.links?.termsOfService)
- const pp = validWebLink(serviceDescription.links?.privacyPolicy)
- if (!tos && !pp) {
- return (
-
-
-
-
-
- This service has not provided terms of service or a privacy policy.
-
-
- )
- }
- const els = []
- if (tos) {
- els.push(
- ,
- )
- }
- if (pp) {
- els.push(
- ,
- )
- }
- if (els.length === 2) {
- els.splice(
- 1,
- 0,
-
- {' '}
- and{' '}
- ,
- )
- }
- return (
-
-
- By creating an account you agree to the {els}.
-
-
- )
- }
-
const isReady = !!email && !!password && !!handle && is13
return (
-
-
-
-
-
+
+
+
{error ? (
-
+
@@ -189,41 +131,55 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
) : undefined}
-
-
- Create a new account
-
-
-
+
+
+ Service provider
+
+
+
+
+
-
+
{toNiceDomain(serviceUrl)}
-
+
- Change
+ Change
- {serviceDescription ? (
- <>
+
+ {serviceDescription ? (
+ <>
+
+
+ Account details
+
+
+
{serviceDescription?.inviteCodeRequired ? (
-
+
void}) => {
/>
) : undefined}
-
+
void}) => {
editable={!isProcessing}
/>
-
-
+
+
void}) => {
editable={!isProcessing}
/>
- >
- ) : undefined}
-
+
+ >
+ ) : undefined}
{serviceDescription ? (
<>
-
-
-
- Choose your username
-
-
-
-
+
+
+ Choose your username
+
+
+
+
+
setHandle(makeValidHandle(v))}
@@ -290,15 +253,15 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
/>
{serviceDescription.availableUserDomains.length > 1 && (
-
+
({
label: `.${d}`,
@@ -309,41 +272,50 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
/>
)}
-
-
+
+
Your full username will be{' '}
-
+
@{createFullHandle(handle, userDomain)}
-
-
- Legal
-
-
+
+
+ Legal
+
+
+
+
setIs13(!is13)}>
-
+
{is13 && (
)}
-
+
I am 13 years old or older
-
+
>
) : undefined}
- Back
+
+ Back
+
{isReady ? (
@@ -351,21 +323,27 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
testID="createAccountButton"
onPress={onPressNext}>
{isProcessing ? (
-
+
) : (
- Next
+
+ Next
+
)}
) : !serviceDescription && error ? (
- Retry
+
+ Retry
+
) : !serviceDescription ? (
<>
- Connecting...
+
+ Connecting...
+
>
) : undefined}
@@ -375,6 +353,69 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
)
}
+const Policies = ({
+ serviceDescription,
+}: {
+ serviceDescription: ServiceDescription
+}) => {
+ const pal = usePalette('default')
+ if (!serviceDescription) {
+ return
+ }
+ const tos = validWebLink(serviceDescription.links?.termsOfService)
+ const pp = validWebLink(serviceDescription.links?.privacyPolicy)
+ if (!tos && !pp) {
+ return (
+
+
+
+
+
+ This service has not provided terms of service or a privacy policy.
+
+
+ )
+ }
+ const els = []
+ if (tos) {
+ els.push(
+ ,
+ )
+ }
+ if (pp) {
+ els.push(
+ ,
+ )
+ }
+ if (els.length === 2) {
+ els.splice(
+ 1,
+ 0,
+
+ {' '}
+ and{' '}
+ ,
+ )
+ }
+ return (
+
+
+ By creating an account you agree to the {els}.
+
+
+ )
+}
+
function validWebLink(url?: string): string | undefined {
return url && (url.startsWith('http://') || url.startsWith('https://'))
? url
@@ -382,42 +423,39 @@ function validWebLink(url?: string): string | undefined {
}
const styles = StyleSheet.create({
+ noTopBorder: {
+ borderTopWidth: 0,
+ },
logoHero: {
paddingTop: 30,
paddingBottom: 40,
},
group: {
borderWidth: 1,
- borderColor: colors.white,
borderRadius: 10,
marginBottom: 20,
marginHorizontal: 20,
- backgroundColor: colors.blue3,
},
- groupTitle: {
- flexDirection: 'row',
- alignItems: 'center',
- paddingVertical: 8,
- paddingHorizontal: 12,
+ groupLabel: {
+ paddingHorizontal: 20,
+ paddingBottom: 5,
},
groupContent: {
borderTopWidth: 1,
- borderTopColor: colors.blue1,
flexDirection: 'row',
alignItems: 'center',
},
groupContentIcon: {
- color: 'white',
marginLeft: 10,
},
textInput: {
flex: 1,
width: '100%',
- backgroundColor: colors.blue3,
- color: colors.white,
paddingVertical: 10,
paddingHorizontal: 12,
- fontSize: 18,
+ fontSize: 17,
+ letterSpacing: 0.25,
+ fontWeight: '400',
borderRadius: 10,
},
textBtn: {
@@ -427,47 +465,33 @@ const styles = StyleSheet.create({
},
textBtnLabel: {
flex: 1,
- color: colors.white,
paddingVertical: 10,
paddingHorizontal: 12,
- fontSize: 18,
},
textBtnFakeInnerBtn: {
flexDirection: 'row',
alignItems: 'center',
- backgroundColor: colors.blue2,
borderRadius: 6,
paddingVertical: 6,
paddingHorizontal: 8,
marginHorizontal: 6,
},
textBtnFakeInnerBtnIcon: {
- color: colors.white,
marginRight: 4,
},
- textBtnFakeInnerBtnLabel: {
- color: colors.white,
- },
picker: {
flex: 1,
width: '100%',
- backgroundColor: colors.blue3,
- color: colors.white,
paddingVertical: 10,
paddingHorizontal: 12,
- fontSize: 18,
+ fontSize: 17,
borderRadius: 10,
},
pickerLabel: {
- color: colors.white,
- fontSize: 18,
- },
- pickerIcon: {
- color: colors.white,
+ fontSize: 17,
},
checkbox: {
borderWidth: 1,
- borderColor: colors.white,
borderRadius: 2,
width: 16,
height: 16,
@@ -475,8 +499,6 @@ const styles = StyleSheet.create({
},
checkboxFilled: {
borderWidth: 1,
- borderColor: colors.white,
- backgroundColor: colors.white,
borderRadius: 2,
width: 16,
height: 16,
@@ -489,8 +511,6 @@ const styles = StyleSheet.create({
paddingBottom: 20,
},
error: {
- borderWidth: 1,
- borderColor: colors.red5,
backgroundColor: colors.red4,
flexDirection: 'row',
alignItems: 'center',
@@ -509,7 +529,6 @@ const styles = StyleSheet.create({
errorIcon: {
borderWidth: 1,
borderColor: colors.white,
- color: colors.white,
borderRadius: 30,
width: 16,
height: 16,
diff --git a/src/view/com/login/Logo.tsx b/src/view/com/login/Logo.tsx
index d1dc9c6715..7045e41528 100644
--- a/src/view/com/login/Logo.tsx
+++ b/src/view/com/login/Logo.tsx
@@ -1,26 +1,29 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
+import LinearGradient from 'react-native-linear-gradient'
import Svg, {Circle, Line, Text as SvgText} from 'react-native-svg'
+import {s, gradients} from '../../lib/styles'
+import {Text} from '../util/text/Text'
-export const Logo = () => {
+export const Logo = ({color, size = 100}: {color: string; size?: number}) => {
return (
-