mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import {
2 type $Typed,
3 type AppBskyEmbedExternal,
4 type AppBskyEmbedImages,
5 type AppBskyEmbedRecord,
6 type AppBskyEmbedRecordWithMedia,
7 type AppBskyEmbedVideo,
8 type AppBskyFeedPost,
9 AtUri,
10 BlobRef,
11 type BskyAgent,
12 type ComAtprotoLabelDefs,
13 type ComAtprotoRepoApplyWrites,
14 type ComAtprotoRepoStrongRef,
15 RichText,
16} from '@atproto/api'
17import {TID} from '@atproto/common-web'
18import * as dcbor from '@ipld/dag-cbor'
19import {t} from '@lingui/macro'
20import {type QueryClient} from '@tanstack/react-query'
21import {sha256} from 'js-sha256'
22import {CID} from 'multiformats/cid'
23import * as Hasher from 'multiformats/hashes/hasher'
24
25import {isNetworkError} from '#/lib/strings/errors'
26import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
27import {logger} from '#/logger'
28import {compressImage} from '#/state/gallery'
29import {
30 fetchResolveGifQuery,
31 fetchResolveLinkQuery,
32} from '#/state/queries/resolve-link'
33import {
34 createThreadgateRecord,
35 threadgateAllowUISettingToAllowRecordValue,
36} from '#/state/queries/threadgate'
37import {
38 type EmbedDraft,
39 type PostDraft,
40 type ThreadDraft,
41} from '#/view/com/composer/state/composer'
42import {createGIFDescription} from '../gif-alt-text'
43import {uploadBlob} from './upload-blob'
44
45export {uploadBlob}
46
47interface PostOpts {
48 thread: ThreadDraft
49 replyTo?: string
50 onStateChange?: (state: string) => void
51 langs?: string[]
52}
53
54export async function post(
55 agent: BskyAgent,
56 queryClient: QueryClient,
57 opts: PostOpts,
58) {
59 const thread = opts.thread
60 opts.onStateChange?.(t`Processing...`)
61
62 let replyPromise:
63 | Promise<AppBskyFeedPost.Record['reply']>
64 | AppBskyFeedPost.Record['reply']
65 | undefined
66 if (opts.replyTo) {
67 // Not awaited to avoid waterfalls.
68 replyPromise = resolveReply(agent, opts.replyTo)
69 }
70
71 // add top 3 languages from user preferences if langs is provided
72 let langs = opts.langs
73 if (opts.langs) {
74 langs = opts.langs.slice(0, 3)
75 }
76
77 const did = agent.assertDid
78 const writes: $Typed<ComAtprotoRepoApplyWrites.Create>[] = []
79 const uris: string[] = []
80
81 let now = new Date()
82 let tid: TID | undefined
83
84 for (let i = 0; i < thread.posts.length; i++) {
85 const draft = thread.posts[i]
86
87 // Not awaited to avoid waterfalls.
88 const rtPromise = resolveRT(agent, draft.richtext)
89 const embedPromise = resolveEmbed(
90 agent,
91 queryClient,
92 draft,
93 opts.onStateChange,
94 )
95 let labels: $Typed<ComAtprotoLabelDefs.SelfLabels> | undefined
96 if (draft.labels.length) {
97 labels = {
98 $type: 'com.atproto.label.defs#selfLabels',
99 values: draft.labels.map(val => ({val})),
100 }
101 }
102
103 // The sorting behavior for multiple posts sharing the same createdAt time is
104 // undefined, so what we'll do here is increment the time by 1 for every post
105 now.setMilliseconds(now.getMilliseconds() + 1)
106 tid = TID.next(tid)
107 const rkey = tid.toString()
108 const uri = `at://${did}/app.bsky.feed.post/${rkey}`
109 uris.push(uri)
110
111 const rt = await rtPromise
112 const embed = await embedPromise
113 const reply = await replyPromise
114 const record: AppBskyFeedPost.Record = {
115 // IMPORTANT: $type has to exist, CID is calculated with the `$type` field
116 // present and will produce the wrong CID if you omit it.
117 $type: 'app.bsky.feed.post',
118 createdAt: now.toISOString(),
119 text: rt.text,
120 facets: rt.facets,
121 reply,
122 embed,
123 langs,
124 labels,
125 }
126 writes.push({
127 $type: 'com.atproto.repo.applyWrites#create',
128 collection: 'app.bsky.feed.post',
129 rkey: rkey,
130 value: record,
131 })
132
133 if (i === 0 && thread.threadgate.some(tg => tg.type !== 'everybody')) {
134 writes.push({
135 $type: 'com.atproto.repo.applyWrites#create',
136 collection: 'app.bsky.feed.threadgate',
137 rkey: rkey,
138 value: createThreadgateRecord({
139 createdAt: now.toISOString(),
140 post: uri,
141 allow: threadgateAllowUISettingToAllowRecordValue(thread.threadgate),
142 }),
143 })
144 }
145
146 if (
147 thread.postgate.embeddingRules?.length ||
148 thread.postgate.detachedEmbeddingUris?.length
149 ) {
150 writes.push({
151 $type: 'com.atproto.repo.applyWrites#create',
152 collection: 'app.bsky.feed.postgate',
153 rkey: rkey,
154 value: {
155 ...thread.postgate,
156 $type: 'app.bsky.feed.postgate',
157 createdAt: now.toISOString(),
158 post: uri,
159 },
160 })
161 }
162
163 // Prepare a ref to the current post for the next post in the thread.
164 const ref = {
165 cid: await computeCid(record),
166 uri,
167 }
168 replyPromise = {
169 root: reply?.root ?? ref,
170 parent: ref,
171 }
172 }
173
174 try {
175 await agent.com.atproto.repo.applyWrites({
176 repo: agent.assertDid,
177 writes: writes,
178 validate: true,
179 })
180 } catch (e: any) {
181 logger.error(`Failed to create post`, {
182 safeMessage: e.message,
183 })
184 if (isNetworkError(e)) {
185 throw new Error(
186 t`Post failed to upload. Please check your Internet connection and try again.`,
187 )
188 } else {
189 throw e
190 }
191 }
192
193 return {uris}
194}
195
196async function resolveRT(agent: BskyAgent, richtext: RichText) {
197 const trimmedText = richtext.text
198 // Trim leading whitespace-only lines (but don't break ASCII art).
199 .replace(/^(\s*\n)+/, '')
200 // Trim any trailing whitespace.
201 .trimEnd()
202 let rt = new RichText({text: trimmedText}, {cleanNewlines: true})
203 await rt.detectFacets(agent)
204
205 rt = shortenLinks(rt)
206 rt = stripInvalidMentions(rt)
207 return rt
208}
209
210async function resolveReply(agent: BskyAgent, replyTo: string) {
211 const replyToUrip = new AtUri(replyTo)
212 const parentPost = await agent.getPost({
213 repo: replyToUrip.host,
214 rkey: replyToUrip.rkey,
215 })
216 if (parentPost) {
217 const parentRef = {
218 uri: parentPost.uri,
219 cid: parentPost.cid,
220 }
221 return {
222 root: parentPost.value.reply?.root || parentRef,
223 parent: parentRef,
224 }
225 }
226}
227
228async function resolveEmbed(
229 agent: BskyAgent,
230 queryClient: QueryClient,
231 draft: PostDraft,
232 onStateChange: ((state: string) => void) | undefined,
233): Promise<
234 | $Typed<AppBskyEmbedImages.Main>
235 | $Typed<AppBskyEmbedVideo.Main>
236 | $Typed<AppBskyEmbedExternal.Main>
237 | $Typed<AppBskyEmbedRecord.Main>
238 | $Typed<AppBskyEmbedRecordWithMedia.Main>
239 | undefined
240> {
241 if (draft.embed.quote) {
242 const [resolvedMedia, resolvedQuote] = await Promise.all([
243 resolveMedia(agent, queryClient, draft.embed, onStateChange),
244 resolveRecord(agent, queryClient, draft.embed.quote.uri),
245 ])
246 if (resolvedMedia) {
247 return {
248 $type: 'app.bsky.embed.recordWithMedia',
249 record: {
250 $type: 'app.bsky.embed.record',
251 record: resolvedQuote,
252 },
253 media: resolvedMedia,
254 }
255 }
256 return {
257 $type: 'app.bsky.embed.record',
258 record: resolvedQuote,
259 }
260 }
261 const resolvedMedia = await resolveMedia(
262 agent,
263 queryClient,
264 draft.embed,
265 onStateChange,
266 )
267 if (resolvedMedia) {
268 return resolvedMedia
269 }
270 if (draft.embed.link) {
271 const resolvedLink = await fetchResolveLinkQuery(
272 queryClient,
273 agent,
274 draft.embed.link.uri,
275 )
276 if (resolvedLink.type === 'record') {
277 return {
278 $type: 'app.bsky.embed.record',
279 record: resolvedLink.record,
280 }
281 }
282 }
283 return undefined
284}
285
286async function resolveMedia(
287 agent: BskyAgent,
288 queryClient: QueryClient,
289 embedDraft: EmbedDraft,
290 onStateChange: ((state: string) => void) | undefined,
291): Promise<
292 | $Typed<AppBskyEmbedExternal.Main>
293 | $Typed<AppBskyEmbedImages.Main>
294 | $Typed<AppBskyEmbedVideo.Main>
295 | undefined
296> {
297 if (embedDraft.media?.type === 'images') {
298 const imagesDraft = embedDraft.media.images
299 logger.debug(`Uploading images`, {
300 count: imagesDraft.length,
301 })
302 onStateChange?.(t`Uploading images...`)
303 const images: AppBskyEmbedImages.Image[] = await Promise.all(
304 imagesDraft.map(async (image, i) => {
305 logger.debug(`Compressing image #${i}`)
306 const {path, width, height, mime} = await compressImage(image)
307 logger.debug(`Uploading image #${i}`)
308 const res = await uploadBlob(agent, path, mime)
309 return {
310 image: res.data.blob,
311 alt: image.alt,
312 aspectRatio: {width, height},
313 }
314 }),
315 )
316 return {
317 $type: 'app.bsky.embed.images',
318 images,
319 }
320 }
321 if (
322 embedDraft.media?.type === 'video' &&
323 embedDraft.media.video.status === 'done'
324 ) {
325 const videoDraft = embedDraft.media.video
326 const captions = await Promise.all(
327 videoDraft.captions
328 .filter(caption => caption.lang !== '')
329 .map(async caption => {
330 const {data} = await agent.uploadBlob(caption.file, {
331 encoding: 'text/vtt',
332 })
333 return {lang: caption.lang, file: data.blob}
334 }),
335 )
336
337 // lexicon numbers must be floats
338 const width = Math.round(videoDraft.asset.width)
339 const height = Math.round(videoDraft.asset.height)
340
341 // aspect ratio values must be >0 - better to leave as unset otherwise
342 // posting will fail if aspect ratio is set to 0
343 const aspectRatio = width > 0 && height > 0 ? {width, height} : undefined
344
345 if (!aspectRatio) {
346 logger.error(
347 `Invalid aspect ratio - got { width: ${videoDraft.asset.width}, height: ${videoDraft.asset.height} }`,
348 )
349 }
350
351 return {
352 $type: 'app.bsky.embed.video',
353 video: videoDraft.pendingPublish.blobRef,
354 alt: videoDraft.altText || undefined,
355 captions: captions.length === 0 ? undefined : captions,
356 aspectRatio,
357 }
358 }
359 if (embedDraft.media?.type === 'gif') {
360 const gifDraft = embedDraft.media
361 const resolvedGif = await fetchResolveGifQuery(
362 queryClient,
363 agent,
364 gifDraft.gif,
365 )
366 let blob: BlobRef | undefined
367 if (resolvedGif.thumb) {
368 onStateChange?.(t`Uploading link thumbnail...`)
369 const {path, mime} = resolvedGif.thumb.source
370 const response = await uploadBlob(agent, path, mime)
371 blob = response.data.blob
372 }
373 return {
374 $type: 'app.bsky.embed.external',
375 external: {
376 uri: resolvedGif.uri,
377 title: resolvedGif.title,
378 description: createGIFDescription(resolvedGif.title, gifDraft.alt),
379 thumb: blob,
380 },
381 }
382 }
383 if (embedDraft.link) {
384 const resolvedLink = await fetchResolveLinkQuery(
385 queryClient,
386 agent,
387 embedDraft.link.uri,
388 )
389 if (resolvedLink.type === 'external') {
390 let blob: BlobRef | undefined
391 if (resolvedLink.thumb) {
392 onStateChange?.(t`Uploading link thumbnail...`)
393 const {path, mime} = resolvedLink.thumb.source
394 const response = await uploadBlob(agent, path, mime)
395 blob = response.data.blob
396 }
397 return {
398 $type: 'app.bsky.embed.external',
399 external: {
400 uri: resolvedLink.uri,
401 title: resolvedLink.title,
402 description: resolvedLink.description,
403 thumb: blob,
404 },
405 }
406 }
407 }
408 return undefined
409}
410
411async function resolveRecord(
412 agent: BskyAgent,
413 queryClient: QueryClient,
414 uri: string,
415): Promise<ComAtprotoRepoStrongRef.Main> {
416 const resolvedLink = await fetchResolveLinkQuery(queryClient, agent, uri)
417 if (resolvedLink.type !== 'record') {
418 throw Error(t`Expected uri to resolve to a record`)
419 }
420 return resolvedLink.record
421}
422
423// The built-in hashing functions from multiformats (`multiformats/hashes/sha2`)
424// are meant for Node.js, this is the cross-platform equivalent.
425const mf_sha256 = Hasher.from({
426 name: 'sha2-256',
427 code: 0x12,
428 encode: input => {
429 const digest = sha256.arrayBuffer(input)
430 return new Uint8Array(digest)
431 },
432})
433
434async function computeCid(record: AppBskyFeedPost.Record): Promise<string> {
435 // IMPORTANT: `prepareObject` prepares the record to be hashed by removing
436 // fields with undefined value, and converting BlobRef instances to the
437 // right IPLD representation.
438 const prepared = prepareForHashing(record)
439 // 1. Encode the record into DAG-CBOR format
440 const encoded = dcbor.encode(prepared)
441 // 2. Hash the record in SHA-256 (code 0x12)
442 const digest = await mf_sha256.digest(encoded)
443 // 3. Create a CIDv1, specifying DAG-CBOR as content (code 0x71)
444 const cid = CID.createV1(0x71, digest)
445 // 4. Get the Base32 representation of the CID (`b` prefix)
446 return cid.toString()
447}
448
449// Returns a transformed version of the object for use in DAG-CBOR.
450function prepareForHashing(v: any): any {
451 // IMPORTANT: BlobRef#ipld() returns the correct object we need for hashing,
452 // the API client will convert this for you but we're hashing in the client,
453 // so we need it *now*.
454 if (v instanceof BlobRef) {
455 return v.ipld()
456 }
457
458 // Walk through arrays
459 if (Array.isArray(v)) {
460 let pure = true
461 const mapped = v.map(value => {
462 if (value !== (value = prepareForHashing(value))) {
463 pure = false
464 }
465 return value
466 })
467 return pure ? v : mapped
468 }
469
470 // Walk through plain objects
471 if (isPlainObject(v)) {
472 const obj: any = {}
473 let pure = true
474 for (const key in v) {
475 let value = v[key]
476 // `value` is undefined
477 if (value === undefined) {
478 pure = false
479 continue
480 }
481 // `prepareObject` returned a value that's different from what we had before
482 if (value !== (value = prepareForHashing(value))) {
483 pure = false
484 }
485 obj[key] = value
486 }
487 // Return as is if we haven't needed to tamper with anything
488 return pure ? v : obj
489 }
490 return v
491}
492
493function isPlainObject(v: any): boolean {
494 if (typeof v !== 'object' || v === null) {
495 return false
496 }
497 const proto = Object.getPrototypeOf(v)
498 return proto === Object.prototype || proto === null
499}