Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
at main 270 lines 8.0 kB view raw
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}