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