Bluesky app fork with some witchin' additions 馃挮
at main 224 lines 6.4 kB view raw
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}