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