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