From 83fdc952371d517ef657203f80f926b42e3a3528 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 4 Aug 2026 00:21:55 +0300 Subject: [PATCH] migrate the blob upload helper to the pds client Co-Authored-By: Claude Fable 5 --- .../contacts/screens/GetContacts.tsx | 16 +++++--- src/features/liveNow/index.tsx | 9 +---- src/lib/api/index.ts | 29 +++++++++----- src/lib/api/legacy-blob.ts | 16 ++++++++ src/lib/api/upload-blob.ts | 40 +++++++++++++++---- src/lib/api/upload-blob.web.ts | 28 +++++++++---- src/screens/Onboarding/StepFinished/index.tsx | 7 ++-- src/state/queries/list.ts | 16 +++----- src/state/queries/profile.ts | 19 ++++----- src/view/com/composer/Composer.tsx | 10 ++++- 10 files changed, 127 insertions(+), 63 deletions(-) create mode 100644 src/lib/api/legacy-blob.ts diff --git a/src/components/contacts/screens/GetContacts.tsx b/src/components/contacts/screens/GetContacts.tsx index 879864b162..d84e0d1ab0 100644 --- a/src/components/contacts/screens/GetContacts.tsx +++ b/src/components/contacts/screens/GetContacts.tsx @@ -8,16 +8,18 @@ import { AppBskyContactImportContacts, type Un$Typed, } from '@atproto/api' +import {type Client} from '@atproto/lex' import {msg, t} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' import {useMutation, useQueryClient} from '@tanstack/react-query' import {uploadBlob} from '#/lib/api' +import {toLegacyBlobRef} from '#/lib/api/legacy-blob' import {cleanError, isNetworkError} from '#/lib/strings/errors' import {logger} from '#/logger' import {findContactsStatusQueryKey} from '#/state/queries/find-contacts' -import {useAgent} from '#/state/session' +import {useAgent, usePdsClient} from '#/state/session' import { Context as OnboardingContext, type OnboardingAction, @@ -55,6 +57,7 @@ export function GetContacts({ const {_} = useLingui() const ax = useAnalytics() const agent = useAgent() + const pdsClient = usePdsClient() const insets = useSafeAreaInsets() const gutters = useGutters([0, 'wide']) const queryClient = useQueryClient() @@ -72,7 +75,7 @@ export function GetContacts({ */ if (context === 'Onboarding' && maybeOnboardingContext) { try { - await createProfileRecord(agent, maybeOnboardingContext) + await createProfileRecord(agent, pdsClient, maybeOnboardingContext) } catch (error) { logger.debug('Error creating profile record:', {safeMessage: error}) } @@ -326,6 +329,7 @@ function showPermissionDeniedAlert() { */ async function createProfileRecord( agent: AtpAgent, + pdsClient: Client, onboardingContext: { state: OnboardingState dispatch: React.Dispatch @@ -334,15 +338,17 @@ async function createProfileRecord( const profileStepResults = onboardingContext.state.profileStepResults const {imageUri, imageMime} = profileStepResults const blobPromise = - imageUri && imageMime ? uploadBlob(agent, imageUri, imageMime) : undefined + imageUri && imageMime + ? uploadBlob(pdsClient, imageUri, imageMime) + : undefined await agent.upsertProfile(async existing => { let next: Un$Typed = existing ?? {} if (blobPromise) { const res = await blobPromise - if (res.data.blob) { - next.avatar = res.data.blob + if (res.blob) { + next.avatar = toLegacyBlobRef(res.blob) } } diff --git a/src/features/liveNow/index.tsx b/src/features/liveNow/index.tsx index 3b60b688bb..525e189ace 100644 --- a/src/features/liveNow/index.tsx +++ b/src/features/liveNow/index.tsx @@ -224,7 +224,6 @@ export function useUpsertLiveStatusMutation( ) { const ax = useAnalytics() const {currentAccount} = useSession() - const agent = useAgent() const pdsClient = usePdsClient() const queryClient = useQueryClient() const control = useDialogContext() @@ -244,15 +243,11 @@ export function useUpsertLiveStatusMutation( const img = await imageToThumb(linkMeta.image) if (img) { const blob = await uploadBlob( - agent, + pdsClient, img.source.path, img.source.mime, ) - /* - * `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 + thumb = blob.blob } } catch (e: any) { ax.logger.error(`Failed to upload thumbnail for live status`, { diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 20a4418f75..d9ee7c7fce 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -43,6 +43,7 @@ import {type app} from '#/lexicons' import * as bsky from '#/types/bsky' import {createGIFDescription} from '../gif-alt-text' import {computeCid} from './computeCid' +import {toLegacyBlobRef} from './legacy-blob' import {uploadBlob} from './upload-blob' export {uploadBlob} @@ -55,9 +56,13 @@ interface PostOpts { /* * Facet/mention resolution is an appview job - it resolves handles through * the appview, and the public fallback keeps it working when logged out. - * The rest of this pipeline still writes through the agent. */ appviewClient: Client + /* + * Record blobs (images, gallery items, link thumbnails, video captions) + * upload to the account's own PDS, never the appview. + */ + pdsClient: Client } export async function post( @@ -97,6 +102,7 @@ export async function post( const rtPromise = resolveRT(opts.appviewClient, draft.richtext) const embedPromise = resolveEmbed( agent, + opts.pdsClient, queryClient, draft, opts.onStateChange, @@ -263,6 +269,7 @@ async function resolveReply(agent: AtpAgent, replyTo: string) { async function resolveEmbed( agent: AtpAgent, + pdsClient: Client, queryClient: QueryClient, draft: PostDraft, onStateChange: ((state: string) => void) | undefined, @@ -277,7 +284,7 @@ async function resolveEmbed( > { if (draft.embed.quote) { const [resolvedMedia, resolvedQuote] = await Promise.all([ - resolveMedia(agent, queryClient, draft.embed, onStateChange), + resolveMedia(agent, pdsClient, queryClient, draft.embed, onStateChange), resolveRecord(agent, queryClient, draft.embed.quote.uri), ]) if (resolvedMedia) { @@ -297,6 +304,7 @@ async function resolveEmbed( } const resolvedMedia = await resolveMedia( agent, + pdsClient, queryClient, draft.embed, onStateChange, @@ -322,6 +330,7 @@ async function resolveEmbed( async function resolveMedia( agent: AtpAgent, + pdsClient: Client, queryClient: QueryClient, embedDraft: EmbedDraft, onStateChange: ((state: string) => void) | undefined, @@ -346,9 +355,9 @@ async function resolveMedia( IMAGE_SIZE_CONFIG_POSTS, ) logger.debug(`Uploading image #${i}`) - const res = await uploadBlob(agent, path, mime) + const res = await uploadBlob(pdsClient, path, mime) return { - image: res.data.blob, + image: toLegacyBlobRef(res.blob), alt: image.alt, aspectRatio: {width, height}, } @@ -373,10 +382,10 @@ async function resolveMedia( IMAGE_SIZE_CONFIG_POSTS, ) logger.debug(`Uploading image #${i}`) - const res = await uploadBlob(agent, path, mime) + const res = await uploadBlob(pdsClient, path, mime) return { $type: 'app.bsky.embed.gallery#image' as const, - image: res.data.blob, + image: toLegacyBlobRef(res.blob), alt: image.alt, aspectRatio: {width, height}, } @@ -438,8 +447,8 @@ async function resolveMedia( if (resolvedGif.thumb) { onStateChange?.(t`Uploading link thumbnail...`) const {path, mime} = resolvedGif.thumb.source - const response = await uploadBlob(agent, path, mime) - blob = response.data.blob + const response = await uploadBlob(pdsClient, path, mime) + blob = toLegacyBlobRef(response.blob) } return { $type: 'app.bsky.embed.external', @@ -462,8 +471,8 @@ async function resolveMedia( if (resolvedLink.thumb) { onStateChange?.(t`Uploading link thumbnail...`) const {path, mime} = resolvedLink.thumb.source - const response = await uploadBlob(agent, path, mime) - blob = response.data.blob + const response = await uploadBlob(pdsClient, path, mime) + blob = toLegacyBlobRef(response.blob) } return { $type: 'app.bsky.embed.external', diff --git a/src/lib/api/legacy-blob.ts b/src/lib/api/legacy-blob.ts new file mode 100644 index 0000000000..b60acadfe4 --- /dev/null +++ b/src/lib/api/legacy-blob.ts @@ -0,0 +1,16 @@ +import {BlobRef} from '@atproto/api' +import {type BlobRef as LexBlobRef} from '@atproto/lex' + +/** + * Bridge a lex blob ref (the plain-JSON `{$type: 'blob', ref, mimeType, size}` + * that {@link uploadBlob} now returns) back to the legacy `BlobRef` class + * instance. + * + * Only needed where a blob is handed to a legacy agent write: the legacy + * lexicon blob validator checks `value instanceof BlobRef`, so a plain lex + * blob fails validation, and the legacy serializer would put the wrong shape + * on the wire. Drop each call as its write moves to the lex client. + */ +export function toLegacyBlobRef(blob: LexBlobRef): BlobRef { + return BlobRef.fromJsonRef(blob as Parameters[0]) +} diff --git a/src/lib/api/upload-blob.ts b/src/lib/api/upload-blob.ts index 0bee39b9b1..c679e15d84 100644 --- a/src/lib/api/upload-blob.ts +++ b/src/lib/api/upload-blob.ts @@ -1,38 +1,62 @@ import {copyAsync} from 'expo-file-system/legacy' -import {type AtpAgent, type ComAtprotoRepoUploadBlob} from '@atproto/api' +import {type BlobRef, type Client, type EncodingString} from '@atproto/lex' import {safeDeleteAsync} from '#/lib/media/manip' /** - * @param encoding Allows overriding the blob's type + * The blob-upload response body: `{blob}`. lex `Client.uploadBlob` returns the + * full XRPC response, so this helper unwraps `res.body` for callers. + */ +type UploadBlobResult = {blob: BlobRef} + +/** + * @param encoding Allows overriding the blob's type. Passed as the lex upload + * option (NEVER a content-type header - lex-client throws if the encoding is + * set via headers). */ export async function uploadBlob( - agent: AtpAgent, + client: Client, input: string | Blob, encoding?: string, -): Promise { +): Promise { if (typeof input === 'string' && input.startsWith('file:')) { const blob = await asBlob(input) - return agent.uploadBlob(blob, {encoding}) + return uploadBlobResult(client, blob, encoding) } if (typeof input === 'string' && input.startsWith('/')) { const blob = await asBlob(`file://${input}`) - return agent.uploadBlob(blob, {encoding}) + return uploadBlobResult(client, blob, encoding) } if (typeof input === 'string' && input.startsWith('data:')) { const blob = await fetch(input).then(r => r.blob()) - return agent.uploadBlob(blob, {encoding}) + return uploadBlobResult(client, blob, encoding) } if (input instanceof Blob) { - return agent.uploadBlob(input, {encoding}) + return uploadBlobResult(client, input, encoding) } throw new TypeError(`Invalid uploadBlob input: ${typeof input}`) } +async function uploadBlobResult( + client: Client, + blob: Blob, + encoding?: string, +): Promise { + const res = await client.uploadBlob(blob, { + /* + * The lex encoding option is a branded mime string + * (`${string}/${string}`); callers pass a plain mime string, so assert the + * brand here. + */ + encoding: encoding as EncodingString | undefined, + }) + return {blob: res.body.blob} +} + async function asBlob(uri: string): Promise { return withSafeFile(uri, async safeUri => { // Note diff --git a/src/lib/api/upload-blob.web.ts b/src/lib/api/upload-blob.web.ts index d74e834647..0a58ad42f1 100644 --- a/src/lib/api/upload-blob.web.ts +++ b/src/lib/api/upload-blob.web.ts @@ -1,28 +1,42 @@ -import {type AtpAgent, type ComAtprotoRepoUploadBlob} from '@atproto/api' +import {type BlobRef, type Client, type EncodingString} from '@atproto/lex' + +/** + * The blob-upload response body: `{blob}`. lex `Client.uploadBlob` returns the + * full XRPC response, so this helper unwraps `res.body` for callers. + */ +type UploadBlobResult = {blob: BlobRef} /** * @note It is recommended, on web, to use the `file` instance of the file * selector input element, rather than a `data:` URL, to avoid * loading the file into memory. `File` extends `Blob` "file" instances can * be passed directly to this function. + * + * @param encoding Passed as the lex upload option (NEVER a content-type header + * - lex-client throws if the encoding is set via headers). */ export async function uploadBlob( - agent: AtpAgent, + client: Client, input: string | Blob, encoding?: string, -): Promise { +): Promise { + /* + * The lex encoding option is a branded mime string (`${string}/${string}`); + * callers pass a plain mime string, so assert the brand here. + */ + const enc = encoding as EncodingString | undefined if ( typeof input === 'string' && (input.startsWith('data:') || input.startsWith('blob:')) ) { const blob = await fetch(input).then(r => r.blob()) - return agent.uploadBlob(blob, {encoding}) + const res = await client.uploadBlob(blob, {encoding: enc}) + return {blob: res.body.blob} } if (input instanceof Blob) { - return agent.uploadBlob(input, { - encoding, - }) + const res = await client.uploadBlob(input, {encoding: enc}) + return {blob: res.body.blob} } throw new TypeError(`Invalid uploadBlob input: ${typeof input}`) diff --git a/src/screens/Onboarding/StepFinished/index.tsx b/src/screens/Onboarding/StepFinished/index.tsx index 6f1dea3a4f..706a160003 100644 --- a/src/screens/Onboarding/StepFinished/index.tsx +++ b/src/screens/Onboarding/StepFinished/index.tsx @@ -15,6 +15,7 @@ import {Trans} from '@lingui/react/macro' import {useQueryClient} from '@tanstack/react-query' import {uploadBlob} from '#/lib/api' +import {toLegacyBlobRef} from '#/lib/api/legacy-blob' import { BSKY_APP_ACCOUNT_DID, DISCOVER_SAVED_FEED, @@ -153,7 +154,7 @@ export function StepFinished() { const {imageUri, imageMime} = profileStepResults const blobPromise = imageUri && imageMime - ? uploadBlob(agent, imageUri, imageMime) + ? uploadBlob(pdsClient, imageUri, imageMime) : undefined await agent.upsertProfile(async existing => { @@ -161,8 +162,8 @@ export function StepFinished() { if (blobPromise) { const res = await blobPromise - if (res.data.blob) { - next.avatar = res.data.blob + if (res.blob) { + next.avatar = toLegacyBlobRef(res.blob) } } diff --git a/src/state/queries/list.ts b/src/state/queries/list.ts index ec551bc8e5..eef0dac585 100644 --- a/src/state/queries/list.ts +++ b/src/state/queries/list.ts @@ -1,5 +1,5 @@ import {type AppBskyGraphDefs} from '@atproto/api' -import {type $Typed, type Client, type l} from '@atproto/lex' +import {type $Typed, type Client} from '@atproto/lex' import { type AtIdentifierString, AtUri, @@ -56,7 +56,6 @@ export interface ListCreateMutateParams { export function useListCreateMutation() { const {currentAccount} = useSession() const queryClient = useQueryClient() - const agent = useAgent() const appviewClient = useAppviewClient() const pdsClient = usePdsClient() return useMutation<{uri: string; cid: string}, Error, ListCreateMutateParams>( @@ -86,12 +85,8 @@ export function useListCreateMutation() { createdAt: toDatetimeString(new Date()), } if (avatar) { - const blobRes = await uploadBlob(agent, avatar.path, avatar.mime) - /* - * `uploadBlob` still returns the legacy `BlobRef` class instance; - * it moves to the client with the rest of the blob pipeline. - */ - record.avatar = blobRes.data.blob as unknown as l.BlobRef + const blobRes = await uploadBlob(pdsClient, avatar.path, avatar.mime) + record.avatar = blobRes.blob } const res = await pdsClient.create(app.bsky.graph.list, record) @@ -120,7 +115,6 @@ export interface ListMetadataMutateParams { } export function useListMetadataMutation() { const {currentAccount} = useSession() - const agent = useAgent() const appviewClient = useAppviewClient() const pdsClient = usePdsClient() const queryClient = useQueryClient() @@ -149,8 +143,8 @@ export function useListMetadataMutation() { record.description = description record.descriptionFacets = descriptionFacets if (avatar) { - const blobRes = await uploadBlob(agent, avatar.path, avatar.mime) - record.avatar = blobRes.data.blob as unknown as l.BlobRef + const blobRes = await uploadBlob(pdsClient, avatar.path, avatar.mime) + record.avatar = blobRes.blob } else if (avatar === null) { record.avatar = undefined } diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts index dea8d35eea..ae5d4b1ad6 100644 --- a/src/state/queries/profile.ts +++ b/src/state/queries/profile.ts @@ -7,7 +7,6 @@ import { type AppBskyGraphGetFollows, type AtpAgent, AtUri, - type ComAtprotoRepoUploadBlob, type Un$Typed, } from '@atproto/api' import { @@ -25,6 +24,7 @@ import { } from '@tanstack/react-query' import {uploadBlob} from '#/lib/api' +import {toLegacyBlobRef} from '#/lib/api/legacy-blob' import {until} from '#/lib/async/until' import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue' import {updateProfileShadow} from '#/state/cache/profile-shadow' @@ -149,6 +149,7 @@ interface ProfileUpdateParams { export function useProfileUpdateMutation() { const queryClient = useQueryClient() const agent = useAgent() + const pdsClient = usePdsClient() const updateProfileVerificationCache = useUpdateProfileVerificationCache() return useMutation({ mutationFn: async ({ @@ -158,22 +159,18 @@ export function useProfileUpdateMutation() { newUserBanner, checkCommitted, }) => { - let newUserAvatarPromise: - | Promise - | undefined + let newUserAvatarPromise: ReturnType | undefined if (newUserAvatar) { newUserAvatarPromise = uploadBlob( - agent, + pdsClient, newUserAvatar.path, newUserAvatar.mime, ) } - let newUserBannerPromise: - | Promise - | undefined + let newUserBannerPromise: ReturnType | undefined if (newUserBanner) { newUserBannerPromise = uploadBlob( - agent, + pdsClient, newUserBanner.path, newUserBanner.mime, ) @@ -191,13 +188,13 @@ export function useProfileUpdateMutation() { } if (newUserAvatarPromise) { const res = await newUserAvatarPromise - next.avatar = res.data.blob + next.avatar = toLegacyBlobRef(res.blob) } else if (newUserAvatar === null) { next.avatar = undefined } if (newUserBannerPromise) { const res = await newUserBannerPromise - next.banner = res.data.blob + next.banner = toLegacyBlobRef(res.blob) } else if (newUserBanner === null) { next.banner = undefined } diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index c2e651556d..1388ce0c45 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -96,7 +96,12 @@ import { import {usePreferencesQuery} from '#/state/queries/preferences' import {useProfileQuery} from '#/state/queries/profile' import {resolveLinkQueryOptions} from '#/state/queries/resolve-link' -import {useAgent, useAppviewClient, useSession} from '#/state/session' +import { + useAgent, + useAppviewClient, + usePdsClient, + useSession, +} from '#/state/session' import {useComposerControls} from '#/state/shell/composer' import {type ComposerOpts, type OnPostSuccessData} from '#/state/shell/composer' import {CharProgress} from '#/view/com/composer/char-progress/CharProgress' @@ -277,6 +282,7 @@ export const ComposePost = ({ : VIDEO_MAX_DURATION_MS const agent = useAgent() const client = useAppviewClient() + const pdsClient = usePdsClient() const queryClient = useQueryClient() const currentDid = currentAccount!.did const {closeComposer} = useComposerControls() @@ -1084,6 +1090,7 @@ export const ComposePost = ({ onStateChange: setPublishingStage, langs: currentLanguages, appviewClient: client, + pdsClient, }) ).uris[0] @@ -1279,6 +1286,7 @@ export const ComposePost = ({ ax, agent, client, + pdsClient, canPost, isPublishing, currentLanguages,