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

Configure Feed

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

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