about summary refs log tree commit diff
diff options
context:
space:
mode:
authordan <dan.abramov@gmail.com>2024-11-01 03:42:38 +0000
committerGitHub <noreply@github.com>2024-11-01 03:42:38 +0000
commit01c9ac0e13e959bae9ab221cd0a724a70c222772 (patch)
tree7b80d2cdde328753276a171adff0f06f79f83ef5
parent4c31403330abeba2c0b9e910239c18672e5fcb0d (diff)
downloadvoidsky-01c9ac0e13e959bae9ab221cd0a724a70c222772.tar.zst
Implement posting threads (#6049)
* Implement posting a thread

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>

* 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>
-rw-r--r--metro.config.js26
-rw-r--r--package.json2
-rw-r--r--src/lib/api/feed/custom.ts8
-rw-r--r--src/lib/api/index.ts223
-rw-r--r--src/view/com/composer/Composer.tsx2
-rw-r--r--yarn.lock49
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']>
+    | 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<string> {
+  // 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"