-
Notifications
You must be signed in to change notification settings - Fork 1.8k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[Wrong Branch] Implement posting threads #5960
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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,102 +56,118 @@ 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, | ||
embed, | ||
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, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. pretty subtle: if we're replying inside a thread, the root is the root of that thread. so we just always carry it forward |
||
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@mary-ext i changed this from your original logic to increment from the actual posting timestamp — not that it matters but would avoid threads always having fake milliseconds. does this look good to you? mdn says they don't overflow and would be added up correctly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Seems good, would give some variance into users' post time, because that sorting bug can end up affecting post visibility as well last I checked.
if two users you follow were to post with the exact same timestamp then one of them will never show in your timeline.