Bluesky app fork with some witchin' additions 馃挮
at main 451 lines 14 kB view raw
1import { 2 createContext, 3 useCallback, 4 useContext, 5 useEffect, 6 useMemo, 7 useRef, 8 useState, 9 useSyncExternalStore, 10} from 'react' 11import {type AtpSessionEvent, type BskyAgent} from '@atproto/api' 12 13import * as persisted from '#/state/persisted' 14import {useCloseAllActiveElements} from '#/state/util' 15import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' 16import {AnalyticsContext, useAnalyticsBase, utils} from '#/analytics' 17import {IS_WEB} from '#/env' 18import {emitSessionDropped} from '../events' 19import { 20 agentToSessionAccount, 21 type BskyAppAgent, 22 createAgentAndCreateAccount, 23 createAgentAndLogin, 24 createAgentAndResume, 25 sessionAccountToSession, 26} from './agent' 27import {type Action, getInitialState, reducer, type State} from './reducer' 28export {isSignupQueued} from './util' 29import {addSessionDebugLog} from './logging' 30export type {SessionAccount} from '#/state/session/types' 31 32import {clearPersistedQueryStorage} from '#/lib/persisted-query-storage' 33import { 34 type SessionApiContext, 35 type SessionStateContext, 36} from '#/state/session/types' 37import {useOnboardingDispatch} from '#/state/shell/onboarding' 38import { 39 clearAgeAssuranceData, 40 clearAgeAssuranceDataForDid, 41} from '#/ageAssurance/data' 42 43const StateContext = createContext<SessionStateContext>({ 44 accounts: [], 45 currentAccount: undefined, 46 hasSession: false, 47}) 48StateContext.displayName = 'SessionStateContext' 49 50const AgentContext = createContext<BskyAgent | null>(null) 51AgentContext.displayName = 'SessionAgentContext' 52 53const ApiContext = createContext<SessionApiContext>({ 54 createAccount: async () => {}, 55 login: async () => {}, 56 logoutCurrentAccount: () => {}, 57 logoutEveryAccount: () => {}, 58 resumeSession: async () => {}, 59 removeAccount: () => {}, 60 partialRefreshSession: async () => {}, 61}) 62ApiContext.displayName = 'SessionApiContext' 63 64class SessionStore { 65 private state: State 66 private listeners = new Set<() => void>() 67 68 constructor() { 69 // Careful: By the time this runs, `persisted` needs to already be filled. 70 const initialState = getInitialState(persisted.get('session').accounts) 71 addSessionDebugLog({type: 'reducer:init', state: initialState}) 72 this.state = initialState 73 } 74 75 getState = (): State => { 76 return this.state 77 } 78 79 subscribe = (listener: () => void) => { 80 this.listeners.add(listener) 81 return () => { 82 this.listeners.delete(listener) 83 } 84 } 85 86 dispatch = (action: Action) => { 87 const nextState = reducer(this.state, action) 88 this.state = nextState 89 // Persist synchronously without waiting for the React render cycle. 90 if (nextState.needsPersist) { 91 nextState.needsPersist = false 92 const persistedData = { 93 accounts: nextState.accounts, 94 currentAccount: nextState.accounts.find( 95 a => a.did === nextState.currentAgentState.did, 96 ), 97 } 98 addSessionDebugLog({type: 'persisted:broadcast', data: persistedData}) 99 persisted.write('session', persistedData) 100 } 101 this.listeners.forEach(listener => listener()) 102 } 103} 104 105export function Provider({children}: React.PropsWithChildren<{}>) { 106 const ax = useAnalyticsBase() 107 const cancelPendingTask = useOneTaskAtATime() 108 const [store] = useState(() => new SessionStore()) 109 const state = useSyncExternalStore(store.subscribe, store.getState) 110 const onboardingDispatch = useOnboardingDispatch() 111 112 const onAgentSessionChange = useCallback( 113 (agent: BskyAgent, accountDid: string, sessionEvent: AtpSessionEvent) => { 114 const refreshedAccount = agentToSessionAccount(agent) // Mutable, so snapshot it right away. 115 if (sessionEvent === 'expired' || sessionEvent === 'create-failed') { 116 emitSessionDropped() 117 } 118 store.dispatch({ 119 type: 'received-agent-event', 120 agent, 121 refreshedAccount, 122 accountDid, 123 sessionEvent, 124 }) 125 }, 126 [store], 127 ) 128 129 const createAccount = useCallback<SessionApiContext['createAccount']>( 130 async (params, metrics) => { 131 addSessionDebugLog({type: 'method:start', method: 'createAccount'}) 132 const signal = cancelPendingTask() 133 ax.metric('account:create:begin', {}) 134 const {agent, account} = await createAgentAndCreateAccount( 135 params, 136 onAgentSessionChange, 137 ) 138 139 if (signal.aborted) { 140 return 141 } 142 store.dispatch({ 143 type: 'switched-to-account', 144 newAgent: agent, 145 newAccount: account, 146 }) 147 ax.metric('account:create:success', metrics, { 148 session: utils.accountToSessionMetadata(account), 149 }) 150 addSessionDebugLog({type: 'method:end', method: 'createAccount', account}) 151 }, 152 [ax, store, onAgentSessionChange, cancelPendingTask], 153 ) 154 155 const login = useCallback<SessionApiContext['login']>( 156 async (params, logContext) => { 157 addSessionDebugLog({type: 'method:start', method: 'login'}) 158 const signal = cancelPendingTask() 159 const {agent, account} = await createAgentAndLogin( 160 params, 161 onAgentSessionChange, 162 ) 163 164 if (signal.aborted) { 165 return 166 } 167 store.dispatch({ 168 type: 'switched-to-account', 169 newAgent: agent, 170 newAccount: account, 171 }) 172 ax.metric( 173 'account:loggedIn', 174 {logContext, withPassword: true}, 175 {session: utils.accountToSessionMetadata(account)}, 176 ) 177 addSessionDebugLog({type: 'method:end', method: 'login', account}) 178 }, 179 [ax, store, onAgentSessionChange, cancelPendingTask], 180 ) 181 182 const logoutCurrentAccount = useCallback< 183 SessionApiContext['logoutCurrentAccount'] 184 >( 185 logContext => { 186 addSessionDebugLog({type: 'method:start', method: 'logout'}) 187 cancelPendingTask() 188 const prevState = store.getState() 189 store.dispatch({ 190 type: 'logged-out-current-account', 191 }) 192 ax.metric( 193 'account:loggedOut', 194 {logContext, scope: 'current'}, 195 { 196 session: utils.accountToSessionMetadata( 197 prevState.accounts.find( 198 a => a.did === prevState.currentAgentState.did, 199 ), 200 ), 201 }, 202 ) 203 addSessionDebugLog({type: 'method:end', method: 'logout'}) 204 if (prevState.currentAgentState.did) { 205 clearAgeAssuranceDataForDid({did: prevState.currentAgentState.did}) 206 void clearPersistedQueryStorage(prevState.currentAgentState.did) 207 } 208 // reset onboarding flow on logout 209 onboardingDispatch({type: 'skip'}) 210 }, 211 [ax, store, cancelPendingTask, onboardingDispatch], 212 ) 213 214 const logoutEveryAccount = useCallback< 215 SessionApiContext['logoutEveryAccount'] 216 >( 217 logContext => { 218 addSessionDebugLog({type: 'method:start', method: 'logout'}) 219 cancelPendingTask() 220 const prevState = store.getState() 221 store.dispatch({ 222 type: 'logged-out-every-account', 223 }) 224 ax.metric( 225 'account:loggedOut', 226 {logContext, scope: 'every'}, 227 { 228 session: utils.accountToSessionMetadata( 229 prevState.accounts.find( 230 a => a.did === prevState.currentAgentState.did, 231 ), 232 ), 233 }, 234 ) 235 addSessionDebugLog({type: 'method:end', method: 'logout'}) 236 clearAgeAssuranceData() 237 for (const account of prevState.accounts) { 238 void clearPersistedQueryStorage(account.did) 239 } 240 // reset onboarding flow on logout 241 onboardingDispatch({type: 'skip'}) 242 }, 243 [store, cancelPendingTask, onboardingDispatch, ax], 244 ) 245 246 const resumeSession = useCallback<SessionApiContext['resumeSession']>( 247 async (storedAccount, isSwitchingAccounts = false) => { 248 addSessionDebugLog({ 249 type: 'method:start', 250 method: 'resumeSession', 251 account: storedAccount, 252 }) 253 const signal = cancelPendingTask() 254 const {agent, account} = await createAgentAndResume( 255 storedAccount, 256 onAgentSessionChange, 257 ) 258 259 if (signal.aborted) { 260 return 261 } 262 store.dispatch({ 263 type: 'switched-to-account', 264 newAgent: agent, 265 newAccount: account, 266 }) 267 addSessionDebugLog({type: 'method:end', method: 'resumeSession', account}) 268 if (isSwitchingAccounts) { 269 // reset onboarding flow on switch account 270 onboardingDispatch({type: 'skip'}) 271 } 272 }, 273 [store, onAgentSessionChange, cancelPendingTask, onboardingDispatch], 274 ) 275 276 const partialRefreshSession = useCallback< 277 SessionApiContext['partialRefreshSession'] 278 >(async () => { 279 const agent = state.currentAgentState.agent as BskyAppAgent 280 const signal = cancelPendingTask() 281 const {data} = await agent.com.atproto.server.getSession() 282 if (signal.aborted) return 283 store.dispatch({ 284 type: 'partial-refresh-session', 285 accountDid: agent.session!.did, 286 patch: { 287 emailConfirmed: data.emailConfirmed, 288 emailAuthFactor: data.emailAuthFactor, 289 }, 290 }) 291 }, [store, state, cancelPendingTask]) 292 293 const removeAccount = useCallback<SessionApiContext['removeAccount']>( 294 account => { 295 addSessionDebugLog({ 296 type: 'method:start', 297 method: 'removeAccount', 298 account, 299 }) 300 cancelPendingTask() 301 store.dispatch({ 302 type: 'removed-account', 303 accountDid: account.did, 304 }) 305 addSessionDebugLog({type: 'method:end', method: 'removeAccount', account}) 306 clearAgeAssuranceDataForDid({did: account.did}) 307 }, 308 [store, cancelPendingTask], 309 ) 310 useEffect(() => { 311 return persisted.onUpdate('session', nextSession => { 312 const synced = nextSession 313 addSessionDebugLog({type: 'persisted:receive', data: synced}) 314 store.dispatch({ 315 type: 'synced-accounts', 316 syncedAccounts: synced.accounts, 317 syncedCurrentDid: synced.currentAccount?.did, 318 }) 319 const syncedAccount = synced.accounts.find( 320 a => a.did === synced.currentAccount?.did, 321 ) 322 if (syncedAccount && syncedAccount.refreshJwt) { 323 if (syncedAccount.did !== state.currentAgentState.did) { 324 resumeSession(syncedAccount) 325 } else { 326 const agent = state.currentAgentState.agent as BskyAgent 327 const prevSession = agent.session 328 agent.sessionManager.session = sessionAccountToSession(syncedAccount) 329 addSessionDebugLog({ 330 type: 'agent:patch', 331 agent, 332 prevSession, 333 nextSession: agent.session, 334 }) 335 } 336 } 337 }) 338 }, [store, state, resumeSession]) 339 340 const stateContext = useMemo( 341 () => ({ 342 accounts: state.accounts, 343 currentAccount: state.accounts.find( 344 a => a.did === state.currentAgentState.did, 345 ), 346 hasSession: !!state.currentAgentState.did, 347 }), 348 [state], 349 ) 350 351 const api = useMemo( 352 () => ({ 353 createAccount, 354 login, 355 logoutCurrentAccount, 356 logoutEveryAccount, 357 resumeSession, 358 removeAccount, 359 partialRefreshSession, 360 }), 361 [ 362 createAccount, 363 login, 364 logoutCurrentAccount, 365 logoutEveryAccount, 366 resumeSession, 367 removeAccount, 368 partialRefreshSession, 369 ], 370 ) 371 372 // @ts-expect-error window type is not declared, debug only 373 if (__DEV__ && IS_WEB) window.agent = state.currentAgentState.agent 374 375 const agent = state.currentAgentState.agent as BskyAppAgent 376 const currentAgentRef = useRef(agent) 377 useEffect(() => { 378 if (currentAgentRef.current !== agent) { 379 // Read the previous value and immediately advance the pointer. 380 const prevAgent = currentAgentRef.current 381 currentAgentRef.current = agent 382 addSessionDebugLog({type: 'agent:switch', prevAgent, nextAgent: agent}) 383 // We never reuse agents so let's fully neutralize the previous one. 384 // This ensures it won't try to consume any refresh tokens. 385 prevAgent.dispose() 386 } 387 }, [agent]) 388 389 return ( 390 <AgentContext.Provider value={agent}> 391 <StateContext.Provider value={stateContext}> 392 <ApiContext.Provider value={api}> 393 <AnalyticsContext 394 metadata={utils.useMeta({ 395 session: utils.accountToSessionMetadata( 396 stateContext.currentAccount, 397 ), 398 })}> 399 {children} 400 </AnalyticsContext> 401 </ApiContext.Provider> 402 </StateContext.Provider> 403 </AgentContext.Provider> 404 ) 405} 406 407function useOneTaskAtATime() { 408 const abortController = useRef<AbortController | null>(null) 409 const cancelPendingTask = useCallback(() => { 410 if (abortController.current) { 411 abortController.current.abort() 412 } 413 abortController.current = new AbortController() 414 return abortController.current.signal 415 }, []) 416 return cancelPendingTask 417} 418 419export function useSession() { 420 return useContext(StateContext) 421} 422 423export function useSessionApi() { 424 return useContext(ApiContext) 425} 426 427export function useRequireAuth() { 428 const {hasSession} = useSession() 429 const closeAll = useCloseAllActiveElements() 430 const {signinDialogControl} = useGlobalDialogsControlContext() 431 432 return useCallback( 433 (fn: () => unknown) => { 434 if (hasSession) { 435 fn() 436 } else { 437 closeAll() 438 signinDialogControl.open() 439 } 440 }, 441 [hasSession, signinDialogControl, closeAll], 442 ) 443} 444 445export function useAgent(): BskyAgent { 446 const agent = useContext(AgentContext) 447 if (!agent) { 448 throw Error('useAgent() must be below <SessionProvider>.') 449 } 450 return agent 451}