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 460 lines 14 kB view raw
1import { 2 Agent as BaseAgent, 3 type AppBskyActorProfile, 4 type AtprotoServiceType, 5 type AtpSessionData, 6 type AtpSessionEvent, 7 BskyAgent, 8 type Did, 9 type Un$Typed, 10} from '@atproto/api' 11import {type FetchHandler} from '@atproto/api/dist/agent' 12import {type SessionManager} from '@atproto/api/dist/session-manager' 13import {TID} from '@atproto/common-web' 14import {type FetchHandlerOptions} from '@atproto/xrpc' 15 16import {networkRetry} from '#/lib/async/retry' 17import { 18 APPVIEW_DID_PROXY, 19 BLUESKY_PROXY_HEADER, 20 BSKY_SERVICE, 21 DISCOVER_SAVED_FEED, 22 IS_PROD_SERVICE, 23 PUBLIC_BSKY_SERVICE, 24 TIMELINE_SAVED_FEED, 25} from '#/lib/constants' 26import {logger} from '#/logger' 27import {snoozeBirthdateUpdateAllowedForDid} from '#/state/birthdate' 28import {restrictChatSettings} from '#/state/queries/messages/restrictChatSettings' 29import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders' 30import { 31 prefetchAgeAssuranceData, 32 setBirthdateForDid, 33 setCreatedAtForDid, 34} from '#/ageAssurance/data' 35import {getAndComputeAgeAssuranceState} from '#/ageAssurance/state' 36import {AgeAssuranceAccess} from '#/ageAssurance/types' 37import {features} from '#/analytics' 38import {emitNetworkConfirmed, emitNetworkLost} from '../events' 39import {readCustomAppViewDidUri} from '../preferences/custom-appview-did' 40import {addSessionErrorLog} from './logging' 41import { 42 configureModerationForAccount, 43 configureModerationForGuest, 44} from './moderation' 45import {type SessionAccount} from './types' 46import {isSessionExpired, isSignupQueued} from './util' 47 48export type ProxyHeaderValue = `${Did}#${AtprotoServiceType}` 49 50export function createPublicAgent() { 51 configureModerationForGuest() // Side effect but only relevant for tests 52 53 const agent = new BskyAppAgent({service: PUBLIC_BSKY_SERVICE}) 54 const proxyDid = 55 readCustomAppViewDidUri() || BLUESKY_PROXY_HEADER.get() || APPVIEW_DID_PROXY 56 agent.configureProxy(proxyDid as ProxyHeaderValue) 57 return agent 58} 59 60export async function createAgentAndResume( 61 storedAccount: SessionAccount, 62 onSessionChange: ( 63 agent: BskyAgent, 64 did: string, 65 event: AtpSessionEvent, 66 ) => void, 67) { 68 const agent = new BskyAppAgent({service: storedAccount.service}) 69 if (storedAccount.pdsUrl) { 70 agent.sessionManager.pdsUrl = new URL(storedAccount.pdsUrl) 71 } 72 const gates = features.refresh({ 73 strategy: 'prefer-low-latency', 74 }) 75 const moderation = configureModerationForAccount(agent, storedAccount) 76 const prevSession: AtpSessionData = sessionAccountToSession(storedAccount) 77 if (isSessionExpired(storedAccount)) { 78 await networkRetry(1, () => agent.resumeSession(prevSession)) 79 } else { 80 agent.sessionManager.session = prevSession 81 } 82 83 // after session is attached 84 const aa = prefetchAgeAssuranceData({agent}) 85 86 const proxyDid = 87 readCustomAppViewDidUri() || BLUESKY_PROXY_HEADER.get() || APPVIEW_DID_PROXY 88 agent.configureProxy(proxyDid as ProxyHeaderValue) 89 90 return agent.prepare({ 91 resolvers: [gates, moderation, aa], 92 onSessionChange, 93 }) 94} 95 96export async function createAgentAndLogin( 97 { 98 service, 99 identifier, 100 password, 101 authFactorToken, 102 }: { 103 service: string 104 identifier: string 105 password: string 106 authFactorToken?: string 107 }, 108 onSessionChange: ( 109 agent: BskyAgent, 110 did: string, 111 event: AtpSessionEvent, 112 ) => void, 113) { 114 const agent = new BskyAppAgent({service}) 115 await agent.login({ 116 identifier, 117 password, 118 authFactorToken, 119 allowTakendown: true, 120 }) 121 122 const account = agentToSessionAccountOrThrow(agent) 123 const gates = features.refresh({strategy: 'prefer-fresh-gates'}) 124 const moderation = configureModerationForAccount(agent, account) 125 const aa = prefetchAgeAssuranceData({agent}) 126 127 const proxyDid = 128 readCustomAppViewDidUri() || BLUESKY_PROXY_HEADER.get() || APPVIEW_DID_PROXY 129 agent.configureProxy(proxyDid as ProxyHeaderValue) 130 131 return agent.prepare({ 132 resolvers: [gates, moderation, aa], 133 onSessionChange, 134 }) 135} 136 137export async function createAgentAndCreateAccount( 138 { 139 service, 140 email, 141 password, 142 handle, 143 birthDate, 144 inviteCode, 145 verificationPhone, 146 verificationCode, 147 }: { 148 service: string 149 email: string 150 password: string 151 handle: string 152 birthDate: Date 153 inviteCode?: string 154 verificationPhone?: string 155 verificationCode?: string 156 }, 157 onSessionChange: ( 158 agent: BskyAgent, 159 did: string, 160 event: AtpSessionEvent, 161 ) => void, 162) { 163 const agent = new BskyAppAgent({service}) 164 await agent.createAccount({ 165 email, 166 password, 167 handle, 168 inviteCode, 169 verificationPhone, 170 verificationCode, 171 }) 172 const account = agentToSessionAccountOrThrow(agent) 173 const gates = features.refresh({strategy: 'prefer-fresh-gates'}) 174 const moderation = configureModerationForAccount(agent, account) 175 176 const createdAt = new Date().toISOString() 177 const birthdate = birthDate.toISOString() 178 179 /* 180 * Since we have a race with account creation, profile creation, and AA 181 * state, set these values locally to ensure sync reads. Values are written 182 * to the server in the next step, so on subsequent reloads, the server will 183 * be the source of truth. 184 */ 185 setCreatedAtForDid({did: account.did, createdAt}) 186 setBirthdateForDid({did: account.did, birthdate}) 187 snoozeBirthdateUpdateAllowedForDid(account.did) 188 // do this last 189 const aa = prefetchAgeAssuranceData({agent}) 190 191 // Not awaited so that we can still get into onboarding. 192 // This is OK because we won't let you toggle adult stuff until you set the date. 193 if (IS_PROD_SERVICE(service)) { 194 void Promise.allSettled([ 195 networkRetry(3, () => { 196 return pdsAgent(agent).setPersonalDetails({ 197 birthDate: birthdate, 198 }) 199 }).catch(e => { 200 logger.info(`createAgentAndCreateAccount: failed to set birthDate`) 201 throw e 202 }), 203 networkRetry(3, () => { 204 return agent.upsertProfile(prev => { 205 const next: Un$Typed<AppBskyActorProfile.Record> = prev || {} 206 next.displayName = handle 207 next.createdAt = createdAt 208 return next 209 }) 210 }).catch(e => { 211 logger.info( 212 `createAgentAndCreateAccount: failed to set initial profile`, 213 ) 214 throw e 215 }), 216 networkRetry(1, () => { 217 return agent.overwriteSavedFeeds([ 218 { 219 ...DISCOVER_SAVED_FEED, 220 id: TID.nextStr(), 221 }, 222 { 223 ...TIMELINE_SAVED_FEED, 224 id: TID.nextStr(), 225 }, 226 ]) 227 }).catch(e => { 228 logger.info(`createAgentAndCreateAccount: failed to set initial feeds`) 229 throw e 230 }), 231 // wait for AA data to load first, then check state 232 aa.then(async () => { 233 const state = getAndComputeAgeAssuranceState({did: account.did}) 234 if (state.access !== AgeAssuranceAccess.Full) { 235 restrictChatSettings({agent: pdsAgent(agent), did: account.did}) 236 } 237 }), 238 ]).then(promises => { 239 const rejected = promises.filter(p => p.status === 'rejected') 240 if (rejected.length > 0) { 241 logger.error( 242 `session: createAgentAndCreateAccount failed to save personal details and feeds`, 243 ) 244 } 245 }) 246 } else { 247 void Promise.allSettled([ 248 networkRetry(3, () => { 249 return pdsAgent(agent).setPersonalDetails({ 250 birthDate: birthDate.toISOString(), 251 }) 252 }).catch(e => { 253 logger.info(`createAgentAndCreateAccount: failed to set birthDate`) 254 throw e 255 }), 256 networkRetry(3, () => { 257 return agent.upsertProfile(prev => { 258 const next: Un$Typed<AppBskyActorProfile.Record> = prev || {} 259 next.createdAt = prev?.createdAt || new Date().toISOString() 260 return next 261 }) 262 }).catch(e => { 263 logger.info( 264 `createAgentAndCreateAccount: failed to set initial profile`, 265 ) 266 throw e 267 }), 268 ]).then(promises => { 269 const rejected = promises.filter(p => p.status === 'rejected') 270 if (rejected.length > 0) { 271 logger.error( 272 `session: createAgentAndCreateAccount failed to save personal details and feeds`, 273 ) 274 } 275 }) 276 } 277 278 try { 279 // snooze first prompt after signup, defer to next prompt 280 snoozeEmailConfirmationPrompt() 281 } catch (e: any) { 282 logger.error(e, {message: `session: failed snoozeEmailConfirmationPrompt`}) 283 } 284 285 const proxyDid = 286 readCustomAppViewDidUri() || BLUESKY_PROXY_HEADER.get() || APPVIEW_DID_PROXY 287 agent.configureProxy(proxyDid as ProxyHeaderValue) 288 289 return agent.prepare({ 290 resolvers: [gates, moderation, aa], 291 onSessionChange, 292 }) 293} 294 295export function agentToSessionAccountOrThrow(agent: BskyAgent): SessionAccount { 296 const account = agentToSessionAccount(agent) 297 if (!account) { 298 throw Error('Expected an active session') 299 } 300 return account 301} 302 303export function agentToSessionAccount( 304 agent: BskyAgent, 305): SessionAccount | undefined { 306 if (!agent.session) { 307 return undefined 308 } 309 return { 310 service: agent.serviceUrl.toString(), 311 did: agent.session.did, 312 handle: agent.session.handle, 313 email: agent.session.email, 314 emailConfirmed: agent.session.emailConfirmed || false, 315 emailAuthFactor: agent.session.emailAuthFactor || false, 316 refreshJwt: agent.session.refreshJwt, 317 accessJwt: agent.session.accessJwt, 318 signupQueued: isSignupQueued(agent.session.accessJwt), 319 active: agent.session.active, 320 status: agent.session.status, 321 pdsUrl: agent.pdsUrl?.toString(), 322 isSelfHosted: !agent.serviceUrl.toString().startsWith(BSKY_SERVICE), 323 } 324} 325 326export function sessionAccountToSession( 327 account: SessionAccount, 328): AtpSessionData { 329 return { 330 // Sorted in the same property order as when returned by BskyAgent (alphabetical). 331 accessJwt: account.accessJwt ?? '', 332 did: account.did, 333 email: account.email, 334 emailAuthFactor: account.emailAuthFactor, 335 emailConfirmed: account.emailConfirmed, 336 handle: account.handle, 337 refreshJwt: account.refreshJwt ?? '', 338 /** 339 * @see https://github.com/bluesky-social/atproto/blob/c5d36d5ba2a2c2a5c4f366a5621c06a5608e361e/packages/api/src/agent.ts#L188 340 */ 341 active: account.active ?? true, 342 status: account.status, 343 } 344} 345 346export class Agent extends BaseAgent { 347 constructor( 348 proxyHeader: ProxyHeaderValue | null, 349 options: SessionManager | FetchHandler | FetchHandlerOptions, 350 ) { 351 super(options) 352 if (proxyHeader) { 353 this.configureProxy(proxyHeader) 354 } 355 } 356} 357 358// Not exported. Use factories above to create it. 359// WARN: In the factories above, we _manually set a proxy header_ for the agent after we do whatever it is we are supposed to do. 360// Ideally, we wouldn't be doing this. However, since there is so much logic that requires making calls to the PDS right now, it 361// feels safer to just let those run as-is and set the header afterward. 362let realFetch = globalThis.fetch 363class BskyAppAgent extends BskyAgent { 364 persistSessionHandler: ((event: AtpSessionEvent) => void) | undefined = 365 undefined 366 367 clone(): this { 368 // `withProxy()` calls `clone()`. Since this class subclasses `BskyAgent`, 369 // we must provide our own clone implementation. 370 return this.copyInto(new BskyAgent(this.sessionManager) as this) 371 } 372 373 constructor({service}: {service: string}) { 374 super({ 375 service, 376 async fetch(...args) { 377 let success = false 378 try { 379 const result = await realFetch(...args) 380 success = true 381 return result 382 } catch (e) { 383 success = false 384 throw e 385 } finally { 386 if (success) { 387 emitNetworkConfirmed() 388 } else { 389 emitNetworkLost() 390 } 391 } 392 }, 393 persistSession: (event: AtpSessionEvent) => { 394 if (this.persistSessionHandler) { 395 this.persistSessionHandler(event) 396 } 397 }, 398 }) 399 const proxyDid = readCustomAppViewDidUri() || APPVIEW_DID_PROXY 400 if (proxyDid) { 401 this.configureProxy(proxyDid as ProxyHeaderValue) 402 } 403 } 404 405 async prepare({ 406 resolvers, 407 onSessionChange, 408 }: { 409 // Not awaited in the calling code so we can delay blocking on them. 410 resolvers: Promise<unknown>[] 411 onSessionChange: ( 412 agent: BskyAgent, 413 did: string, 414 event: AtpSessionEvent, 415 ) => void 416 }) { 417 // There's nothing else left to do, so block on them here. 418 await Promise.all(resolvers) 419 420 // Now the agent is ready. 421 const account = agentToSessionAccountOrThrow(this) 422 this.persistSessionHandler = event => { 423 onSessionChange(this, account.did, event) 424 if (event !== 'create' && event !== 'update') { 425 addSessionErrorLog(account.did, event) 426 } 427 } 428 return {account, agent: this} 429 } 430 431 dispose() { 432 this.sessionManager.session = undefined 433 this.persistSessionHandler = undefined 434 } 435 436 cloneWithoutProxy(): BskyAgent { 437 const cloned = new BskyAgent({service: this.serviceUrl.toString()}) 438 cloned.sessionManager.session = this.sessionManager.session 439 return cloned 440 } 441} 442 443/** 444 * Returns an agent configured to make requests directly to the user's PDS 445 * without the appview proxy header. Use this for com.atproto.* methods and 446 * other PDS-specific operations like preferences. 447 */ 448export function pdsAgent<T extends BaseAgent>(agent: T): T { 449 if ( 450 'cloneWithoutProxy' in agent && 451 typeof agent.cloneWithoutProxy === 'function' 452 ) { 453 return agent.cloneWithoutProxy() as T 454 } 455 const clone = agent.clone() as T 456 clone.configureProxy(null) 457 return clone 458} 459 460export type {BskyAppAgent}