Fixes to session management (#117)

* Update session-management to solve incorrectly dropped sessions

* Reset the nav on account switch

* Reset the feed on me.load()

* Update tests to reflect new account-switching behavior
This commit is contained in:
Paul Frazee
2023-01-30 18:30:42 -06:00
committed by GitHub
parent d563beb239
commit 43bc272615
11 changed files with 103 additions and 64 deletions
+19 -3
View File
@@ -180,7 +180,7 @@ describe('Account flows', () => {
// signed in // signed in
await waitFor(() => { await waitFor(() => {
expect(getByTestId('settingsScreen')).toBeTruthy() // we go back to settings in this situation expect(getByTestId('homeFeed')).toBeTruthy() // we go back to settings in this situation
expect(rootStore?.me?.displayName).toBe('Bob') expect(rootStore?.me?.displayName).toBe('Bob')
expect(rootStore?.me?.handle).toBe('bob.test') expect(rootStore?.me?.handle).toBe('bob.test')
expect(rootStore?.session.accounts.length).toBe(2) expect(rootStore?.session.accounts.length).toBe(2)
@@ -191,7 +191,15 @@ describe('Account flows', () => {
}) })
it('can instantly switch between accounts', async () => { it('can instantly switch between accounts', async () => {
const {getByTestId} = render(<MobileShell />, rootStore) const {getAllByTestId, getByTestId} = 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( await waitFor(
() => expect(getByTestId('settingsScreen')).toBeTruthy(), () => expect(getByTestId('settingsScreen')).toBeTruthy(),
WAIT_OPTS, WAIT_OPTS,
@@ -212,7 +220,15 @@ describe('Account flows', () => {
}) })
it('will prompt for a password if you sign out', async () => { it('will prompt for a password if you sign out', async () => {
const {getByTestId} = render(<MobileShell />, rootStore) const {getAllByTestId, getByTestId} = 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( await waitFor(
() => expect(getByTestId('settingsScreen')).toBeTruthy(), () => expect(getByTestId('settingsScreen')).toBeTruthy(),
WAIT_OPTS, WAIT_OPTS,
+2 -11
View File
@@ -1,6 +1,6 @@
import {autorun} from 'mobx' import {autorun} from 'mobx'
import {Platform} from 'react-native' import {Platform} from 'react-native'
import {sessionClient as AtpApi, SessionServiceClient} from '@atproto/api' import {sessionClient as SessionAtpApi} from '@atproto/api'
import {RootStoreModel} from './models/root-store' import {RootStoreModel} from './models/root-store'
import * as libapi from './lib/api' import * as libapi from './lib/api'
import * as storage from './lib/storage' import * as storage from './lib/storage'
@@ -19,7 +19,7 @@ export async function setupState(serviceUri = DEFAULT_SERVICE) {
libapi.doPolyfill() libapi.doPolyfill()
const api = AtpApi.service(serviceUri) as SessionServiceClient const api = SessionAtpApi.service(serviceUri)
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)) || {}
@@ -38,15 +38,6 @@ export async function setupState(serviceUri = DEFAULT_SERVICE) {
.catch((e: any) => { .catch((e: any) => {
rootStore.log.warn('Failed initial connect', e) rootStore.log.warn('Failed initial connect', e)
}) })
// @ts-ignore .on() is correct -prf
api.sessionManager.on('session', () => {
if (!api.sessionManager.session && rootStore.session.hasSession) {
// reset session
rootStore.session.clear()
} else if (api.sessionManager.session) {
rootStore.session.updateAuthTokens(api.sessionManager.session)
}
})
// track changes & save to storage // track changes & save to storage
autorun(() => { autorun(() => {
+3 -2
View File
@@ -4,8 +4,8 @@
*/ */
// import {ReactNativeStore} from './auth' // import {ReactNativeStore} from './auth'
import { import AtpApi, {
sessionClient as AtpApi, sessionClient as SessionAtpApi,
AppBskyEmbedImages, AppBskyEmbedImages,
AppBskyEmbedExternal, AppBskyEmbedExternal,
} from '@atproto/api' } from '@atproto/api'
@@ -21,6 +21,7 @@ const TIMEOUT = 10e3 // 10s
export function doPolyfill() { export function doPolyfill() {
AtpApi.xrpc.fetch = fetchHandler AtpApi.xrpc.fetch = fetchHandler
SessionAtpApi.xrpc.fetch = fetchHandler
} }
export interface ExternalEmbedDraft { export interface ExternalEmbedDraft {
+15
View File
@@ -245,6 +245,21 @@ export class FeedModel {
// public api // public api
// = // =
/**
* Nuke all data
*/
clear() {
this.isLoading = false
this.isRefreshing = false
this.hasNewLatest = false
this.hasLoaded = false
this.error = ''
this.hasMore = true
this.loadMoreCursor = undefined
this.pollCursor = undefined
this.feed = []
}
/** /**
* Load for first render * Load for first render
*/ */
+1
View File
@@ -94,6 +94,7 @@ export class MeModel {
this.avatar = '' this.avatar = ''
} }
}) })
this.mainFeed.clear()
this.mainFeed = new FeedModel(this.rootStore, 'home', { this.mainFeed = new FeedModel(this.rootStore, 'home', {
algorithm: 'reverse-chronological', algorithm: 'reverse-chronological',
}) })
+29 -3
View File
@@ -3,7 +3,10 @@
*/ */
import {makeAutoObservable} from 'mobx' import {makeAutoObservable} from 'mobx'
import {sessionClient as AtpApi, SessionServiceClient} from '@atproto/api' import {
sessionClient as SessionAtpApi,
SessionServiceClient,
} from '@atproto/api'
import {createContext, useContext} from 'react' import {createContext, useContext} from 'react'
import {DeviceEventEmitter, EmitterSubscription} from 'react-native' import {DeviceEventEmitter, EmitterSubscription} from 'react-native'
import BackgroundFetch from 'react-native-background-fetch' import BackgroundFetch from 'react-native-background-fetch'
@@ -19,6 +22,7 @@ import {OnboardModel} from './onboard'
import {isNetworkError} from '../../lib/errors' import {isNetworkError} from '../../lib/errors'
export class RootStoreModel { export class RootStoreModel {
api: SessionServiceClient
log = new LogModel() log = new LogModel()
session = new SessionModel(this) session = new SessionModel(this)
nav = new NavigationModel() nav = new NavigationModel()
@@ -28,7 +32,9 @@ export class RootStoreModel {
profiles = new ProfilesViewModel(this) profiles = new ProfilesViewModel(this)
linkMetas = new LinkMetasViewModel(this) linkMetas = new LinkMetasViewModel(this)
constructor(public api: SessionServiceClient) { constructor(api: SessionServiceClient) {
this.api = api // to keep typescript from whining
this.setAPI(api)
makeAutoObservable(this, { makeAutoObservable(this, {
api: false, api: false,
resolveName: false, resolveName: false,
@@ -38,6 +44,24 @@ export class RootStoreModel {
this.initBgFetch() this.initBgFetch()
} }
setAPI(api: SessionServiceClient) {
if (this.api) {
this.api.sessionManager.removeAllListeners('session')
}
this.api = api
this.api.sessionManager.on('session', this.onSessionChange.bind(this))
}
onSessionChange() {
if (!this.api.sessionManager.session && this.session.hasSession) {
this.log.debug('Session invalidated, logging the user out')
this.session.clear()
} else if (this.api.sessionManager.session) {
this.log.debug('Session refreshed, updating auth tokens')
this.session.updateAuthTokens(this.api.sessionManager.session)
}
}
async resolveName(didOrHandle: string) { async resolveName(didOrHandle: string) {
if (!didOrHandle) { if (!didOrHandle) {
throw new Error('Invalid handle: ""') throw new Error('Invalid handle: ""')
@@ -147,7 +171,9 @@ export class RootStoreModel {
} }
} }
const throwawayInst = new RootStoreModel(AtpApi.service('http://localhost')) // this will be replaced by the loader, we just need to supply a value at init const throwawayInst = new RootStoreModel(
SessionAtpApi.service('http://localhost'),
) // this will be replaced by the loader, we just need to supply a value at init
const RootStoreContext = createContext<RootStoreModel>(throwawayInst) const RootStoreContext = createContext<RootStoreModel>(throwawayInst)
export const RootStoreProvider = RootStoreContext.Provider export const RootStoreProvider = RootStoreContext.Provider
export const useStores = () => useContext(RootStoreContext) export const useStores = () => useContext(RootStoreContext)
+16 -34
View File
@@ -1,8 +1,7 @@
import {makeAutoObservable, runInAction} from 'mobx' import {makeAutoObservable, runInAction} from 'mobx'
import { import AtpApi, {
sessionClient as AtpApi, sessionClient as SessionAtpApi,
Session, Session,
SessionServiceClient,
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'
@@ -95,6 +94,7 @@ export class SessionModel {
setState(data: SessionData) { setState(data: SessionData) {
this.data = data this.data = data
this.addSessionToAccounts()
} }
setOnline(online: boolean, attemptingConnect?: boolean) { setOnline(online: boolean, attemptingConnect?: boolean) {
@@ -124,7 +124,12 @@ export class SessionModel {
try { try {
const serviceUri = new URL(this.data.service) const serviceUri = new URL(this.data.service)
this.rootStore.api.xrpc.uri = serviceUri const api = SessionAtpApi.service(serviceUri)
api.sessionManager.set({
refreshJwt: this.data.refreshJwt,
accessJwt: this.data.accessJwt,
})
this.rootStore.setAPI(api)
} catch (e: any) { } catch (e: any) {
this.rootStore.log.error( this.rootStore.log.error(
`Invalid service URL: ${this.data.service}. Resetting session.`, `Invalid service URL: ${this.data.service}. Resetting session.`,
@@ -133,11 +138,6 @@ export class SessionModel {
this.clear() this.clear()
return false return false
} }
this.rootStore.api.sessionManager.set({
refreshJwt: this.data.refreshJwt,
accessJwt: this.data.accessJwt,
})
return true return true
} }
@@ -212,15 +212,7 @@ 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 this.rootStore.me.load().then(() => {
.load()
.catch(e => {
this.rootStore.log.error(
'Failed to fetch local user information',
e,
)
})
.then(() => {
this.addSessionToAccounts() this.addSessionToAccounts()
}) })
return true // success return true // success
@@ -242,7 +234,7 @@ export class SessionModel {
* Helper to fetch the accounts config settings from an account. * 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)
const res = await api.com.atproto.server.getAccountsConfig({}) const res = await api.com.atproto.server.getAccountsConfig({})
return res.data return res.data
} }
@@ -259,7 +251,7 @@ export class SessionModel {
handle: string handle: string
password: string password: string
}) { }) {
const api = AtpApi.service(service) as SessionServiceClient const api = AtpApi.service(service)
const res = await api.com.atproto.session.create({handle, password}) const res = await api.com.atproto.session.create({handle, password})
if (res.data.accessJwt && res.data.refreshJwt) { if (res.data.accessJwt && res.data.refreshJwt) {
this.setState({ this.setState({
@@ -271,12 +263,7 @@ export class SessionModel {
}) })
this.configureApi() this.configureApi()
this.setOnline(true, false) this.setOnline(true, false)
this.rootStore.me this.rootStore.me.load().then(() => {
.load()
.catch(e => {
this.rootStore.log.error('Failed to fetch local user information', e)
})
.then(() => {
this.addSessionToAccounts() this.addSessionToAccounts()
}) })
} }
@@ -291,7 +278,7 @@ export class SessionModel {
} }
// test that the session is good // test that the session is good
const api = AtpApi.service(account.service) const api = SessionAtpApi.service(account.service)
api.sessionManager.set({ api.sessionManager.set({
refreshJwt: account.refreshJwt, refreshJwt: account.refreshJwt,
accessJwt: account.accessJwt, accessJwt: account.accessJwt,
@@ -339,7 +326,7 @@ export class SessionModel {
handle: string handle: string
inviteCode?: string inviteCode?: string
}) { }) {
const api = AtpApi.service(service) as SessionServiceClient const api = AtpApi.service(service)
const res = await api.com.atproto.account.create({ const res = await api.com.atproto.account.create({
handle, handle,
password, password,
@@ -356,12 +343,7 @@ export class SessionModel {
}) })
this.rootStore.onboard.start() this.rootStore.onboard.start()
this.configureApi() this.configureApi()
this.rootStore.me this.rootStore.me.load().then(() => {
.load()
.catch(e => {
this.rootStore.log.error('Failed to fetch local user information', e)
})
.then(() => {
this.addSessionToAccounts() this.addSessionToAccounts()
}) })
} }
+3 -3
View File
@@ -10,7 +10,7 @@ import {
} from 'react-native' } from 'react-native'
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 AtpApi from '@atproto/api'
import {useAnalytics} from '@segment/analytics-react-native' import {useAnalytics} from '@segment/analytics-react-native'
import {LogoTextHero} from './Logo' import {LogoTextHero} from './Logo'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
@@ -471,7 +471,7 @@ const ForgotPasswordForm = ({
setIsProcessing(true) setIsProcessing(true)
try { try {
const api = AtpApi.service(serviceUrl) as SessionServiceClient const api = AtpApi.service(serviceUrl)
await api.com.atproto.account.requestPasswordReset({email}) await api.com.atproto.account.requestPasswordReset({email})
onEmailSent() onEmailSent()
} catch (e: any) { } catch (e: any) {
@@ -602,7 +602,7 @@ const SetNewPasswordForm = ({
setIsProcessing(true) setIsProcessing(true)
try { try {
const api = AtpApi.service(serviceUrl) as SessionServiceClient const api = AtpApi.service(serviceUrl)
await api.com.atproto.account.resetPassword({token: resetCode, password}) await api.com.atproto.account.resetPassword({token: resetCode, password})
onPasswordSet() onPasswordSet()
} catch (e: any) { } catch (e: any) {
+1 -1
View File
@@ -69,7 +69,7 @@ export const Home = observer(function Home({
store.me.mainFeed.setup() store.me.mainFeed.setup()
} }
return cleanup return cleanup
}, [visible, store, navIdx, doPoll, wasVisible]) }, [visible, store, store.me.mainFeed, navIdx, doPoll, wasVisible])
const onPressCompose = (imagesOpen?: boolean) => { const onPressCompose = (imagesOpen?: boolean) => {
store.shell.openComposer({imagesOpen}) store.shell.openComposer({imagesOpen})
+2
View File
@@ -39,11 +39,13 @@ export const Settings = observer(function Settings({
setIsSwitching(true) setIsSwitching(true)
if (await store.session.resumeSession(acct)) { if (await store.session.resumeSession(acct)) {
setIsSwitching(false) setIsSwitching(false)
store.nav.tab.fixedTabReset()
Toast.show(`Signed in as ${acct.displayName || acct.handle}`) Toast.show(`Signed in as ${acct.displayName || acct.handle}`)
return return
} }
setIsSwitching(false) setIsSwitching(false)
Toast.show('Sorry! We need you to enter your password.') Toast.show('Sorry! We need you to enter your password.')
store.nav.tab.fixedTabReset()
store.session.clear() store.session.clear()
} }
const onPressAddAccount = () => { const onPressAddAccount = () => {
+6 -1
View File
@@ -12813,11 +12813,16 @@ tslib@^1.8.1:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.4.0: tslib@^2.0.1, tslib@^2.0.3, tslib@^2.4.0:
version "2.4.1" version "2.4.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.1.tgz#0d0bfbaac2880b91e22df0768e55be9753a5b17e" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.1.tgz#0d0bfbaac2880b91e22df0768e55be9753a5b17e"
integrity sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA== integrity sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==
tslib@^2.1.0:
version "2.5.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.0.tgz#42bfed86f5787aeb41d031866c8f402429e0fddf"
integrity sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==
tsutils@^3.21.0: tsutils@^3.21.0:
version "3.21.0" version "3.21.0"
resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"