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