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