Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at theme-changes 294 lines 8.7 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 | 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}