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