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

Configure Feed

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

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