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