Pare down session as much as possible

This commit is contained in:
Eric Bailey 2023-11-10 09:59:04 -06:00
parent d0d93168d4
commit 436a14eabb
15 changed files with 126 additions and 532 deletions

View file

@ -63,7 +63,6 @@ export class RootStoreModel {
serialize(): unknown {
return {
appInfo: this.appInfo,
session: this.session.serialize(),
me: this.me.serialize(),
preferences: this.preferences.serialize(),
}
@ -80,9 +79,6 @@ export class RootStoreModel {
if (hasProp(v, 'me')) {
this.me.hydrate(v.me)
}
if (hasProp(v, 'session')) {
this.session.hydrate(v.session)
}
if (hasProp(v, 'preferences')) {
this.preferences.hydrate(v.preferences)
}
@ -92,18 +88,7 @@ export class RootStoreModel {
/**
* Called during init to resume any stored session.
*/
async attemptSessionResumption() {
logger.debug('RootStoreModel:attemptSessionResumption')
try {
await this.session.attemptSessionResumption()
logger.debug('Session initialized', {
hasSession: this.session.hasSession,
})
this.updateSessionState()
} catch (e: any) {
logger.warn('Failed to initialize session', {error: e})
}
}
async attemptSessionResumption() {}
/**
* Called by the session model. Refreshes session-oriented state.
@ -135,11 +120,10 @@ export class RootStoreModel {
}
/**
* Clears all session-oriented state.
* Clears all session-oriented state, previously called on LOGOUT
*/
clearAllSessionState() {
logger.debug('RootStoreModel:clearAllSessionState')
this.session.clear()
resetToTab('HomeTab')
this.me.clear()
}

View file

@ -1,274 +1,26 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {makeAutoObservable} from 'mobx'
import {
BskyAgent,
AtpSessionEvent,
AtpSessionData,
ComAtprotoServerDescribeServer as DescribeServer,
} from '@atproto/api'
import normalizeUrl from 'normalize-url'
import {isObj, hasProp} from 'lib/type-guards'
import {networkRetry} from 'lib/async/retry'
import {z} from 'zod'
import {RootStoreModel} from './root-store'
import {IS_PROD} from 'lib/constants'
import {track} from 'lib/analytics/analytics'
import {logger} from '#/logger'
export type ServiceDescription = DescribeServer.OutputSchema
export const activeSession = z.object({
service: z.string(),
did: z.string(),
})
export type ActiveSession = z.infer<typeof activeSession>
export const accountData = z.object({
service: z.string(),
refreshJwt: z.string().optional(),
accessJwt: z.string().optional(),
handle: z.string(),
did: z.string(),
email: z.string().optional(),
displayName: z.string().optional(),
aviUrl: z.string().optional(),
emailConfirmed: z.boolean().optional(),
})
export type AccountData = z.infer<typeof accountData>
interface AdditionalAccountData {
displayName?: string
aviUrl?: string
}
export class SessionModel {
// DEBUG
// emergency log facility to help us track down this logout issue
// remove when resolved
// -prf
_log(message: string, details?: Record<string, any>) {
details = details || {}
details.state = {
data: this.data,
accounts: this.accounts.map(
a =>
`${!!a.accessJwt && !!a.refreshJwt ? '✅' : '❌'} ${a.handle} (${
a.service
})`,
),
isResumingSession: this.isResumingSession,
}
logger.debug(message, details, logger.DebugContext.session)
}
/**
* Currently-active session
*/
data: ActiveSession | null = null
/**
* A listing of the currently & previous sessions
*/
accounts: AccountData[] = []
/**
* Flag to indicate if we're doing our initial-load session resumption
*/
isResumingSession = false
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(this, {
rootStore: false,
serialize: false,
hydrate: false,
hasSession: false,
})
}
get currentSession() {
if (!this.data) {
return undefined
}
const {did, service} = this.data
return this.accounts.find(
account =>
normalizeUrl(account.service) === normalizeUrl(service) &&
account.did === did &&
!!account.accessJwt &&
!!account.refreshJwt,
)
get currentSession(): any {
return undefined
}
get hasSession() {
return !!this.currentSession && !!this.rootStore.agent.session
}
get hasAccounts() {
return this.accounts.length >= 1
}
get switchableAccounts() {
return this.accounts.filter(acct => acct.did !== this.data?.did)
}
get emailNeedsConfirmation() {
return !this.currentSession?.emailConfirmed
}
get isSandbox() {
if (!this.data) {
return false
}
return !IS_PROD(this.data.service)
}
serialize(): unknown {
return {
data: this.data,
accounts: this.accounts,
}
}
hydrate(v: unknown) {
this.accounts = []
if (isObj(v)) {
if (hasProp(v, 'data') && activeSession.safeParse(v.data)) {
this.data = v.data as ActiveSession
}
if (hasProp(v, 'accounts') && Array.isArray(v.accounts)) {
for (const account of v.accounts) {
if (accountData.safeParse(account)) {
this.accounts.push(account as AccountData)
}
}
}
}
}
clear() {
this.data = null
}
/**
* Attempts to resume the previous session loaded from storage
*/
async attemptSessionResumption() {
const sess = this.currentSession
if (sess) {
this._log('SessionModel:attemptSessionResumption found stored session')
this.isResumingSession = true
try {
return await this.resumeSession(sess)
} finally {
runInAction(() => {
this.isResumingSession = false
})
}
} else {
this._log(
'SessionModel:attemptSessionResumption has no session to resume',
)
}
}
/**
* Sets the active session
*/
async setActiveSession(agent: BskyAgent, did: string) {
this._log('SessionModel:setActiveSession')
const hadSession = !!this.data
this.data = {
service: agent.service.toString(),
did,
}
await this.rootStore.handleSessionChange(agent, {hadSession})
}
/**
* Upserts a session into the accounts
*/
persistSession(
service: string,
did: string,
event: AtpSessionEvent,
session?: AtpSessionData,
addedInfo?: AdditionalAccountData,
) {
this._log('SessionModel:persistSession', {
service,
did,
event,
hasSession: !!session,
})
const existingAccount = this.accounts.find(
account => account.service === service && account.did === did,
)
// fall back to any preexisting access tokens
let refreshJwt = session?.refreshJwt || existingAccount?.refreshJwt
let accessJwt = session?.accessJwt || existingAccount?.accessJwt
if (event === 'expired') {
// only clear the tokens when they're known to have expired
refreshJwt = undefined
accessJwt = undefined
}
const newAccount = {
service,
did,
refreshJwt,
accessJwt,
handle: session?.handle || existingAccount?.handle || '',
email: session?.email || existingAccount?.email || '',
displayName: addedInfo
? addedInfo.displayName
: existingAccount?.displayName || '',
aviUrl: addedInfo ? addedInfo.aviUrl : existingAccount?.aviUrl || '',
emailConfirmed: session?.emailConfirmed,
}
if (!existingAccount) {
this.accounts.push(newAccount)
} else {
this.accounts = [
newAccount,
...this.accounts.filter(
account => !(account.service === service && account.did === did),
),
]
}
// if the session expired, fire an event to let the user know
if (event === 'expired') {
this.rootStore.handleSessionDrop()
}
}
/**
* Clears any session tokens from the accounts; used on logout.
*/
clearSessionTokens() {
this._log('SessionModel:clearSessionTokens')
this.accounts = this.accounts.map(acct => ({
service: acct.service,
handle: acct.handle,
did: acct.did,
displayName: acct.displayName,
aviUrl: acct.aviUrl,
email: acct.email,
emailConfirmed: acct.emailConfirmed,
}))
}
/**
* Fetches additional information about an account on load.
*/
async loadAccountInfo(agent: BskyAgent, did: string) {
const res = await agent.getProfile({actor: did}).catch(_e => undefined)
if (res) {
return {
displayName: res.data.displayName,
aviUrl: res.data.avatar,
}
}
return false
}
/**
@ -280,193 +32,8 @@ export class SessionModel {
return res.data
}
/**
* Attempt to resume a session that we still have access tokens for.
*/
async resumeSession(account: AccountData): Promise<boolean> {
this._log('SessionModel:resumeSession')
if (!(account.accessJwt && account.refreshJwt && account.service)) {
this._log(
'SessionModel:resumeSession aborted due to lack of access tokens',
)
return false
}
const agent = new BskyAgent({
service: account.service,
persistSession: (evt: AtpSessionEvent, sess?: AtpSessionData) => {
this.persistSession(account.service, account.did, evt, sess)
},
})
try {
await networkRetry(3, () =>
agent.resumeSession({
accessJwt: account.accessJwt || '',
refreshJwt: account.refreshJwt || '',
did: account.did,
handle: account.handle,
email: account.email,
emailConfirmed: account.emailConfirmed,
}),
)
const addedInfo = await this.loadAccountInfo(agent, account.did)
this.persistSession(
account.service,
account.did,
'create',
agent.session,
addedInfo,
)
this._log('SessionModel:resumeSession succeeded')
} catch (e: any) {
this._log('SessionModel:resumeSession failed', {
error: e.toString(),
})
return false
}
await this.setActiveSession(agent, account.did)
return true
}
/**
* Create a new session.
*/
async login({
service,
identifier,
password,
}: {
service: string
identifier: string
password: string
}) {
this._log('SessionModel:login')
const agent = new BskyAgent({service})
await agent.login({identifier, password})
if (!agent.session) {
throw new Error('Failed to establish session')
}
const did = agent.session.did
const addedInfo = await this.loadAccountInfo(agent, did)
this.persistSession(service, did, 'create', agent.session, addedInfo)
agent.setPersistSessionHandler(
(evt: AtpSessionEvent, sess?: AtpSessionData) => {
this.persistSession(service, did, evt, sess)
},
)
await this.setActiveSession(agent, did)
this._log('SessionModel:login succeeded')
}
async createAccount({
service,
email,
password,
handle,
inviteCode,
}: {
service: string
email: string
password: string
handle: string
inviteCode?: string
}) {
this._log('SessionModel:createAccount')
const agent = new BskyAgent({service})
await agent.createAccount({
handle,
password,
email,
inviteCode,
})
if (!agent.session) {
throw new Error('Failed to establish session')
}
const did = agent.session.did
const addedInfo = await this.loadAccountInfo(agent, did)
this.persistSession(service, did, 'create', agent.session, addedInfo)
agent.setPersistSessionHandler(
(evt: AtpSessionEvent, sess?: AtpSessionData) => {
this.persistSession(service, did, evt, sess)
},
)
await this.setActiveSession(agent, did)
this._log('SessionModel:createAccount succeeded')
track('Create Account Successfully')
}
/**
* Close all sessions across all accounts.
*/
async logout() {
this._log('SessionModel:logout')
// TODO
// need to evaluate why deleting the session has caused errors at times
// -prf
/*if (this.hasSession) {
this.rootStore.agent.com.atproto.session.delete().catch((e: any) => {
this.rootStore.log.warn(
'(Minor issue) Failed to delete session on the server',
e,
)
})
}*/
this.clearSessionTokens()
this.rootStore.clearAllSessionState()
}
/**
* Removes an account from the list of stored accounts.
*/
removeAccount(handle: string) {
this.accounts = this.accounts.filter(acc => acc.handle !== handle)
}
/**
* Reloads the session from the server. Useful when account details change, like the handle.
*/
async reloadFromServer() {
const sess = this.currentSession
if (!sess) {
return
}
const res = await this.rootStore.agent
.getProfile({actor: sess.did})
.catch(_e => undefined)
if (res?.success) {
const updated = {
...sess,
handle: res.data.handle,
displayName: res.data.displayName,
aviUrl: res.data.avatar,
}
runInAction(() => {
this.accounts = [
updated,
...this.accounts.filter(
account =>
!(
account.service === updated.service &&
account.did === updated.did
),
),
]
})
await this.rootStore.me.load()
}
}
updateLocalAccountData(changes: Partial<AccountData>) {
this.accounts = this.accounts.map(acct =>
acct.did === this.data?.did ? {...acct, ...changes} : acct,
)
}
async reloadFromServer() {}
}

View file

@ -1,20 +1,25 @@
import React from 'react'
import {DeviceEventEmitter} from 'react-native'
import {BskyAgent, AtpPersistSessionHandler} from '@atproto/api'
import {networkRetry} from '#/lib/async/retry'
import {logger} from '#/logger'
import * as persisted from '#/state/persisted'
import {PUBLIC_BSKY_AGENT} from '#/state/queries'
import {IS_PROD} from '#/lib/constants'
export type SessionAccount = persisted.PersistedAccount
export type StateContext = {
export type SessionState = {
agent: BskyAgent
isInitialLoad: boolean
isSwitchingAccounts: boolean
accounts: persisted.PersistedAccount[]
currentAccount: persisted.PersistedAccount | undefined
}
export type StateContext = SessionState & {
hasSession: boolean
isSandbox: boolean
}
export type ApiContext = {
createAccount: (props: {
@ -30,15 +35,13 @@ export type ApiContext = {
password: string
}) => Promise<void>
logout: () => Promise<void>
initSession: (account: persisted.PersistedAccount) => Promise<void>
resumeSession: (account?: persisted.PersistedAccount) => Promise<void>
removeAccount: (
account: Partial<Pick<persisted.PersistedAccount, 'handle' | 'did'>>,
) => void
selectAccount: (account: persisted.PersistedAccount) => Promise<void>
initSession: (account: SessionAccount) => Promise<void>
resumeSession: (account?: SessionAccount) => Promise<void>
removeAccount: (account: SessionAccount) => void
selectAccount: (account: SessionAccount) => Promise<void>
updateCurrentAccount: (
account: Partial<
Pick<persisted.PersistedAccount, 'handle' | 'email' | 'emailConfirmed'>
Pick<SessionAccount, 'handle' | 'email' | 'emailConfirmed'>
>,
) => void
clearCurrentAccount: () => void
@ -46,11 +49,12 @@ export type ApiContext = {
const StateContext = React.createContext<StateContext>({
agent: PUBLIC_BSKY_AGENT,
hasSession: false,
isInitialLoad: true,
isSwitchingAccounts: false,
accounts: [],
currentAccount: undefined,
hasSession: false,
isSandbox: false,
})
const ApiContext = React.createContext<ApiContext>({
@ -94,6 +98,8 @@ function createPersistSessionHandler(
logger.DebugContext.session,
)
if (expired) DeviceEventEmitter.emit('session-dropped')
persistSessionCallback({
expired,
refreshedAccount,
@ -103,9 +109,8 @@ function createPersistSessionHandler(
export function Provider({children}: React.PropsWithChildren<{}>) {
const isDirty = React.useRef(false)
const [state, setState] = React.useState<StateContext>({
const [state, setState] = React.useState<SessionState>({
agent: PUBLIC_BSKY_AGENT,
hasSession: false,
isInitialLoad: true, // try to resume the session first
isSwitchingAccounts: false,
accounts: persisted.get('session').accounts,
@ -113,7 +118,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
})
const setStateAndPersist = React.useCallback(
(fn: (prev: StateContext) => StateContext) => {
(fn: (prev: SessionState) => SessionState) => {
isDirty.current = true
setState(fn)
},
@ -312,9 +317,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
setStateAndPersist(s => {
return {
...s,
accounts: s.accounts.filter(
a => !(a.did === account.did || a.handle === account.handle),
),
accounts: s.accounts.filter(a => a.did !== account.did),
}
})
},
@ -431,6 +434,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
() => ({
...state,
hasSession: !!state.currentAccount,
isSandbox: state.currentAccount
? !IS_PROD(state.currentAccount?.service)
: false,
}),
[state],
)