diff options
Diffstat (limited to 'src/state/session/index.tsx')
-rw-r--r-- | src/state/session/index.tsx | 438 |
1 files changed, 210 insertions, 228 deletions
diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index 582680e97..276e3b97b 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {AtpPersistSessionHandler, BskyAgent} from '@atproto/api' +import {AtpSessionData, AtpSessionEvent, BskyAgent} from '@atproto/api' import {track} from '#/lib/analytics/analytics' import {networkRetry} from '#/lib/async/retry' @@ -35,8 +35,6 @@ const PUBLIC_BSKY_AGENT = new BskyAgent({service: PUBLIC_BSKY_SERVICE}) configureModerationForGuest() const StateContext = React.createContext<SessionStateContext>({ - isInitialLoad: true, - isSwitchingAccounts: false, accounts: [], currentAccount: undefined, hasSession: false, @@ -47,9 +45,7 @@ const ApiContext = React.createContext<SessionApiContext>({ login: async () => {}, logout: async () => {}, initSession: async () => {}, - resumeSession: async () => {}, removeAccount: () => {}, - selectAccount: async () => {}, updateCurrentAccount: () => {}, clearCurrentAccount: () => {}, }) @@ -60,33 +56,26 @@ function __getAgent() { return __globalAgent } +type AgentState = { + readonly agent: BskyAgent + readonly did: string | undefined +} + type State = { accounts: SessionStateContext['accounts'] - currentAccountDid: string | undefined + currentAgentState: AgentState needsPersist: boolean } export function Provider({children}: React.PropsWithChildren<{}>) { - const [isInitialLoad, setIsInitialLoad] = React.useState(true) - const [isSwitchingAccounts, setIsSwitchingAccounts] = React.useState(false) - const [state, setState] = React.useState<State>({ + const [state, setState] = React.useState<State>(() => ({ accounts: persisted.get('session').accounts, - currentAccountDid: undefined, // assume logged out to start - needsPersist: false, - }) - - const upsertAccount = React.useCallback( - (account: SessionAccount, expired = false) => { - setState(s => { - return { - accounts: [account, ...s.accounts.filter(a => a.did !== account.did)], - currentAccountDid: expired ? undefined : account.did, - needsPersist: true, - } - }) + currentAgentState: { + agent: PUBLIC_BSKY_AGENT, + did: undefined, // assume logged out to start }, - [setState], - ) + needsPersist: false, + })) const clearCurrentAccount = React.useCallback(() => { logger.warn(`session: clear current account`) @@ -94,80 +83,112 @@ export function Provider({children}: React.PropsWithChildren<{}>) { configureModerationForGuest() setState(s => ({ accounts: s.accounts, - currentAccountDid: undefined, + currentAgentState: { + agent: PUBLIC_BSKY_AGENT, + did: undefined, + }, needsPersist: true, })) }, [setState]) - const createPersistSessionHandler = React.useCallback( + const onAgentSessionChange = React.useCallback( ( agent: BskyAgent, account: SessionAccount, - persistSessionCallback: (props: { - expired: boolean - refreshedAccount: SessionAccount - }) => void, - { - networkErrorCallback, - }: { - networkErrorCallback?: () => void - } = {}, - ): AtpPersistSessionHandler => { - return function persistSession(event, session) { - const expired = event === 'expired' || event === 'create-failed' - - if (event === 'network-error') { - logger.warn( - `session: persistSessionHandler received network-error event`, - ) - networkErrorCallback?.() - return - } - - // TODO: use agentToSessionAccount for this too. - const refreshedAccount: SessionAccount = { - service: account.service, - did: session?.did || account.did, - handle: session?.handle || account.handle, - email: session?.email || account.email, - emailConfirmed: session?.emailConfirmed || account.emailConfirmed, - emailAuthFactor: session?.emailAuthFactor || account.emailAuthFactor, - deactivated: isSessionDeactivated(session?.accessJwt), - pdsUrl: agent.pdsUrl?.toString(), - - /* - * Tokens are undefined if the session expires, or if creation fails for - * any reason e.g. tokens are invalid, network error, etc. - */ - refreshJwt: session?.refreshJwt, - accessJwt: session?.accessJwt, - } - - logger.debug(`session: persistSession`, { - event, - deactivated: refreshedAccount.deactivated, - }) + event: AtpSessionEvent, + session: AtpSessionData | undefined, + ) => { + const expired = event === 'expired' || event === 'create-failed' + + if (event === 'network-error') { + logger.warn( + `session: persistSessionHandler received network-error event`, + ) + logger.warn(`session: clear current account`) + __globalAgent = PUBLIC_BSKY_AGENT + configureModerationForGuest() + setState(s => ({ + accounts: s.accounts, + currentAgentState: { + agent: PUBLIC_BSKY_AGENT, + did: undefined, + }, + needsPersist: true, + })) + return + } - if (expired) { - logger.warn(`session: expired`) - emitSessionDropped() - } + // TODO: use agentToSessionAccount for this too. + const refreshedAccount: SessionAccount = { + service: account.service, + did: session?.did ?? account.did, + handle: session?.handle ?? account.handle, + email: session?.email ?? account.email, + emailConfirmed: session?.emailConfirmed ?? account.emailConfirmed, + emailAuthFactor: session?.emailAuthFactor ?? account.emailAuthFactor, + deactivated: isSessionDeactivated(session?.accessJwt), + pdsUrl: agent.pdsUrl?.toString(), /* - * If the session expired, or it was successfully created/updated, we want - * to update/persist the data. - * - * If the session creation failed, it could be a network error, or it could - * be more serious like an invalid token(s). We can't differentiate, so in - * order to allow the user to get a fresh token (if they need it), we need - * to persist this data and wipe their tokens, effectively logging them - * out. + * Tokens are undefined if the session expires, or if creation fails for + * any reason e.g. tokens are invalid, network error, etc. */ - persistSessionCallback({ - expired, - refreshedAccount, - }) + refreshJwt: session?.refreshJwt, + accessJwt: session?.accessJwt, } + + logger.debug(`session: persistSession`, { + event, + deactivated: refreshedAccount.deactivated, + }) + + if (expired) { + logger.warn(`session: expired`) + emitSessionDropped() + __globalAgent = PUBLIC_BSKY_AGENT + configureModerationForGuest() + setState(s => ({ + accounts: s.accounts, + currentAgentState: { + agent: PUBLIC_BSKY_AGENT, + did: undefined, + }, + needsPersist: true, + })) + } + + /* + * If the session expired, or it was successfully created/updated, we want + * to update/persist the data. + * + * If the session creation failed, it could be a network error, or it could + * be more serious like an invalid token(s). We can't differentiate, so in + * order to allow the user to get a fresh token (if they need it), we need + * to persist this data and wipe their tokens, effectively logging them + * out. + */ + setState(s => { + const existingAccount = s.accounts.find( + a => a.did === refreshedAccount.did, + ) + if ( + !expired && + existingAccount && + refreshedAccount && + JSON.stringify(existingAccount) === JSON.stringify(refreshedAccount) + ) { + // Fast path without a state update. + return s + } + return { + accounts: [ + refreshedAccount, + ...s.accounts.filter(a => a.did !== refreshedAccount.did), + ], + currentAgentState: s.currentAgentState, + needsPersist: true, + } + }) }, [], ) @@ -197,26 +218,28 @@ export function Provider({children}: React.PropsWithChildren<{}>) { }, ) - agent.setPersistSessionHandler( - createPersistSessionHandler( - agent, - account, - ({expired, refreshedAccount}) => { - upsertAccount(refreshedAccount, expired) - }, - {networkErrorCallback: clearCurrentAccount}, - ), - ) + agent.setPersistSessionHandler((event, session) => { + onAgentSessionChange(agent, account, event, session) + }) __globalAgent = agent await fetchingGates - upsertAccount(account) + setState(s => { + return { + accounts: [account, ...s.accounts.filter(a => a.did !== account.did)], + currentAgentState: { + did: account.did, + agent: agent, + }, + needsPersist: true, + } + }) logger.debug(`session: created account`, {}, logger.DebugContext.session) track('Create Account') logEvent('account:create:success', {}) }, - [upsertAccount, clearCurrentAccount, createPersistSessionHandler], + [onAgentSessionChange], ) const login = React.useCallback<SessionApiContext['login']>( @@ -229,35 +252,39 @@ export function Provider({children}: React.PropsWithChildren<{}>) { authFactorToken, }) - agent.setPersistSessionHandler( - createPersistSessionHandler( - agent, - account, - ({expired, refreshedAccount}) => { - upsertAccount(refreshedAccount, expired) - }, - {networkErrorCallback: clearCurrentAccount}, - ), - ) + agent.setPersistSessionHandler((event, session) => { + onAgentSessionChange(agent, account, event, session) + }) __globalAgent = agent // @ts-ignore if (IS_DEV && isWeb) window.agent = agent await fetchingGates - upsertAccount(account) + setState(s => { + return { + accounts: [account, ...s.accounts.filter(a => a.did !== account.did)], + currentAgentState: { + did: account.did, + agent: agent, + }, + needsPersist: true, + } + }) logger.debug(`session: logged in`, {}, logger.DebugContext.session) track('Sign In', {resumedSession: false}) logEvent('account:loggedIn', {logContext, withPassword: true}) }, - [upsertAccount, clearCurrentAccount, createPersistSessionHandler], + [onAgentSessionChange], ) const logout = React.useCallback<SessionApiContext['logout']>( async logContext => { logger.debug(`session: logout`) - clearCurrentAccount() + logger.warn(`session: clear current account`) + __globalAgent = PUBLIC_BSKY_AGENT + configureModerationForGuest() setState(s => { return { accounts: s.accounts.map(a => ({ @@ -265,13 +292,16 @@ export function Provider({children}: React.PropsWithChildren<{}>) { refreshJwt: undefined, accessJwt: undefined, })), - currentAccountDid: s.currentAccountDid, + currentAgentState: { + did: undefined, + agent: PUBLIC_BSKY_AGENT, + }, needsPersist: true, } }) logEvent('account:loggedOut', {logContext}) }, - [clearCurrentAccount, setState], + [setState], ) const initSession = React.useCallback<SessionApiContext['initSession']>( @@ -286,16 +316,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) { agent.pdsUrl = agent.api.xrpc.uri = new URL(account.pdsUrl) } - agent.setPersistSessionHandler( - createPersistSessionHandler( - agent, - account, - ({expired, refreshedAccount}) => { - upsertAccount(refreshedAccount, expired) - }, - {networkErrorCallback: clearCurrentAccount}, - ), - ) + agent.setPersistSessionHandler((event, session) => { + onAgentSessionChange(agent, account, event, session) + }) // @ts-ignore if (IS_DEV && isWeb) window.agent = agent @@ -305,8 +328,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) { isSessionDeactivated(account.accessJwt) || account.deactivated const prevSession = { - accessJwt: account.accessJwt || '', - refreshJwt: account.refreshJwt || '', + accessJwt: account.accessJwt ?? '', + refreshJwt: account.refreshJwt ?? '', did: account.did, handle: account.handle, } @@ -314,23 +337,22 @@ export function Provider({children}: React.PropsWithChildren<{}>) { if (isSessionExpired(account)) { logger.debug(`session: attempting to resume using previous session`) - try { - const freshAccount = await resumeSessionWithFreshAccount() - __globalAgent = agent - await fetchingGates - upsertAccount(freshAccount) - } catch (e) { - /* - * Note: `agent.persistSession` is also called when this fails, and - * we handle that failure via `createPersistSessionHandler` - */ - logger.info(`session: resumeSessionWithFreshAccount failed`, { - message: e, - }) - - __globalAgent = PUBLIC_BSKY_AGENT - // TODO: Should this update currentAccountDid? - } + const freshAccount = await resumeSessionWithFreshAccount() + __globalAgent = agent + await fetchingGates + setState(s => { + return { + accounts: [ + freshAccount, + ...s.accounts.filter(a => a.did !== freshAccount.did), + ], + currentAgentState: { + did: freshAccount.did, + agent: agent, + }, + needsPersist: true, + } + }) } else { logger.debug(`session: attempting to reuse previous session`) @@ -338,7 +360,19 @@ export function Provider({children}: React.PropsWithChildren<{}>) { __globalAgent = agent await fetchingGates - upsertAccount(account) + setState(s => { + return { + accounts: [ + account, + ...s.accounts.filter(a => a.did !== account.did), + ], + currentAgentState: { + did: account.did, + agent: agent, + }, + needsPersist: true, + } + }) if (accountOrSessionDeactivated) { // don't attempt to resume @@ -349,26 +383,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) { // Intentionally not awaited to unblock the UI: resumeSessionWithFreshAccount() - .then(freshAccount => { - if (JSON.stringify(account) !== JSON.stringify(freshAccount)) { - logger.info( - `session: reuse of previous session returned a fresh account, upserting`, - ) - upsertAccount(freshAccount) - } - }) - .catch(e => { - /* - * Note: `agent.persistSession` is also called when this fails, and - * we handle that failure via `createPersistSessionHandler` - */ - logger.info(`session: resumeSessionWithFreshAccount failed`, { - message: e, - }) - - __globalAgent = PUBLIC_BSKY_AGENT - // TODO: Should this update currentAccountDid? - }) } async function resumeSessionWithFreshAccount(): Promise<SessionAccount> { @@ -386,22 +400,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { return sessionAccount } }, - [upsertAccount, clearCurrentAccount, createPersistSessionHandler], - ) - - const resumeSession = React.useCallback<SessionApiContext['resumeSession']>( - async account => { - try { - if (account) { - await initSession(account) - } - } catch (e) { - logger.error(`session: resumeSession failed`, {message: e}) - } finally { - setIsInitialLoad(false) - } - }, - [initSession], + [onAgentSessionChange], ) const removeAccount = React.useCallback<SessionApiContext['removeAccount']>( @@ -409,7 +408,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { setState(s => { return { accounts: s.accounts.filter(a => a.did !== account.did), - currentAccountDid: s.currentAccountDid, + currentAgentState: s.currentAgentState, needsPersist: true, } }) @@ -423,23 +422,19 @@ export function Provider({children}: React.PropsWithChildren<{}>) { account => { setState(s => { const currentAccount = s.accounts.find( - a => a.did === s.currentAccountDid, + a => a.did === s.currentAgentState.did, ) // ignore, should never happen if (!currentAccount) return s const updatedAccount = { ...currentAccount, - handle: account.handle || currentAccount.handle, - email: account.email || currentAccount.email, + handle: account.handle ?? currentAccount.handle, + email: account.email ?? currentAccount.email, emailConfirmed: - account.emailConfirmed !== undefined - ? account.emailConfirmed - : currentAccount.emailConfirmed, + account.emailConfirmed ?? currentAccount.emailConfirmed, emailAuthFactor: - account.emailAuthFactor !== undefined - ? account.emailAuthFactor - : currentAccount.emailAuthFactor, + account.emailAuthFactor ?? currentAccount.emailAuthFactor, } return { @@ -447,7 +442,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { updatedAccount, ...s.accounts.filter(a => a.did !== currentAccount.did), ], - currentAccountDid: s.currentAccountDid, + currentAgentState: s.currentAgentState, needsPersist: true, } }) @@ -455,30 +450,13 @@ export function Provider({children}: React.PropsWithChildren<{}>) { [setState], ) - const selectAccount = React.useCallback<SessionApiContext['selectAccount']>( - async (account, logContext) => { - setIsSwitchingAccounts(true) - try { - await initSession(account) - setIsSwitchingAccounts(false) - logEvent('account:loggedIn', {logContext, withPassword: false}) - } catch (e) { - // reset this in case of error - setIsSwitchingAccounts(false) - // but other listeners need a throw - throw e - } - }, - [initSession], - ) - React.useEffect(() => { if (state.needsPersist) { state.needsPersist = false persisted.write('session', { accounts: state.accounts, currentAccount: state.accounts.find( - a => a.did === state.currentAccountDid, + a => a.did === state.currentAgentState.did, ), }) } @@ -489,16 +467,21 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const persistedSession = persisted.get('session') logger.debug(`session: persisted onUpdate`, {}) + setState(s => ({ + accounts: persistedSession.accounts, + currentAgentState: s.currentAgentState, + needsPersist: false, // Synced from another tab. Don't persist to avoid cycles. + })) const selectedAccount = persistedSession.accounts.find( a => a.did === persistedSession.currentAccount?.did, ) if (selectedAccount && selectedAccount.refreshJwt) { - if (selectedAccount.did !== state.currentAccountDid) { + if (selectedAccount.did !== state.currentAgentState.did) { logger.debug(`session: persisted onUpdate, switching accounts`, { from: { - did: state.currentAccountDid, + did: state.currentAgentState.did, }, to: { did: selectedAccount.did, @@ -516,8 +499,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) { */ // @ts-ignore we checked for `refreshJwt` above __globalAgent.session = selectedAccount + // TODO: This needs a setState. } - } else if (!selectedAccount && state.currentAccountDid) { + } else if (!selectedAccount && state.currentAgentState.did) { logger.debug( `session: persisted onUpdate, logging out`, {}, @@ -530,28 +514,30 @@ export function Provider({children}: React.PropsWithChildren<{}>) { * handled by `persistSession` (which nukes this accounts tokens only), * or by a `logout` call which nukes all accounts tokens) */ - clearCurrentAccount() + logger.warn(`session: clear current account`) + __globalAgent = PUBLIC_BSKY_AGENT + configureModerationForGuest() + setState(s => ({ + accounts: s.accounts, + currentAgentState: { + did: undefined, + agent: PUBLIC_BSKY_AGENT, + }, + needsPersist: false, // Synced from another tab. Don't persist to avoid cycles. + })) } - - setState(() => ({ - accounts: persistedSession.accounts, - currentAccountDid: selectedAccount?.did, - needsPersist: false, // Synced from another tab. Don't persist to avoid cycles. - })) }) - }, [state, setState, clearCurrentAccount, initSession]) + }, [state, setState, initSession]) const stateContext = React.useMemo( () => ({ accounts: state.accounts, currentAccount: state.accounts.find( - a => a.did === state.currentAccountDid, + a => a.did === state.currentAgentState.did, ), - isInitialLoad, - isSwitchingAccounts, - hasSession: !!state.currentAccountDid, + hasSession: !!state.currentAgentState.did, }), - [state, isInitialLoad, isSwitchingAccounts], + [state], ) const api = React.useMemo( @@ -560,9 +546,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { login, logout, initSession, - resumeSession, removeAccount, - selectAccount, updateCurrentAccount, clearCurrentAccount, }), @@ -571,9 +555,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { login, logout, initSession, - resumeSession, removeAccount, - selectAccount, updateCurrentAccount, clearCurrentAccount, ], |