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