From 01c9ac0e13e959bae9ab221cd0a724a70c222772 Mon Sep 17 00:00:00 2001 From: dan Date: Fri, 1 Nov 2024 03:42:38 +0000 Subject: Implement posting threads (#6049) * Implement posting a thread Co-authored-by: Dan Abramov * Fix native build * Remove dependency on web crypto API * Fix unrelated TS error (wtf) --------- Co-authored-by: Mary <148872143+mary-ext@users.noreply.github.com> --- metro.config.js | 26 +++++ package.json | 2 + src/lib/api/feed/custom.ts | 8 +- src/lib/api/index.ts | 223 ++++++++++++++++++++++++++----------- src/view/com/composer/Composer.tsx | 2 +- yarn.lock | 49 +++++++- 6 files changed, 241 insertions(+), 69 deletions(-) diff --git a/metro.config.js b/metro.config.js index a49d95f9a..ad0a54fc8 100644 --- a/metro.config.js +++ b/metro.config.js @@ -6,6 +6,32 @@ cfg.resolver.sourceExts = process.env.RN_SRC_EXT ? process.env.RN_SRC_EXT.split(',').concat(cfg.resolver.sourceExts) : cfg.resolver.sourceExts +if (cfg.resolver.resolveRequest) { + throw Error('Update this override because it is conflicting now.') +} +cfg.resolver.resolveRequest = (context, moduleName, platform) => { + // HACK: manually resolve a few packages that use `exports` in `package.json`. + // A proper solution is to enable `unstable_enablePackageExports` but this needs careful testing. + if (moduleName.startsWith('multiformats/hashes/hasher')) { + return context.resolveRequest( + context, + 'multiformats/dist/src/hashes/hasher', + platform, + ) + } + if (moduleName.startsWith('multiformats/cid')) { + return context.resolveRequest( + context, + 'multiformats/dist/src/cid', + platform, + ) + } + if (moduleName === '@ipld/dag-cbor') { + return context.resolveRequest(context, '@ipld/dag-cbor/src', platform) + } + return context.resolveRequest(context, moduleName, platform) +} + cfg.transformer.getTransformOptions = async () => ({ transform: { experimentalImportSupport: true, diff --git a/package.json b/package.json index 69fe73e46..a5d2a43a4 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "@fortawesome/free-solid-svg-icons": "^6.1.1", "@fortawesome/react-native-fontawesome": "^0.3.2", "@haileyok/bluesky-video": "0.2.3", + "@ipld/dag-cbor": "^9.2.0", "@lingui/react": "^4.5.0", "@mattermost/react-native-paste-input": "^0.7.1", "@miblanchard/react-native-slider": "^2.3.1", @@ -158,6 +159,7 @@ "lodash.set": "^4.3.2", "lodash.shuffle": "^4.2.0", "lodash.throttle": "^4.1.1", + "multiformats": "^13.1.0", "nanoid": "^5.0.5", "normalize-url": "^8.0.0", "patch-package": "^6.5.1", diff --git a/src/lib/api/feed/custom.ts b/src/lib/api/feed/custom.ts index c5e0a35f5..dbb02467f 100644 --- a/src/lib/api/feed/custom.ts +++ b/src/lib/api/feed/custom.ts @@ -128,7 +128,9 @@ async function loggedOutFetch({ headers: {'Accept-Language': contentLangs, ...labelersHeader}, }, ) - let data = res.ok ? jsonStringToLex(await res.text()) : null + let data = res.ok + ? (jsonStringToLex(await res.text()) as GetCustomFeed.OutputSchema) + : null if (data?.feed?.length) { return { success: true, @@ -143,7 +145,9 @@ async function loggedOutFetch({ }&limit=${limit}`, {method: 'GET', headers: {'Accept-Language': '', ...labelersHeader}}, ) - data = res.ok ? jsonStringToLex(await res.text()) : null + data = res.ok + ? (jsonStringToLex(await res.text()) as GetCustomFeed.OutputSchema) + : null if (data?.feed?.length) { return { success: true, diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 46dbd1e66..75b9938fc 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -5,7 +5,6 @@ import { AppBskyEmbedRecordWithMedia, AppBskyEmbedVideo, AppBskyFeedPost, - AppBskyFeedPostgate, AtUri, BlobRef, BskyAgent, @@ -15,8 +14,12 @@ import { RichText, } from '@atproto/api' import {TID} from '@atproto/common-web' +import * as dcbor from '@ipld/dag-cbor' import {t} from '@lingui/macro' import {QueryClient} from '@tanstack/react-query' +import {sha256} from 'js-sha256' +import {CID} from 'multiformats/cid' +import * as Hasher from 'multiformats/hashes/hasher' import {isNetworkError} from '#/lib/strings/errors' import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' @@ -53,52 +56,65 @@ export async function post( opts: PostOpts, ) { const thread = opts.thread - const draft = thread.posts[0] // TODO: Support threads. - opts.onStateChange?.(t`Processing...`) - // NB -- Do not await anything here to avoid waterfalls! - // Instead, store Promises which will be unwrapped as they're needed. - const rtPromise = resolveRT(agent, draft.richtext) - const embedPromise = resolveEmbed( - agent, - queryClient, - draft, - opts.onStateChange, - ) - let replyPromise + + let replyPromise: + | Promise + | AppBskyFeedPost.Record['reply'] + | undefined if (opts.replyTo) { + // Not awaited to avoid waterfalls. replyPromise = resolveReply(agent, opts.replyTo) } - // set labels - let labels: ComAtprotoLabelDefs.SelfLabels | undefined - if (draft.labels.length) { - labels = { - $type: 'com.atproto.label.defs#selfLabels', - values: draft.labels.map(val => ({val})), - } - } - // add top 3 languages from user preferences if langs is provided let langs = opts.langs if (opts.langs) { langs = opts.langs.slice(0, 3) } - const rkey = TID.nextStr() - const uri = `at://${agent.assertDid}/app.bsky.feed.post/${rkey}` - const date = new Date().toISOString() - + const did = agent.assertDid const writes: ComAtprotoRepoApplyWrites.Create[] = [] + const uris: string[] = [] + + let now = new Date() + let tid: TID | undefined + + for (let i = 0; i < thread.posts.length; i++) { + const draft = thread.posts[i] + + // Not awaited to avoid waterfalls. + const rtPromise = resolveRT(agent, draft.richtext) + const embedPromise = resolveEmbed( + agent, + queryClient, + draft, + opts.onStateChange, + ) + let labels: ComAtprotoLabelDefs.SelfLabels | undefined + if (draft.labels.length) { + labels = { + $type: 'com.atproto.label.defs#selfLabels', + values: draft.labels.map(val => ({val})), + } + } + + // The sorting behavior for multiple posts sharing the same createdAt time is + // undefined, so what we'll do here is increment the time by 1 for every post + now.setMilliseconds(now.getMilliseconds() + 1) + tid = TID.next(tid) + const rkey = tid.toString() + const uri = `at://${did}/app.bsky.feed.post/${rkey}` + uris.push(uri) - // Create post record - { const rt = await rtPromise const embed = await embedPromise const reply = await replyPromise const record: AppBskyFeedPost.Record = { + // IMPORTANT: $type has to exist, CID is calculated with the `$type` field + // present and will produce the wrong CID if you omit it. $type: 'app.bsky.feed.post', - createdAt: date, + createdAt: now.toISOString(), text: rt.text, facets: rt.facets, reply, @@ -106,49 +122,52 @@ export async function post( langs, labels, } - writes.push({ $type: 'com.atproto.repo.applyWrites#create', collection: 'app.bsky.feed.post', rkey: rkey, value: record, }) - } - - // Create threadgate record - if (thread.threadgate.some(tg => tg.type !== 'everybody')) { - const record = createThreadgateRecord({ - createdAt: date, - post: uri, - allow: threadgateAllowUISettingToAllowRecordValue(thread.threadgate), - }) - writes.push({ - $type: 'com.atproto.repo.applyWrites#create', - collection: 'app.bsky.feed.threadgate', - rkey: rkey, - value: record, - }) - } + if (i === 0 && thread.threadgate.some(tg => tg.type !== 'everybody')) { + writes.push({ + $type: 'com.atproto.repo.applyWrites#create', + collection: 'app.bsky.feed.threadgate', + rkey: rkey, + value: createThreadgateRecord({ + createdAt: now.toISOString(), + post: uri, + allow: threadgateAllowUISettingToAllowRecordValue(thread.threadgate), + }), + }) + } - // Create postgate record - if ( - thread.postgate.embeddingRules?.length || - thread.postgate.detachedEmbeddingUris?.length - ) { - const record: AppBskyFeedPostgate.Record = { - ...thread.postgate, - $type: 'app.bsky.feed.postgate', - createdAt: date, - post: uri, + if ( + thread.postgate.embeddingRules?.length || + thread.postgate.detachedEmbeddingUris?.length + ) { + writes.push({ + $type: 'com.atproto.repo.applyWrites#create', + collection: 'app.bsky.feed.postgate', + rkey: rkey, + value: { + ...thread.postgate, + $type: 'app.bsky.feed.postgate', + createdAt: now.toISOString(), + post: uri, + }, + }) } - writes.push({ - $type: 'com.atproto.repo.applyWrites#create', - collection: 'app.bsky.feed.postgate', - rkey: rkey, - value: record, - }) + // Prepare a ref to the current post for the next post in the thread. + const ref = { + cid: await computeCid(record), + uri, + } + replyPromise = { + root: reply?.root ?? ref, + parent: ref, + } } try { @@ -170,7 +189,7 @@ export async function post( } } - return {uri} + return {uris} } async function resolveRT(agent: BskyAgent, richtext: RichText) { @@ -382,3 +401,81 @@ async function resolveRecord( } return resolvedLink.record } + +// 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 { + // 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. +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: any = {} + let pure = true + for (const key in v) { + 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 +} + +function isPlainObject(v: any): boolean { + if (typeof v !== 'object' || v === null) { + return false + } + const proto = Object.getPrototypeOf(v) + return proto === Object.prototype || proto === null +} diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index b464a88fc..129869e47 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -366,7 +366,7 @@ export const ComposePost = ({ onStateChange: setPublishingStage, langs: toPostLanguages(langPrefs.postLanguage), }) - ).uri + ).uris[0] try { await whenAppViewReady(agent, postUri, res => { const postedThread = res.data.thread diff --git a/yarn.lock b/yarn.lock index bdad0bda4..09c837238 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4209,6 +4209,14 @@ cborg "^1.6.0" multiformats "^9.5.4" +"@ipld/dag-cbor@^9.2.0": + version "9.2.1" + resolved "https://registry.yarnpkg.com/@ipld/dag-cbor/-/dag-cbor-9.2.1.tgz#e61f413770bb0fb27ffafa9577049869272d2056" + integrity sha512-nyY48yE7r3dnJVlxrdaimrbloh4RokQaNRdI//btfTkcTEZbpmSrbYcBQ4VKTf8ZxXAOUJy4VsRpkJo+y9RTnA== + dependencies: + cborg "^4.0.0" + multiformats "^13.1.0" + "@isaacs/cliui@^8.0.2": version "8.0.2" resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" @@ -9024,6 +9032,11 @@ cborg@^1.6.0: resolved "https://registry.yarnpkg.com/cborg/-/cborg-1.10.2.tgz#83cd581b55b3574c816f82696307c7512db759a1" integrity sha512-b3tFPA9pUr2zCUiCfRd2+wok2/LBSNUMKOuRRok+WlvvAgEt/PlbgPTsZUcwCOs53IJvLgTp0eotwtosE6njug== +cborg@^4.0.0: + version "4.2.6" + resolved "https://registry.yarnpkg.com/cborg/-/cborg-4.2.6.tgz#7491c29986a87c647d6e2c232e64c82214ca660e" + integrity sha512-77vo4KlSwfjCIXcyZUVei4l2gdjesSCeYSx4U/Upwix7pcWZq8uw21sVRpjwn7mjEi//ieJPTj1MRWDHmud1Rg== + chalk@5.3.0: version "5.3.0" resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.3.0.tgz#67c20a7ebef70e7f3970a01f90fa210cb6860385" @@ -15817,6 +15830,11 @@ multicast-dns@^7.2.5: dns-packet "^5.2.2" thunky "^1.0.2" +multiformats@^13.1.0: + version "13.3.0" + resolved "https://registry.yarnpkg.com/multiformats/-/multiformats-13.3.0.tgz#1f5188bc7c4fe08ff829ae1c18dc33409042fb71" + integrity sha512-CBiqvsufgmpo01VT5ze94O+uc+Pbf6f/sThlvWss0sBZmAOu6GQn5usrYV2sf2mr17FWYc0rO8c/CNe2T90QAA== + multiformats@^9.4.2, multiformats@^9.5.4, multiformats@^9.6.4, multiformats@^9.9.0: version "9.9.0" resolved "https://registry.yarnpkg.com/multiformats/-/multiformats-9.9.0.tgz#c68354e7d21037a8f1f8833c8ccd68618e8f1d37" @@ -19645,7 +19663,16 @@ string-natural-compare@^3.0.1: resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4" integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -19754,7 +19781,7 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -19768,6 +19795,13 @@ strip-ansi@^5.0.0, strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -21483,7 +21517,7 @@ workbox-window@6.6.1: "@types/trusted-types" "^2.0.2" workbox-core "6.6.1" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -21501,6 +21535,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.0.1, wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" -- cgit 1.4.1