Bluesky app fork with some witchin' additions 💫

[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

authored by danabra.mov and committed by

GitHub 0910525e 4fe5a869

+554 -503
+6 -6
src/App.native.tsx
··· 57 57 function InnerApp() { 58 58 const [isReady, setIsReady] = React.useState(false) 59 59 const {currentAccount} = useSession() 60 - const {initSession} = useSessionApi() 60 + const {resumeSession} = useSessionApi() 61 61 const theme = useColorModeTheme() 62 62 const {_} = useLingui() 63 63 ··· 65 65 66 66 // init 67 67 useEffect(() => { 68 - async function resumeSession(account?: SessionAccount) { 68 + async function onLaunch(account?: SessionAccount) { 69 69 try { 70 70 if (account) { 71 - await initSession(account) 71 + await resumeSession(account) 72 72 } 73 73 } catch (e) { 74 - logger.error(`session: resumeSession failed`, {message: e}) 74 + logger.error(`session: resume failed`, {message: e}) 75 75 } finally { 76 76 setIsReady(true) 77 77 } 78 78 } 79 79 const account = readLastActiveAccount() 80 - resumeSession(account) 81 - }, [initSession]) 80 + onLaunch(account) 81 + }, [resumeSession]) 82 82 83 83 useEffect(() => { 84 84 return listenSessionDropped(() => {
+5 -5
src/App.web.tsx
··· 45 45 function InnerApp() { 46 46 const [isReady, setIsReady] = React.useState(false) 47 47 const {currentAccount} = useSession() 48 - const {initSession} = useSessionApi() 48 + const {resumeSession} = useSessionApi() 49 49 const theme = useColorModeTheme() 50 50 const {_} = useLingui() 51 51 useIntentHandler() 52 52 53 53 // init 54 54 useEffect(() => { 55 - async function resumeSession(account?: SessionAccount) { 55 + async function onLaunch(account?: SessionAccount) { 56 56 try { 57 57 if (account) { 58 - await initSession(account) 58 + await resumeSession(account) 59 59 } 60 60 } catch (e) { 61 61 logger.error(`session: resumeSession failed`, {message: e}) ··· 64 64 } 65 65 } 66 66 const account = readLastActiveAccount() 67 - resumeSession(account) 68 - }, [initSession]) 67 + onLaunch(account) 68 + }, [resumeSession]) 69 69 70 70 useEffect(() => { 71 71 return listenSessionDropped(() => {
+3 -3
src/lib/hooks/useAccountSwitcher.ts
··· 15 15 const [pendingDid, setPendingDid] = useState<string | null>(null) 16 16 const {_} = useLingui() 17 17 const {track} = useAnalytics() 18 - const {initSession} = useSessionApi() 18 + const {resumeSession} = useSessionApi() 19 19 const {requestSwitchToAccount} = useLoggedOutViewControls() 20 20 21 21 const onPressSwitchAccount = useCallback( ··· 39 39 // So we change the URL ourselves. The navigator will pick it up on remount. 40 40 history.pushState(null, '', '/') 41 41 } 42 - await initSession(account) 42 + await resumeSession(account) 43 43 logEvent('account:loggedIn', {logContext, withPassword: false}) 44 44 Toast.show(_(msg`Signed in as @${account.handle}`)) 45 45 } else { ··· 57 57 setPendingDid(null) 58 58 } 59 59 }, 60 - [_, track, initSession, requestSwitchToAccount, pendingDid], 60 + [_, track, resumeSession, requestSwitchToAccount, pendingDid], 61 61 ) 62 62 63 63 return {onPressSwitchAccount, pendingDid}
+3 -3
src/screens/Login/ChooseAccountForm.tsx
··· 26 26 const {track, screen} = useAnalytics() 27 27 const {_} = useLingui() 28 28 const {currentAccount} = useSession() 29 - const {initSession} = useSessionApi() 29 + const {resumeSession} = useSessionApi() 30 30 const {setShowLoggedOut} = useLoggedOutViewControls() 31 31 32 32 React.useEffect(() => { ··· 51 51 } 52 52 try { 53 53 setPendingDid(account.did) 54 - await initSession(account) 54 + await resumeSession(account) 55 55 logEvent('account:loggedIn', { 56 56 logContext: 'ChooseAccountForm', 57 57 withPassword: false, ··· 71 71 [ 72 72 currentAccount, 73 73 track, 74 - initSession, 74 + resumeSession, 75 75 pendingDid, 76 76 onSelectAccount, 77 77 setShowLoggedOut,
+190
src/state/session/agent.ts
··· 1 + import {BskyAgent} from '@atproto/api' 2 + import {AtpSessionEvent} from '@atproto-labs/api' 3 + 4 + import {networkRetry} from '#/lib/async/retry' 5 + import {PUBLIC_BSKY_SERVICE} from '#/lib/constants' 6 + import {tryFetchGates} from '#/lib/statsig/statsig' 7 + import { 8 + configureModerationForAccount, 9 + configureModerationForGuest, 10 + } from './moderation' 11 + import {SessionAccount} from './types' 12 + import {isSessionDeactivated, isSessionExpired} from './util' 13 + import {IS_PROD_SERVICE} from '#/lib/constants' 14 + import {DEFAULT_PROD_FEEDS} from '../queries/preferences' 15 + 16 + export function createPublicAgent() { 17 + configureModerationForGuest() // Side effect but only relevant for tests 18 + return new BskyAgent({service: PUBLIC_BSKY_SERVICE}) 19 + } 20 + 21 + export async function createAgentAndResume( 22 + storedAccount: SessionAccount, 23 + onSessionChange: ( 24 + agent: BskyAgent, 25 + did: string, 26 + event: AtpSessionEvent, 27 + ) => void, 28 + ) { 29 + const agent = new BskyAgent({service: storedAccount.service}) 30 + if (storedAccount.pdsUrl) { 31 + agent.pdsUrl = agent.api.xrpc.uri = new URL(storedAccount.pdsUrl) 32 + } 33 + const gates = tryFetchGates(storedAccount.did, 'prefer-low-latency') 34 + const moderation = configureModerationForAccount(agent, storedAccount) 35 + const prevSession = { 36 + accessJwt: storedAccount.accessJwt ?? '', 37 + refreshJwt: storedAccount.refreshJwt ?? '', 38 + did: storedAccount.did, 39 + handle: storedAccount.handle, 40 + } 41 + if (isSessionExpired(storedAccount)) { 42 + await networkRetry(1, () => agent.resumeSession(prevSession)) 43 + } else { 44 + agent.session = prevSession 45 + if (!storedAccount.deactivated) { 46 + // Intentionally not awaited to unblock the UI: 47 + networkRetry(1, () => agent.resumeSession(prevSession)) 48 + } 49 + } 50 + 51 + return prepareAgent(agent, gates, moderation, onSessionChange) 52 + } 53 + 54 + export async function createAgentAndLogin( 55 + { 56 + service, 57 + identifier, 58 + password, 59 + authFactorToken, 60 + }: { 61 + service: string 62 + identifier: string 63 + password: string 64 + authFactorToken?: string 65 + }, 66 + onSessionChange: ( 67 + agent: BskyAgent, 68 + did: string, 69 + event: AtpSessionEvent, 70 + ) => void, 71 + ) { 72 + const agent = new BskyAgent({service}) 73 + await agent.login({identifier, password, authFactorToken}) 74 + 75 + const account = agentToSessionAccountOrThrow(agent) 76 + const gates = tryFetchGates(account.did, 'prefer-fresh-gates') 77 + const moderation = configureModerationForAccount(agent, account) 78 + return prepareAgent(agent, moderation, gates, onSessionChange) 79 + } 80 + 81 + export async function createAgentAndCreateAccount( 82 + { 83 + service, 84 + email, 85 + password, 86 + handle, 87 + birthDate, 88 + inviteCode, 89 + verificationPhone, 90 + verificationCode, 91 + }: { 92 + service: string 93 + email: string 94 + password: string 95 + handle: string 96 + birthDate: Date 97 + inviteCode?: string 98 + verificationPhone?: string 99 + verificationCode?: string 100 + }, 101 + onSessionChange: ( 102 + agent: BskyAgent, 103 + did: string, 104 + event: AtpSessionEvent, 105 + ) => void, 106 + ) { 107 + const agent = new BskyAgent({service}) 108 + await agent.createAccount({ 109 + email, 110 + password, 111 + handle, 112 + inviteCode, 113 + verificationPhone, 114 + verificationCode, 115 + }) 116 + const account = agentToSessionAccountOrThrow(agent) 117 + const gates = tryFetchGates(account.did, 'prefer-fresh-gates') 118 + const moderation = configureModerationForAccount(agent, account) 119 + if (!account.deactivated) { 120 + /*dont await*/ agent.upsertProfile(_existing => { 121 + return { 122 + displayName: '', 123 + // HACKFIX 124 + // creating a bunch of identical profile objects is breaking the relay 125 + // tossing this unspecced field onto it to reduce the size of the problem 126 + // -prf 127 + createdAt: new Date().toISOString(), 128 + } 129 + }) 130 + } 131 + 132 + // Not awaited so that we can still get into onboarding. 133 + // This is OK because we won't let you toggle adult stuff until you set the date. 134 + agent.setPersonalDetails({birthDate: birthDate.toISOString()}) 135 + if (IS_PROD_SERVICE(service)) { 136 + agent.setSavedFeeds(DEFAULT_PROD_FEEDS.saved, DEFAULT_PROD_FEEDS.pinned) 137 + } 138 + 139 + return prepareAgent(agent, gates, moderation, onSessionChange) 140 + } 141 + 142 + async function prepareAgent( 143 + agent: BskyAgent, 144 + // Not awaited in the calling code so we can delay blocking on them. 145 + gates: Promise<void>, 146 + moderation: Promise<void>, 147 + onSessionChange: ( 148 + agent: BskyAgent, 149 + did: string, 150 + event: AtpSessionEvent, 151 + ) => void, 152 + ) { 153 + // There's nothing else left to do, so block on them here. 154 + await Promise.all([gates, moderation]) 155 + 156 + // Now the agent is ready. 157 + const account = agentToSessionAccountOrThrow(agent) 158 + agent.setPersistSessionHandler(event => { 159 + onSessionChange(agent, account.did, event) 160 + }) 161 + return {agent, account} 162 + } 163 + 164 + export function agentToSessionAccountOrThrow(agent: BskyAgent): SessionAccount { 165 + const account = agentToSessionAccount(agent) 166 + if (!account) { 167 + throw Error('Expected an active session') 168 + } 169 + return account 170 + } 171 + 172 + export function agentToSessionAccount( 173 + agent: BskyAgent, 174 + ): SessionAccount | undefined { 175 + if (!agent.session) { 176 + return undefined 177 + } 178 + return { 179 + service: agent.service.toString(), 180 + did: agent.session.did, 181 + handle: agent.session.handle, 182 + email: agent.session.email, 183 + emailConfirmed: agent.session.emailConfirmed || false, 184 + emailAuthFactor: agent.session.emailAuthFactor || false, 185 + refreshJwt: agent.session.refreshJwt, 186 + accessJwt: agent.session.accessJwt, 187 + deactivated: isSessionDeactivated(agent.session.accessJwt), 188 + pdsUrl: agent.pdsUrl?.toString(), 189 + } 190 + }
+70 -296
src/state/session/index.tsx
··· 2 2 import {AtpSessionEvent, BskyAgent} from '@atproto/api' 3 3 4 4 import {track} from '#/lib/analytics/analytics' 5 - import {networkRetry} from '#/lib/async/retry' 6 - import {PUBLIC_BSKY_SERVICE} from '#/lib/constants' 7 - import {logEvent, tryFetchGates} from '#/lib/statsig/statsig' 5 + import {logEvent} from '#/lib/statsig/statsig' 8 6 import {isWeb} from '#/platform/detection' 9 7 import * as persisted from '#/state/persisted' 10 8 import {useCloseAllActiveElements} from '#/state/util' ··· 13 11 import {emitSessionDropped} from '../events' 14 12 import { 15 13 agentToSessionAccount, 16 - configureModerationForAccount, 17 - configureModerationForGuest, 18 14 createAgentAndCreateAccount, 19 15 createAgentAndLogin, 20 - isSessionDeactivated, 21 - isSessionExpired, 22 - } from './util' 16 + createAgentAndResume, 17 + } from './agent' 18 + import {getInitialState, reducer} from './reducer' 23 19 20 + export {isSessionDeactivated} from './util' 24 21 export type {SessionAccount} from '#/state/session/types' 25 - import { 26 - SessionAccount, 27 - SessionApiContext, 28 - SessionStateContext, 29 - } from '#/state/session/types' 30 - 31 - export {isSessionDeactivated} 22 + import {SessionApiContext, SessionStateContext} from '#/state/session/types' 32 23 33 24 const StateContext = React.createContext<SessionStateContext>({ 34 25 accounts: [], ··· 42 33 createAccount: async () => {}, 43 34 login: async () => {}, 44 35 logout: async () => {}, 45 - initSession: async () => {}, 36 + resumeSession: async () => {}, 46 37 removeAccount: () => {}, 47 38 updateCurrentAccount: () => {}, 48 39 }) 49 40 50 - type AgentState = { 51 - readonly agent: BskyAgent 52 - readonly did: string | undefined 53 - } 54 - 55 - type State = { 56 - accounts: SessionStateContext['accounts'] 57 - currentAgentState: AgentState 58 - needsPersist: boolean 59 - } 60 - 61 - type Action = 62 - | { 63 - type: 'received-agent-event' 64 - agent: BskyAgent 65 - accountDid: string 66 - refreshedAccount: SessionAccount | undefined 67 - sessionEvent: AtpSessionEvent 68 - } 69 - | { 70 - type: 'switched-to-account' 71 - newAgent: BskyAgent 72 - newAccount: SessionAccount 73 - } 74 - | { 75 - type: 'updated-current-account' 76 - updatedFields: Partial< 77 - Pick< 78 - SessionAccount, 79 - 'handle' | 'email' | 'emailConfirmed' | 'emailAuthFactor' 80 - > 81 - > 82 - } 83 - | { 84 - type: 'removed-account' 85 - accountDid: string 86 - } 87 - | { 88 - type: 'logged-out' 89 - } 90 - | { 91 - type: 'synced-accounts' 92 - syncedAccounts: SessionAccount[] 93 - syncedCurrentDid: string | undefined 94 - } 95 - 96 - function createPublicAgentState() { 97 - configureModerationForGuest() // Side effect but only relevant for tests 98 - return { 99 - agent: new BskyAgent({service: PUBLIC_BSKY_SERVICE}), 100 - did: undefined, 101 - } 102 - } 103 - 104 - function getInitialState(): State { 105 - return { 106 - accounts: persisted.get('session').accounts, 107 - currentAgentState: createPublicAgentState(), 108 - needsPersist: false, 109 - } 110 - } 111 - 112 - function reducer(state: State, action: Action): State { 113 - switch (action.type) { 114 - case 'received-agent-event': { 115 - const {agent, accountDid, refreshedAccount, sessionEvent} = action 116 - if (agent !== state.currentAgentState.agent) { 117 - // Only consider events from the active agent. 118 - return state 119 - } 120 - if (sessionEvent === 'network-error') { 121 - // Don't change stored accounts but kick to the choose account screen. 122 - return { 123 - accounts: state.accounts, 124 - currentAgentState: createPublicAgentState(), 125 - needsPersist: true, 126 - } 127 - } 128 - const existingAccount = state.accounts.find(a => a.did === accountDid) 129 - if ( 130 - !existingAccount || 131 - JSON.stringify(existingAccount) === JSON.stringify(refreshedAccount) 132 - ) { 133 - // Fast path without a state update. 134 - return state 135 - } 136 - return { 137 - accounts: state.accounts.map(a => { 138 - if (a.did === accountDid) { 139 - if (refreshedAccount) { 140 - return refreshedAccount 141 - } else { 142 - return { 143 - ...a, 144 - // If we didn't receive a refreshed account, clear out the tokens. 145 - accessJwt: undefined, 146 - refreshJwt: undefined, 147 - } 148 - } 149 - } else { 150 - return a 151 - } 152 - }), 153 - currentAgentState: refreshedAccount 154 - ? state.currentAgentState 155 - : createPublicAgentState(), // Log out if expired. 156 - needsPersist: true, 157 - } 158 - } 159 - case 'switched-to-account': { 160 - const {newAccount, newAgent} = action 161 - return { 162 - accounts: [ 163 - newAccount, 164 - ...state.accounts.filter(a => a.did !== newAccount.did), 165 - ], 166 - currentAgentState: { 167 - did: newAccount.did, 168 - agent: newAgent, 169 - }, 170 - needsPersist: true, 171 - } 172 - } 173 - case 'updated-current-account': { 174 - const {updatedFields} = action 175 - return { 176 - accounts: state.accounts.map(a => { 177 - if (a.did === state.currentAgentState.did) { 178 - return { 179 - ...a, 180 - ...updatedFields, 181 - } 182 - } else { 183 - return a 184 - } 185 - }), 186 - currentAgentState: state.currentAgentState, 187 - needsPersist: true, 188 - } 189 - } 190 - case 'removed-account': { 191 - const {accountDid} = action 192 - return { 193 - accounts: state.accounts.filter(a => a.did !== accountDid), 194 - currentAgentState: 195 - state.currentAgentState.did === accountDid 196 - ? createPublicAgentState() // Log out if removing the current one. 197 - : state.currentAgentState, 198 - needsPersist: true, 199 - } 200 - } 201 - case 'logged-out': { 202 - return { 203 - accounts: state.accounts.map(a => ({ 204 - ...a, 205 - // Clear tokens for *every* account (this is a hard logout). 206 - refreshJwt: undefined, 207 - accessJwt: undefined, 208 - })), 209 - currentAgentState: createPublicAgentState(), 210 - needsPersist: true, 211 - } 212 - } 213 - case 'synced-accounts': { 214 - const {syncedAccounts, syncedCurrentDid} = action 215 - return { 216 - accounts: syncedAccounts, 217 - currentAgentState: 218 - syncedCurrentDid === state.currentAgentState.did 219 - ? state.currentAgentState 220 - : createPublicAgentState(), // Log out if different user. 221 - needsPersist: false, // Synced from another tab. Don't persist to avoid cycles. 222 - } 223 - } 224 - } 225 - } 226 - 227 41 export function Provider({children}: React.PropsWithChildren<{}>) { 228 - const [state, dispatch] = React.useReducer(reducer, null, getInitialState) 42 + const cancelPendingTask = useOneTaskAtATime() 43 + const [state, dispatch] = React.useReducer(reducer, null, () => 44 + getInitialState(persisted.get('session').accounts), 45 + ) 229 46 230 47 const onAgentSessionChange = React.useCallback( 231 48 (agent: BskyAgent, accountDid: string, sessionEvent: AtpSessionEvent) => { ··· 245 62 ) 246 63 247 64 const createAccount = React.useCallback<SessionApiContext['createAccount']>( 248 - async ({ 249 - service, 250 - email, 251 - password, 252 - handle, 253 - birthDate, 254 - inviteCode, 255 - verificationPhone, 256 - verificationCode, 257 - }) => { 65 + async params => { 66 + const signal = cancelPendingTask() 258 67 track('Try Create Account') 259 68 logEvent('account:create:begin', {}) 260 - const {agent, account, fetchingGates} = await createAgentAndCreateAccount( 261 - { 262 - service, 263 - email, 264 - password, 265 - handle, 266 - birthDate, 267 - inviteCode, 268 - verificationPhone, 269 - verificationCode, 270 - }, 69 + const {agent, account} = await createAgentAndCreateAccount( 70 + params, 71 + onAgentSessionChange, 271 72 ) 272 - agent.setPersistSessionHandler(event => { 273 - onAgentSessionChange(agent, account.did, event) 274 - }) 275 - await fetchingGates 73 + 74 + if (signal.aborted) { 75 + return 76 + } 276 77 dispatch({ 277 78 type: 'switched-to-account', 278 79 newAgent: agent, ··· 281 82 track('Create Account') 282 83 logEvent('account:create:success', {}) 283 84 }, 284 - [onAgentSessionChange], 85 + [onAgentSessionChange, cancelPendingTask], 285 86 ) 286 87 287 88 const login = React.useCallback<SessionApiContext['login']>( 288 - async ({service, identifier, password, authFactorToken}, logContext) => { 289 - const {agent, account, fetchingGates} = await createAgentAndLogin({ 290 - service, 291 - identifier, 292 - password, 293 - authFactorToken, 294 - }) 295 - agent.setPersistSessionHandler(event => { 296 - onAgentSessionChange(agent, account.did, event) 297 - }) 298 - await fetchingGates 89 + async (params, logContext) => { 90 + const signal = cancelPendingTask() 91 + const {agent, account} = await createAgentAndLogin( 92 + params, 93 + onAgentSessionChange, 94 + ) 95 + 96 + if (signal.aborted) { 97 + return 98 + } 299 99 dispatch({ 300 100 type: 'switched-to-account', 301 101 newAgent: agent, ··· 304 104 track('Sign In', {resumedSession: false}) 305 105 logEvent('account:loggedIn', {logContext, withPassword: true}) 306 106 }, 307 - [onAgentSessionChange], 107 + [onAgentSessionChange, cancelPendingTask], 308 108 ) 309 109 310 110 const logout = React.useCallback<SessionApiContext['logout']>( 311 - async logContext => { 111 + logContext => { 112 + cancelPendingTask() 312 113 dispatch({ 313 114 type: 'logged-out', 314 115 }) 315 116 logEvent('account:loggedOut', {logContext}) 316 117 }, 317 - [], 118 + [cancelPendingTask], 318 119 ) 319 120 320 - const initSession = React.useCallback<SessionApiContext['initSession']>( 321 - async account => { 322 - const fetchingGates = tryFetchGates(account.did, 'prefer-low-latency') 323 - const agent = new BskyAgent({service: account.service}) 324 - // restore the correct PDS URL if available 325 - if (account.pdsUrl) { 326 - agent.pdsUrl = agent.api.xrpc.uri = new URL(account.pdsUrl) 327 - } 328 - agent.setPersistSessionHandler(event => { 329 - onAgentSessionChange(agent, account.did, event) 330 - }) 331 - await configureModerationForAccount(agent, account) 121 + const resumeSession = React.useCallback<SessionApiContext['resumeSession']>( 122 + async storedAccount => { 123 + const signal = cancelPendingTask() 124 + const {agent, account} = await createAgentAndResume( 125 + storedAccount, 126 + onAgentSessionChange, 127 + ) 332 128 333 - const prevSession = { 334 - accessJwt: account.accessJwt ?? '', 335 - refreshJwt: account.refreshJwt ?? '', 336 - did: account.did, 337 - handle: account.handle, 129 + if (signal.aborted) { 130 + return 338 131 } 339 - 340 - if (isSessionExpired(account)) { 341 - const freshAccount = await resumeSessionWithFreshAccount() 342 - await fetchingGates 343 - dispatch({ 344 - type: 'switched-to-account', 345 - newAgent: agent, 346 - newAccount: freshAccount, 347 - }) 348 - } else { 349 - agent.session = prevSession 350 - await fetchingGates 351 - dispatch({ 352 - type: 'switched-to-account', 353 - newAgent: agent, 354 - newAccount: account, 355 - }) 356 - if (isSessionDeactivated(account.accessJwt) || account.deactivated) { 357 - // don't attempt to resume 358 - // use will be taken to the deactivated screen 359 - return 360 - } 361 - // Intentionally not awaited to unblock the UI: 362 - resumeSessionWithFreshAccount() 363 - } 364 - 365 - async function resumeSessionWithFreshAccount(): Promise<SessionAccount> { 366 - await networkRetry(1, () => agent.resumeSession(prevSession)) 367 - const sessionAccount = agentToSessionAccount(agent) 368 - /* 369 - * If `agent.resumeSession` fails above, it'll throw. This is just to 370 - * make TypeScript happy. 371 - */ 372 - if (!sessionAccount) { 373 - throw new Error(`session: initSession failed to establish a session`) 374 - } 375 - return sessionAccount 376 - } 132 + dispatch({ 133 + type: 'switched-to-account', 134 + newAgent: agent, 135 + newAccount: account, 136 + }) 377 137 }, 378 - [onAgentSessionChange], 138 + [onAgentSessionChange, cancelPendingTask], 379 139 ) 380 140 381 141 const removeAccount = React.useCallback<SessionApiContext['removeAccount']>( 382 142 account => { 143 + cancelPendingTask() 383 144 dispatch({ 384 145 type: 'removed-account', 385 146 accountDid: account.did, 386 147 }) 387 148 }, 388 - [], 149 + [cancelPendingTask], 389 150 ) 390 151 391 152 const updateCurrentAccount = React.useCallback< ··· 422 183 ) 423 184 if (syncedAccount && syncedAccount.refreshJwt) { 424 185 if (syncedAccount.did !== state.currentAgentState.did) { 425 - initSession(syncedAccount) 186 + resumeSession(syncedAccount) 426 187 } else { 427 188 // @ts-ignore we checked for `refreshJwt` above 428 189 state.currentAgentState.agent.session = syncedAccount 429 190 } 430 191 } 431 192 }) 432 - }, [state, initSession]) 193 + }, [state, resumeSession]) 433 194 434 195 const stateContext = React.useMemo( 435 196 () => ({ ··· 447 208 createAccount, 448 209 login, 449 210 logout, 450 - initSession, 211 + resumeSession, 451 212 removeAccount, 452 213 updateCurrentAccount, 453 214 }), ··· 455 216 createAccount, 456 217 login, 457 218 logout, 458 - initSession, 219 + resumeSession, 459 220 removeAccount, 460 221 updateCurrentAccount, 461 222 ], ··· 464 225 // @ts-ignore 465 226 if (IS_DEV && isWeb) window.agent = state.currentAgentState.agent 466 227 228 + const agent = state.currentAgentState.agent as BskyAgent 467 229 return ( 468 - <AgentContext.Provider value={state.currentAgentState.agent}> 230 + <AgentContext.Provider value={agent}> 469 231 <StateContext.Provider value={stateContext}> 470 232 <ApiContext.Provider value={api}>{children}</ApiContext.Provider> 471 233 </StateContext.Provider> 472 234 </AgentContext.Provider> 473 235 ) 236 + } 237 + 238 + function useOneTaskAtATime() { 239 + const abortController = React.useRef<AbortController | null>(null) 240 + const cancelPendingTask = React.useCallback(() => { 241 + if (abortController.current) { 242 + abortController.current.abort() 243 + } 244 + abortController.current = new AbortController() 245 + return abortController.current.signal 246 + }, []) 247 + return cancelPendingTask 474 248 } 475 249 476 250 export function useSession() {
+50
src/state/session/moderation.ts
··· 1 + import {BSKY_LABELER_DID, BskyAgent} from '@atproto/api' 2 + 3 + import {IS_TEST_USER} from '#/lib/constants' 4 + import {readLabelers} from './agent-config' 5 + import {SessionAccount} from './types' 6 + 7 + export function configureModerationForGuest() { 8 + // This global mutation is *only* OK because this code is only relevant for testing. 9 + // Don't add any other global behavior here! 10 + switchToBskyAppLabeler() 11 + } 12 + 13 + export async function configureModerationForAccount( 14 + agent: BskyAgent, 15 + account: SessionAccount, 16 + ) { 17 + // This global mutation is *only* OK because this code is only relevant for testing. 18 + // Don't add any other global behavior here! 19 + switchToBskyAppLabeler() 20 + if (IS_TEST_USER(account.handle)) { 21 + await trySwitchToTestAppLabeler(agent) 22 + } 23 + 24 + // The code below is actually relevant to production (and isn't global). 25 + const labelerDids = await readLabelers(account.did).catch(_ => {}) 26 + if (labelerDids) { 27 + agent.configureLabelersHeader( 28 + labelerDids.filter(did => did !== BSKY_LABELER_DID), 29 + ) 30 + } else { 31 + // If there are no headers in the storage, we'll not send them on the initial requests. 32 + // If we wanted to fix this, we could block on the preferences query here. 33 + } 34 + } 35 + 36 + function switchToBskyAppLabeler() { 37 + BskyAgent.configure({appLabelers: [BSKY_LABELER_DID]}) 38 + } 39 + 40 + async function trySwitchToTestAppLabeler(agent: BskyAgent) { 41 + const did = ( 42 + await agent 43 + .resolveHandle({handle: 'mod-authority.test'}) 44 + .catch(_ => undefined) 45 + )?.data.did 46 + if (did) { 47 + console.warn('USING TEST ENV MODERATION') 48 + BskyAgent.configure({appLabelers: [did]}) 49 + } 50 + }
+188
src/state/session/reducer.ts
··· 1 + import {AtpSessionEvent} from '@atproto/api' 2 + 3 + import {createPublicAgent} from './agent' 4 + import {SessionAccount} from './types' 5 + 6 + // A hack so that the reducer can't read anything from the agent. 7 + // From the reducer's point of view, it should be a completely opaque object. 8 + type OpaqueBskyAgent = { 9 + readonly api: unknown 10 + readonly app: unknown 11 + readonly com: unknown 12 + } 13 + 14 + type AgentState = { 15 + readonly agent: OpaqueBskyAgent 16 + readonly did: string | undefined 17 + } 18 + 19 + export type State = { 20 + readonly accounts: SessionAccount[] 21 + readonly currentAgentState: AgentState 22 + needsPersist: boolean // Mutated in an effect. 23 + } 24 + 25 + export type Action = 26 + | { 27 + type: 'received-agent-event' 28 + agent: OpaqueBskyAgent 29 + accountDid: string 30 + refreshedAccount: SessionAccount | undefined 31 + sessionEvent: AtpSessionEvent 32 + } 33 + | { 34 + type: 'switched-to-account' 35 + newAgent: OpaqueBskyAgent 36 + newAccount: SessionAccount 37 + } 38 + | { 39 + type: 'updated-current-account' 40 + updatedFields: Partial< 41 + Pick< 42 + SessionAccount, 43 + 'handle' | 'email' | 'emailConfirmed' | 'emailAuthFactor' 44 + > 45 + > 46 + } 47 + | { 48 + type: 'removed-account' 49 + accountDid: string 50 + } 51 + | { 52 + type: 'logged-out' 53 + } 54 + | { 55 + type: 'synced-accounts' 56 + syncedAccounts: SessionAccount[] 57 + syncedCurrentDid: string | undefined 58 + } 59 + 60 + function createPublicAgentState(): AgentState { 61 + return { 62 + agent: createPublicAgent(), 63 + did: undefined, 64 + } 65 + } 66 + 67 + export function getInitialState(persistedAccounts: SessionAccount[]): State { 68 + return { 69 + accounts: persistedAccounts, 70 + currentAgentState: createPublicAgentState(), 71 + needsPersist: false, 72 + } 73 + } 74 + 75 + export function reducer(state: State, action: Action): State { 76 + switch (action.type) { 77 + case 'received-agent-event': { 78 + const {agent, accountDid, refreshedAccount, sessionEvent} = action 79 + if (agent !== state.currentAgentState.agent) { 80 + // Only consider events from the active agent. 81 + return state 82 + } 83 + if (sessionEvent === 'network-error') { 84 + // Don't change stored accounts but kick to the choose account screen. 85 + return { 86 + accounts: state.accounts, 87 + currentAgentState: createPublicAgentState(), 88 + needsPersist: true, 89 + } 90 + } 91 + const existingAccount = state.accounts.find(a => a.did === accountDid) 92 + if ( 93 + !existingAccount || 94 + JSON.stringify(existingAccount) === JSON.stringify(refreshedAccount) 95 + ) { 96 + // Fast path without a state update. 97 + return state 98 + } 99 + return { 100 + accounts: state.accounts.map(a => { 101 + if (a.did === accountDid) { 102 + if (refreshedAccount) { 103 + return refreshedAccount 104 + } else { 105 + return { 106 + ...a, 107 + // If we didn't receive a refreshed account, clear out the tokens. 108 + accessJwt: undefined, 109 + refreshJwt: undefined, 110 + } 111 + } 112 + } else { 113 + return a 114 + } 115 + }), 116 + currentAgentState: refreshedAccount 117 + ? state.currentAgentState 118 + : createPublicAgentState(), // Log out if expired. 119 + needsPersist: true, 120 + } 121 + } 122 + case 'switched-to-account': { 123 + const {newAccount, newAgent} = action 124 + return { 125 + accounts: [ 126 + newAccount, 127 + ...state.accounts.filter(a => a.did !== newAccount.did), 128 + ], 129 + currentAgentState: { 130 + did: newAccount.did, 131 + agent: newAgent, 132 + }, 133 + needsPersist: true, 134 + } 135 + } 136 + case 'updated-current-account': { 137 + const {updatedFields} = action 138 + return { 139 + accounts: state.accounts.map(a => { 140 + if (a.did === state.currentAgentState.did) { 141 + return { 142 + ...a, 143 + ...updatedFields, 144 + } 145 + } else { 146 + return a 147 + } 148 + }), 149 + currentAgentState: state.currentAgentState, 150 + needsPersist: true, 151 + } 152 + } 153 + case 'removed-account': { 154 + const {accountDid} = action 155 + return { 156 + accounts: state.accounts.filter(a => a.did !== accountDid), 157 + currentAgentState: 158 + state.currentAgentState.did === accountDid 159 + ? createPublicAgentState() // Log out if removing the current one. 160 + : state.currentAgentState, 161 + needsPersist: true, 162 + } 163 + } 164 + case 'logged-out': { 165 + return { 166 + accounts: state.accounts.map(a => ({ 167 + ...a, 168 + // Clear tokens for *every* account (this is a hard logout). 169 + refreshJwt: undefined, 170 + accessJwt: undefined, 171 + })), 172 + currentAgentState: createPublicAgentState(), 173 + needsPersist: true, 174 + } 175 + } 176 + case 'synced-accounts': { 177 + const {syncedAccounts, syncedCurrentDid} = action 178 + return { 179 + accounts: syncedAccounts, 180 + currentAgentState: 181 + syncedCurrentDid === state.currentAgentState.did 182 + ? state.currentAgentState 183 + : createPublicAgentState(), // Log out if different user. 184 + needsPersist: false, // Synced from another tab. Don't persist to avoid cycles. 185 + } 186 + } 187 + } 188 + }
+3 -4
src/state/session/types.ts
··· 8 8 currentAccount: SessionAccount | undefined 9 9 hasSession: boolean 10 10 } 11 + 11 12 export type SessionApiContext = { 12 13 createAccount: (props: { 13 14 service: string ··· 33 34 * access tokens from all accounts, so that returning as any user will 34 35 * require a full login. 35 36 */ 36 - logout: ( 37 - logContext: LogEvents['account:loggedOut']['logContext'], 38 - ) => Promise<void> 39 - initSession: (account: SessionAccount) => Promise<void> 37 + logout: (logContext: LogEvents['account:loggedOut']['logContext']) => void 38 + resumeSession: (account: SessionAccount) => Promise<void> 40 39 removeAccount: (account: SessionAccount) => void 41 40 updateCurrentAccount: ( 42 41 account: Partial<
+36
src/state/session/util.ts
··· 1 + import {jwtDecode} from 'jwt-decode' 2 + 3 + import {hasProp} from '#/lib/type-guards' 4 + import {logger} from '#/logger' 5 + import * as persisted from '#/state/persisted' 6 + import {SessionAccount} from './types' 7 + 8 + export function readLastActiveAccount() { 9 + const {currentAccount, accounts} = persisted.get('session') 10 + return accounts.find(a => a.did === currentAccount?.did) 11 + } 12 + 13 + export function isSessionDeactivated(accessJwt: string | undefined) { 14 + if (accessJwt) { 15 + const sessData = jwtDecode(accessJwt) 16 + return ( 17 + hasProp(sessData, 'scope') && sessData.scope === 'com.atproto.deactivated' 18 + ) 19 + } 20 + return false 21 + } 22 + 23 + export function isSessionExpired(account: SessionAccount) { 24 + try { 25 + if (account.accessJwt) { 26 + const decoded = jwtDecode(account.accessJwt) 27 + if (decoded.exp) { 28 + const didExpire = Date.now() >= decoded.exp * 1000 29 + return didExpire 30 + } 31 + } 32 + } catch (e) { 33 + logger.error(`session: could not decode jwt`) 34 + } 35 + return true 36 + }
-186
src/state/session/util/index.ts
··· 1 - import {BSKY_LABELER_DID, BskyAgent} from '@atproto/api' 2 - import {jwtDecode} from 'jwt-decode' 3 - 4 - import {IS_PROD_SERVICE, IS_TEST_USER} from '#/lib/constants' 5 - import {tryFetchGates} from '#/lib/statsig/statsig' 6 - import {hasProp} from '#/lib/type-guards' 7 - import {logger} from '#/logger' 8 - import * as persisted from '#/state/persisted' 9 - import {DEFAULT_PROD_FEEDS} from '#/state/queries/preferences' 10 - import {readLabelers} from '../agent-config' 11 - import {SessionAccount, SessionApiContext} from '../types' 12 - 13 - export function isSessionDeactivated(accessJwt: string | undefined) { 14 - if (accessJwt) { 15 - const sessData = jwtDecode(accessJwt) 16 - return ( 17 - hasProp(sessData, 'scope') && sessData.scope === 'com.atproto.deactivated' 18 - ) 19 - } 20 - return false 21 - } 22 - 23 - export function readLastActiveAccount() { 24 - const {currentAccount, accounts} = persisted.get('session') 25 - return accounts.find(a => a.did === currentAccount?.did) 26 - } 27 - 28 - export function agentToSessionAccount( 29 - agent: BskyAgent, 30 - ): SessionAccount | undefined { 31 - if (!agent.session) return undefined 32 - 33 - return { 34 - service: agent.service.toString(), 35 - did: agent.session.did, 36 - handle: agent.session.handle, 37 - email: agent.session.email, 38 - emailConfirmed: agent.session.emailConfirmed || false, 39 - emailAuthFactor: agent.session.emailAuthFactor || false, 40 - refreshJwt: agent.session.refreshJwt, 41 - accessJwt: agent.session.accessJwt, 42 - deactivated: isSessionDeactivated(agent.session.accessJwt), 43 - pdsUrl: agent.pdsUrl?.toString(), 44 - } 45 - } 46 - 47 - export function configureModerationForGuest() { 48 - switchToBskyAppLabeler() 49 - } 50 - 51 - export async function configureModerationForAccount( 52 - agent: BskyAgent, 53 - account: SessionAccount, 54 - ) { 55 - switchToBskyAppLabeler() 56 - if (IS_TEST_USER(account.handle)) { 57 - await trySwitchToTestAppLabeler(agent) 58 - } 59 - 60 - const labelerDids = await readLabelers(account.did).catch(_ => {}) 61 - if (labelerDids) { 62 - agent.configureLabelersHeader( 63 - labelerDids.filter(did => did !== BSKY_LABELER_DID), 64 - ) 65 - } else { 66 - // If there are no headers in the storage, we'll not send them on the initial requests. 67 - // If we wanted to fix this, we could block on the preferences query here. 68 - } 69 - } 70 - 71 - function switchToBskyAppLabeler() { 72 - BskyAgent.configure({appLabelers: [BSKY_LABELER_DID]}) 73 - } 74 - 75 - async function trySwitchToTestAppLabeler(agent: BskyAgent) { 76 - const did = ( 77 - await agent 78 - .resolveHandle({handle: 'mod-authority.test'}) 79 - .catch(_ => undefined) 80 - )?.data.did 81 - if (did) { 82 - console.warn('USING TEST ENV MODERATION') 83 - BskyAgent.configure({appLabelers: [did]}) 84 - } 85 - } 86 - 87 - export function isSessionExpired(account: SessionAccount) { 88 - try { 89 - if (account.accessJwt) { 90 - const decoded = jwtDecode(account.accessJwt) 91 - if (decoded.exp) { 92 - const didExpire = Date.now() >= decoded.exp * 1000 93 - return didExpire 94 - } 95 - } 96 - } catch (e) { 97 - logger.error(`session: could not decode jwt`) 98 - } 99 - return true 100 - } 101 - 102 - export async function createAgentAndLogin({ 103 - service, 104 - identifier, 105 - password, 106 - authFactorToken, 107 - }: { 108 - service: string 109 - identifier: string 110 - password: string 111 - authFactorToken?: string 112 - }) { 113 - const agent = new BskyAgent({service}) 114 - await agent.login({identifier, password, authFactorToken}) 115 - 116 - const account = agentToSessionAccount(agent) 117 - if (!agent.session || !account) { 118 - throw new Error(`session: login failed to establish a session`) 119 - } 120 - 121 - const fetchingGates = tryFetchGates(account.did, 'prefer-fresh-gates') 122 - await configureModerationForAccount(agent, account) 123 - 124 - return { 125 - agent, 126 - account, 127 - fetchingGates, 128 - } 129 - } 130 - 131 - export async function createAgentAndCreateAccount({ 132 - service, 133 - email, 134 - password, 135 - handle, 136 - birthDate, 137 - inviteCode, 138 - verificationPhone, 139 - verificationCode, 140 - }: Parameters<SessionApiContext['createAccount']>[0]) { 141 - const agent = new BskyAgent({service}) 142 - await agent.createAccount({ 143 - email, 144 - password, 145 - handle, 146 - inviteCode, 147 - verificationPhone, 148 - verificationCode, 149 - }) 150 - 151 - const account = agentToSessionAccount(agent)! 152 - if (!agent.session || !account) { 153 - throw new Error(`session: createAccount failed to establish a session`) 154 - } 155 - 156 - const fetchingGates = tryFetchGates(account.did, 'prefer-fresh-gates') 157 - 158 - if (!account.deactivated) { 159 - /*dont await*/ agent.upsertProfile(_existing => { 160 - return { 161 - displayName: '', 162 - 163 - // HACKFIX 164 - // creating a bunch of identical profile objects is breaking the relay 165 - // tossing this unspecced field onto it to reduce the size of the problem 166 - // -prf 167 - createdAt: new Date().toISOString(), 168 - } 169 - }) 170 - } 171 - 172 - // Not awaited so that we can still get into onboarding. 173 - // This is OK because we won't let you toggle adult stuff until you set the date. 174 - agent.setPersonalDetails({birthDate: birthDate.toISOString()}) 175 - if (IS_PROD_SERVICE(service)) { 176 - agent.setSavedFeeds(DEFAULT_PROD_FEEDS.saved, DEFAULT_PROD_FEEDS.pinned) 177 - } 178 - 179 - await configureModerationForAccount(agent, account) 180 - 181 - return { 182 - agent, 183 - account, 184 - fetchingGates, 185 - } 186 - }