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