Files
bsky-social-app/src/state/session/agent.ts
T
dan 57be2ea15b Don't kick to login screen on network error (#4911)
* 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.
2024-08-13 18:51:49 +01:00

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}