From 57c2d61931457bbde262d66b6a5f2f9ef4c8305b Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 4 Apr 2024 12:39:26 -0500 Subject: [PATCH] Use BskyAgent as the source of truth --- src/state/session/index.tsx | 344 +++++++++++++++++------------------- 1 file changed, 164 insertions(+), 180 deletions(-) diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index 5dabe19be2..102ab9721b 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -19,6 +19,9 @@ import {useCloseAllActiveElements} from '#/state/util' import {emitSessionDropped} from '../events' import {readLabelers} from './agent-config' +/** + * @deprecated use `agent` from `useSession` instead + */ let __globalAgent: BskyAgent = PUBLIC_BSKY_AGENT /** @@ -26,6 +29,8 @@ let __globalAgent: BskyAgent = PUBLIC_BSKY_AGENT * Never hold on to the object returned by this function. * Call `getAgent()` at the time of invocation to ensure * that you never have a stale agent. + * + * @deprecated use `agent` from `useSession` instead */ export function getAgent() { return __globalAgent @@ -33,15 +38,14 @@ export function getAgent() { export type SessionAccount = persisted.PersistedAccount -export type SessionState = { +export type StateContext = { + agent: BskyAgent isInitialLoad: boolean isSwitchingAccounts: boolean + hasSession: boolean accounts: SessionAccount[] currentAccount: SessionAccount | undefined } -export type StateContext = SessionState & { - hasSession: boolean -} export type ApiContext = { createAccount: (props: { service: string @@ -83,14 +87,14 @@ export type ApiContext = { account: SessionAccount, logContext: LogEvents['account:loggedIn']['logContext'], ) => Promise - updateCurrentAccount: ( - account: Partial< - Pick - >, - ) => void + /** + * Refreshes the BskyAgent's session and derive a fresh `currentAccount` + */ + refreshSession: () => void } const StateContext = React.createContext({ + agent: PUBLIC_BSKY_AGENT, isInitialLoad: true, isSwitchingAccounts: false, accounts: [], @@ -106,10 +110,43 @@ const ApiContext = React.createContext({ resumeSession: async () => {}, removeAccount: () => {}, selectAccount: async () => {}, - updateCurrentAccount: () => {}, + refreshSession: () => {}, clearCurrentAccount: () => {}, }) +function agentToSessionAccount(agent: BskyAgent): SessionAccount | undefined { + if (!agent.session) return undefined + + return { + service: agent.service.toString(), + did: agent.session.did, + handle: agent.session.handle, + email: agent.session.email, + emailConfirmed: agent.session.emailConfirmed, + deactivated: isSessionDeactivated(agent.session.accessJwt), + + /* + * Tokens are undefined if the session expires, or if creation fails for + * any reason e.g. tokens are invalid, network error, etc. + */ + refreshJwt: agent.session.refreshJwt, + accessJwt: agent.session.accessJwt, + } +} + +function sessionAccountToAgentSession( + account: SessionAccount, +): BskyAgent['session'] { + return { + did: account.did, + handle: account.handle, + email: account.email, + emailConfirmed: account.emailConfirmed, + accessJwt: account.accessJwt || '', + refreshJwt: account.refreshJwt || '', + } +} + function createPersistSessionHandler( account: SessionAccount, persistSessionCallback: (props: { @@ -176,43 +213,40 @@ function createPersistSessionHandler( export function Provider({children}: React.PropsWithChildren<{}>) { const isDirty = React.useRef(false) - const [state, setState] = React.useState({ - isInitialLoad: true, - isSwitchingAccounts: false, - accounts: persisted.get('session').accounts, - currentAccount: undefined, // assume logged out to start - }) - - const setStateAndPersist = React.useCallback( - (fn: (prev: SessionState) => SessionState) => { - isDirty.current = true - setState(fn) - }, - [setState], + const [agent, setAgent] = React.useState(PUBLIC_BSKY_AGENT) + const [accounts, setAccounts] = React.useState( + persisted.get('session').accounts, + ) + const [isInitialLoad, setIsInitialLoad] = React.useState(true) + const [isSwitchingAccounts, setIsSwitchingAccounts] = React.useState(false) + const currentAccount = React.useMemo( + () => agentToSessionAccount(agent), + [agent], ) - const setCurrentAccount = React.useCallback( + const persistNextUpdate = React.useCallback( + () => (isDirty.current = true), + [], + ) + + const upsertAccount = React.useCallback( (account: SessionAccount) => { - setStateAndPersist(s => { - return { - ...s, - currentAccount: account, - accounts: [account, ...s.accounts.filter(a => a.did !== account.did)], - } - }) + persistNextUpdate() + setAccounts(accounts => [ + account, + ...accounts.filter(a => a.did !== account.did), + ]) }, - [setStateAndPersist], + [setAccounts, persistNextUpdate], ) const clearCurrentAccount = React.useCallback(() => { logger.warn(`session: clear current account`) - __globalAgent = PUBLIC_BSKY_AGENT + + persistNextUpdate() + setAgent(PUBLIC_BSKY_AGENT) BskyAgent.configure({appLabelers: [BSKY_LABELER_DID]}) - setStateAndPersist(s => ({ - ...s, - currentAccount: undefined, - })) - }, [setStateAndPersist]) + }, [persistNextUpdate, setAgent]) const createAccount = React.useCallback( async ({ @@ -258,16 +292,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { }) } - const account: SessionAccount = { - service: agent.service.toString(), - did: agent.session.did, - handle: agent.session.handle, - email: agent.session.email!, // TODO this is always defined? - emailConfirmed: false, - refreshJwt: agent.session.refreshJwt, - accessJwt: agent.session.accessJwt, - deactivated, - } + const account = agentToSessionAccount(agent)! await configureModeration(agent, account) @@ -275,24 +300,21 @@ export function Provider({children}: React.PropsWithChildren<{}>) { createPersistSessionHandler( account, ({expired, refreshedAccount}) => { - if (expired) { - clearCurrentAccount() - } else { - setCurrentAccount(refreshedAccount) - } + upsertAccount(refreshedAccount) + if (expired) clearCurrentAccount() }, {networkErrorCallback: clearCurrentAccount}, ), ) - __globalAgent = agent - setCurrentAccount(account) + setAgent(agent) + upsertAccount(account) logger.debug(`session: created account`, {}, logger.DebugContext.session) track('Create Account') logEvent('account:create:success', {}) }, - [setCurrentAccount, clearCurrentAccount], + [upsertAccount, clearCurrentAccount], ) const login = React.useCallback( @@ -300,68 +322,54 @@ export function Provider({children}: React.PropsWithChildren<{}>) { logger.debug(`session: login`, {}, logger.DebugContext.session) const agent = new BskyAgent({service}) - await agent.login({identifier, password}) if (!agent.session) { throw new Error(`session: login failed to establish a session`) } - const account: SessionAccount = { - service: agent.service.toString(), - did: agent.session.did, - handle: agent.session.handle, - email: agent.session.email, - emailConfirmed: agent.session.emailConfirmed || false, - refreshJwt: agent.session.refreshJwt, - accessJwt: agent.session.accessJwt, - deactivated: isSessionDeactivated(agent.session.accessJwt), - } - + const account = agentToSessionAccount(agent)! await configureModeration(agent, account) agent.setPersistSessionHandler( createPersistSessionHandler( account, ({expired, refreshedAccount}) => { - if (expired) { - clearCurrentAccount() - } else { - setCurrentAccount(refreshedAccount) - } + upsertAccount(refreshedAccount) + if (expired) clearCurrentAccount() }, {networkErrorCallback: clearCurrentAccount}, ), ) - __globalAgent = agent - setCurrentAccount(account) + setAgent(agent) + upsertAccount(account) logger.debug(`session: logged in`, {}, logger.DebugContext.session) track('Sign In', {resumedSession: false}) logEvent('account:loggedIn', {logContext, withPassword: true}) }, - [setCurrentAccount, clearCurrentAccount], + [upsertAccount, clearCurrentAccount], ) const logout = React.useCallback( async logContext => { logger.debug(`session: logout`) + clearCurrentAccount() - setStateAndPersist(s => { - return { - ...s, - accounts: s.accounts.map(a => ({ - ...a, - refreshJwt: undefined, - accessJwt: undefined, - })), - } - }) + persistNextUpdate() + setAccounts(accounts => + accounts.map(a => ({ + ...a, + accessJwt: undefined, + refreshJwt: undefined, + })), + ) + logEvent('account:loggedOut', {logContext}) }, - [clearCurrentAccount, setStateAndPersist], + [clearCurrentAccount, persistNextUpdate, setAccounts], ) const initSession = React.useCallback( @@ -373,24 +381,17 @@ export function Provider({children}: React.PropsWithChildren<{}>) { persistSession: createPersistSessionHandler( account, ({expired, refreshedAccount}) => { - if (expired) { - clearCurrentAccount() - } else { - __globalAgent = agent - setCurrentAccount(refreshedAccount) - } + upsertAccount(refreshedAccount) + if (expired) clearCurrentAccount() }, {networkErrorCallback: clearCurrentAccount}, ), }) const prevSession = { + ...account, accessJwt: account.accessJwt || '', refreshJwt: account.refreshJwt || '', - did: account.did, - handle: account.handle, - deactivated: - isSessionDeactivated(account.accessJwt) || account.deactivated, } let canReusePrevSession = false @@ -418,8 +419,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) { logger.DebugContext.session, ) agent.session = prevSession - __globalAgent = agent - setCurrentAccount(account) + setAgent(agent) + upsertAccount(account) } else { logger.debug( `session: attempting to resumeSession using previous session`, @@ -429,13 +430,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) { try { // will call `persistSession` on `BskyAgent` instance above if success await networkRetry(1, () => agent.resumeSession(prevSession)) + setAgent(agent) } catch (e) { logger.error(`session: resumeSession failed`, {message: e}) clearCurrentAccount() } } }, - [setCurrentAccount, clearCurrentAccount], + [upsertAccount, clearCurrentAccount], ) const resumeSession = React.useCallback( @@ -447,119 +449,92 @@ export function Provider({children}: React.PropsWithChildren<{}>) { } catch (e) { logger.error(`session: resumeSession failed`, {message: e}) } finally { - setState(s => ({ - ...s, - isInitialLoad: false, - })) + setIsInitialLoad(false) } }, - [initSession], + [initSession, setIsInitialLoad], ) const removeAccount = React.useCallback( account => { - setStateAndPersist(s => { - return { - ...s, - accounts: s.accounts.filter(a => a.did !== account.did), - } - }) + persistNextUpdate() + setAccounts(accounts => accounts.filter(a => a.did !== account.did)) }, - [setStateAndPersist], + [setAccounts, persistNextUpdate], ) - const updateCurrentAccount = React.useCallback< - ApiContext['updateCurrentAccount'] - >( - account => { - setStateAndPersist(s => { - const currentAccount = s.currentAccount - - // ignore, should never happen - if (!currentAccount) return s - - const updatedAccount = { - ...currentAccount, - handle: account.handle || currentAccount.handle, - email: account.email || currentAccount.email, - emailConfirmed: - account.emailConfirmed !== undefined - ? account.emailConfirmed - : currentAccount.emailConfirmed, - } - - return { - ...s, - currentAccount: updatedAccount, - accounts: [ - updatedAccount, - ...s.accounts.filter(a => a.did !== currentAccount.did), - ], - } - }) - }, - [setStateAndPersist], - ) + const refreshSession = React.useCallback< + ApiContext['refreshSession'] + >(async () => { + await agent.refreshSession() + persistNextUpdate() + upsertAccount(agentToSessionAccount(agent)!) + setAgent(agent.clone()) + }, [agent, setAgent, persistNextUpdate, upsertAccount]) const selectAccount = React.useCallback( async (account, logContext) => { - setState(s => ({...s, isSwitchingAccounts: true})) + setIsSwitchingAccounts(true) try { await initSession(account) - setState(s => ({...s, isSwitchingAccounts: false})) + setIsSwitchingAccounts(false) logEvent('account:loggedIn', {logContext, withPassword: false}) } catch (e) { // reset this in case of error - setState(s => ({...s, isSwitchingAccounts: false})) + setIsSwitchingAccounts(false) // but other listeners need a throw throw e } }, - [setState, initSession], + [setIsSwitchingAccounts, initSession], ) React.useEffect(() => { if (isDirty.current) { isDirty.current = false persisted.write('session', { - accounts: state.accounts, - currentAccount: state.currentAccount, + accounts, + currentAccount, }) } - }, [state]) + }, [accounts, currentAccount]) React.useEffect(() => { return persisted.onUpdate(async () => { - const session = persisted.get('session') + const persistedSession = persisted.get('session') - logger.debug(`session: persisted onUpdate`, {}) + logger.debug(`session: persisted onUpdate`, { + persistedCurrentAccount: persistedSession.currentAccount, + currentAccount, + }) - if (session.currentAccount && session.currentAccount.refreshJwt) { - if (session.currentAccount?.did !== state.currentAccount?.did) { + setAccounts(persistedSession.accounts) + + if ( + persistedSession.currentAccount && + persistedSession.currentAccount.refreshJwt + ) { + if (persistedSession.currentAccount?.did !== currentAccount?.did) { logger.debug(`session: persisted onUpdate, switching accounts`, { from: { - did: state.currentAccount?.did, - handle: state.currentAccount?.handle, + did: currentAccount?.did, + handle: currentAccount?.handle, }, to: { - did: session.currentAccount.did, - handle: session.currentAccount.handle, + did: persistedSession.currentAccount.did, + handle: persistedSession.currentAccount.handle, }, }) - await initSession(session.currentAccount) + await initSession(persistedSession.currentAccount) } else { logger.debug(`session: persisted onUpdate, updating session`, {}) - - /* - * Use updated session in this tab's agent. Do not call - * setCurrentAccount, since that will only persist the session that's - * already persisted, and we'll get a loop between tabs. - */ - // @ts-ignore we checked for `refreshJwt` above - __globalAgent.session = session.currentAccount + agent.session = sessionAccountToAgentSession( + persistedSession.currentAccount, + ) + setAgent(agent.clone()) } - } else if (!session.currentAccount && state.currentAccount) { + } else if (!persistedSession.currentAccount && currentAccount) { logger.debug( `session: persisted onUpdate, logging out`, {}, @@ -574,20 +549,26 @@ export function Provider({children}: React.PropsWithChildren<{}>) { */ clearCurrentAccount() } - - setState(s => ({ - ...s, - accounts: session.accounts, - })) }) - }, [state, setState, clearCurrentAccount, initSession]) + }, [ + currentAccount, + setAccounts, + clearCurrentAccount, + initSession, + agent, + setAgent, + ]) const stateContext = React.useMemo( () => ({ - ...state, - hasSession: !!state.currentAccount, + agent, + isInitialLoad, + isSwitchingAccounts, + currentAccount, + accounts, + hasSession: Boolean(currentAccount), }), - [state], + [agent, isInitialLoad, isSwitchingAccounts, accounts, currentAccount], ) const api = React.useMemo( @@ -599,7 +580,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { resumeSession, removeAccount, selectAccount, - updateCurrentAccount, + refreshSession, clearCurrentAccount, }), [ @@ -610,11 +591,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) { resumeSession, removeAccount, selectAccount, - updateCurrentAccount, + refreshSession, clearCurrentAccount, ], ) + // as we migrate, continue to keep this updated + __globalAgent = agent + return ( {children}