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