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