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