migrate the blob upload helper to the pds client
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+19
-10
@@ -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',
|
||||
|
||||
@@ -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<typeof BlobRef.fromJsonRef>[0])
|
||||
}
|
||||
@@ -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<ComAtprotoRepoUploadBlob.Response> {
|
||||
): Promise<UploadBlobResult> {
|
||||
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<UploadBlobResult> {
|
||||
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<Blob> {
|
||||
return withSafeFile(uri, async safeUri => {
|
||||
// Note
|
||||
|
||||
@@ -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<ComAtprotoRepoUploadBlob.Response> {
|
||||
): Promise<UploadBlobResult> {
|
||||
/*
|
||||
* 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}`)
|
||||
|
||||
Reference in New Issue
Block a user