57be2ea15b
* Don't kick the user on network errors * Track online status for RQ * Use health endpoint * Update test with new behavior * Only poll while offline * Handle races between the check and network events * Reduce the poll kickoff interval * Don't cache partially fetched pinned feeds This isn't a new issue but it's more prominent with the offline handling. We're currently silently caching pinned infos that failed to fetch. This avoids showing a big spinner on failure but it also kills all feeds which is very confusing. If the request to get feed gens fails, let's fail the whole query. Then it can be retried.
303 lines
8.4 KiB
TypeScript
303 lines
8.4 KiB
TypeScript
import {AtpSessionData, AtpSessionEvent, BskyAgent} from '@atproto/api'
|
|
import {TID} from '@atproto/common-web'
|
|
|
|
import {networkRetry} from '#/lib/async/retry'
|
|
import {
|
|
DISCOVER_SAVED_FEED,
|
|
IS_PROD_SERVICE,
|
|
PUBLIC_BSKY_SERVICE,
|
|
TIMELINE_SAVED_FEED,
|
|
} from '#/lib/constants'
|
|
import {tryFetchGates} from '#/lib/statsig/statsig'
|
|
import {getAge} from '#/lib/strings/time'
|
|
import {logger} from '#/logger'
|
|
import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders'
|
|
import {emitNetworkConfirmed, emitNetworkLost} from '../events'
|
|
import {addSessionErrorLog} from './logging'
|
|
import {
|
|
configureModerationForAccount,
|
|
configureModerationForGuest,
|
|
} from './moderation'
|
|
import {SessionAccount} from './types'
|
|
import {isSessionExpired, isSignupQueued} from './util'
|
|
|
|
export function createPublicAgent() {
|
|
configureModerationForGuest() // Side effect but only relevant for tests
|
|
return new BskyAppAgent({service: PUBLIC_BSKY_SERVICE})
|
|
}
|
|
|
|
export async function createAgentAndResume(
|
|
storedAccount: SessionAccount,
|
|
onSessionChange: (
|
|
agent: BskyAgent,
|
|
did: string,
|
|
event: AtpSessionEvent,
|
|
) => void,
|
|
) {
|
|
const agent = new BskyAppAgent({service: storedAccount.service})
|
|
if (storedAccount.pdsUrl) {
|
|
agent.sessionManager.pdsUrl = new URL(storedAccount.pdsUrl)
|
|
}
|
|
const gates = tryFetchGates(storedAccount.did, 'prefer-low-latency')
|
|
const moderation = configureModerationForAccount(agent, storedAccount)
|
|
const prevSession: AtpSessionData = sessionAccountToSession(storedAccount)
|
|
if (isSessionExpired(storedAccount)) {
|
|
await networkRetry(1, () => agent.resumeSession(prevSession))
|
|
} else {
|
|
agent.sessionManager.session = prevSession
|
|
if (!storedAccount.signupQueued) {
|
|
networkRetry(3, () => agent.resumeSession(prevSession)).catch(
|
|
(e: any) => {
|
|
logger.error(`networkRetry failed to resume session`, {
|
|
status: e?.status || 'unknown',
|
|
// this field name is ignored by Sentry scrubbers
|
|
safeMessage: e?.message || 'unknown',
|
|
})
|
|
|
|
throw e
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
return agent.prepare(gates, moderation, onSessionChange)
|
|
}
|
|
|
|
export async function createAgentAndLogin(
|
|
{
|
|
service,
|
|
identifier,
|
|
password,
|
|
authFactorToken,
|
|
}: {
|
|
service: string
|
|
identifier: string
|
|
password: string
|
|
authFactorToken?: string
|
|
},
|
|
onSessionChange: (
|
|
agent: BskyAgent,
|
|
did: string,
|
|
event: AtpSessionEvent,
|
|
) => void,
|
|
) {
|
|
const agent = new BskyAppAgent({service})
|
|
await agent.login({identifier, password, authFactorToken})
|
|
|
|
const account = agentToSessionAccountOrThrow(agent)
|
|
const gates = tryFetchGates(account.did, 'prefer-fresh-gates')
|
|
const moderation = configureModerationForAccount(agent, account)
|
|
return agent.prepare(gates, moderation, onSessionChange)
|
|
}
|
|
|
|
export async function createAgentAndCreateAccount(
|
|
{
|
|
service,
|
|
email,
|
|
password,
|
|
handle,
|
|
birthDate,
|
|
inviteCode,
|
|
verificationPhone,
|
|
verificationCode,
|
|
}: {
|
|
service: string
|
|
email: string
|
|
password: string
|
|
handle: string
|
|
birthDate: Date
|
|
inviteCode?: string
|
|
verificationPhone?: string
|
|
verificationCode?: string
|
|
},
|
|
onSessionChange: (
|
|
agent: BskyAgent,
|
|
did: string,
|
|
event: AtpSessionEvent,
|
|
) => void,
|
|
) {
|
|
const agent = new BskyAppAgent({service})
|
|
await agent.createAccount({
|
|
email,
|
|
password,
|
|
handle,
|
|
inviteCode,
|
|
verificationPhone,
|
|
verificationCode,
|
|
})
|
|
const account = agentToSessionAccountOrThrow(agent)
|
|
const gates = tryFetchGates(account.did, 'prefer-fresh-gates')
|
|
const moderation = configureModerationForAccount(agent, account)
|
|
|
|
// Not awaited so that we can still get into onboarding.
|
|
// This is OK because we won't let you toggle adult stuff until you set the date.
|
|
if (IS_PROD_SERVICE(service)) {
|
|
try {
|
|
networkRetry(1, async () => {
|
|
await agent.setPersonalDetails({birthDate: birthDate.toISOString()})
|
|
await agent.overwriteSavedFeeds([
|
|
{
|
|
...DISCOVER_SAVED_FEED,
|
|
id: TID.nextStr(),
|
|
},
|
|
{
|
|
...TIMELINE_SAVED_FEED,
|
|
id: TID.nextStr(),
|
|
},
|
|
])
|
|
|
|
if (getAge(birthDate) < 18) {
|
|
await agent.api.com.atproto.repo.putRecord({
|
|
repo: account.did,
|
|
collection: 'chat.bsky.actor.declaration',
|
|
rkey: 'self',
|
|
record: {
|
|
$type: 'chat.bsky.actor.declaration',
|
|
allowIncoming: 'none',
|
|
},
|
|
})
|
|
}
|
|
})
|
|
} catch (e: any) {
|
|
logger.error(e, {
|
|
context: `session: createAgentAndCreateAccount failed to save personal details and feeds`,
|
|
})
|
|
}
|
|
} else {
|
|
agent.setPersonalDetails({birthDate: birthDate.toISOString()})
|
|
}
|
|
|
|
try {
|
|
// snooze first prompt after signup, defer to next prompt
|
|
snoozeEmailConfirmationPrompt()
|
|
} catch (e: any) {
|
|
logger.error(e, {context: `session: failed snoozeEmailConfirmationPrompt`})
|
|
}
|
|
|
|
return agent.prepare(gates, moderation, onSessionChange)
|
|
}
|
|
|
|
export function agentToSessionAccountOrThrow(agent: BskyAgent): SessionAccount {
|
|
const account = agentToSessionAccount(agent)
|
|
if (!account) {
|
|
throw Error('Expected an active session')
|
|
}
|
|
return account
|
|
}
|
|
|
|
export function agentToSessionAccount(
|
|
agent: BskyAgent,
|
|
): SessionAccount | undefined {
|
|
if (!agent.session) {
|
|
return undefined
|
|
}
|
|
return {
|
|
service: agent.service.toString(),
|
|
did: agent.session.did,
|
|
handle: agent.session.handle,
|
|
email: agent.session.email,
|
|
emailConfirmed: agent.session.emailConfirmed || false,
|
|
emailAuthFactor: agent.session.emailAuthFactor || false,
|
|
refreshJwt: agent.session.refreshJwt,
|
|
accessJwt: agent.session.accessJwt,
|
|
signupQueued: isSignupQueued(agent.session.accessJwt),
|
|
active: agent.session.active,
|
|
status: agent.session.status as SessionAccount['status'],
|
|
pdsUrl: agent.pdsUrl?.toString(),
|
|
}
|
|
}
|
|
|
|
export function sessionAccountToSession(
|
|
account: SessionAccount,
|
|
): AtpSessionData {
|
|
return {
|
|
// Sorted in the same property order as when returned by BskyAgent (alphabetical).
|
|
accessJwt: account.accessJwt ?? '',
|
|
did: account.did,
|
|
email: account.email,
|
|
emailAuthFactor: account.emailAuthFactor,
|
|
emailConfirmed: account.emailConfirmed,
|
|
handle: account.handle,
|
|
refreshJwt: account.refreshJwt ?? '',
|
|
/**
|
|
* @see https://github.com/bluesky-social/atproto/blob/c5d36d5ba2a2c2a5c4f366a5621c06a5608e361e/packages/api/src/agent.ts#L188
|
|
*/
|
|
active: account.active ?? true,
|
|
status: account.status,
|
|
}
|
|
}
|
|
|
|
// Not exported. Use factories above to create it.
|
|
let realFetch = globalThis.fetch
|
|
class BskyAppAgent extends BskyAgent {
|
|
persistSessionHandler: ((event: AtpSessionEvent) => void) | undefined =
|
|
undefined
|
|
|
|
constructor({service}: {service: string}) {
|
|
super({
|
|
service,
|
|
async fetch(...args) {
|
|
let success = false
|
|
try {
|
|
const result = await realFetch(...args)
|
|
success = true
|
|
return result
|
|
} catch (e) {
|
|
success = false
|
|
throw e
|
|
} finally {
|
|
if (success) {
|
|
emitNetworkConfirmed()
|
|
} else {
|
|
emitNetworkLost()
|
|
}
|
|
}
|
|
},
|
|
persistSession: (event: AtpSessionEvent) => {
|
|
if (this.persistSessionHandler) {
|
|
this.persistSessionHandler(event)
|
|
}
|
|
},
|
|
})
|
|
}
|
|
|
|
async prepare(
|
|
// Not awaited in the calling code so we can delay blocking on them.
|
|
gates: Promise<void>,
|
|
moderation: Promise<void>,
|
|
onSessionChange: (
|
|
agent: BskyAgent,
|
|
did: string,
|
|
event: AtpSessionEvent,
|
|
) => void,
|
|
) {
|
|
// There's nothing else left to do, so block on them here.
|
|
await Promise.all([gates, moderation])
|
|
|
|
// Now the agent is ready.
|
|
const account = agentToSessionAccountOrThrow(this)
|
|
let lastSession = this.sessionManager.session
|
|
this.persistSessionHandler = event => {
|
|
if (this.sessionManager.session) {
|
|
lastSession = this.sessionManager.session
|
|
} else if (event === 'network-error') {
|
|
// Put it back, we'll try again later.
|
|
this.sessionManager.session = lastSession
|
|
}
|
|
|
|
onSessionChange(this, account.did, event)
|
|
if (event !== 'create' && event !== 'update') {
|
|
addSessionErrorLog(account.did, event)
|
|
}
|
|
}
|
|
return {account, agent: this}
|
|
}
|
|
|
|
dispose() {
|
|
this.sessionManager.session = undefined
|
|
this.persistSessionHandler = undefined
|
|
}
|
|
}
|
|
|
|
export type {BskyAppAgent}
|