mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import {
2 AppBskyEmbedExternal,
3 AppBskyEmbedImages,
4 AppBskyEmbedRecord,
5 AppBskyEmbedRecordWithMedia,
6 AppBskyEmbedVideo,
7 AppBskyFeedPost,
8 AtUri,
9 BlobRef,
10 BskyAgent,
11 ComAtprotoLabelDefs,
12 ComAtprotoRepoApplyWrites,
13 ComAtprotoRepoStrongRef,
14 RichText,
15} from '@atproto/api'
16import {TID} from '@atproto/common-web'
17import * as dcbor from '@ipld/dag-cbor'
18import {t} from '@lingui/macro'
19import {QueryClient} from '@tanstack/react-query'
20import {sha256} from 'js-sha256'
21import {CID} from 'multiformats/cid'
22import * as Hasher from 'multiformats/hashes/hasher'
23
24import {isNetworkError} from '#/lib/strings/errors'
25import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
26import {logger} from '#/logger'
27import {compressImage} from '#/state/gallery'
28import {
29 fetchResolveGifQuery,
30 fetchResolveLinkQuery,
31} from '#/state/queries/resolve-link'
32import {
33 createThreadgateRecord,
34 threadgateAllowUISettingToAllowRecordValue,
35} from '#/state/queries/threadgate'
36import {
37 EmbedDraft,
38 PostDraft,
39 ThreadDraft,
40} from '#/view/com/composer/state/composer'
41import {createGIFDescription} from '../gif-alt-text'
42import {uploadBlob} from './upload-blob'
43
44export {uploadBlob}
45
46interface PostOpts {
47 thread: ThreadDraft
48 replyTo?: string
49 onStateChange?: (state: string) => void
50 langs?: string[]
51}
52
53export async function post(
54 agent: BskyAgent,
55 queryClient: QueryClient,
56 opts: PostOpts,
57) {
58 const thread = opts.thread
59 opts.onStateChange?.(t`Processing...`)
60
61 let replyPromise:
62 | Promise<AppBskyFeedPost.Record['reply']>
63 | AppBskyFeedPost.Record['reply']
64 | undefined
65 if (opts.replyTo) {
66 // Not awaited to avoid waterfalls.
67 replyPromise = resolveReply(agent, opts.replyTo)
68 }
69
70 // add top 3 languages from user preferences if langs is provided
71 let langs = opts.langs
72 if (opts.langs) {
73 langs = opts.langs.slice(0, 3)
74 }
75
76 const did = agent.assertDid
77 const writes: ComAtprotoRepoApplyWrites.Create[] = []
78 const uris: string[] = []
79
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
110 const rt = await rtPromise
111 const embed = await embedPromise
112 const reply = await replyPromise
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.
116 $type: 'app.bsky.feed.post',
117 createdAt: now.toISOString(),
118 text: rt.text,
119 facets: rt.facets,
120 reply,
121 embed,
122 langs,
123 labels,
124 }
125 writes.push({
126 $type: 'com.atproto.repo.applyWrites#create',
127 collection: 'app.bsky.feed.post',
128 rkey: rkey,
129 value: record,
130 })
131
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 }
144
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 }
161
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,
170 }
171 }
172
173 try {
174 await agent.com.atproto.repo.applyWrites({
175 repo: agent.assertDid,
176 writes: writes,
177 validate: true,
178 })
179 } catch (e: any) {
180 logger.error(`Failed to create post`, {
181 safeMessage: e.message,
182 })
183 if (isNetworkError(e)) {
184 throw new Error(
185 t`Post failed to upload. Please check your Internet connection and try again.`,
186 )
187 } else {
188 throw e
189 }
190 }
191
192 return {uris}
193}
194
195async function resolveRT(agent: BskyAgent, richtext: RichText) {
196 let rt = new RichText({text: richtext.text.trimEnd()}, {cleanNewlines: true})
197 await rt.detectFacets(agent)
198
199 rt = shortenLinks(rt)
200 rt = stripInvalidMentions(rt)
201 return rt
202}
203
204async function resolveReply(agent: BskyAgent, replyTo: string) {
205 const replyToUrip = new AtUri(replyTo)
206 const parentPost = await agent.getPost({
207 repo: replyToUrip.host,
208 rkey: replyToUrip.rkey,
209 })
210 if (parentPost) {
211 const parentRef = {
212 uri: parentPost.uri,
213 cid: parentPost.cid,
214 }
215 return {
216 root: parentPost.value.reply?.root || parentRef,
217 parent: parentRef,
218 }
219 }
220}
221
222async function resolveEmbed(
223 agent: BskyAgent,
224 queryClient: QueryClient,
225 draft: PostDraft,
226 onStateChange: ((state: string) => void) | undefined,
227): Promise<
228 | AppBskyEmbedImages.Main
229 | AppBskyEmbedVideo.Main
230 | AppBskyEmbedExternal.Main
231 | AppBskyEmbedRecord.Main
232 | AppBskyEmbedRecordWithMedia.Main
233 | undefined
234> {
235 if (draft.embed.quote) {
236 const [resolvedMedia, resolvedQuote] = await Promise.all([
237 resolveMedia(agent, queryClient, draft.embed, onStateChange),
238 resolveRecord(agent, queryClient, draft.embed.quote.uri),
239 ])
240 if (resolvedMedia) {
241 return {
242 $type: 'app.bsky.embed.recordWithMedia',
243 record: {
244 $type: 'app.bsky.embed.record',
245 record: resolvedQuote,
246 },
247 media: resolvedMedia,
248 }
249 }
250 return {
251 $type: 'app.bsky.embed.record',
252 record: resolvedQuote,
253 }
254 }
255 const resolvedMedia = await resolveMedia(
256 agent,
257 queryClient,
258 draft.embed,
259 onStateChange,
260 )
261 if (resolvedMedia) {
262 return resolvedMedia
263 }
264 if (draft.embed.link) {
265 const resolvedLink = await fetchResolveLinkQuery(
266 queryClient,
267 agent,
268 draft.embed.link.uri,
269 )
270 if (resolvedLink.type === 'record') {
271 return {
272 $type: 'app.bsky.embed.record',
273 record: resolvedLink.record,
274 }
275 }
276 }
277 return undefined
278}
279
280async function resolveMedia(
281 agent: BskyAgent,
282 queryClient: QueryClient,
283 embedDraft: EmbedDraft,
284 onStateChange: ((state: string) => void) | undefined,
285): Promise<
286 | AppBskyEmbedExternal.Main
287 | AppBskyEmbedImages.Main
288 | AppBskyEmbedVideo.Main
289 | undefined
290> {
291 if (embedDraft.media?.type === 'images') {
292 const imagesDraft = embedDraft.media.images
293 logger.debug(`Uploading images`, {
294 count: imagesDraft.length,
295 })
296 onStateChange?.(t`Uploading images...`)
297 const images: AppBskyEmbedImages.Image[] = await Promise.all(
298 imagesDraft.map(async (image, i) => {
299 logger.debug(`Compressing image #${i}`)
300 const {path, width, height, mime} = await compressImage(image)
301 logger.debug(`Uploading image #${i}`)
302 const res = await uploadBlob(agent, path, mime)
303 return {
304 image: res.data.blob,
305 alt: image.alt,
306 aspectRatio: {width, height},
307 }
308 }),
309 )
310 return {
311 $type: 'app.bsky.embed.images',
312 images,
313 }
314 }
315 if (
316 embedDraft.media?.type === 'video' &&
317 embedDraft.media.video.status === 'done'
318 ) {
319 const videoDraft = embedDraft.media.video
320 const captions = await Promise.all(
321 videoDraft.captions
322 .filter(caption => caption.lang !== '')
323 .map(async caption => {
324 const {data} = await agent.uploadBlob(caption.file, {
325 encoding: 'text/vtt',
326 })
327 return {lang: caption.lang, file: data.blob}
328 }),
329 )
330 return {
331 $type: 'app.bsky.embed.video',
332 video: videoDraft.pendingPublish.blobRef,
333 alt: videoDraft.altText || undefined,
334 captions: captions.length === 0 ? undefined : captions,
335 aspectRatio: {
336 width: videoDraft.asset.width,
337 height: videoDraft.asset.height,
338 },
339 }
340 }
341 if (embedDraft.media?.type === 'gif') {
342 const gifDraft = embedDraft.media
343 const resolvedGif = await fetchResolveGifQuery(
344 queryClient,
345 agent,
346 gifDraft.gif,
347 )
348 let blob: BlobRef | undefined
349 if (resolvedGif.thumb) {
350 onStateChange?.(t`Uploading link thumbnail...`)
351 const {path, mime} = resolvedGif.thumb.source
352 const response = await uploadBlob(agent, path, mime)
353 blob = response.data.blob
354 }
355 return {
356 $type: 'app.bsky.embed.external',
357 external: {
358 uri: resolvedGif.uri,
359 title: resolvedGif.title,
360 description: createGIFDescription(resolvedGif.title, gifDraft.alt),
361 thumb: blob,
362 },
363 }
364 }
365 if (embedDraft.link) {
366 const resolvedLink = await fetchResolveLinkQuery(
367 queryClient,
368 agent,
369 embedDraft.link.uri,
370 )
371 if (resolvedLink.type === 'external') {
372 let blob: BlobRef | undefined
373 if (resolvedLink.thumb) {
374 onStateChange?.(t`Uploading link thumbnail...`)
375 const {path, mime} = resolvedLink.thumb.source
376 const response = await uploadBlob(agent, path, mime)
377 blob = response.data.blob
378 }
379 return {
380 $type: 'app.bsky.embed.external',
381 external: {
382 uri: resolvedLink.uri,
383 title: resolvedLink.title,
384 description: resolvedLink.description,
385 thumb: blob,
386 },
387 }
388 }
389 }
390 return undefined
391}
392
393async function resolveRecord(
394 agent: BskyAgent,
395 queryClient: QueryClient,
396 uri: string,
397): Promise<ComAtprotoRepoStrongRef.Main> {
398 const resolvedLink = await fetchResolveLinkQuery(queryClient, agent, uri)
399 if (resolvedLink.type !== 'record') {
400 throw Error(t`Expected uri to resolve to a record`)
401 }
402 return resolvedLink.record
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.
407const 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
416async 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.
432function 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
475function 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}