forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {type AtpAgent, type AtpSessionEvent} from '@atproto/api'
2
3import {unregisterPushToken} from '#/lib/notifications/notifications'
4import {logger} from '#/lib/notifications/util'
5import {createPublicAgent} from './agent'
6import {wrapSessionReducerForLogging} from './logging'
7import {type SessionAccount} from './types'
8import {createTemporaryAgentsAndResume} from './util'
9
10// A hack so that the reducer can't read anything from the agent.
11// From the reducer's point of view, it should be a completely opaque object.
12type OpaqueBskyAgent = {
13 readonly service?: URL | undefined
14 readonly api: unknown
15 readonly app: unknown
16 readonly com: unknown
17}
18
19type AgentState = {
20 readonly agent: OpaqueBskyAgent
21 readonly did: string | undefined
22}
23
24export type State = {
25 readonly accounts: SessionAccount[]
26 readonly currentAgentState: AgentState
27 needsPersist: boolean // Mutated in an effect.
28}
29
30export type Action =
31 | {
32 type: 'received-agent-event'
33 agent: OpaqueBskyAgent
34 accountDid: string
35 refreshedAccount: SessionAccount | undefined
36 sessionEvent: AtpSessionEvent
37 }
38 | {
39 type: 'switched-to-account'
40 newAgent: OpaqueBskyAgent
41 newAccount: SessionAccount
42 }
43 | {
44 type: 'removed-account'
45 accountDid: string
46 }
47 | {
48 type: 'reordered-accounts'
49 accounts: SessionAccount[]
50 }
51 | {
52 type: 'logged-out-current-account'
53 }
54 | {
55 type: 'logged-out-every-account'
56 }
57 | {
58 type: 'synced-accounts'
59 syncedAccounts: SessionAccount[]
60 syncedCurrentDid: string | undefined
61 }
62 | {
63 type: 'partial-refresh-session'
64 accountDid: string
65 patch: Pick<SessionAccount, 'emailConfirmed' | 'emailAuthFactor'>
66 }
67
68function createPublicAgentState(): AgentState {
69 return {
70 agent: createPublicAgent(),
71 did: undefined,
72 }
73}
74
75export function getInitialState(persistedAccounts: SessionAccount[]): State {
76 return {
77 accounts: persistedAccounts,
78 currentAgentState: createPublicAgentState(),
79 needsPersist: false,
80 }
81}
82
83let reducer = (state: State, action: Action): State => {
84 switch (action.type) {
85 case 'received-agent-event': {
86 const {agent, accountDid, refreshedAccount, sessionEvent} = action
87 if (
88 refreshedAccount === undefined &&
89 agent !== state.currentAgentState.agent
90 ) {
91 // If the session got cleared out (e.g. due to expiry or network error) but
92 // this account isn't the active one, don't clear it out at this time.
93 // This way, if the problem is transient, it'll work on next resume.
94 return state
95 }
96 if (sessionEvent === 'network-error') {
97 // Assume it's transient.
98 return state
99 }
100 const existingAccount = state.accounts.find(a => a.did === accountDid)
101 if (
102 !existingAccount ||
103 JSON.stringify(existingAccount) === JSON.stringify(refreshedAccount)
104 ) {
105 // Fast path without a state update.
106 return state
107 }
108 return {
109 accounts: state.accounts.map(a => {
110 if (a.did === accountDid) {
111 if (refreshedAccount) {
112 return {
113 ...refreshedAccount,
114 addedAt: a.addedAt,
115 lastActiveAt: a.lastActiveAt,
116 }
117 } else {
118 return {
119 ...a,
120 // If we didn't receive a refreshed account, clear out the tokens.
121 accessJwt: undefined,
122 refreshJwt: undefined,
123 }
124 }
125 } else {
126 return a
127 }
128 }),
129 currentAgentState: refreshedAccount
130 ? state.currentAgentState
131 : createPublicAgentState(), // Log out if expired.
132 needsPersist: true,
133 }
134 }
135 case 'switched-to-account': {
136 const {newAccount, newAgent} = action
137 const existingAccount = state.accounts.find(a => a.did === newAccount.did)
138 const now = new Date().toISOString()
139 const mergedAccount = {
140 ...existingAccount,
141 ...newAccount,
142 addedAt: existingAccount?.addedAt ?? now,
143 lastActiveAt: now,
144 }
145 return {
146 accounts: existingAccount
147 ? state.accounts.map(a =>
148 a.did === mergedAccount.did ? mergedAccount : a,
149 )
150 : [mergedAccount, ...state.accounts],
151 currentAgentState: {
152 did: mergedAccount.did,
153 agent: newAgent,
154 },
155 needsPersist: true,
156 }
157 }
158 case 'removed-account': {
159 const {accountDid} = action
160
161 // side effect
162 const account = state.accounts.find(a => a.did === accountDid)
163 if (account) {
164 createTemporaryAgentsAndResume([account])
165 .then(agents => unregisterPushToken(agents))
166 .then(() =>
167 logger.debug('Push token unregistered', {did: accountDid}),
168 )
169 .catch(err => {
170 logger.error('Failed to unregister push token', {
171 did: accountDid,
172 error: err,
173 })
174 })
175 }
176
177 return {
178 accounts: state.accounts.filter(a => a.did !== accountDid),
179 currentAgentState:
180 state.currentAgentState.did === accountDid
181 ? createPublicAgentState() // Log out if removing the current one.
182 : state.currentAgentState,
183 needsPersist: true,
184 }
185 }
186 case 'reordered-accounts': {
187 return {
188 ...state,
189 accounts: action.accounts,
190 needsPersist: true,
191 }
192 }
193 case 'logged-out-current-account': {
194 const {currentAgentState} = state
195 const accountDid = currentAgentState.did
196 // side effect
197 const account = state.accounts.find(a => a.did === accountDid)
198 if (account && accountDid) {
199 createTemporaryAgentsAndResume([account])
200 .then(agents => unregisterPushToken(agents))
201 .then(() =>
202 logger.debug('Push token unregistered', {did: accountDid}),
203 )
204 .catch(err => {
205 logger.error('Failed to unregister push token', {
206 did: accountDid,
207 error: err,
208 })
209 })
210 }
211
212 return {
213 accounts: state.accounts.map(a =>
214 a.did === accountDid
215 ? {
216 ...a,
217 refreshJwt: undefined,
218 accessJwt: undefined,
219 }
220 : a,
221 ),
222 currentAgentState: createPublicAgentState(),
223 needsPersist: true,
224 }
225 }
226 case 'logged-out-every-account': {
227 createTemporaryAgentsAndResume(state.accounts)
228 .then(agents => unregisterPushToken(agents))
229 .then(() => logger.debug('Push token unregistered'))
230 .catch(err => {
231 logger.error('Failed to unregister push token', {
232 error: err,
233 })
234 })
235
236 return {
237 accounts: state.accounts.map(a => ({
238 ...a,
239 // Clear tokens for *every* account (this is a hard logout).
240 refreshJwt: undefined,
241 accessJwt: undefined,
242 })),
243 currentAgentState: createPublicAgentState(),
244 needsPersist: true,
245 }
246 }
247 case 'synced-accounts': {
248 const {syncedAccounts, syncedCurrentDid} = action
249 return {
250 accounts: syncedAccounts,
251 currentAgentState:
252 syncedCurrentDid === state.currentAgentState.did
253 ? state.currentAgentState
254 : createPublicAgentState(), // Log out if different user.
255 needsPersist: false, // Synced from another tab. Don't persist to avoid cycles.
256 }
257 }
258 case 'partial-refresh-session': {
259 const {accountDid, patch} = action
260 const agent = state.currentAgentState.agent as AtpAgent
261
262 /*
263 * Only mutating values that are safe. Be very careful with this.
264 */
265 if (agent.session) {
266 agent.session.emailConfirmed =
267 patch.emailConfirmed ?? agent.session.emailConfirmed
268 agent.session.emailAuthFactor =
269 patch.emailAuthFactor ?? agent.session.emailAuthFactor
270 }
271
272 return {
273 ...state,
274 currentAgentState: {
275 ...state.currentAgentState,
276 agent,
277 },
278 accounts: state.accounts.map(a => {
279 if (a.did === accountDid) {
280 return {
281 ...a,
282 emailConfirmed: patch.emailConfirmed ?? a.emailConfirmed,
283 emailAuthFactor: patch.emailAuthFactor ?? a.emailAuthFactor,
284 }
285 }
286 return a
287 }),
288 needsPersist: true,
289 }
290 }
291 }
292}
293reducer = wrapSessionReducerForLogging(reducer)
294export {reducer}