Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
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
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: 'logged-out-current-account'
49 }
50 | {
51 type: 'logged-out-every-account'
52 }
53 | {
54 type: 'synced-accounts'
55 syncedAccounts: SessionAccount[]
56 syncedCurrentDid: string | undefined
57 }
58 | {
59 type: 'partial-refresh-session'
60 accountDid: string
61 patch: Pick<SessionAccount, 'emailConfirmed' | 'emailAuthFactor'>
62 }
63
64function createPublicAgentState(): AgentState {
65 return {
66 agent: createPublicAgent(),
67 did: undefined,
68 }
69}
70
71export function getInitialState(persistedAccounts: SessionAccount[]): State {
72 return {
73 accounts: persistedAccounts,
74 currentAgentState: createPublicAgentState(),
75 needsPersist: false,
76 }
77}
78
79let reducer = (state: State, action: Action): State => {
80 switch (action.type) {
81 case 'received-agent-event': {
82 const {agent, accountDid, refreshedAccount, sessionEvent} = action
83 if (
84 refreshedAccount === undefined &&
85 agent !== state.currentAgentState.agent
86 ) {
87 // If the session got cleared out (e.g. due to expiry or network error) but
88 // this account isn't the active one, don't clear it out at this time.
89 // This way, if the problem is transient, it'll work on next resume.
90 return state
91 }
92 if (sessionEvent === 'network-error') {
93 // Assume it's transient.
94 return state
95 }
96 const existingAccount = state.accounts.find(a => a.did === accountDid)
97 if (
98 !existingAccount ||
99 JSON.stringify(existingAccount) === JSON.stringify(refreshedAccount)
100 ) {
101 // Fast path without a state update.
102 return state
103 }
104 return {
105 accounts: state.accounts.map(a => {
106 if (a.did === accountDid) {
107 if (refreshedAccount) {
108 return refreshedAccount
109 } else {
110 return {
111 ...a,
112 // If we didn't receive a refreshed account, clear out the tokens.
113 accessJwt: undefined,
114 refreshJwt: undefined,
115 }
116 }
117 } else {
118 return a
119 }
120 }),
121 currentAgentState: refreshedAccount
122 ? state.currentAgentState
123 : createPublicAgentState(), // Log out if expired.
124 needsPersist: true,
125 }
126 }
127 case 'switched-to-account': {
128 const {newAccount, newAgent} = action
129 return {
130 accounts: [
131 newAccount,
132 ...state.accounts.filter(a => a.did !== newAccount.did),
133 ],
134 currentAgentState: {
135 did: newAccount.did,
136 agent: newAgent,
137 },
138 needsPersist: true,
139 }
140 }
141 case 'removed-account': {
142 const {accountDid} = action
143
144 // side effect
145 const account = state.accounts.find(a => a.did === accountDid)
146 if (account) {
147 createTemporaryAgentsAndResume([account])
148 .then(agents => unregisterPushToken(agents))
149 .then(() =>
150 logger.debug('Push token unregistered', {did: accountDid}),
151 )
152 .catch(err => {
153 logger.error('Failed to unregister push token', {
154 did: accountDid,
155 error: err,
156 })
157 })
158 }
159
160 return {
161 accounts: state.accounts.filter(a => a.did !== accountDid),
162 currentAgentState:
163 state.currentAgentState.did === accountDid
164 ? createPublicAgentState() // Log out if removing the current one.
165 : state.currentAgentState,
166 needsPersist: true,
167 }
168 }
169 case 'logged-out-current-account': {
170 const {currentAgentState} = state
171 const accountDid = currentAgentState.did
172 // side effect
173 const account = state.accounts.find(a => a.did === accountDid)
174 if (account && accountDid) {
175 createTemporaryAgentsAndResume([account])
176 .then(agents => unregisterPushToken(agents))
177 .then(() =>
178 logger.debug('Push token unregistered', {did: accountDid}),
179 )
180 .catch(err => {
181 logger.error('Failed to unregister push token', {
182 did: accountDid,
183 error: err,
184 })
185 })
186 }
187
188 return {
189 accounts: state.accounts.map(a =>
190 a.did === accountDid
191 ? {
192 ...a,
193 refreshJwt: undefined,
194 accessJwt: undefined,
195 }
196 : a,
197 ),
198 currentAgentState: createPublicAgentState(),
199 needsPersist: true,
200 }
201 }
202 case 'logged-out-every-account': {
203 createTemporaryAgentsAndResume(state.accounts)
204 .then(agents => unregisterPushToken(agents))
205 .then(() => logger.debug('Push token unregistered'))
206 .catch(err => {
207 logger.error('Failed to unregister push token', {
208 error: err,
209 })
210 })
211
212 return {
213 accounts: state.accounts.map(a => ({
214 ...a,
215 // Clear tokens for *every* account (this is a hard logout).
216 refreshJwt: undefined,
217 accessJwt: undefined,
218 })),
219 currentAgentState: createPublicAgentState(),
220 needsPersist: true,
221 }
222 }
223 case 'synced-accounts': {
224 const {syncedAccounts, syncedCurrentDid} = action
225 return {
226 accounts: syncedAccounts,
227 currentAgentState:
228 syncedCurrentDid === state.currentAgentState.did
229 ? state.currentAgentState
230 : createPublicAgentState(), // Log out if different user.
231 needsPersist: false, // Synced from another tab. Don't persist to avoid cycles.
232 }
233 }
234 case 'partial-refresh-session': {
235 const {accountDid, patch} = action
236 const agent = state.currentAgentState.agent as AtpAgent
237
238 /*
239 * Only mutating values that are safe. Be very careful with this.
240 */
241 if (agent.session) {
242 agent.session.emailConfirmed =
243 patch.emailConfirmed ?? agent.session.emailConfirmed
244 agent.session.emailAuthFactor =
245 patch.emailAuthFactor ?? agent.session.emailAuthFactor
246 }
247
248 return {
249 ...state,
250 currentAgentState: {
251 ...state.currentAgentState,
252 agent,
253 },
254 accounts: state.accounts.map(a => {
255 if (a.did === accountDid) {
256 return {
257 ...a,
258 emailConfirmed: patch.emailConfirmed ?? a.emailConfirmed,
259 emailAuthFactor: patch.emailAuthFactor ?? a.emailAuthFactor,
260 }
261 }
262 return a
263 }),
264 needsPersist: true,
265 }
266 }
267 }
268}
269reducer = wrapSessionReducerForLogging(reducer)
270export {reducer}