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