Skip to content
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 4 commits into from
Nov 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions metro.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
8 changes: 6 additions & 2 deletions src/lib/api/feed/custom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
223 changes: 160 additions & 63 deletions src/lib/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
AppBskyEmbedRecordWithMedia,
AppBskyEmbedVideo,
AppBskyFeedPost,
AppBskyFeedPostgate,
AtUri,
BlobRef,
BskyAgent,
Expand All @@ -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'
Expand Down Expand Up @@ -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)
Copy link
Collaborator Author

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.

Copy link
Contributor

@mary-ext mary-ext Oct 27, 2024

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.

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,
Copy link
Collaborator Author

@gaearon gaearon Oct 27, 2024

Choose a reason for hiding this comment

The 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 {
Expand All @@ -170,7 +189,7 @@ export async function post(
}
}

return {uri}
return {uris}
}

async function resolveRT(agent: BskyAgent, richtext: RichText) {
Expand Down Expand Up @@ -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
}
2 changes: 1 addition & 1 deletion src/view/com/composer/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading