Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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>

authored by

dan
Dan Abramov
Mary
and committed by
GitHub
01c9ac0e 4c314033

+241 -69
+26
metro.config.js
··· 6 6 ? process.env.RN_SRC_EXT.split(',').concat(cfg.resolver.sourceExts) 7 7 : cfg.resolver.sourceExts 8 8 9 + if (cfg.resolver.resolveRequest) { 10 + throw Error('Update this override because it is conflicting now.') 11 + } 12 + cfg.resolver.resolveRequest = (context, moduleName, platform) => { 13 + // HACK: manually resolve a few packages that use `exports` in `package.json`. 14 + // A proper solution is to enable `unstable_enablePackageExports` but this needs careful testing. 15 + if (moduleName.startsWith('multiformats/hashes/hasher')) { 16 + return context.resolveRequest( 17 + context, 18 + 'multiformats/dist/src/hashes/hasher', 19 + platform, 20 + ) 21 + } 22 + if (moduleName.startsWith('multiformats/cid')) { 23 + return context.resolveRequest( 24 + context, 25 + 'multiformats/dist/src/cid', 26 + platform, 27 + ) 28 + } 29 + if (moduleName === '@ipld/dag-cbor') { 30 + return context.resolveRequest(context, '@ipld/dag-cbor/src', platform) 31 + } 32 + return context.resolveRequest(context, moduleName, platform) 33 + } 34 + 9 35 cfg.transformer.getTransformOptions = async () => ({ 10 36 transform: { 11 37 experimentalImportSupport: true,
+2
package.json
··· 69 69 "@fortawesome/free-solid-svg-icons": "^6.1.1", 70 70 "@fortawesome/react-native-fontawesome": "^0.3.2", 71 71 "@haileyok/bluesky-video": "0.2.3", 72 + "@ipld/dag-cbor": "^9.2.0", 72 73 "@lingui/react": "^4.5.0", 73 74 "@mattermost/react-native-paste-input": "^0.7.1", 74 75 "@miblanchard/react-native-slider": "^2.3.1", ··· 158 159 "lodash.set": "^4.3.2", 159 160 "lodash.shuffle": "^4.2.0", 160 161 "lodash.throttle": "^4.1.1", 162 + "multiformats": "^13.1.0", 161 163 "nanoid": "^5.0.5", 162 164 "normalize-url": "^8.0.0", 163 165 "patch-package": "^6.5.1",
+6 -2
src/lib/api/feed/custom.ts
··· 128 128 headers: {'Accept-Language': contentLangs, ...labelersHeader}, 129 129 }, 130 130 ) 131 - let data = res.ok ? jsonStringToLex(await res.text()) : null 131 + let data = res.ok 132 + ? (jsonStringToLex(await res.text()) as GetCustomFeed.OutputSchema) 133 + : null 132 134 if (data?.feed?.length) { 133 135 return { 134 136 success: true, ··· 143 145 }&limit=${limit}`, 144 146 {method: 'GET', headers: {'Accept-Language': '', ...labelersHeader}}, 145 147 ) 146 - data = res.ok ? jsonStringToLex(await res.text()) : null 148 + data = res.ok 149 + ? (jsonStringToLex(await res.text()) as GetCustomFeed.OutputSchema) 150 + : null 147 151 if (data?.feed?.length) { 148 152 return { 149 153 success: true,
+160 -63
src/lib/api/index.ts
··· 5 5 AppBskyEmbedRecordWithMedia, 6 6 AppBskyEmbedVideo, 7 7 AppBskyFeedPost, 8 - AppBskyFeedPostgate, 9 8 AtUri, 10 9 BlobRef, 11 10 BskyAgent, ··· 15 14 RichText, 16 15 } from '@atproto/api' 17 16 import {TID} from '@atproto/common-web' 17 + import * as dcbor from '@ipld/dag-cbor' 18 18 import {t} from '@lingui/macro' 19 19 import {QueryClient} from '@tanstack/react-query' 20 + import {sha256} from 'js-sha256' 21 + import {CID} from 'multiformats/cid' 22 + import * as Hasher from 'multiformats/hashes/hasher' 20 23 21 24 import {isNetworkError} from '#/lib/strings/errors' 22 25 import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' ··· 53 56 opts: PostOpts, 54 57 ) { 55 58 const thread = opts.thread 56 - const draft = thread.posts[0] // TODO: Support threads. 57 - 58 59 opts.onStateChange?.(t`Processing...`) 59 - // NB -- Do not await anything here to avoid waterfalls! 60 - // Instead, store Promises which will be unwrapped as they're needed. 61 - const rtPromise = resolveRT(agent, draft.richtext) 62 - const embedPromise = resolveEmbed( 63 - agent, 64 - queryClient, 65 - draft, 66 - opts.onStateChange, 67 - ) 68 - let replyPromise 60 + 61 + let replyPromise: 62 + | Promise<AppBskyFeedPost.Record['reply']> 63 + | AppBskyFeedPost.Record['reply'] 64 + | undefined 69 65 if (opts.replyTo) { 66 + // Not awaited to avoid waterfalls. 70 67 replyPromise = resolveReply(agent, opts.replyTo) 71 68 } 72 69 73 - // set labels 74 - let labels: ComAtprotoLabelDefs.SelfLabels | undefined 75 - if (draft.labels.length) { 76 - labels = { 77 - $type: 'com.atproto.label.defs#selfLabels', 78 - values: draft.labels.map(val => ({val})), 79 - } 80 - } 81 - 82 70 // add top 3 languages from user preferences if langs is provided 83 71 let langs = opts.langs 84 72 if (opts.langs) { 85 73 langs = opts.langs.slice(0, 3) 86 74 } 87 75 88 - const rkey = TID.nextStr() 89 - const uri = `at://${agent.assertDid}/app.bsky.feed.post/${rkey}` 90 - const date = new Date().toISOString() 91 - 76 + const did = agent.assertDid 92 77 const writes: ComAtprotoRepoApplyWrites.Create[] = [] 78 + const uris: string[] = [] 93 79 94 - // Create post record 95 - { 80 + let now = new Date() 81 + let tid: TID | undefined 82 + 83 + for (let i = 0; i < thread.posts.length; i++) { 84 + const draft = thread.posts[i] 85 + 86 + // Not awaited to avoid waterfalls. 87 + const rtPromise = resolveRT(agent, draft.richtext) 88 + const embedPromise = resolveEmbed( 89 + agent, 90 + queryClient, 91 + draft, 92 + opts.onStateChange, 93 + ) 94 + let labels: ComAtprotoLabelDefs.SelfLabels | undefined 95 + if (draft.labels.length) { 96 + labels = { 97 + $type: 'com.atproto.label.defs#selfLabels', 98 + values: draft.labels.map(val => ({val})), 99 + } 100 + } 101 + 102 + // The sorting behavior for multiple posts sharing the same createdAt time is 103 + // undefined, so what we'll do here is increment the time by 1 for every post 104 + now.setMilliseconds(now.getMilliseconds() + 1) 105 + tid = TID.next(tid) 106 + const rkey = tid.toString() 107 + const uri = `at://${did}/app.bsky.feed.post/${rkey}` 108 + uris.push(uri) 109 + 96 110 const rt = await rtPromise 97 111 const embed = await embedPromise 98 112 const reply = await replyPromise 99 113 const record: AppBskyFeedPost.Record = { 114 + // IMPORTANT: $type has to exist, CID is calculated with the `$type` field 115 + // present and will produce the wrong CID if you omit it. 100 116 $type: 'app.bsky.feed.post', 101 - createdAt: date, 117 + createdAt: now.toISOString(), 102 118 text: rt.text, 103 119 facets: rt.facets, 104 120 reply, ··· 106 122 langs, 107 123 labels, 108 124 } 109 - 110 125 writes.push({ 111 126 $type: 'com.atproto.repo.applyWrites#create', 112 127 collection: 'app.bsky.feed.post', 113 128 rkey: rkey, 114 129 value: record, 115 130 }) 116 - } 117 131 118 - // Create threadgate record 119 - if (thread.threadgate.some(tg => tg.type !== 'everybody')) { 120 - const record = createThreadgateRecord({ 121 - createdAt: date, 122 - post: uri, 123 - allow: threadgateAllowUISettingToAllowRecordValue(thread.threadgate), 124 - }) 132 + if (i === 0 && thread.threadgate.some(tg => tg.type !== 'everybody')) { 133 + writes.push({ 134 + $type: 'com.atproto.repo.applyWrites#create', 135 + collection: 'app.bsky.feed.threadgate', 136 + rkey: rkey, 137 + value: createThreadgateRecord({ 138 + createdAt: now.toISOString(), 139 + post: uri, 140 + allow: threadgateAllowUISettingToAllowRecordValue(thread.threadgate), 141 + }), 142 + }) 143 + } 125 144 126 - writes.push({ 127 - $type: 'com.atproto.repo.applyWrites#create', 128 - collection: 'app.bsky.feed.threadgate', 129 - rkey: rkey, 130 - value: record, 131 - }) 132 - } 145 + if ( 146 + thread.postgate.embeddingRules?.length || 147 + thread.postgate.detachedEmbeddingUris?.length 148 + ) { 149 + writes.push({ 150 + $type: 'com.atproto.repo.applyWrites#create', 151 + collection: 'app.bsky.feed.postgate', 152 + rkey: rkey, 153 + value: { 154 + ...thread.postgate, 155 + $type: 'app.bsky.feed.postgate', 156 + createdAt: now.toISOString(), 157 + post: uri, 158 + }, 159 + }) 160 + } 133 161 134 - // Create postgate record 135 - if ( 136 - thread.postgate.embeddingRules?.length || 137 - thread.postgate.detachedEmbeddingUris?.length 138 - ) { 139 - const record: AppBskyFeedPostgate.Record = { 140 - ...thread.postgate, 141 - $type: 'app.bsky.feed.postgate', 142 - createdAt: date, 143 - post: uri, 162 + // Prepare a ref to the current post for the next post in the thread. 163 + const ref = { 164 + cid: await computeCid(record), 165 + uri, 166 + } 167 + replyPromise = { 168 + root: reply?.root ?? ref, 169 + parent: ref, 144 170 } 145 - 146 - writes.push({ 147 - $type: 'com.atproto.repo.applyWrites#create', 148 - collection: 'app.bsky.feed.postgate', 149 - rkey: rkey, 150 - value: record, 151 - }) 152 171 } 153 172 154 173 try { ··· 170 189 } 171 190 } 172 191 173 - return {uri} 192 + return {uris} 174 193 } 175 194 176 195 async function resolveRT(agent: BskyAgent, richtext: RichText) { ··· 382 401 } 383 402 return resolvedLink.record 384 403 } 404 + 405 + // The built-in hashing functions from multiformats (`multiformats/hashes/sha2`) 406 + // are meant for Node.js, this is the cross-platform equivalent. 407 + const mf_sha256 = Hasher.from({ 408 + name: 'sha2-256', 409 + code: 0x12, 410 + encode: input => { 411 + const digest = sha256.arrayBuffer(input) 412 + return new Uint8Array(digest) 413 + }, 414 + }) 415 + 416 + async function computeCid(record: AppBskyFeedPost.Record): Promise<string> { 417 + // IMPORTANT: `prepareObject` prepares the record to be hashed by removing 418 + // fields with undefined value, and converting BlobRef instances to the 419 + // right IPLD representation. 420 + const prepared = prepareForHashing(record) 421 + // 1. Encode the record into DAG-CBOR format 422 + const encoded = dcbor.encode(prepared) 423 + // 2. Hash the record in SHA-256 (code 0x12) 424 + const digest = await mf_sha256.digest(encoded) 425 + // 3. Create a CIDv1, specifying DAG-CBOR as content (code 0x71) 426 + const cid = CID.createV1(0x71, digest) 427 + // 4. Get the Base32 representation of the CID (`b` prefix) 428 + return cid.toString() 429 + } 430 + 431 + // Returns a transformed version of the object for use in DAG-CBOR. 432 + function prepareForHashing(v: any): any { 433 + // IMPORTANT: BlobRef#ipld() returns the correct object we need for hashing, 434 + // the API client will convert this for you but we're hashing in the client, 435 + // so we need it *now*. 436 + if (v instanceof BlobRef) { 437 + return v.ipld() 438 + } 439 + 440 + // Walk through arrays 441 + if (Array.isArray(v)) { 442 + let pure = true 443 + const mapped = v.map(value => { 444 + if (value !== (value = prepareForHashing(value))) { 445 + pure = false 446 + } 447 + return value 448 + }) 449 + return pure ? v : mapped 450 + } 451 + 452 + // Walk through plain objects 453 + if (isPlainObject(v)) { 454 + const obj: any = {} 455 + let pure = true 456 + for (const key in v) { 457 + let value = v[key] 458 + // `value` is undefined 459 + if (value === undefined) { 460 + pure = false 461 + continue 462 + } 463 + // `prepareObject` returned a value that's different from what we had before 464 + if (value !== (value = prepareForHashing(value))) { 465 + pure = false 466 + } 467 + obj[key] = value 468 + } 469 + // Return as is if we haven't needed to tamper with anything 470 + return pure ? v : obj 471 + } 472 + return v 473 + } 474 + 475 + function isPlainObject(v: any): boolean { 476 + if (typeof v !== 'object' || v === null) { 477 + return false 478 + } 479 + const proto = Object.getPrototypeOf(v) 480 + return proto === Object.prototype || proto === null 481 + }
+1 -1
src/view/com/composer/Composer.tsx
··· 366 366 onStateChange: setPublishingStage, 367 367 langs: toPostLanguages(langPrefs.postLanguage), 368 368 }) 369 - ).uri 369 + ).uris[0] 370 370 try { 371 371 await whenAppViewReady(agent, postUri, res => { 372 372 const postedThread = res.data.thread
+46 -3
yarn.lock
··· 4209 4209 cborg "^1.6.0" 4210 4210 multiformats "^9.5.4" 4211 4211 4212 + "@ipld/dag-cbor@^9.2.0": 4213 + version "9.2.1" 4214 + resolved "https://registry.yarnpkg.com/@ipld/dag-cbor/-/dag-cbor-9.2.1.tgz#e61f413770bb0fb27ffafa9577049869272d2056" 4215 + integrity sha512-nyY48yE7r3dnJVlxrdaimrbloh4RokQaNRdI//btfTkcTEZbpmSrbYcBQ4VKTf8ZxXAOUJy4VsRpkJo+y9RTnA== 4216 + dependencies: 4217 + cborg "^4.0.0" 4218 + multiformats "^13.1.0" 4219 + 4212 4220 "@isaacs/cliui@^8.0.2": 4213 4221 version "8.0.2" 4214 4222 resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" ··· 9023 9031 version "1.10.2" 9024 9032 resolved "https://registry.yarnpkg.com/cborg/-/cborg-1.10.2.tgz#83cd581b55b3574c816f82696307c7512db759a1" 9025 9033 integrity sha512-b3tFPA9pUr2zCUiCfRd2+wok2/LBSNUMKOuRRok+WlvvAgEt/PlbgPTsZUcwCOs53IJvLgTp0eotwtosE6njug== 9034 + 9035 + cborg@^4.0.0: 9036 + version "4.2.6" 9037 + resolved "https://registry.yarnpkg.com/cborg/-/cborg-4.2.6.tgz#7491c29986a87c647d6e2c232e64c82214ca660e" 9038 + integrity sha512-77vo4KlSwfjCIXcyZUVei4l2gdjesSCeYSx4U/Upwix7pcWZq8uw21sVRpjwn7mjEi//ieJPTj1MRWDHmud1Rg== 9026 9039 9027 9040 chalk@5.3.0: 9028 9041 version "5.3.0" ··· 15817 15830 dns-packet "^5.2.2" 15818 15831 thunky "^1.0.2" 15819 15832 15833 + multiformats@^13.1.0: 15834 + version "13.3.0" 15835 + resolved "https://registry.yarnpkg.com/multiformats/-/multiformats-13.3.0.tgz#1f5188bc7c4fe08ff829ae1c18dc33409042fb71" 15836 + integrity sha512-CBiqvsufgmpo01VT5ze94O+uc+Pbf6f/sThlvWss0sBZmAOu6GQn5usrYV2sf2mr17FWYc0rO8c/CNe2T90QAA== 15837 + 15820 15838 multiformats@^9.4.2, multiformats@^9.5.4, multiformats@^9.6.4, multiformats@^9.9.0: 15821 15839 version "9.9.0" 15822 15840 resolved "https://registry.yarnpkg.com/multiformats/-/multiformats-9.9.0.tgz#c68354e7d21037a8f1f8833c8ccd68618e8f1d37" ··· 19645 19663 resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4" 19646 19664 integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== 19647 19665 19648 - "string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: 19666 + "string-width-cjs@npm:string-width@^4.2.0": 19667 + version "4.2.3" 19668 + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" 19669 + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== 19670 + dependencies: 19671 + emoji-regex "^8.0.0" 19672 + is-fullwidth-code-point "^3.0.0" 19673 + strip-ansi "^6.0.1" 19674 + 19675 + string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: 19649 19676 version "4.2.3" 19650 19677 resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" 19651 19678 integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== ··· 19754 19781 is-obj "^1.0.1" 19755 19782 is-regexp "^1.0.0" 19756 19783 19757 - "strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: 19784 + "strip-ansi-cjs@npm:strip-ansi@^6.0.1": 19758 19785 version "6.0.1" 19759 19786 resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" 19760 19787 integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== ··· 19767 19794 integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA== 19768 19795 dependencies: 19769 19796 ansi-regex "^4.1.0" 19797 + 19798 + strip-ansi@^6.0.0, strip-ansi@^6.0.1: 19799 + version "6.0.1" 19800 + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" 19801 + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== 19802 + dependencies: 19803 + ansi-regex "^5.0.1" 19770 19804 19771 19805 strip-ansi@^7.0.1: 19772 19806 version "7.1.0" ··· 21483 21517 "@types/trusted-types" "^2.0.2" 21484 21518 workbox-core "6.6.1" 21485 21519 21486 - "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: 21520 + "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": 21487 21521 version "7.0.0" 21488 21522 resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" 21489 21523 integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== ··· 21496 21530 version "6.2.0" 21497 21531 resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" 21498 21532 integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== 21533 + dependencies: 21534 + ansi-styles "^4.0.0" 21535 + string-width "^4.1.0" 21536 + strip-ansi "^6.0.0" 21537 + 21538 + wrap-ansi@^7.0.0: 21539 + version "7.0.0" 21540 + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" 21541 + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== 21499 21542 dependencies: 21500 21543 ansi-styles "^4.0.0" 21501 21544 string-width "^4.1.0"