migrate the status, germ declaration and block records to the pds client

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-08-03 23:48:18 +03:00
parent 3a970a7104
commit 4c5294a1a2
3 changed files with 80 additions and 52 deletions
+35 -19
View File
@@ -2,13 +2,12 @@ import {useMemo} from 'react'
import {
type $Typed,
type AppBskyActorDefs,
type AppBskyActorStatus,
AppBskyEmbedExternal,
AtUri,
ComAtprotoRepoPutRecord,
moderateStatus,
} from '@atproto/api'
import {retry} from '@atproto/common-web'
import {type l} from '@atproto/lex'
import {type AtIdentifierString, AtUri, toDatetimeString} from '@atproto/syntax'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
@@ -17,18 +16,20 @@ import {isAfter, parseISO} from 'date-fns'
import {uploadBlob} from '#/lib/api'
import {imageToThumb} from '#/lib/api/resolve'
import {getLinkMeta, type LinkMeta} from '#/lib/link-meta/link-meta'
import {matchXrpcError} from '#/lib/xrpc-error'
import {useAppConfig} from '#/state/appConfig'
import {
updateProfileShadow,
useMaybeProfileShadow,
} from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useAgent, useSession} from '#/state/session'
import {useAgent, usePdsClient, useSession} from '#/state/session'
import {useTickEveryMinute} from '#/state/shell'
import {useDialogContext} from '#/components/Dialog'
import * as Toast from '#/components/Toast'
import {useAnalytics} from '#/analytics'
import {getLiveNowHost, getLiveServiceNames} from '#/features/liveNow/utils'
import {app, com} from '#/lexicons'
import type * as bsky from '#/types/bsky'
export * from '#/features/liveNow/utils'
@@ -224,6 +225,7 @@ export function useUpsertLiveStatusMutation(
const ax = useAnalytics()
const {currentAccount} = useSession()
const agent = useAgent()
const pdsClient = usePdsClient()
const queryClient = useQueryClient()
const control = useDialogContext()
const {_} = useLingui()
@@ -232,10 +234,10 @@ export function useUpsertLiveStatusMutation(
mutationFn: async () => {
if (!currentAccount) throw new Error('Not logged in')
let embed: $Typed<AppBskyEmbedExternal.Main> | undefined
let embed: $Typed<app.bsky.embed.external.Main> | undefined
if (linkMeta) {
let thumb
let thumb: l.BlobRef | undefined
if (linkMeta.image) {
try {
@@ -246,7 +248,11 @@ export function useUpsertLiveStatusMutation(
img.source.path,
img.source.mime,
)
thumb = blob.data.blob
/*
* `uploadBlob` still returns the legacy `BlobRef` class
* instance; it moves to the client with the blob pipeline.
*/
thumb = blob.data.blob as unknown as l.BlobRef
}
} catch (e: any) {
ax.logger.error(`Failed to upload thumbnail for live status`, {
@@ -263,7 +269,8 @@ export function useUpsertLiveStatusMutation(
$type: 'app.bsky.embed.external#external',
title: linkMeta.title ?? '',
description: linkMeta.description ?? '',
uri: linkMeta.url,
// `getLinkMeta` returns a plain url string
uri: linkMeta.url as l.UriString,
thumb,
},
}
@@ -271,32 +278,41 @@ export function useUpsertLiveStatusMutation(
const record = {
$type: 'app.bsky.actor.status',
createdAt: createdAt ?? new Date().toISOString(),
createdAt: toDatetimeString(
createdAt ? new Date(createdAt) : new Date(),
),
status: 'app.bsky.actor.status#live',
durationMinutes: duration,
embed,
} satisfies AppBskyActorStatus.Record
} satisfies app.bsky.actor.status.Main
const upsert = async () => {
const repo = currentAccount.did
// the session account is still legacy-typed, so its did is unbranded
const repo = currentAccount.did as AtIdentifierString
const collection = 'app.bsky.actor.status'
const existing = await agent.com.atproto.repo
.getRecord({repo, collection, rkey: 'self'})
const existing = await pdsClient
.call(com.atproto.repo.getRecord, {repo, collection, rkey: 'self'})
.catch(_e => undefined)
await agent.com.atproto.repo.putRecord({
/*
* Stays on the raw `putRecord`, not `pdsClient.put`: the lexicon lets
* `swapRecord` be null (meaning "must not already exist"), while the
* record-helper option type is `string | undefined`.
*/
await pdsClient.call(com.atproto.repo.putRecord, {
repo,
collection,
rkey: 'self',
record,
swapRecord: existing?.data.cid || null,
swapRecord: existing?.cid || null,
})
}
await retry(upsert, {
maxRetries: 5,
retryable: e => e instanceof ComAtprotoRepoPutRecord.InvalidSwapError,
retryable: e =>
matchXrpcError(e, com.atproto.repo.putRecord) === 'InvalidSwap',
})
return {
@@ -353,7 +369,7 @@ export function useUpsertLiveStatusMutation(
export function useRemoveLiveStatusMutation() {
const ax = useAnalytics()
const {currentAccount} = useSession()
const agent = useAgent()
const pdsClient = usePdsClient()
const queryClient = useQueryClient()
const control = useDialogContext()
const {_} = useLingui()
@@ -362,8 +378,8 @@ export function useRemoveLiveStatusMutation() {
mutationFn: async () => {
if (!currentAccount) throw new Error('Not logged in')
await agent.app.bsky.actor.status.delete({
repo: currentAccount.did,
await pdsClient.delete(app.bsky.actor.status, {
repo: currentAccount.did as AtIdentifierString,
rkey: 'self',
})
},
+28 -23
View File
@@ -1,10 +1,8 @@
import {Platform, View} from 'react-native'
import {Image} from 'expo-image'
import {
type AppBskyActorDefs,
type AppBskyActorGetProfile,
type AtpAgent,
} from '@atproto/api'
import {type AppBskyActorDefs} from '@atproto/api'
import {type Client} from '@atproto/lex'
import {type DidString} from '@atproto/syntax'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
@@ -13,7 +11,7 @@ import {useMutation, useQueryClient} from '@tanstack/react-query'
import {until} from '#/lib/async/until'
import {isNetworkError} from '#/lib/strings/errors'
import {RQKEY} from '#/state/queries/profile'
import {useAgent, useSession} from '#/state/session'
import {useAppviewClient, usePdsClient, useSession} from '#/state/session'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
@@ -24,6 +22,7 @@ import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {app, com} from '#/lexicons'
import type * as bsky from '#/types/bsky'
export function GermButton({
@@ -119,25 +118,27 @@ function GermSelfButton({did}: {did: string}) {
const ax = useAnalytics()
const {_} = useLingui()
const selfExplanationDialogControl = Dialog.useDialogControl()
const agent = useAgent()
const appviewClient = useAppviewClient()
const pdsClient = usePdsClient()
const queryClient = useQueryClient()
const {mutate: deleteDeclaration, isPending} = useMutation({
mutationFn: async () => {
const previousRecord = await agent.com.germnetwork.declaration
.get({
repo: did,
const previousRecord = await pdsClient
// the profile view is still legacy-typed, so its did is unbranded
.get(com.germnetwork.declaration, {
repo: did as DidString,
rkey: 'self',
})
.then(res => res.value)
.catch(() => null)
await agent.com.germnetwork.declaration.delete({
repo: did,
await pdsClient.delete(com.germnetwork.declaration, {
repo: did as DidString,
rkey: 'self',
})
await whenAppViewReady(agent, did, res => !res.data.associated?.germ)
await whenAppViewReady(appviewClient, did, res => !res.associated?.germ)
return previousRecord
},
@@ -147,14 +148,15 @@ function GermSelfButton({did}: {did: string}) {
async function undo() {
if (!previousRecord) return
try {
await agent.com.germnetwork.declaration.put(
{
repo: did,
rkey: 'self',
},
previousRecord,
await pdsClient.put(com.germnetwork.declaration, previousRecord, {
repo: did as DidString,
rkey: 'self',
})
await whenAppViewReady(
appviewClient,
did,
res => !!res.associated?.germ,
)
await whenAppViewReady(agent, did, res => !!res.data.associated?.germ)
await queryClient.refetchQueries({queryKey: RQKEY(did)})
Toast.show(_(msg`Germ DM reconnected`))
@@ -323,14 +325,17 @@ function platform() {
}
async function whenAppViewReady(
agent: AtpAgent,
appviewClient: Client,
actor: string,
fn: (res: AppBskyActorGetProfile.Response) => boolean,
fn: (res: app.bsky.actor.getProfile.$OutputBody) => boolean,
) {
await until(
5, // 5 tries
1e3, // 1s delay between tries
fn,
() => agent.app.bsky.actor.getProfile({actor}),
() =>
appviewClient.call(app.bsky.actor.getProfile, {
actor: actor as DidString,
}),
)
}
+17 -10
View File
@@ -10,6 +10,11 @@ import {
type ComAtprotoRepoUploadBlob,
type Un$Typed,
} from '@atproto/api'
import {
type AtIdentifierString,
type DidString,
toDatetimeString,
} from '@atproto/syntax'
import {
type InfiniteData,
keepPreviousData,
@@ -33,10 +38,11 @@ import {
useUnstableProfileViewCache,
} from '#/state/queries/unstable-profile-cache'
import {useUpdateProfileVerificationCache} from '#/state/queries/verification/useUpdateProfileVerificationCache'
import {useAgent, useSession} from '#/state/session'
import {useAgent, usePdsClient, useSession} from '#/state/session'
import * as userActionHistory from '#/state/userActionHistory'
import {useAnalytics} from '#/analytics'
import {type Metrics, toClout} from '#/analytics/metrics'
import {app} from '#/lexicons'
import type * as bsky from '#/types/bsky'
import {
ProgressGuideAction,
@@ -634,17 +640,18 @@ export function useProfileBlockMutationQueue(
function useProfileBlockMutation() {
const {currentAccount} = useSession()
const agent = useAgent()
const pdsClient = usePdsClient()
const queryClient = useQueryClient()
return useMutation<{uri: string; cid: string}, Error, {did: string}>({
mutationFn: async ({did}) => {
if (!currentAccount) {
throw new Error('Not signed in')
}
return await agent.app.bsky.graph.block.create(
{repo: currentAccount.did},
{subject: did, createdAt: new Date().toISOString()},
)
return await pdsClient.create(app.bsky.graph.block, {
// the profile view is still legacy-typed, so its did is unbranded
subject: did as DidString,
createdAt: toDatetimeString(new Date()),
})
},
onSuccess(_, {did}) {
void queryClient.invalidateQueries({queryKey: RQKEY_MY_BLOCKED()})
@@ -655,16 +662,16 @@ function useProfileBlockMutation() {
function useProfileUnblockMutation() {
const {currentAccount} = useSession()
const agent = useAgent()
const pdsClient = usePdsClient()
const queryClient = useQueryClient()
return useMutation<void, Error, {did: string; blockUri: string}>({
mutationFn: async ({blockUri}) => {
if (!currentAccount) {
throw new Error('Not signed in')
}
const {rkey} = new AtUri(blockUri)
await agent.app.bsky.graph.block.delete({
repo: currentAccount.did,
const {rkeySafe: rkey} = new AtUri(blockUri)
await pdsClient.delete(app.bsky.graph.block, {
repo: currentAccount.did as AtIdentifierString,
rkey,
})
},