Use BskyAgent as the source of truth

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