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