Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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