From 0910525e2efe124c63c1c8147a98450e43110681 Mon Sep 17 00:00:00 2001 From: dan Date: Wed, 8 May 2024 03:30:55 +0100 Subject: [PATCH] [Session] Code cleanup (#3854) * Split utils into files * Move reducer to another file * Write types explicitly * Remove unnnecessary check * Move things around a bit * Move more stuff into agent factories * Move more stuff into agent * Fix gates await * Clarify comments * Enforce more via types * Nit * initSession -> resumeSession * Protect against races * Make agent opaque to reducer * Check using plain condition --- src/App.native.tsx | 12 +- src/App.web.tsx | 10 +- src/lib/hooks/useAccountSwitcher.ts | 6 +- src/screens/Login/ChooseAccountForm.tsx | 6 +- src/state/session/agent.ts | 190 ++++++++++++ src/state/session/index.tsx | 366 +++++------------------- src/state/session/moderation.ts | 50 ++++ src/state/session/reducer.ts | 188 ++++++++++++ src/state/session/types.ts | 7 +- src/state/session/util.ts | 36 +++ src/state/session/util/index.ts | 186 ------------ 11 files changed, 554 insertions(+), 503 deletions(-) create mode 100644 src/state/session/agent.ts create mode 100644 src/state/session/moderation.ts create mode 100644 src/state/session/reducer.ts create mode 100644 src/state/session/util.ts delete mode 100644 src/state/session/util/index.ts diff --git a/src/App.native.tsx b/src/App.native.tsx index a3b24f44..79104f17 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -57,7 +57,7 @@ SplashScreen.preventAutoHideAsync() function InnerApp() { const [isReady, setIsReady] = React.useState(false) const {currentAccount} = useSession() - const {initSession} = useSessionApi() + const {resumeSession} = useSessionApi() const theme = useColorModeTheme() const {_} = useLingui() @@ -65,20 +65,20 @@ function InnerApp() { // init useEffect(() => { - async function resumeSession(account?: SessionAccount) { + async function onLaunch(account?: SessionAccount) { try { if (account) { - await initSession(account) + await resumeSession(account) } } catch (e) { - logger.error(`session: resumeSession failed`, {message: e}) + logger.error(`session: resume failed`, {message: e}) } finally { setIsReady(true) } } const account = readLastActiveAccount() - resumeSession(account) - }, [initSession]) + onLaunch(account) + }, [resumeSession]) useEffect(() => { return listenSessionDropped(() => { diff --git a/src/App.web.tsx b/src/App.web.tsx index 87909a27..40ceb694 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -45,17 +45,17 @@ import {listenSessionDropped} from './state/events' function InnerApp() { const [isReady, setIsReady] = React.useState(false) const {currentAccount} = useSession() - const {initSession} = useSessionApi() + const {resumeSession} = useSessionApi() const theme = useColorModeTheme() const {_} = useLingui() useIntentHandler() // init useEffect(() => { - async function resumeSession(account?: SessionAccount) { + async function onLaunch(account?: SessionAccount) { try { if (account) { - await initSession(account) + await resumeSession(account) } } catch (e) { logger.error(`session: resumeSession failed`, {message: e}) @@ -64,8 +64,8 @@ function InnerApp() { } } const account = readLastActiveAccount() - resumeSession(account) - }, [initSession]) + onLaunch(account) + }, [resumeSession]) useEffect(() => { return listenSessionDropped(() => { diff --git a/src/lib/hooks/useAccountSwitcher.ts b/src/lib/hooks/useAccountSwitcher.ts index ad529f91..33d56eb8 100644 --- a/src/lib/hooks/useAccountSwitcher.ts +++ b/src/lib/hooks/useAccountSwitcher.ts @@ -15,7 +15,7 @@ export function useAccountSwitcher() { const [pendingDid, setPendingDid] = useState(null) const {_} = useLingui() const {track} = useAnalytics() - const {initSession} = useSessionApi() + const {resumeSession} = useSessionApi() const {requestSwitchToAccount} = useLoggedOutViewControls() const onPressSwitchAccount = useCallback( @@ -39,7 +39,7 @@ export function useAccountSwitcher() { // So we change the URL ourselves. The navigator will pick it up on remount. history.pushState(null, '', '/') } - await initSession(account) + await resumeSession(account) logEvent('account:loggedIn', {logContext, withPassword: false}) Toast.show(_(msg`Signed in as @${account.handle}`)) } else { @@ -57,7 +57,7 @@ export function useAccountSwitcher() { setPendingDid(null) } }, - [_, track, initSession, requestSwitchToAccount, pendingDid], + [_, track, resumeSession, requestSwitchToAccount, pendingDid], ) return {onPressSwitchAccount, pendingDid} diff --git a/src/screens/Login/ChooseAccountForm.tsx b/src/screens/Login/ChooseAccountForm.tsx index b02b8e16..8c002b16 100644 --- a/src/screens/Login/ChooseAccountForm.tsx +++ b/src/screens/Login/ChooseAccountForm.tsx @@ -26,7 +26,7 @@ export const ChooseAccountForm = ({ const {track, screen} = useAnalytics() const {_} = useLingui() const {currentAccount} = useSession() - const {initSession} = useSessionApi() + const {resumeSession} = useSessionApi() const {setShowLoggedOut} = useLoggedOutViewControls() React.useEffect(() => { @@ -51,7 +51,7 @@ export const ChooseAccountForm = ({ } try { setPendingDid(account.did) - await initSession(account) + await resumeSession(account) logEvent('account:loggedIn', { logContext: 'ChooseAccountForm', withPassword: false, @@ -71,7 +71,7 @@ export const ChooseAccountForm = ({ [ currentAccount, track, - initSession, + resumeSession, pendingDid, onSelectAccount, setShowLoggedOut, diff --git a/src/state/session/agent.ts b/src/state/session/agent.ts new file mode 100644 index 00000000..ab7ebc79 --- /dev/null +++ b/src/state/session/agent.ts @@ -0,0 +1,190 @@ +import {BskyAgent} from '@atproto/api' +import {AtpSessionEvent} from '@atproto-labs/api' + +import {networkRetry} from '#/lib/async/retry' +import {PUBLIC_BSKY_SERVICE} from '#/lib/constants' +import {tryFetchGates} from '#/lib/statsig/statsig' +import { + configureModerationForAccount, + configureModerationForGuest, +} from './moderation' +import {SessionAccount} from './types' +import {isSessionDeactivated, isSessionExpired} from './util' +import {IS_PROD_SERVICE} from '#/lib/constants' +import {DEFAULT_PROD_FEEDS} from '../queries/preferences' + +export function createPublicAgent() { + configureModerationForGuest() // Side effect but only relevant for tests + return new BskyAgent({service: PUBLIC_BSKY_SERVICE}) +} + +export async function createAgentAndResume( + storedAccount: SessionAccount, + onSessionChange: ( + agent: BskyAgent, + did: string, + event: AtpSessionEvent, + ) => void, +) { + const agent = new BskyAgent({service: storedAccount.service}) + if (storedAccount.pdsUrl) { + agent.pdsUrl = agent.api.xrpc.uri = new URL(storedAccount.pdsUrl) + } + const gates = tryFetchGates(storedAccount.did, 'prefer-low-latency') + const moderation = configureModerationForAccount(agent, storedAccount) + const prevSession = { + accessJwt: storedAccount.accessJwt ?? '', + refreshJwt: storedAccount.refreshJwt ?? '', + did: storedAccount.did, + handle: storedAccount.handle, + } + if (isSessionExpired(storedAccount)) { + await networkRetry(1, () => agent.resumeSession(prevSession)) + } else { + agent.session = prevSession + if (!storedAccount.deactivated) { + // Intentionally not awaited to unblock the UI: + networkRetry(1, () => agent.resumeSession(prevSession)) + } + } + + return prepareAgent(agent, gates, moderation, onSessionChange) +} + +export async function createAgentAndLogin( + { + service, + identifier, + password, + authFactorToken, + }: { + service: string + identifier: string + password: string + authFactorToken?: string + }, + onSessionChange: ( + agent: BskyAgent, + did: string, + event: AtpSessionEvent, + ) => void, +) { + const agent = new BskyAgent({service}) + await agent.login({identifier, password, authFactorToken}) + + const account = agentToSessionAccountOrThrow(agent) + const gates = tryFetchGates(account.did, 'prefer-fresh-gates') + const moderation = configureModerationForAccount(agent, account) + return prepareAgent(agent, moderation, gates, onSessionChange) +} + +export async function createAgentAndCreateAccount( + { + service, + email, + password, + handle, + birthDate, + inviteCode, + verificationPhone, + verificationCode, + }: { + service: string + email: string + password: string + handle: string + birthDate: Date + inviteCode?: string + verificationPhone?: string + verificationCode?: string + }, + onSessionChange: ( + agent: BskyAgent, + did: string, + event: AtpSessionEvent, + ) => void, +) { + const agent = new BskyAgent({service}) + await agent.createAccount({ + email, + password, + handle, + inviteCode, + verificationPhone, + verificationCode, + }) + const account = agentToSessionAccountOrThrow(agent) + const gates = tryFetchGates(account.did, 'prefer-fresh-gates') + const moderation = configureModerationForAccount(agent, account) + if (!account.deactivated) { + /*dont await*/ agent.upsertProfile(_existing => { + return { + displayName: '', + // HACKFIX + // creating a bunch of identical profile objects is breaking the relay + // tossing this unspecced field onto it to reduce the size of the problem + // -prf + createdAt: new Date().toISOString(), + } + }) + } + + // Not awaited so that we can still get into onboarding. + // This is OK because we won't let you toggle adult stuff until you set the date. + agent.setPersonalDetails({birthDate: birthDate.toISOString()}) + if (IS_PROD_SERVICE(service)) { + agent.setSavedFeeds(DEFAULT_PROD_FEEDS.saved, DEFAULT_PROD_FEEDS.pinned) + } + + return prepareAgent(agent, gates, moderation, onSessionChange) +} + +async function prepareAgent( + agent: BskyAgent, + // Not awaited in the calling code so we can delay blocking on them. + gates: Promise, + moderation: Promise, + onSessionChange: ( + agent: BskyAgent, + did: string, + event: AtpSessionEvent, + ) => void, +) { + // There's nothing else left to do, so block on them here. + await Promise.all([gates, moderation]) + + // Now the agent is ready. + const account = agentToSessionAccountOrThrow(agent) + agent.setPersistSessionHandler(event => { + onSessionChange(agent, account.did, event) + }) + return {agent, account} +} + +export function agentToSessionAccountOrThrow(agent: BskyAgent): SessionAccount { + const account = agentToSessionAccount(agent) + if (!account) { + throw Error('Expected an active session') + } + return account +} + +export 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 || false, + emailAuthFactor: agent.session.emailAuthFactor || false, + refreshJwt: agent.session.refreshJwt, + accessJwt: agent.session.accessJwt, + deactivated: isSessionDeactivated(agent.session.accessJwt), + pdsUrl: agent.pdsUrl?.toString(), + } +} diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index 0a015d56..a8bd90ca 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -2,9 +2,7 @@ import React from 'react' import {AtpSessionEvent, BskyAgent} from '@atproto/api' import {track} from '#/lib/analytics/analytics' -import {networkRetry} from '#/lib/async/retry' -import {PUBLIC_BSKY_SERVICE} from '#/lib/constants' -import {logEvent, tryFetchGates} from '#/lib/statsig/statsig' +import {logEvent} from '#/lib/statsig/statsig' import {isWeb} from '#/platform/detection' import * as persisted from '#/state/persisted' import {useCloseAllActiveElements} from '#/state/util' @@ -13,22 +11,15 @@ import {IS_DEV} from '#/env' import {emitSessionDropped} from '../events' import { agentToSessionAccount, - configureModerationForAccount, - configureModerationForGuest, createAgentAndCreateAccount, createAgentAndLogin, - isSessionDeactivated, - isSessionExpired, -} from './util' + createAgentAndResume, +} from './agent' +import {getInitialState, reducer} from './reducer' +export {isSessionDeactivated} from './util' export type {SessionAccount} from '#/state/session/types' -import { - SessionAccount, - SessionApiContext, - SessionStateContext, -} from '#/state/session/types' - -export {isSessionDeactivated} +import {SessionApiContext, SessionStateContext} from '#/state/session/types' const StateContext = React.createContext({ accounts: [], @@ -42,190 +33,16 @@ const ApiContext = React.createContext({ createAccount: async () => {}, login: async () => {}, logout: async () => {}, - initSession: async () => {}, + resumeSession: async () => {}, removeAccount: () => {}, updateCurrentAccount: () => {}, }) -type AgentState = { - readonly agent: BskyAgent - readonly did: string | undefined -} - -type State = { - accounts: SessionStateContext['accounts'] - currentAgentState: AgentState - needsPersist: boolean -} - -type Action = - | { - type: 'received-agent-event' - agent: BskyAgent - accountDid: string - refreshedAccount: SessionAccount | undefined - sessionEvent: AtpSessionEvent - } - | { - type: 'switched-to-account' - newAgent: BskyAgent - newAccount: SessionAccount - } - | { - type: 'updated-current-account' - updatedFields: Partial< - Pick< - SessionAccount, - 'handle' | 'email' | 'emailConfirmed' | 'emailAuthFactor' - > - > - } - | { - type: 'removed-account' - accountDid: string - } - | { - type: 'logged-out' - } - | { - type: 'synced-accounts' - syncedAccounts: SessionAccount[] - syncedCurrentDid: string | undefined - } - -function createPublicAgentState() { - configureModerationForGuest() // Side effect but only relevant for tests - return { - agent: new BskyAgent({service: PUBLIC_BSKY_SERVICE}), - did: undefined, - } -} - -function getInitialState(): State { - return { - accounts: persisted.get('session').accounts, - currentAgentState: createPublicAgentState(), - needsPersist: false, - } -} - -function reducer(state: State, action: Action): State { - switch (action.type) { - case 'received-agent-event': { - const {agent, accountDid, refreshedAccount, sessionEvent} = action - if (agent !== state.currentAgentState.agent) { - // Only consider events from the active agent. - 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 'updated-current-account': { - const {updatedFields} = action - return { - accounts: state.accounts.map(a => { - if (a.did === state.currentAgentState.did) { - return { - ...a, - ...updatedFields, - } - } else { - return a - } - }), - currentAgentState: state.currentAgentState, - 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. - } - } - } -} - export function Provider({children}: React.PropsWithChildren<{}>) { - const [state, dispatch] = React.useReducer(reducer, null, getInitialState) + const cancelPendingTask = useOneTaskAtATime() + const [state, dispatch] = React.useReducer(reducer, null, () => + getInitialState(persisted.get('session').accounts), + ) const onAgentSessionChange = React.useCallback( (agent: BskyAgent, accountDid: string, sessionEvent: AtpSessionEvent) => { @@ -245,34 +62,18 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) const createAccount = React.useCallback( - async ({ - service, - email, - password, - handle, - birthDate, - inviteCode, - verificationPhone, - verificationCode, - }) => { + async params => { + const signal = cancelPendingTask() track('Try Create Account') logEvent('account:create:begin', {}) - const {agent, account, fetchingGates} = await createAgentAndCreateAccount( - { - service, - email, - password, - handle, - birthDate, - inviteCode, - verificationPhone, - verificationCode, - }, + const {agent, account} = await createAgentAndCreateAccount( + params, + onAgentSessionChange, ) - agent.setPersistSessionHandler(event => { - onAgentSessionChange(agent, account.did, event) - }) - await fetchingGates + + if (signal.aborted) { + return + } dispatch({ type: 'switched-to-account', newAgent: agent, @@ -281,21 +82,20 @@ export function Provider({children}: React.PropsWithChildren<{}>) { track('Create Account') logEvent('account:create:success', {}) }, - [onAgentSessionChange], + [onAgentSessionChange, cancelPendingTask], ) const login = React.useCallback( - async ({service, identifier, password, authFactorToken}, logContext) => { - const {agent, account, fetchingGates} = await createAgentAndLogin({ - service, - identifier, - password, - authFactorToken, - }) - agent.setPersistSessionHandler(event => { - onAgentSessionChange(agent, account.did, event) - }) - await fetchingGates + async (params, logContext) => { + const signal = cancelPendingTask() + const {agent, account} = await createAgentAndLogin( + params, + onAgentSessionChange, + ) + + if (signal.aborted) { + return + } dispatch({ type: 'switched-to-account', newAgent: agent, @@ -304,88 +104,49 @@ export function Provider({children}: React.PropsWithChildren<{}>) { track('Sign In', {resumedSession: false}) logEvent('account:loggedIn', {logContext, withPassword: true}) }, - [onAgentSessionChange], + [onAgentSessionChange, cancelPendingTask], ) const logout = React.useCallback( - async logContext => { + logContext => { + cancelPendingTask() dispatch({ type: 'logged-out', }) logEvent('account:loggedOut', {logContext}) }, - [], + [cancelPendingTask], ) - const initSession = React.useCallback( - async account => { - const fetchingGates = tryFetchGates(account.did, 'prefer-low-latency') - const agent = new BskyAgent({service: account.service}) - // restore the correct PDS URL if available - if (account.pdsUrl) { - agent.pdsUrl = agent.api.xrpc.uri = new URL(account.pdsUrl) + const resumeSession = React.useCallback( + async storedAccount => { + const signal = cancelPendingTask() + const {agent, account} = await createAgentAndResume( + storedAccount, + onAgentSessionChange, + ) + + if (signal.aborted) { + return } - agent.setPersistSessionHandler(event => { - onAgentSessionChange(agent, account.did, event) + dispatch({ + type: 'switched-to-account', + newAgent: agent, + newAccount: account, }) - await configureModerationForAccount(agent, account) - - const prevSession = { - accessJwt: account.accessJwt ?? '', - refreshJwt: account.refreshJwt ?? '', - did: account.did, - handle: account.handle, - } - - if (isSessionExpired(account)) { - const freshAccount = await resumeSessionWithFreshAccount() - await fetchingGates - dispatch({ - type: 'switched-to-account', - newAgent: agent, - newAccount: freshAccount, - }) - } else { - agent.session = prevSession - await fetchingGates - dispatch({ - type: 'switched-to-account', - newAgent: agent, - newAccount: account, - }) - if (isSessionDeactivated(account.accessJwt) || account.deactivated) { - // don't attempt to resume - // use will be taken to the deactivated screen - return - } - // Intentionally not awaited to unblock the UI: - resumeSessionWithFreshAccount() - } - - async function resumeSessionWithFreshAccount(): Promise { - await networkRetry(1, () => agent.resumeSession(prevSession)) - const sessionAccount = agentToSessionAccount(agent) - /* - * If `agent.resumeSession` fails above, it'll throw. This is just to - * make TypeScript happy. - */ - if (!sessionAccount) { - throw new Error(`session: initSession failed to establish a session`) - } - return sessionAccount - } }, - [onAgentSessionChange], + [onAgentSessionChange, cancelPendingTask], ) const removeAccount = React.useCallback( account => { + cancelPendingTask() dispatch({ type: 'removed-account', accountDid: account.did, }) }, - [], + [cancelPendingTask], ) const updateCurrentAccount = React.useCallback< @@ -422,14 +183,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) if (syncedAccount && syncedAccount.refreshJwt) { if (syncedAccount.did !== state.currentAgentState.did) { - initSession(syncedAccount) + resumeSession(syncedAccount) } else { // @ts-ignore we checked for `refreshJwt` above state.currentAgentState.agent.session = syncedAccount } } }) - }, [state, initSession]) + }, [state, resumeSession]) const stateContext = React.useMemo( () => ({ @@ -447,7 +208,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { createAccount, login, logout, - initSession, + resumeSession, removeAccount, updateCurrentAccount, }), @@ -455,7 +216,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { createAccount, login, logout, - initSession, + resumeSession, removeAccount, updateCurrentAccount, ], @@ -464,8 +225,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) { // @ts-ignore if (IS_DEV && isWeb) window.agent = state.currentAgentState.agent + const agent = state.currentAgentState.agent as BskyAgent return ( - + {children} @@ -473,6 +235,18 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) } +function useOneTaskAtATime() { + const abortController = React.useRef(null) + const cancelPendingTask = React.useCallback(() => { + if (abortController.current) { + abortController.current.abort() + } + abortController.current = new AbortController() + return abortController.current.signal + }, []) + return cancelPendingTask +} + export function useSession() { return React.useContext(StateContext) } diff --git a/src/state/session/moderation.ts b/src/state/session/moderation.ts new file mode 100644 index 00000000..d8ded90f --- /dev/null +++ b/src/state/session/moderation.ts @@ -0,0 +1,50 @@ +import {BSKY_LABELER_DID, BskyAgent} from '@atproto/api' + +import {IS_TEST_USER} from '#/lib/constants' +import {readLabelers} from './agent-config' +import {SessionAccount} from './types' + +export function configureModerationForGuest() { + // This global mutation is *only* OK because this code is only relevant for testing. + // Don't add any other global behavior here! + switchToBskyAppLabeler() +} + +export async function configureModerationForAccount( + agent: BskyAgent, + account: SessionAccount, +) { + // This global mutation is *only* OK because this code is only relevant for testing. + // Don't add any other global behavior here! + switchToBskyAppLabeler() + if (IS_TEST_USER(account.handle)) { + await trySwitchToTestAppLabeler(agent) + } + + // The code below is actually relevant to production (and isn't global). + const labelerDids = await readLabelers(account.did).catch(_ => {}) + if (labelerDids) { + agent.configureLabelersHeader( + labelerDids.filter(did => did !== BSKY_LABELER_DID), + ) + } else { + // If there are no headers in the storage, we'll not send them on the initial requests. + // If we wanted to fix this, we could block on the preferences query here. + } +} + +function switchToBskyAppLabeler() { + BskyAgent.configure({appLabelers: [BSKY_LABELER_DID]}) +} + +async function trySwitchToTestAppLabeler(agent: BskyAgent) { + const did = ( + await agent + .resolveHandle({handle: 'mod-authority.test'}) + .catch(_ => undefined) + )?.data.did + if (did) { + console.warn('USING TEST ENV MODERATION') + BskyAgent.configure({appLabelers: [did]}) + } +} diff --git a/src/state/session/reducer.ts b/src/state/session/reducer.ts new file mode 100644 index 00000000..e873f620 --- /dev/null +++ b/src/state/session/reducer.ts @@ -0,0 +1,188 @@ +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 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: 'updated-current-account' + updatedFields: Partial< + Pick< + SessionAccount, + 'handle' | 'email' | 'emailConfirmed' | 'emailAuthFactor' + > + > + } + | { + 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 (agent !== state.currentAgentState.agent) { + // Only consider events from the active agent. + 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 'updated-current-account': { + const {updatedFields} = action + return { + accounts: state.accounts.map(a => { + if (a.did === state.currentAgentState.did) { + return { + ...a, + ...updatedFields, + } + } else { + return a + } + }), + currentAgentState: state.currentAgentState, + 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. + } + } + } +} diff --git a/src/state/session/types.ts b/src/state/session/types.ts index b9fd3150..b74eeddc 100644 --- a/src/state/session/types.ts +++ b/src/state/session/types.ts @@ -8,6 +8,7 @@ export type SessionStateContext = { currentAccount: SessionAccount | undefined hasSession: boolean } + export type SessionApiContext = { createAccount: (props: { service: string @@ -33,10 +34,8 @@ export type SessionApiContext = { * access tokens from all accounts, so that returning as any user will * require a full login. */ - logout: ( - logContext: LogEvents['account:loggedOut']['logContext'], - ) => Promise - initSession: (account: SessionAccount) => Promise + logout: (logContext: LogEvents['account:loggedOut']['logContext']) => void + resumeSession: (account: SessionAccount) => Promise removeAccount: (account: SessionAccount) => void updateCurrentAccount: ( account: Partial< diff --git a/src/state/session/util.ts b/src/state/session/util.ts new file mode 100644 index 00000000..8948ecd6 --- /dev/null +++ b/src/state/session/util.ts @@ -0,0 +1,36 @@ +import {jwtDecode} from 'jwt-decode' + +import {hasProp} from '#/lib/type-guards' +import {logger} from '#/logger' +import * as persisted from '#/state/persisted' +import {SessionAccount} from './types' + +export function readLastActiveAccount() { + const {currentAccount, accounts} = persisted.get('session') + return accounts.find(a => a.did === currentAccount?.did) +} + +export function isSessionDeactivated(accessJwt: string | undefined) { + if (accessJwt) { + const sessData = jwtDecode(accessJwt) + return ( + hasProp(sessData, 'scope') && sessData.scope === 'com.atproto.deactivated' + ) + } + return false +} + +export function isSessionExpired(account: SessionAccount) { + try { + if (account.accessJwt) { + const decoded = jwtDecode(account.accessJwt) + if (decoded.exp) { + const didExpire = Date.now() >= decoded.exp * 1000 + return didExpire + } + } + } catch (e) { + logger.error(`session: could not decode jwt`) + } + return true +} diff --git a/src/state/session/util/index.ts b/src/state/session/util/index.ts deleted file mode 100644 index 8c98aceb..00000000 --- a/src/state/session/util/index.ts +++ /dev/null @@ -1,186 +0,0 @@ -import {BSKY_LABELER_DID, BskyAgent} from '@atproto/api' -import {jwtDecode} from 'jwt-decode' - -import {IS_PROD_SERVICE, IS_TEST_USER} from '#/lib/constants' -import {tryFetchGates} from '#/lib/statsig/statsig' -import {hasProp} from '#/lib/type-guards' -import {logger} from '#/logger' -import * as persisted from '#/state/persisted' -import {DEFAULT_PROD_FEEDS} from '#/state/queries/preferences' -import {readLabelers} from '../agent-config' -import {SessionAccount, SessionApiContext} from '../types' - -export function isSessionDeactivated(accessJwt: string | undefined) { - if (accessJwt) { - const sessData = jwtDecode(accessJwt) - return ( - hasProp(sessData, 'scope') && sessData.scope === 'com.atproto.deactivated' - ) - } - return false -} - -export function readLastActiveAccount() { - const {currentAccount, accounts} = persisted.get('session') - return accounts.find(a => a.did === currentAccount?.did) -} - -export 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 || false, - emailAuthFactor: agent.session.emailAuthFactor || false, - refreshJwt: agent.session.refreshJwt, - accessJwt: agent.session.accessJwt, - deactivated: isSessionDeactivated(agent.session.accessJwt), - pdsUrl: agent.pdsUrl?.toString(), - } -} - -export function configureModerationForGuest() { - switchToBskyAppLabeler() -} - -export async function configureModerationForAccount( - agent: BskyAgent, - account: SessionAccount, -) { - switchToBskyAppLabeler() - if (IS_TEST_USER(account.handle)) { - await trySwitchToTestAppLabeler(agent) - } - - const labelerDids = await readLabelers(account.did).catch(_ => {}) - if (labelerDids) { - agent.configureLabelersHeader( - labelerDids.filter(did => did !== BSKY_LABELER_DID), - ) - } else { - // If there are no headers in the storage, we'll not send them on the initial requests. - // If we wanted to fix this, we could block on the preferences query here. - } -} - -function switchToBskyAppLabeler() { - BskyAgent.configure({appLabelers: [BSKY_LABELER_DID]}) -} - -async function trySwitchToTestAppLabeler(agent: BskyAgent) { - const did = ( - await agent - .resolveHandle({handle: 'mod-authority.test'}) - .catch(_ => undefined) - )?.data.did - if (did) { - console.warn('USING TEST ENV MODERATION') - BskyAgent.configure({appLabelers: [did]}) - } -} - -export function isSessionExpired(account: SessionAccount) { - try { - if (account.accessJwt) { - const decoded = jwtDecode(account.accessJwt) - if (decoded.exp) { - const didExpire = Date.now() >= decoded.exp * 1000 - return didExpire - } - } - } catch (e) { - logger.error(`session: could not decode jwt`) - } - return true -} - -export async function createAgentAndLogin({ - service, - identifier, - password, - authFactorToken, -}: { - service: string - identifier: string - password: string - authFactorToken?: string -}) { - const agent = new BskyAgent({service}) - await agent.login({identifier, password, authFactorToken}) - - const account = agentToSessionAccount(agent) - if (!agent.session || !account) { - throw new Error(`session: login failed to establish a session`) - } - - const fetchingGates = tryFetchGates(account.did, 'prefer-fresh-gates') - await configureModerationForAccount(agent, account) - - return { - agent, - account, - fetchingGates, - } -} - -export async function createAgentAndCreateAccount({ - service, - email, - password, - handle, - birthDate, - inviteCode, - verificationPhone, - verificationCode, -}: Parameters[0]) { - const agent = new BskyAgent({service}) - await agent.createAccount({ - email, - password, - handle, - inviteCode, - verificationPhone, - verificationCode, - }) - - const account = agentToSessionAccount(agent)! - if (!agent.session || !account) { - throw new Error(`session: createAccount failed to establish a session`) - } - - const fetchingGates = tryFetchGates(account.did, 'prefer-fresh-gates') - - if (!account.deactivated) { - /*dont await*/ agent.upsertProfile(_existing => { - return { - displayName: '', - - // HACKFIX - // creating a bunch of identical profile objects is breaking the relay - // tossing this unspecced field onto it to reduce the size of the problem - // -prf - createdAt: new Date().toISOString(), - } - }) - } - - // Not awaited so that we can still get into onboarding. - // This is OK because we won't let you toggle adult stuff until you set the date. - agent.setPersonalDetails({birthDate: birthDate.toISOString()}) - if (IS_PROD_SERVICE(service)) { - agent.setSavedFeeds(DEFAULT_PROD_FEEDS.saved, DEFAULT_PROD_FEEDS.pinned) - } - - await configureModerationForAccount(agent, account) - - return { - agent, - account, - fetchingGates, - } -}