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

Configure Feed

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

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