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