import {AtpSessionEvent} from '@atproto/api' import {createPublicAgent} from './agent' import {SessionAccount} from './types' // A hack so that the reducer can't read anything from the agent. // From the reducer's point of view, it should be a completely opaque object. type OpaqueBskyAgent = { readonly service: URL readonly api: unknown readonly app: unknown readonly com: unknown } type AgentState = { readonly agent: OpaqueBskyAgent readonly did: string | undefined } export type State = { readonly accounts: SessionAccount[] readonly currentAgentState: AgentState needsPersist: boolean // Mutated in an effect. } export type Action = | { type: 'received-agent-event' agent: OpaqueBskyAgent accountDid: string refreshedAccount: SessionAccount | undefined sessionEvent: AtpSessionEvent } | { type: 'switched-to-account' newAgent: OpaqueBskyAgent newAccount: SessionAccount } | { type: 'removed-account' accountDid: string } | { type: 'logged-out' } | { type: 'synced-accounts' syncedAccounts: SessionAccount[] syncedCurrentDid: string | undefined } function createPublicAgentState(): AgentState { return { agent: createPublicAgent(), did: undefined, } } export function getInitialState(persistedAccounts: SessionAccount[]): State { return { accounts: persistedAccounts, currentAgentState: createPublicAgentState(), needsPersist: false, } } export function reducer(state: State, action: Action): State { switch (action.type) { case 'received-agent-event': { const {agent, accountDid, refreshedAccount, sessionEvent} = action if ( refreshedAccount === undefined && agent !== state.currentAgentState.agent ) { // If the session got cleared out (e.g. due to expiry or network error) but // this account isn't the active one, don't clear it out at this time. // This way, if the problem is transient, it'll work on next resume. return state } if (sessionEvent === 'network-error') { // Don't change stored accounts but kick to the choose account screen. return { accounts: state.accounts, currentAgentState: createPublicAgentState(), needsPersist: true, } } const existingAccount = state.accounts.find(a => a.did === accountDid) if ( !existingAccount || JSON.stringify(existingAccount) === JSON.stringify(refreshedAccount) ) { // Fast path without a state update. return state } return { accounts: state.accounts.map(a => { if (a.did === accountDid) { if (refreshedAccount) { return refreshedAccount } else { return { ...a, // If we didn't receive a refreshed account, clear out the tokens. accessJwt: undefined, refreshJwt: undefined, } } } else { return a } }), currentAgentState: refreshedAccount ? state.currentAgentState : createPublicAgentState(), // Log out if expired. needsPersist: true, } } case 'switched-to-account': { const {newAccount, newAgent} = action return { accounts: [ newAccount, ...state.accounts.filter(a => a.did !== newAccount.did), ], currentAgentState: { did: newAccount.did, agent: newAgent, }, needsPersist: true, } } case 'removed-account': { const {accountDid} = action return { accounts: state.accounts.filter(a => a.did !== accountDid), currentAgentState: state.currentAgentState.did === accountDid ? createPublicAgentState() // Log out if removing the current one. : state.currentAgentState, needsPersist: true, } } case 'logged-out': { return { accounts: state.accounts.map(a => ({ ...a, // Clear tokens for *every* account (this is a hard logout). refreshJwt: undefined, accessJwt: undefined, })), currentAgentState: createPublicAgentState(), needsPersist: true, } } case 'synced-accounts': { const {syncedAccounts, syncedCurrentDid} = action return { accounts: syncedAccounts, currentAgentState: syncedCurrentDid === state.currentAgentState.did ? state.currentAgentState : createPublicAgentState(), // Log out if different user. needsPersist: false, // Synced from another tab. Don't persist to avoid cycles. } } } }