[SDK] Migrate the post pipeline and blob uploads (#11380)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+4
-1
@@ -333,11 +333,14 @@
|
|||||||
"^multiformats/cid$": "<rootDir>/node_modules/multiformats/dist/src/cid.js",
|
"^multiformats/cid$": "<rootDir>/node_modules/multiformats/dist/src/cid.js",
|
||||||
"^multiformats/bases/base32$": "<rootDir>/node_modules/multiformats/dist/src/bases/base32.js",
|
"^multiformats/bases/base32$": "<rootDir>/node_modules/multiformats/dist/src/bases/base32.js",
|
||||||
"^multiformats/hashes/digest$": "<rootDir>/node_modules/multiformats/dist/src/hashes/digest.js",
|
"^multiformats/hashes/digest$": "<rootDir>/node_modules/multiformats/dist/src/hashes/digest.js",
|
||||||
|
"^multiformats/hashes/hasher$": "<rootDir>/node_modules/multiformats/dist/src/hashes/hasher.js",
|
||||||
"^multiformats/hashes/sha2$": "<rootDir>/node_modules/multiformats/dist/src/hashes/sha2.js",
|
"^multiformats/hashes/sha2$": "<rootDir>/node_modules/multiformats/dist/src/hashes/sha2.js",
|
||||||
"^uint8arrays/from-string$": "<rootDir>/node_modules/uint8arrays/dist/src/from-string.js",
|
"^uint8arrays/from-string$": "<rootDir>/node_modules/uint8arrays/dist/src/from-string.js",
|
||||||
"^uint8arrays/to-string$": "<rootDir>/node_modules/uint8arrays/dist/src/to-string.js",
|
"^uint8arrays/to-string$": "<rootDir>/node_modules/uint8arrays/dist/src/to-string.js",
|
||||||
"^unicode-segmenter/grapheme$": "<rootDir>/node_modules/unicode-segmenter/grapheme.cjs",
|
"^unicode-segmenter/grapheme$": "<rootDir>/node_modules/unicode-segmenter/grapheme.cjs",
|
||||||
"^await-lock$": "<rootDir>/node_modules/await-lock/build/AwaitLock.js"
|
"^await-lock$": "<rootDir>/node_modules/await-lock/build/AwaitLock.js",
|
||||||
|
"^@ipld/dag-cbor$": "<rootDir>/node_modules/@ipld/dag-cbor/src/index.js",
|
||||||
|
"^cborg$": "<rootDir>/node_modules/cborg/cborg.js"
|
||||||
},
|
},
|
||||||
"transformIgnorePatterns": [
|
"transformIgnorePatterns": [
|
||||||
"node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|nanoid|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|normalize-url|react-native-svg|@sentry/.*|sentry-expo|bcp-47-match|@atproto/.*|@bsky.app/sdk|tlds|multiformats|uint8arrays|@ipld/.*|cborg|await-lock)"
|
"node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|nanoid|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|normalize-url|react-native-svg|@sentry/.*|sentry-expo|bcp-47-match|@atproto/.*|@bsky.app/sdk|tlds|multiformats|uint8arrays|@ipld/.*|cborg|await-lock)"
|
||||||
|
|||||||
@@ -8,16 +8,18 @@ import {
|
|||||||
AppBskyContactImportContacts,
|
AppBskyContactImportContacts,
|
||||||
type Un$Typed,
|
type Un$Typed,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
|
import {type Client} from '@atproto/lex'
|
||||||
import {msg, t} from '@lingui/core/macro'
|
import {msg, t} 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'
|
||||||
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {uploadBlob} from '#/lib/api'
|
import {uploadBlob} from '#/lib/api'
|
||||||
|
import {toLegacyBlobRef} from '#/lib/api/legacy-blob'
|
||||||
import {cleanError, isNetworkError} from '#/lib/strings/errors'
|
import {cleanError, isNetworkError} from '#/lib/strings/errors'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {findContactsStatusQueryKey} from '#/state/queries/find-contacts'
|
import {findContactsStatusQueryKey} from '#/state/queries/find-contacts'
|
||||||
import {useAgent} from '#/state/session'
|
import {useAgent, usePdsClient} from '#/state/session'
|
||||||
import {
|
import {
|
||||||
Context as OnboardingContext,
|
Context as OnboardingContext,
|
||||||
type OnboardingAction,
|
type OnboardingAction,
|
||||||
@@ -55,6 +57,7 @@ export function GetContacts({
|
|||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const ax = useAnalytics()
|
const ax = useAnalytics()
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
|
const pdsClient = usePdsClient()
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
const gutters = useGutters([0, 'wide'])
|
const gutters = useGutters([0, 'wide'])
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
@@ -72,7 +75,7 @@ export function GetContacts({
|
|||||||
*/
|
*/
|
||||||
if (context === 'Onboarding' && maybeOnboardingContext) {
|
if (context === 'Onboarding' && maybeOnboardingContext) {
|
||||||
try {
|
try {
|
||||||
await createProfileRecord(agent, maybeOnboardingContext)
|
await createProfileRecord(agent, pdsClient, maybeOnboardingContext)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.debug('Error creating profile record:', {safeMessage: error})
|
logger.debug('Error creating profile record:', {safeMessage: error})
|
||||||
}
|
}
|
||||||
@@ -326,6 +329,7 @@ function showPermissionDeniedAlert() {
|
|||||||
*/
|
*/
|
||||||
async function createProfileRecord(
|
async function createProfileRecord(
|
||||||
agent: AtpAgent,
|
agent: AtpAgent,
|
||||||
|
pdsClient: Client,
|
||||||
onboardingContext: {
|
onboardingContext: {
|
||||||
state: OnboardingState
|
state: OnboardingState
|
||||||
dispatch: React.Dispatch<OnboardingAction>
|
dispatch: React.Dispatch<OnboardingAction>
|
||||||
@@ -334,15 +338,17 @@ async function createProfileRecord(
|
|||||||
const profileStepResults = onboardingContext.state.profileStepResults
|
const profileStepResults = onboardingContext.state.profileStepResults
|
||||||
const {imageUri, imageMime} = profileStepResults
|
const {imageUri, imageMime} = profileStepResults
|
||||||
const blobPromise =
|
const blobPromise =
|
||||||
imageUri && imageMime ? uploadBlob(agent, imageUri, imageMime) : undefined
|
imageUri && imageMime
|
||||||
|
? uploadBlob(pdsClient, imageUri, imageMime)
|
||||||
|
: undefined
|
||||||
|
|
||||||
await agent.upsertProfile(async existing => {
|
await agent.upsertProfile(async existing => {
|
||||||
let next: Un$Typed<AppBskyActorProfile.Record> = existing ?? {}
|
let next: Un$Typed<AppBskyActorProfile.Record> = existing ?? {}
|
||||||
|
|
||||||
if (blobPromise) {
|
if (blobPromise) {
|
||||||
const res = await blobPromise
|
const res = await blobPromise
|
||||||
if (res.data.blob) {
|
if (res.blob) {
|
||||||
next.avatar = res.data.blob
|
next.avatar = toLegacyBlobRef(res.blob)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -224,7 +224,6 @@ export function useUpsertLiveStatusMutation(
|
|||||||
) {
|
) {
|
||||||
const ax = useAnalytics()
|
const ax = useAnalytics()
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const agent = useAgent()
|
|
||||||
const pdsClient = usePdsClient()
|
const pdsClient = usePdsClient()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const control = useDialogContext()
|
const control = useDialogContext()
|
||||||
@@ -244,15 +243,11 @@ export function useUpsertLiveStatusMutation(
|
|||||||
const img = await imageToThumb(linkMeta.image)
|
const img = await imageToThumb(linkMeta.image)
|
||||||
if (img) {
|
if (img) {
|
||||||
const blob = await uploadBlob(
|
const blob = await uploadBlob(
|
||||||
agent,
|
pdsClient,
|
||||||
img.source.path,
|
img.source.path,
|
||||||
img.source.mime,
|
img.source.mime,
|
||||||
)
|
)
|
||||||
/*
|
thumb = blob.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`, {
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/*
|
||||||
|
* The jest suite ships global manual mocks for `multiformats/cid` and
|
||||||
|
* `multiformats/hashes/hasher` (in root `__mocks__/`) so unrelated tests don't
|
||||||
|
* pull in real crypto. This test is precisely about the real CID hashing, so we
|
||||||
|
* opt back into the actual implementations here.
|
||||||
|
*/
|
||||||
|
jest.unmock('multiformats/cid')
|
||||||
|
jest.unmock('multiformats/hashes/hasher')
|
||||||
|
|
||||||
|
import {BlobRef} from '@atproto/api'
|
||||||
|
import {CID} from 'multiformats/cid'
|
||||||
|
|
||||||
|
import {computeCid} from '#/lib/api/computeCid'
|
||||||
|
import {type app, type com} from '#/lexicons'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Golden-CID regression test for the composer post pipeline.
|
||||||
|
*
|
||||||
|
* `computeCid` hashes a post record in the client so a thread's later posts can
|
||||||
|
* reference earlier posts by CID before the server assigns them. The hash is
|
||||||
|
* byte-sensitive: any drift in how records (especially blobs) are serialized to
|
||||||
|
* DAG-CBOR silently produces the wrong CID and breaks reply chains with NO type
|
||||||
|
* error. These golden values MUST remain byte-identical - they gate the
|
||||||
|
* structural lex-blob shape check.
|
||||||
|
*
|
||||||
|
* The blob CID below is a fixed, deterministic CIDv1/raw/sha256 used purely as a
|
||||||
|
* stable fixture - it is not derived from any real upload.
|
||||||
|
*/
|
||||||
|
const BLOB_CID = 'bafkreieq5jui4j25lacwomsqgjeswwl3y5zcdrresptwgmfylxo2depppq'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a post record with an image embed whose blob is the given value.
|
||||||
|
*/
|
||||||
|
function postWithImageBlob(blob: unknown): app.bsky.feed.post.Main {
|
||||||
|
return {
|
||||||
|
$type: 'app.bsky.feed.post',
|
||||||
|
createdAt: '2024-01-01T00:00:00.001Z',
|
||||||
|
text: 'post with image',
|
||||||
|
embed: {
|
||||||
|
$type: 'app.bsky.embed.images',
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
image: blob,
|
||||||
|
alt: 'alt text',
|
||||||
|
aspectRatio: {width: 100, height: 200},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as app.bsky.feed.post.Main
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('computeCid', () => {
|
||||||
|
it('case 1: plain post record with no blob', async () => {
|
||||||
|
const record: app.bsky.feed.post.Main = {
|
||||||
|
$type: 'app.bsky.feed.post',
|
||||||
|
createdAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
text: 'hello world',
|
||||||
|
}
|
||||||
|
expect(await computeCid(record)).toBe(
|
||||||
|
'bafyreieawtmh7hwfrqpamqkodza5r62bbfhsepe2iyustgxhgbhi6b2lfi',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('case 2: record whose embed carries a plain-JSON lex blob', async () => {
|
||||||
|
/*
|
||||||
|
* The blob shape lex `uploadBlob` returns: a plain object
|
||||||
|
* `{$type: 'blob', ref, mimeType, size}` with `ref` a parsed CID. The
|
||||||
|
* structural lex-blob guard passes it through `prepareForHashing`
|
||||||
|
* untouched and DAG-CBOR encodes its CID `ref` as a CID link. The golden
|
||||||
|
* CID below is the byte-identical value the pre-migration `BlobRef` class
|
||||||
|
* instance produced via `.ipld()`.
|
||||||
|
*/
|
||||||
|
const blob = {
|
||||||
|
$type: 'blob' as const,
|
||||||
|
ref: CID.parse(BLOB_CID),
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
size: 12345,
|
||||||
|
}
|
||||||
|
expect(await computeCid(postWithImageBlob(blob))).toBe(
|
||||||
|
'bafyreiem7g6vja66nebr7he4fshfnlyndyldbvle2n265oixscmepjcbii',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('case 2b: a legacy BlobRef instance hashes to the same CID', async () => {
|
||||||
|
/*
|
||||||
|
* The video pipeline still yields legacy `BlobRef` class instances, so
|
||||||
|
* `prepareForHashing` keeps its `instanceof` guard. Both branches must
|
||||||
|
* agree: this asserts the SAME golden CID as case 2.
|
||||||
|
*/
|
||||||
|
const blob = new BlobRef(CID.parse(BLOB_CID), 'image/jpeg', 12345)
|
||||||
|
expect(await computeCid(postWithImageBlob(blob))).toBe(
|
||||||
|
'bafyreiem7g6vja66nebr7he4fshfnlyndyldbvle2n265oixscmepjcbii',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('case 3: three-post thread chains reply StrongRef CIDs', async () => {
|
||||||
|
const did = 'did:plc:abc123'
|
||||||
|
const base = new Date('2024-01-01T00:00:00.000Z')
|
||||||
|
const golden = [
|
||||||
|
'bafyreig62rxs34h5rvznfrracwkjlfgad5b25qxglp2hcziqdfas2nw2ee',
|
||||||
|
'bafyreicxcj2tq5jrh5jcaczg3eli5cvxitgzu7kpu3fm5v3njq2byjxirq',
|
||||||
|
'bafyreigvaswuhlpd7dllja2xrqswhqbruyv2kar7mvbn7gdm5ldzu6vkti',
|
||||||
|
]
|
||||||
|
|
||||||
|
let reply: app.bsky.feed.post.Main['reply'] | undefined
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const now = new Date(base.getTime() + i)
|
||||||
|
const uri = `at://${did}/app.bsky.feed.post/rkey${i}`
|
||||||
|
const record = {
|
||||||
|
$type: 'app.bsky.feed.post',
|
||||||
|
createdAt: now.toISOString(),
|
||||||
|
text: `post ${i}`,
|
||||||
|
reply,
|
||||||
|
} as app.bsky.feed.post.Main
|
||||||
|
const cid = await computeCid(record)
|
||||||
|
expect(cid).toBe(golden[i])
|
||||||
|
const ref = {cid, uri} as com.atproto.repo.strongRef.Main
|
||||||
|
reply = {root: reply?.root ?? ref, parent: ref}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import {BlobRef} from '@atproto/api'
|
||||||
|
import {sha256} from 'js-sha256'
|
||||||
|
import {CID} from 'multiformats/cid'
|
||||||
|
import * as Hasher from 'multiformats/hashes/hasher'
|
||||||
|
|
||||||
|
import {type app} from '#/lexicons'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Client-side CID computation for post records, extracted from the post
|
||||||
|
* pipeline so it can be unit-tested in isolation (importing the pipeline pulls
|
||||||
|
* in the native gallery/media chain). See `computeCid.test.ts` for the
|
||||||
|
* golden-CID regression fixtures that gate any change to this serialization.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// The built-in hashing functions from multiformats (`multiformats/hashes/sha2`)
|
||||||
|
// are meant for Node.js, this is the cross-platform equivalent.
|
||||||
|
const mf_sha256 = Hasher.from({
|
||||||
|
name: 'sha2-256',
|
||||||
|
code: 0x12,
|
||||||
|
encode: input => {
|
||||||
|
const digest = sha256.arrayBuffer(input)
|
||||||
|
return new Uint8Array(digest)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function computeCid(
|
||||||
|
record: app.bsky.feed.post.Main,
|
||||||
|
): Promise<string> {
|
||||||
|
/*
|
||||||
|
* Lazily loaded since it's only needed when posting a thread, and its
|
||||||
|
* `cborg` dependency is ~190KB that would otherwise be in the initial
|
||||||
|
* web bundle.
|
||||||
|
*/
|
||||||
|
const dcbor = await importDagCbor()
|
||||||
|
/*
|
||||||
|
* IMPORTANT: `prepareForHashing` prepares the record to be hashed by
|
||||||
|
* removing fields with undefined value, and converting blobs to the right
|
||||||
|
* IPLD representation.
|
||||||
|
*/
|
||||||
|
const prepared = prepareForHashing(record)
|
||||||
|
// 1. Encode the record into DAG-CBOR format
|
||||||
|
const encoded = dcbor.encode(prepared)
|
||||||
|
// 2. Hash the record in SHA-256 (code 0x12)
|
||||||
|
const digest = await mf_sha256.digest(encoded)
|
||||||
|
// 3. Create a CIDv1, specifying DAG-CBOR as content (code 0x71)
|
||||||
|
const cid = CID.createV1(0x71, digest)
|
||||||
|
// 4. Get the Base32 representation of the CID (`b` prefix)
|
||||||
|
return cid.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True for a plain-JSON lexicon blob, the shape lex `uploadBlob` returns
|
||||||
|
* (`{$type: 'blob', ref, mimeType, size}` with `ref` a parsed CID). Lex blobs
|
||||||
|
* are plain objects, not class instances.
|
||||||
|
*/
|
||||||
|
function isLexBlob(v: unknown): boolean {
|
||||||
|
if (v == null || typeof v !== 'object') return false
|
||||||
|
const o = v as Record<string, unknown>
|
||||||
|
return o.$type === 'blob' && 'ref' in o && 'mimeType' in o
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Returns a transformed version of the object for use in DAG-CBOR.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function prepareForHashing(v: any): any {
|
||||||
|
/*
|
||||||
|
* A plain-JSON lex blob is already in the right IPLD shape (its `ref` is a
|
||||||
|
* parsed CID that DAG-CBOR encodes as a CID link), so pass it through
|
||||||
|
* untouched.
|
||||||
|
*/
|
||||||
|
if (isLexBlob(v)) {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The video pipeline still reads its blob off the legacy agent
|
||||||
|
* (`app.bsky.video.getJobStatus` in composer `state/video`), which yields a
|
||||||
|
* `BlobRef` class instance. `ipld()` gives the same IPLD shape a lex blob
|
||||||
|
* already has, so both branches hash identically. Drop this guard once the
|
||||||
|
* video client is migrated.
|
||||||
|
*/
|
||||||
|
if (v instanceof BlobRef) {
|
||||||
|
return v.ipld()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk through arrays
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
let pure = true
|
||||||
|
const mapped = v.map(value => {
|
||||||
|
if (value !== (value = prepareForHashing(value))) {
|
||||||
|
pure = false
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
})
|
||||||
|
return pure ? v : mapped
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk through plain objects
|
||||||
|
if (isPlainObject(v)) {
|
||||||
|
const rec = v as Record<string, unknown>
|
||||||
|
const obj: Record<string, unknown> = {}
|
||||||
|
let pure = true
|
||||||
|
for (const key in rec) {
|
||||||
|
let value = rec[key]
|
||||||
|
// `value` is undefined
|
||||||
|
if (value === undefined) {
|
||||||
|
pure = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* `prepareForHashing` returned a value that's different from what we had
|
||||||
|
* before
|
||||||
|
*/
|
||||||
|
if (value !== (value = prepareForHashing(value))) {
|
||||||
|
pure = false
|
||||||
|
}
|
||||||
|
obj[key] = value
|
||||||
|
}
|
||||||
|
// Return as is if we haven't needed to tamper with anything
|
||||||
|
return pure ? v : obj
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function isPlainObject(v: any): boolean {
|
||||||
|
if (typeof v !== 'object' || v === null) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const proto = Object.getPrototypeOf(v)
|
||||||
|
return proto === Object.prototype || proto === null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load `@ipld/dag-cbor` on demand. The dynamic `import()` lets web bundlers
|
||||||
|
* emit it (and its ~190KB `cborg` dependency) as a separate chunk that only
|
||||||
|
* loads when posting a thread. Under jest (which runs without
|
||||||
|
* `--experimental-vm-modules`) dynamic import throws, so we fall back to a
|
||||||
|
* lazy `require`, which resolves through the test moduleNameMapper.
|
||||||
|
*/
|
||||||
|
function importDagCbor(): Promise<typeof import('@ipld/dag-cbor')> {
|
||||||
|
if (process.env.NODE_ENV === 'test') {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
return Promise.resolve(require('@ipld/dag-cbor'))
|
||||||
|
}
|
||||||
|
return import('@ipld/dag-cbor')
|
||||||
|
}
|
||||||
+87
-169
@@ -1,28 +1,14 @@
|
|||||||
import {
|
import {type AtpAgent, ChatBskyGroupDefs} from '@atproto/api'
|
||||||
type $Typed,
|
|
||||||
type AppBskyEmbedExternal,
|
|
||||||
type AppBskyEmbedGallery,
|
|
||||||
type AppBskyEmbedImages,
|
|
||||||
type AppBskyEmbedRecord,
|
|
||||||
type AppBskyEmbedRecordWithMedia,
|
|
||||||
type AppBskyEmbedVideo,
|
|
||||||
AppBskyFeedPost,
|
|
||||||
type AtpAgent,
|
|
||||||
BlobRef,
|
|
||||||
ChatBskyGroupDefs,
|
|
||||||
type ComAtprotoLabelDefs,
|
|
||||||
type ComAtprotoRepoApplyWrites,
|
|
||||||
type ComAtprotoRepoStrongRef,
|
|
||||||
} from '@atproto/api'
|
|
||||||
import {TID} from '@atproto/common-web'
|
import {TID} from '@atproto/common-web'
|
||||||
import {type Client} from '@atproto/lex'
|
import {type $Typed, type Client} from '@atproto/lex'
|
||||||
import {toDatetimeString} from '@atproto/syntax'
|
import {
|
||||||
|
type AtUriString,
|
||||||
|
toDatetimeString,
|
||||||
|
type UriString,
|
||||||
|
} from '@atproto/syntax'
|
||||||
import {RichText} from '@bsky.app/sdk/richtext'
|
import {RichText} from '@bsky.app/sdk/richtext'
|
||||||
import {t} from '@lingui/core/macro'
|
import {t} from '@lingui/core/macro'
|
||||||
import {type QueryClient} from '@tanstack/react-query'
|
import {type QueryClient} from '@tanstack/react-query'
|
||||||
import {sha256} from 'js-sha256'
|
|
||||||
import {CID} from 'multiformats/cid'
|
|
||||||
import * as Hasher from 'multiformats/hashes/hasher'
|
|
||||||
|
|
||||||
import {IMAGE_SIZE_CONFIG_POSTS} from '#/lib/constants'
|
import {IMAGE_SIZE_CONFIG_POSTS} from '#/lib/constants'
|
||||||
import {isNetworkError} from '#/lib/strings/errors'
|
import {isNetworkError} from '#/lib/strings/errors'
|
||||||
@@ -42,8 +28,11 @@ import {
|
|||||||
type PostDraft,
|
type PostDraft,
|
||||||
type ThreadDraft,
|
type ThreadDraft,
|
||||||
} from '#/view/com/composer/state/composer'
|
} from '#/view/com/composer/state/composer'
|
||||||
|
import {app, com} from '#/lexicons'
|
||||||
import * as bsky from '#/types/bsky'
|
import * as bsky from '#/types/bsky'
|
||||||
import {createGIFDescription} from '../gif-alt-text'
|
import {createGIFDescription} from '../gif-alt-text'
|
||||||
|
import {computeCid} from './computeCid'
|
||||||
|
import {fromLegacyBlobRef} from './legacy-blob'
|
||||||
import {uploadBlob} from './upload-blob'
|
import {uploadBlob} from './upload-blob'
|
||||||
|
|
||||||
export {uploadBlob}
|
export {uploadBlob}
|
||||||
@@ -54,13 +43,23 @@ interface PostOpts {
|
|||||||
onStateChange?: (state: string) => void
|
onStateChange?: (state: string) => void
|
||||||
langs?: string[]
|
langs?: string[]
|
||||||
/*
|
/*
|
||||||
* Facet/mention resolution is an appview job - it resolves handles through
|
* Facet/mention resolution and reply-root lookup are appview jobs - they
|
||||||
* the appview, and the public fallback keeps it working when logged out.
|
* resolve handles and read posts through the appview, and the public
|
||||||
* The rest of this pipeline still writes through the agent.
|
* fallback keeps facet detection working when logged out.
|
||||||
*/
|
*/
|
||||||
appviewClient: Client
|
appviewClient: Client
|
||||||
|
/*
|
||||||
|
* The repo write itself (applyWrites) plus every record blob (images,
|
||||||
|
* gallery items, link thumbnails, video captions) goes to the account's own
|
||||||
|
* PDS, never the appview.
|
||||||
|
*/
|
||||||
|
pdsClient: Client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `agent` is only still here for the link/gif resolvers in `resolve.ts`,
|
||||||
|
* which read through it; drop the parameter when those move to the clients.
|
||||||
|
*/
|
||||||
export async function post(
|
export async function post(
|
||||||
agent: AtpAgent,
|
agent: AtpAgent,
|
||||||
queryClient: QueryClient,
|
queryClient: QueryClient,
|
||||||
@@ -70,12 +69,12 @@ export async function post(
|
|||||||
opts.onStateChange?.(t`Processing...`)
|
opts.onStateChange?.(t`Processing...`)
|
||||||
|
|
||||||
let replyPromise:
|
let replyPromise:
|
||||||
| Promise<AppBskyFeedPost.Record['reply']>
|
| Promise<app.bsky.feed.post.Main['reply']>
|
||||||
| AppBskyFeedPost.Record['reply']
|
| app.bsky.feed.post.Main['reply']
|
||||||
| undefined
|
| undefined
|
||||||
if (opts.replyTo) {
|
if (opts.replyTo) {
|
||||||
// Not awaited to avoid waterfalls.
|
// Not awaited to avoid waterfalls.
|
||||||
replyPromise = resolveReply(agent, opts.replyTo)
|
replyPromise = resolveReply(opts.appviewClient, opts.replyTo)
|
||||||
}
|
}
|
||||||
|
|
||||||
// add top 3 languages from user preferences if langs is provided
|
// add top 3 languages from user preferences if langs is provided
|
||||||
@@ -84,8 +83,8 @@ export async function post(
|
|||||||
langs = opts.langs.slice(0, 3)
|
langs = opts.langs.slice(0, 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
const did = agent.assertDid
|
const did = opts.pdsClient.assertDid
|
||||||
const writes: $Typed<ComAtprotoRepoApplyWrites.Create>[] = []
|
const writes: com.atproto.repo.applyWrites.$InputBody['writes'] = []
|
||||||
const uris: string[] = []
|
const uris: string[] = []
|
||||||
|
|
||||||
let now = new Date()
|
let now = new Date()
|
||||||
@@ -98,11 +97,12 @@ export async function post(
|
|||||||
const rtPromise = resolveRT(opts.appviewClient, draft.richtext)
|
const rtPromise = resolveRT(opts.appviewClient, draft.richtext)
|
||||||
const embedPromise = resolveEmbed(
|
const embedPromise = resolveEmbed(
|
||||||
agent,
|
agent,
|
||||||
|
opts.pdsClient,
|
||||||
queryClient,
|
queryClient,
|
||||||
draft,
|
draft,
|
||||||
opts.onStateChange,
|
opts.onStateChange,
|
||||||
)
|
)
|
||||||
let labels: $Typed<ComAtprotoLabelDefs.SelfLabels> | undefined
|
let labels: $Typed<com.atproto.label.defs.SelfLabels> | undefined
|
||||||
if (draft.labels.length) {
|
if (draft.labels.length) {
|
||||||
labels = {
|
labels = {
|
||||||
$type: 'com.atproto.label.defs#selfLabels',
|
$type: 'com.atproto.label.defs#selfLabels',
|
||||||
@@ -115,17 +115,17 @@ export async function post(
|
|||||||
now.setMilliseconds(now.getMilliseconds() + 1)
|
now.setMilliseconds(now.getMilliseconds() + 1)
|
||||||
tid = TID.next(tid)
|
tid = TID.next(tid)
|
||||||
const rkey = tid.toString()
|
const rkey = tid.toString()
|
||||||
const uri = `at://${did}/app.bsky.feed.post/${rkey}`
|
const uri = `at://${did}/app.bsky.feed.post/${rkey}` as AtUriString
|
||||||
uris.push(uri)
|
uris.push(uri)
|
||||||
|
|
||||||
const rt = await rtPromise
|
const rt = await rtPromise
|
||||||
const embed = await embedPromise
|
const embed = await embedPromise
|
||||||
const reply = await replyPromise
|
const reply = await replyPromise
|
||||||
const record: AppBskyFeedPost.Record = {
|
const record: app.bsky.feed.post.Main = {
|
||||||
// IMPORTANT: $type has to exist, CID is calculated with the `$type` field
|
// IMPORTANT: $type has to exist, CID is calculated with the `$type` field
|
||||||
// present and will produce the wrong CID if you omit it.
|
// present and will produce the wrong CID if you omit it.
|
||||||
$type: 'app.bsky.feed.post',
|
$type: 'app.bsky.feed.post',
|
||||||
createdAt: now.toISOString(),
|
createdAt: toDatetimeString(now),
|
||||||
text: rt.text,
|
text: rt.text,
|
||||||
facets: rt.facets,
|
facets: rt.facets,
|
||||||
reply,
|
reply,
|
||||||
@@ -164,7 +164,7 @@ export async function post(
|
|||||||
value: {
|
value: {
|
||||||
...thread.postgate,
|
...thread.postgate,
|
||||||
$type: 'app.bsky.feed.postgate',
|
$type: 'app.bsky.feed.postgate',
|
||||||
createdAt: now.toISOString(),
|
createdAt: toDatetimeString(now),
|
||||||
post: uri,
|
post: uri,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -182,8 +182,8 @@ export async function post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await agent.com.atproto.repo.applyWrites({
|
await opts.pdsClient.call(com.atproto.repo.applyWrites, {
|
||||||
repo: agent.assertDid,
|
repo: did,
|
||||||
writes: writes,
|
writes: writes,
|
||||||
validate: true,
|
validate: true,
|
||||||
})
|
})
|
||||||
@@ -224,9 +224,9 @@ export class ReplyDeletedError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveReply(agent: AtpAgent, replyTo: string) {
|
async function resolveReply(appviewClient: Client, replyTo: string) {
|
||||||
const {data} = await agent.app.bsky.feed.getPosts({
|
const data = await appviewClient.call(app.bsky.feed.getPosts, {
|
||||||
uris: [replyTo],
|
uris: [replyTo as AtUriString],
|
||||||
})
|
})
|
||||||
const parentPost = data.posts[0]
|
const parentPost = data.posts[0]
|
||||||
if (!parentPost) {
|
if (!parentPost) {
|
||||||
@@ -237,14 +237,9 @@ async function resolveReply(agent: AtpAgent, replyTo: string) {
|
|||||||
uri: parentPost.uri,
|
uri: parentPost.uri,
|
||||||
cid: parentPost.cid,
|
cid: parentPost.cid,
|
||||||
}
|
}
|
||||||
let rootRef = parentRef
|
let rootRef: com.atproto.repo.strongRef.Main = parentRef
|
||||||
|
|
||||||
if (
|
if (bsky.isType(app.bsky.feed.post, parentPost.record)) {
|
||||||
bsky.dangerousIsType<AppBskyFeedPost.Record>(
|
|
||||||
parentPost.record,
|
|
||||||
AppBskyFeedPost.isRecord,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
if (parentPost.record.reply) {
|
if (parentPost.record.reply) {
|
||||||
rootRef = parentPost.record.reply.root
|
rootRef = parentPost.record.reply.root
|
||||||
}
|
}
|
||||||
@@ -258,21 +253,14 @@ async function resolveReply(agent: AtpAgent, replyTo: string) {
|
|||||||
|
|
||||||
async function resolveEmbed(
|
async function resolveEmbed(
|
||||||
agent: AtpAgent,
|
agent: AtpAgent,
|
||||||
|
pdsClient: Client,
|
||||||
queryClient: QueryClient,
|
queryClient: QueryClient,
|
||||||
draft: PostDraft,
|
draft: PostDraft,
|
||||||
onStateChange: ((state: string) => void) | undefined,
|
onStateChange: ((state: string) => void) | undefined,
|
||||||
): Promise<
|
): Promise<app.bsky.feed.post.Main['embed']> {
|
||||||
| $Typed<AppBskyEmbedImages.Main>
|
|
||||||
| $Typed<AppBskyEmbedGallery.Main>
|
|
||||||
| $Typed<AppBskyEmbedVideo.Main>
|
|
||||||
| $Typed<AppBskyEmbedExternal.Main>
|
|
||||||
| $Typed<AppBskyEmbedRecord.Main>
|
|
||||||
| $Typed<AppBskyEmbedRecordWithMedia.Main>
|
|
||||||
| undefined
|
|
||||||
> {
|
|
||||||
if (draft.embed.quote) {
|
if (draft.embed.quote) {
|
||||||
const [resolvedMedia, resolvedQuote] = await Promise.all([
|
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),
|
resolveRecord(agent, queryClient, draft.embed.quote.uri),
|
||||||
])
|
])
|
||||||
if (resolvedMedia) {
|
if (resolvedMedia) {
|
||||||
@@ -292,6 +280,7 @@ async function resolveEmbed(
|
|||||||
}
|
}
|
||||||
const resolvedMedia = await resolveMedia(
|
const resolvedMedia = await resolveMedia(
|
||||||
agent,
|
agent,
|
||||||
|
pdsClient,
|
||||||
queryClient,
|
queryClient,
|
||||||
draft.embed,
|
draft.embed,
|
||||||
onStateChange,
|
onStateChange,
|
||||||
@@ -308,7 +297,12 @@ async function resolveEmbed(
|
|||||||
if (resolvedLink.type === 'record') {
|
if (resolvedLink.type === 'record') {
|
||||||
return {
|
return {
|
||||||
$type: 'app.bsky.embed.record',
|
$type: 'app.bsky.embed.record',
|
||||||
record: resolvedLink.record,
|
/*
|
||||||
|
* `resolve.ts` is still legacy-typed - its strong refs and URIs carry
|
||||||
|
* unbranded strings. Assert at the boundary until it moves to the
|
||||||
|
* clients.
|
||||||
|
*/
|
||||||
|
record: resolvedLink.record as com.atproto.repo.strongRef.Main,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -317,14 +311,15 @@ async function resolveEmbed(
|
|||||||
|
|
||||||
async function resolveMedia(
|
async function resolveMedia(
|
||||||
agent: AtpAgent,
|
agent: AtpAgent,
|
||||||
|
pdsClient: Client,
|
||||||
queryClient: QueryClient,
|
queryClient: QueryClient,
|
||||||
embedDraft: EmbedDraft,
|
embedDraft: EmbedDraft,
|
||||||
onStateChange: ((state: string) => void) | undefined,
|
onStateChange: ((state: string) => void) | undefined,
|
||||||
): Promise<
|
): Promise<
|
||||||
| $Typed<AppBskyEmbedExternal.Main>
|
| $Typed<app.bsky.embed.external.Main>
|
||||||
| $Typed<AppBskyEmbedImages.Main>
|
| $Typed<app.bsky.embed.images.Main>
|
||||||
| $Typed<AppBskyEmbedGallery.Main>
|
| $Typed<app.bsky.embed.gallery.Main>
|
||||||
| $Typed<AppBskyEmbedVideo.Main>
|
| $Typed<app.bsky.embed.video.Main>
|
||||||
| undefined
|
| undefined
|
||||||
> {
|
> {
|
||||||
if (embedDraft.media?.type === 'images') {
|
if (embedDraft.media?.type === 'images') {
|
||||||
@@ -333,7 +328,7 @@ async function resolveMedia(
|
|||||||
count: imagesDraft.length,
|
count: imagesDraft.length,
|
||||||
})
|
})
|
||||||
onStateChange?.(t`Uploading images...`)
|
onStateChange?.(t`Uploading images...`)
|
||||||
const images: AppBskyEmbedImages.Image[] = await Promise.all(
|
const images: app.bsky.embed.images.Image[] = await Promise.all(
|
||||||
imagesDraft.map(async (image, i) => {
|
imagesDraft.map(async (image, i) => {
|
||||||
logger.debug(`Compressing image #${i}`)
|
logger.debug(`Compressing image #${i}`)
|
||||||
const {path, width, height, mime} = await compressImage(
|
const {path, width, height, mime} = await compressImage(
|
||||||
@@ -341,9 +336,9 @@ async function resolveMedia(
|
|||||||
IMAGE_SIZE_CONFIG_POSTS,
|
IMAGE_SIZE_CONFIG_POSTS,
|
||||||
)
|
)
|
||||||
logger.debug(`Uploading image #${i}`)
|
logger.debug(`Uploading image #${i}`)
|
||||||
const res = await uploadBlob(agent, path, mime)
|
const res = await uploadBlob(pdsClient, path, mime)
|
||||||
return {
|
return {
|
||||||
image: res.data.blob,
|
image: res.blob,
|
||||||
alt: image.alt,
|
alt: image.alt,
|
||||||
aspectRatio: {width, height},
|
aspectRatio: {width, height},
|
||||||
}
|
}
|
||||||
@@ -360,7 +355,7 @@ async function resolveMedia(
|
|||||||
count: imagesDraft.length,
|
count: imagesDraft.length,
|
||||||
})
|
})
|
||||||
onStateChange?.(t`Uploading images...`)
|
onStateChange?.(t`Uploading images...`)
|
||||||
const items: $Typed<AppBskyEmbedGallery.Image>[] = await Promise.all(
|
const items: $Typed<app.bsky.embed.gallery.Image>[] = await Promise.all(
|
||||||
imagesDraft.map(async (image, i) => {
|
imagesDraft.map(async (image, i) => {
|
||||||
logger.debug(`Compressing image #${i}`)
|
logger.debug(`Compressing image #${i}`)
|
||||||
const {path, width, height, mime} = await compressImage(
|
const {path, width, height, mime} = await compressImage(
|
||||||
@@ -368,10 +363,10 @@ async function resolveMedia(
|
|||||||
IMAGE_SIZE_CONFIG_POSTS,
|
IMAGE_SIZE_CONFIG_POSTS,
|
||||||
)
|
)
|
||||||
logger.debug(`Uploading image #${i}`)
|
logger.debug(`Uploading image #${i}`)
|
||||||
const res = await uploadBlob(agent, path, mime)
|
const res = await uploadBlob(pdsClient, path, mime)
|
||||||
return {
|
return {
|
||||||
$type: 'app.bsky.embed.gallery#image' as const,
|
$type: 'app.bsky.embed.gallery#image' as const,
|
||||||
image: res.data.blob,
|
image: res.blob,
|
||||||
alt: image.alt,
|
alt: image.alt,
|
||||||
aspectRatio: {width, height},
|
aspectRatio: {width, height},
|
||||||
}
|
}
|
||||||
@@ -391,10 +386,10 @@ async function resolveMedia(
|
|||||||
videoDraft.captions
|
videoDraft.captions
|
||||||
.filter(caption => caption.lang !== '')
|
.filter(caption => caption.lang !== '')
|
||||||
.map(async caption => {
|
.map(async caption => {
|
||||||
const {data} = await agent.uploadBlob(caption.file, {
|
const res = await pdsClient.uploadBlob(caption.file, {
|
||||||
encoding: 'text/vtt',
|
encoding: 'text/vtt',
|
||||||
})
|
})
|
||||||
return {lang: caption.lang, file: data.blob}
|
return {lang: caption.lang, file: res.body.blob}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -414,7 +409,11 @@ async function resolveMedia(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
$type: 'app.bsky.embed.video',
|
$type: 'app.bsky.embed.video',
|
||||||
video: videoDraft.pendingPublish.blobRef,
|
/*
|
||||||
|
* The video pipeline still reads its blob off the legacy agent, so
|
||||||
|
* normalize it to the lex shape before it reaches the lex write.
|
||||||
|
*/
|
||||||
|
video: fromLegacyBlobRef(videoDraft.pendingPublish.blobRef),
|
||||||
alt: videoDraft.altText || undefined,
|
alt: videoDraft.altText || undefined,
|
||||||
captions: captions.length === 0 ? undefined : captions,
|
captions: captions.length === 0 ? undefined : captions,
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
@@ -429,17 +428,17 @@ async function resolveMedia(
|
|||||||
agent,
|
agent,
|
||||||
gifDraft.gif,
|
gifDraft.gif,
|
||||||
)
|
)
|
||||||
let blob: BlobRef | undefined
|
let blob: app.bsky.embed.external.External['thumb']
|
||||||
if (resolvedGif.thumb) {
|
if (resolvedGif.thumb) {
|
||||||
onStateChange?.(t`Uploading link thumbnail...`)
|
onStateChange?.(t`Uploading link thumbnail...`)
|
||||||
const {path, mime} = resolvedGif.thumb.source
|
const {path, mime} = resolvedGif.thumb.source
|
||||||
const response = await uploadBlob(agent, path, mime)
|
const response = await uploadBlob(pdsClient, path, mime)
|
||||||
blob = response.data.blob
|
blob = response.blob
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
$type: 'app.bsky.embed.external',
|
$type: 'app.bsky.embed.external',
|
||||||
external: {
|
external: {
|
||||||
uri: resolvedGif.uri,
|
uri: resolvedGif.uri as UriString,
|
||||||
title: resolvedGif.title,
|
title: resolvedGif.title,
|
||||||
description: createGIFDescription(resolvedGif.title, gifDraft.alt),
|
description: createGIFDescription(resolvedGif.title, gifDraft.alt),
|
||||||
thumb: blob,
|
thumb: blob,
|
||||||
@@ -453,21 +452,23 @@ async function resolveMedia(
|
|||||||
embedDraft.link.uri,
|
embedDraft.link.uri,
|
||||||
)
|
)
|
||||||
if (resolvedLink.type === 'external') {
|
if (resolvedLink.type === 'external') {
|
||||||
let blob: BlobRef | undefined
|
let blob: app.bsky.embed.external.External['thumb']
|
||||||
if (resolvedLink.thumb) {
|
if (resolvedLink.thumb) {
|
||||||
onStateChange?.(t`Uploading link thumbnail...`)
|
onStateChange?.(t`Uploading link thumbnail...`)
|
||||||
const {path, mime} = resolvedLink.thumb.source
|
const {path, mime} = resolvedLink.thumb.source
|
||||||
const response = await uploadBlob(agent, path, mime)
|
const response = await uploadBlob(pdsClient, path, mime)
|
||||||
blob = response.data.blob
|
blob = response.blob
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
$type: 'app.bsky.embed.external',
|
$type: 'app.bsky.embed.external',
|
||||||
external: {
|
external: {
|
||||||
uri: resolvedLink.uri,
|
uri: resolvedLink.uri as UriString,
|
||||||
title: resolvedLink.title,
|
title: resolvedLink.title,
|
||||||
description: resolvedLink.description,
|
description: resolvedLink.description,
|
||||||
thumb: blob,
|
thumb: blob,
|
||||||
associatedRefs: resolvedLink.associatedRefs,
|
associatedRefs: resolvedLink.associatedRefs as
|
||||||
|
| com.atproto.repo.strongRef.Main[]
|
||||||
|
| undefined,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -478,7 +479,7 @@ async function resolveMedia(
|
|||||||
return {
|
return {
|
||||||
$type: 'app.bsky.embed.external',
|
$type: 'app.bsky.embed.external',
|
||||||
external: {
|
external: {
|
||||||
uri: resolvedLink.uri,
|
uri: resolvedLink.uri as UriString,
|
||||||
title: resolvedLink.view.name,
|
title: resolvedLink.view.name,
|
||||||
description: `${resolvedLink.view.memberCount}/${resolvedLink.view.memberLimit}`,
|
description: `${resolvedLink.view.memberCount}/${resolvedLink.view.memberLimit}`,
|
||||||
},
|
},
|
||||||
@@ -488,101 +489,18 @@ async function resolveMedia(
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* `resolve.ts` still resolves through the agent and returns legacy-typed refs;
|
||||||
|
* assert at the boundary until it moves to the clients.
|
||||||
|
*/
|
||||||
async function resolveRecord(
|
async function resolveRecord(
|
||||||
agent: AtpAgent,
|
agent: AtpAgent,
|
||||||
queryClient: QueryClient,
|
queryClient: QueryClient,
|
||||||
uri: string,
|
uri: string,
|
||||||
): Promise<ComAtprotoRepoStrongRef.Main> {
|
): Promise<com.atproto.repo.strongRef.Main> {
|
||||||
const resolvedLink = await fetchResolveLinkQuery(queryClient, agent, uri)
|
const resolvedLink = await fetchResolveLinkQuery(queryClient, agent, uri)
|
||||||
if (resolvedLink.type !== 'record') {
|
if (resolvedLink.type !== 'record') {
|
||||||
throw Error(t`Expected uri to resolve to a record`)
|
throw Error(t`Expected uri to resolve to a record`)
|
||||||
}
|
}
|
||||||
return resolvedLink.record
|
return resolvedLink.record as com.atproto.repo.strongRef.Main
|
||||||
}
|
|
||||||
|
|
||||||
// The built-in hashing functions from multiformats (`multiformats/hashes/sha2`)
|
|
||||||
// are meant for Node.js, this is the cross-platform equivalent.
|
|
||||||
const mf_sha256 = Hasher.from({
|
|
||||||
name: 'sha2-256',
|
|
||||||
code: 0x12,
|
|
||||||
encode: input => {
|
|
||||||
const digest = sha256.arrayBuffer(input)
|
|
||||||
return new Uint8Array(digest)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
async function computeCid(record: AppBskyFeedPost.Record): Promise<string> {
|
|
||||||
/*
|
|
||||||
* Lazily loaded since it's only needed when posting a thread, and its
|
|
||||||
* `cborg` dependency is ~190KB that would otherwise be in the initial
|
|
||||||
* web bundle.
|
|
||||||
*/
|
|
||||||
const dcbor = await import('@ipld/dag-cbor')
|
|
||||||
// IMPORTANT: `prepareObject` prepares the record to be hashed by removing
|
|
||||||
// fields with undefined value, and converting BlobRef instances to the
|
|
||||||
// right IPLD representation.
|
|
||||||
const prepared = prepareForHashing(record)
|
|
||||||
// 1. Encode the record into DAG-CBOR format
|
|
||||||
const encoded = dcbor.encode(prepared)
|
|
||||||
// 2. Hash the record in SHA-256 (code 0x12)
|
|
||||||
const digest = await mf_sha256.digest(encoded)
|
|
||||||
// 3. Create a CIDv1, specifying DAG-CBOR as content (code 0x71)
|
|
||||||
const cid = CID.createV1(0x71, digest)
|
|
||||||
// 4. Get the Base32 representation of the CID (`b` prefix)
|
|
||||||
return cid.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns a transformed version of the object for use in DAG-CBOR.
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
function prepareForHashing(v: any): any {
|
|
||||||
// IMPORTANT: BlobRef#ipld() returns the correct object we need for hashing,
|
|
||||||
// the API client will convert this for you but we're hashing in the client,
|
|
||||||
// so we need it *now*.
|
|
||||||
if (v instanceof BlobRef) {
|
|
||||||
return v.ipld()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Walk through arrays
|
|
||||||
if (Array.isArray(v)) {
|
|
||||||
let pure = true
|
|
||||||
const mapped = v.map(value => {
|
|
||||||
if (value !== (value = prepareForHashing(value))) {
|
|
||||||
pure = false
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
})
|
|
||||||
return pure ? v : mapped
|
|
||||||
}
|
|
||||||
|
|
||||||
// Walk through plain objects
|
|
||||||
if (isPlainObject(v)) {
|
|
||||||
const obj: Record<string, unknown> = {}
|
|
||||||
let pure = true
|
|
||||||
for (const key in v) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
||||||
let value = v[key]
|
|
||||||
// `value` is undefined
|
|
||||||
if (value === undefined) {
|
|
||||||
pure = false
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// `prepareObject` returned a value that's different from what we had before
|
|
||||||
if (value !== (value = prepareForHashing(value))) {
|
|
||||||
pure = false
|
|
||||||
}
|
|
||||||
obj[key] = value
|
|
||||||
}
|
|
||||||
// Return as is if we haven't needed to tamper with anything
|
|
||||||
return pure ? v : obj
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
function isPlainObject(v: any): boolean {
|
|
||||||
if (typeof v !== 'object' || v === null) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
const proto = Object.getPrototypeOf(v)
|
|
||||||
return proto === Object.prototype || proto === null
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a legacy `BlobRef` class instance to the plain-JSON lex blob shape.
|
||||||
|
*
|
||||||
|
* Required for any blob that reaches a lex write: the lex serializer walks
|
||||||
|
* plain objects, so a class instance goes on the wire with its internal
|
||||||
|
* `original` field and no `$type`. `ipld()` yields exactly the lex shape, and
|
||||||
|
* hashes identically (see `computeCid.test.ts` case 2b).
|
||||||
|
*
|
||||||
|
* Only the video pipeline still needs this - it reads its blob off the legacy
|
||||||
|
* agent (`app.bsky.video.getJobStatus`). Drop it when the video client moves.
|
||||||
|
*/
|
||||||
|
export function fromLegacyBlobRef(blob: BlobRef): LexBlobRef {
|
||||||
|
return blob.ipld()
|
||||||
|
}
|
||||||
@@ -1,38 +1,62 @@
|
|||||||
import {copyAsync} from 'expo-file-system/legacy'
|
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'
|
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(
|
export async function uploadBlob(
|
||||||
agent: AtpAgent,
|
client: Client,
|
||||||
input: string | Blob,
|
input: string | Blob,
|
||||||
encoding?: string,
|
encoding?: string,
|
||||||
): Promise<ComAtprotoRepoUploadBlob.Response> {
|
): Promise<UploadBlobResult> {
|
||||||
if (typeof input === 'string' && input.startsWith('file:')) {
|
if (typeof input === 'string' && input.startsWith('file:')) {
|
||||||
const blob = await asBlob(input)
|
const blob = await asBlob(input)
|
||||||
return agent.uploadBlob(blob, {encoding})
|
return uploadBlobResult(client, blob, encoding)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof input === 'string' && input.startsWith('/')) {
|
if (typeof input === 'string' && input.startsWith('/')) {
|
||||||
const blob = await asBlob(`file://${input}`)
|
const blob = await asBlob(`file://${input}`)
|
||||||
return agent.uploadBlob(blob, {encoding})
|
return uploadBlobResult(client, blob, encoding)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof input === 'string' && input.startsWith('data:')) {
|
if (typeof input === 'string' && input.startsWith('data:')) {
|
||||||
const blob = await fetch(input).then(r => r.blob())
|
const blob = await fetch(input).then(r => r.blob())
|
||||||
return agent.uploadBlob(blob, {encoding})
|
return uploadBlobResult(client, blob, encoding)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input instanceof Blob) {
|
if (input instanceof Blob) {
|
||||||
return agent.uploadBlob(input, {encoding})
|
return uploadBlobResult(client, input, encoding)
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new TypeError(`Invalid uploadBlob input: ${typeof input}`)
|
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> {
|
async function asBlob(uri: string): Promise<Blob> {
|
||||||
return withSafeFile(uri, async safeUri => {
|
return withSafeFile(uri, async safeUri => {
|
||||||
// Note
|
// 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
|
* @note It is recommended, on web, to use the `file` instance of the file
|
||||||
* selector input element, rather than a `data:` URL, to avoid
|
* selector input element, rather than a `data:` URL, to avoid
|
||||||
* loading the file into memory. `File` extends `Blob` "file" instances can
|
* loading the file into memory. `File` extends `Blob` "file" instances can
|
||||||
* be passed directly to this function.
|
* 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(
|
export async function uploadBlob(
|
||||||
agent: AtpAgent,
|
client: Client,
|
||||||
input: string | Blob,
|
input: string | Blob,
|
||||||
encoding?: string,
|
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 (
|
if (
|
||||||
typeof input === 'string' &&
|
typeof input === 'string' &&
|
||||||
(input.startsWith('data:') || input.startsWith('blob:'))
|
(input.startsWith('data:') || input.startsWith('blob:'))
|
||||||
) {
|
) {
|
||||||
const blob = await fetch(input).then(r => r.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) {
|
if (input instanceof Blob) {
|
||||||
return agent.uploadBlob(input, {
|
const res = await client.uploadBlob(input, {encoding: enc})
|
||||||
encoding,
|
return {blob: res.body.blob}
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new TypeError(`Invalid uploadBlob input: ${typeof input}`)
|
throw new TypeError(`Invalid uploadBlob input: ${typeof input}`)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {Trans} from '@lingui/react/macro'
|
|||||||
import {useQueryClient} from '@tanstack/react-query'
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {uploadBlob} from '#/lib/api'
|
import {uploadBlob} from '#/lib/api'
|
||||||
|
import {toLegacyBlobRef} from '#/lib/api/legacy-blob'
|
||||||
import {
|
import {
|
||||||
BSKY_APP_ACCOUNT_DID,
|
BSKY_APP_ACCOUNT_DID,
|
||||||
DISCOVER_SAVED_FEED,
|
DISCOVER_SAVED_FEED,
|
||||||
@@ -153,7 +154,7 @@ export function StepFinished() {
|
|||||||
const {imageUri, imageMime} = profileStepResults
|
const {imageUri, imageMime} = profileStepResults
|
||||||
const blobPromise =
|
const blobPromise =
|
||||||
imageUri && imageMime
|
imageUri && imageMime
|
||||||
? uploadBlob(agent, imageUri, imageMime)
|
? uploadBlob(pdsClient, imageUri, imageMime)
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
await agent.upsertProfile(async existing => {
|
await agent.upsertProfile(async existing => {
|
||||||
@@ -161,8 +162,8 @@ export function StepFinished() {
|
|||||||
|
|
||||||
if (blobPromise) {
|
if (blobPromise) {
|
||||||
const res = await blobPromise
|
const res = await blobPromise
|
||||||
if (res.data.blob) {
|
if (res.blob) {
|
||||||
next.avatar = res.data.blob
|
next.avatar = toLegacyBlobRef(res.blob)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {type AppBskyGraphDefs} from '@atproto/api'
|
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 {
|
import {
|
||||||
type AtIdentifierString,
|
type AtIdentifierString,
|
||||||
AtUri,
|
AtUri,
|
||||||
@@ -56,7 +56,6 @@ export interface ListCreateMutateParams {
|
|||||||
export function useListCreateMutation() {
|
export function useListCreateMutation() {
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const agent = useAgent()
|
|
||||||
const appviewClient = useAppviewClient()
|
const appviewClient = useAppviewClient()
|
||||||
const pdsClient = usePdsClient()
|
const pdsClient = usePdsClient()
|
||||||
return useMutation<{uri: string; cid: string}, Error, ListCreateMutateParams>(
|
return useMutation<{uri: string; cid: string}, Error, ListCreateMutateParams>(
|
||||||
@@ -86,12 +85,8 @@ export function useListCreateMutation() {
|
|||||||
createdAt: toDatetimeString(new Date()),
|
createdAt: toDatetimeString(new Date()),
|
||||||
}
|
}
|
||||||
if (avatar) {
|
if (avatar) {
|
||||||
const blobRes = await uploadBlob(agent, avatar.path, avatar.mime)
|
const blobRes = await uploadBlob(pdsClient, avatar.path, avatar.mime)
|
||||||
/*
|
record.avatar = blobRes.blob
|
||||||
* `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 res = await pdsClient.create(app.bsky.graph.list, record)
|
const res = await pdsClient.create(app.bsky.graph.list, record)
|
||||||
|
|
||||||
@@ -120,7 +115,6 @@ export interface ListMetadataMutateParams {
|
|||||||
}
|
}
|
||||||
export function useListMetadataMutation() {
|
export function useListMetadataMutation() {
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const agent = useAgent()
|
|
||||||
const appviewClient = useAppviewClient()
|
const appviewClient = useAppviewClient()
|
||||||
const pdsClient = usePdsClient()
|
const pdsClient = usePdsClient()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
@@ -149,8 +143,8 @@ export function useListMetadataMutation() {
|
|||||||
record.description = description
|
record.description = description
|
||||||
record.descriptionFacets = descriptionFacets
|
record.descriptionFacets = descriptionFacets
|
||||||
if (avatar) {
|
if (avatar) {
|
||||||
const blobRes = await uploadBlob(agent, avatar.path, avatar.mime)
|
const blobRes = await uploadBlob(pdsClient, avatar.path, avatar.mime)
|
||||||
record.avatar = blobRes.data.blob as unknown as l.BlobRef
|
record.avatar = blobRes.blob
|
||||||
} else if (avatar === null) {
|
} else if (avatar === null) {
|
||||||
record.avatar = undefined
|
record.avatar = undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
type AppBskyGraphGetFollows,
|
type AppBskyGraphGetFollows,
|
||||||
type AtpAgent,
|
type AtpAgent,
|
||||||
AtUri,
|
AtUri,
|
||||||
type ComAtprotoRepoUploadBlob,
|
|
||||||
type Un$Typed,
|
type Un$Typed,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {
|
import {
|
||||||
@@ -25,6 +24,7 @@ import {
|
|||||||
} from '@tanstack/react-query'
|
} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {uploadBlob} from '#/lib/api'
|
import {uploadBlob} from '#/lib/api'
|
||||||
|
import {toLegacyBlobRef} from '#/lib/api/legacy-blob'
|
||||||
import {until} from '#/lib/async/until'
|
import {until} from '#/lib/async/until'
|
||||||
import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue'
|
import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue'
|
||||||
import {updateProfileShadow} from '#/state/cache/profile-shadow'
|
import {updateProfileShadow} from '#/state/cache/profile-shadow'
|
||||||
@@ -149,6 +149,7 @@ interface ProfileUpdateParams {
|
|||||||
export function useProfileUpdateMutation() {
|
export function useProfileUpdateMutation() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
|
const pdsClient = usePdsClient()
|
||||||
const updateProfileVerificationCache = useUpdateProfileVerificationCache()
|
const updateProfileVerificationCache = useUpdateProfileVerificationCache()
|
||||||
return useMutation<void, Error, ProfileUpdateParams>({
|
return useMutation<void, Error, ProfileUpdateParams>({
|
||||||
mutationFn: async ({
|
mutationFn: async ({
|
||||||
@@ -158,22 +159,18 @@ export function useProfileUpdateMutation() {
|
|||||||
newUserBanner,
|
newUserBanner,
|
||||||
checkCommitted,
|
checkCommitted,
|
||||||
}) => {
|
}) => {
|
||||||
let newUserAvatarPromise:
|
let newUserAvatarPromise: ReturnType<typeof uploadBlob> | undefined
|
||||||
| Promise<ComAtprotoRepoUploadBlob.Response>
|
|
||||||
| undefined
|
|
||||||
if (newUserAvatar) {
|
if (newUserAvatar) {
|
||||||
newUserAvatarPromise = uploadBlob(
|
newUserAvatarPromise = uploadBlob(
|
||||||
agent,
|
pdsClient,
|
||||||
newUserAvatar.path,
|
newUserAvatar.path,
|
||||||
newUserAvatar.mime,
|
newUserAvatar.mime,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
let newUserBannerPromise:
|
let newUserBannerPromise: ReturnType<typeof uploadBlob> | undefined
|
||||||
| Promise<ComAtprotoRepoUploadBlob.Response>
|
|
||||||
| undefined
|
|
||||||
if (newUserBanner) {
|
if (newUserBanner) {
|
||||||
newUserBannerPromise = uploadBlob(
|
newUserBannerPromise = uploadBlob(
|
||||||
agent,
|
pdsClient,
|
||||||
newUserBanner.path,
|
newUserBanner.path,
|
||||||
newUserBanner.mime,
|
newUserBanner.mime,
|
||||||
)
|
)
|
||||||
@@ -191,13 +188,13 @@ export function useProfileUpdateMutation() {
|
|||||||
}
|
}
|
||||||
if (newUserAvatarPromise) {
|
if (newUserAvatarPromise) {
|
||||||
const res = await newUserAvatarPromise
|
const res = await newUserAvatarPromise
|
||||||
next.avatar = res.data.blob
|
next.avatar = toLegacyBlobRef(res.blob)
|
||||||
} else if (newUserAvatar === null) {
|
} else if (newUserAvatar === null) {
|
||||||
next.avatar = undefined
|
next.avatar = undefined
|
||||||
}
|
}
|
||||||
if (newUserBannerPromise) {
|
if (newUserBannerPromise) {
|
||||||
const res = await newUserBannerPromise
|
const res = await newUserBannerPromise
|
||||||
next.banner = res.data.blob
|
next.banner = toLegacyBlobRef(res.blob)
|
||||||
} else if (newUserBanner === null) {
|
} else if (newUserBanner === null) {
|
||||||
next.banner = undefined
|
next.banner = undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,12 @@ import {
|
|||||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||||
import {useProfileQuery} from '#/state/queries/profile'
|
import {useProfileQuery} from '#/state/queries/profile'
|
||||||
import {resolveLinkQueryOptions} from '#/state/queries/resolve-link'
|
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 {useComposerControls} from '#/state/shell/composer'
|
||||||
import {type ComposerOpts, type OnPostSuccessData} from '#/state/shell/composer'
|
import {type ComposerOpts, type OnPostSuccessData} from '#/state/shell/composer'
|
||||||
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
|
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
|
||||||
@@ -277,6 +282,7 @@ export const ComposePost = ({
|
|||||||
: VIDEO_MAX_DURATION_MS
|
: VIDEO_MAX_DURATION_MS
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
const client = useAppviewClient()
|
const client = useAppviewClient()
|
||||||
|
const pdsClient = usePdsClient()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const currentDid = currentAccount!.did
|
const currentDid = currentAccount!.did
|
||||||
const {closeComposer} = useComposerControls()
|
const {closeComposer} = useComposerControls()
|
||||||
@@ -1084,6 +1090,7 @@ export const ComposePost = ({
|
|||||||
onStateChange: setPublishingStage,
|
onStateChange: setPublishingStage,
|
||||||
langs: currentLanguages,
|
langs: currentLanguages,
|
||||||
appviewClient: client,
|
appviewClient: client,
|
||||||
|
pdsClient,
|
||||||
})
|
})
|
||||||
).uris[0]
|
).uris[0]
|
||||||
|
|
||||||
@@ -1279,6 +1286,7 @@ export const ComposePost = ({
|
|||||||
ax,
|
ax,
|
||||||
agent,
|
agent,
|
||||||
client,
|
client,
|
||||||
|
pdsClient,
|
||||||
canPost,
|
canPost,
|
||||||
isPublishing,
|
isPublishing,
|
||||||
currentLanguages,
|
currentLanguages,
|
||||||
|
|||||||
Reference in New Issue
Block a user