forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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}