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 {pdsAgent} from '#/state/session/agent'
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 pdsAgent(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`Skeet 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 {text: parsedText, facets: markdownFacets} =
205 parseMarkdownLinks(trimmedText)
206
207 let rt = new RichText({text: parsedText})
208 await rt.detectFacets(agent)
209
210 if (markdownFacets.length > 0) {
211 const nonOverlapping = (rt.facets || []).filter(f => {
212 return !markdownFacets.some(mf => {
213 return (
214 (f.index.byteStart >= mf.index.byteStart &&
215 f.index.byteStart < mf.index.byteEnd) ||
216 (f.index.byteEnd > mf.index.byteStart &&
217 f.index.byteEnd <= mf.index.byteEnd) ||
218 (mf.index.byteStart >= f.index.byteStart &&
219 mf.index.byteStart < f.index.byteEnd)
220 )
221 })
222 })
223 rt.facets = [...nonOverlapping, ...markdownFacets].sort(
224 (a, b) => a.index.byteStart - b.index.byteStart,
225 )
226 }
227
228 rt = shortenLinks(rt)
229 rt = stripInvalidMentions(rt)
230 return rt
231}
232
233async function resolveReply(agent: BskyAgent, replyTo: string) {
234 const replyToUrip = new AtUri(replyTo)
235 const parentPost = await agent.getPost({
236 repo: replyToUrip.host,
237 rkey: replyToUrip.rkey,
238 })
239 if (parentPost) {
240 const parentRef = {
241 uri: parentPost.uri,
242 cid: parentPost.cid,
243 }
244 return {
245 root: parentPost.value.reply?.root || parentRef,
246 parent: parentRef,
247 }
248 }
249}
250
251async function resolveEmbed(
252 agent: BskyAgent,
253 queryClient: QueryClient,
254 draft: PostDraft,
255 onStateChange: ((state: string) => void) | undefined,
256): Promise<
257 | $Typed<AppBskyEmbedImages.Main>
258 | $Typed<AppBskyEmbedVideo.Main>
259 | $Typed<AppBskyEmbedExternal.Main>
260 | $Typed<AppBskyEmbedRecord.Main>
261 | $Typed<AppBskyEmbedRecordWithMedia.Main>
262 | undefined
263> {
264 if (draft.embed.quote) {
265 const [resolvedMedia, resolvedQuote] = await Promise.all([
266 resolveMedia(agent, queryClient, draft.embed, onStateChange),
267 resolveRecord(agent, queryClient, draft.embed.quote.uri),
268 ])
269 if (resolvedMedia) {
270 return {
271 $type: 'app.bsky.embed.recordWithMedia',
272 record: {
273 $type: 'app.bsky.embed.record',
274 record: resolvedQuote,
275 },
276 media: resolvedMedia,
277 }
278 }
279 return {
280 $type: 'app.bsky.embed.record',
281 record: resolvedQuote,
282 }
283 }
284 const resolvedMedia = await resolveMedia(
285 agent,
286 queryClient,
287 draft.embed,
288 onStateChange,
289 )
290 if (resolvedMedia) {
291 return resolvedMedia
292 }
293 if (draft.embed.link) {
294 const resolvedLink = await fetchResolveLinkQuery(
295 queryClient,
296 agent,
297 draft.embed.link.uri,
298 )
299 if (resolvedLink.type === 'record') {
300 return {
301 $type: 'app.bsky.embed.record',
302 record: resolvedLink.record,
303 }
304 }
305 }
306 return undefined
307}
308
309async function resolveMedia(
310 agent: BskyAgent,
311 queryClient: QueryClient,
312 embedDraft: EmbedDraft,
313 onStateChange: ((state: string) => void) | undefined,
314): Promise<
315 | $Typed<AppBskyEmbedExternal.Main>
316 | $Typed<AppBskyEmbedImages.Main>
317 | $Typed<AppBskyEmbedVideo.Main>
318 | undefined
319> {
320 if (embedDraft.media?.type === 'images') {
321 const imagesDraft = embedDraft.media.images
322 logger.debug(`Uploading images`, {
323 count: imagesDraft.length,
324 })
325 onStateChange?.(t`Uploading images...`)
326 const images: AppBskyEmbedImages.Image[] = await Promise.all(
327 imagesDraft.map(async (image, i) => {
328 if (image.blobRef) {
329 logger.debug(`Reusing existing blob for image #${i}`)
330 return {
331 image: image.blobRef,
332 alt: image.alt,
333 aspectRatio: {
334 width: image.source.width,
335 height: image.source.height,
336 },
337 }
338 }
339 logger.debug(`Compressing image #${i}`)
340 const {path, width, height, mime} = await compressImage(image)
341 logger.debug(`Uploading image #${i}`)
342 const res = await uploadBlob(agent, path, mime)
343 return {
344 image: res.data.blob,
345 alt: image.alt,
346 aspectRatio: {width, height},
347 }
348 }),
349 )
350 return {
351 $type: 'app.bsky.embed.images',
352 images,
353 }
354 }
355 if (
356 embedDraft.media?.type === 'video' &&
357 embedDraft.media.video.status === 'done'
358 ) {
359 const videoDraft = embedDraft.media.video
360 const captions = await Promise.all(
361 videoDraft.captions
362 .filter(caption => caption.lang !== '')
363 .map(async caption => {
364 const {data} = await agent.uploadBlob(caption.file, {
365 encoding: 'text/vtt',
366 })
367 return {lang: caption.lang, file: data.blob}
368 }),
369 )
370
371 const width = Math.round(
372 videoDraft.asset?.width ||
373 ('redraftDimensions' in videoDraft ? videoDraft.redraftDimensions.width : 1000)
374 )
375 const height = Math.round(
376 videoDraft.asset?.height ||
377 ('redraftDimensions' in videoDraft ? videoDraft.redraftDimensions.height : 1000)
378 )
379
380 // aspect ratio values must be >0 - better to leave as unset otherwise
381 // posting will fail if aspect ratio is set to 0
382 const aspectRatio = width > 0 && height > 0 ? {width, height} : undefined
383
384 if (!aspectRatio) {
385 logger.error(
386 `Invalid aspect ratio - got { width: ${width}, height: ${height} }`,
387 )
388 }
389
390 return {
391 $type: 'app.bsky.embed.video',
392 video: videoDraft.pendingPublish.blobRef,
393 alt: videoDraft.altText || undefined,
394 captions: captions.length === 0 ? undefined : captions,
395 aspectRatio,
396 }
397 }
398 if (embedDraft.media?.type === 'gif') {
399 const gifDraft = embedDraft.media
400 const resolvedGif = await fetchResolveGifQuery(
401 queryClient,
402 agent,
403 gifDraft.gif,
404 )
405 let blob: BlobRef | undefined
406 if (resolvedGif.thumb) {
407 onStateChange?.(t`Uploading link thumbnail...`)
408 const {path, mime} = resolvedGif.thumb.source
409 const response = await uploadBlob(agent, path, mime)
410 blob = response.data.blob
411 }
412 return {
413 $type: 'app.bsky.embed.external',
414 external: {
415 uri: resolvedGif.uri,
416 title: resolvedGif.title,
417 description: createGIFDescription(resolvedGif.title, gifDraft.alt),
418 thumb: blob,
419 },
420 }
421 }
422 if (embedDraft.link) {
423 const resolvedLink = await fetchResolveLinkQuery(
424 queryClient,
425 agent,
426 embedDraft.link.uri,
427 )
428 if (resolvedLink.type === 'external') {
429 let blob: BlobRef | undefined
430 if (resolvedLink.thumb) {
431 onStateChange?.(t`Uploading link thumbnail...`)
432 const {path, mime} = resolvedLink.thumb.source
433 const response = await uploadBlob(agent, path, mime)
434 blob = response.data.blob
435 }
436 return {
437 $type: 'app.bsky.embed.external',
438 external: {
439 uri: resolvedLink.uri,
440 title: resolvedLink.title,
441 description: resolvedLink.description,
442 thumb: blob,
443 },
444 }
445 }
446 }
447 return undefined
448}
449
450async function resolveRecord(
451 agent: BskyAgent,
452 queryClient: QueryClient,
453 uri: string,
454): Promise<ComAtprotoRepoStrongRef.Main> {
455 const resolvedLink = await fetchResolveLinkQuery(queryClient, agent, uri)
456 if (resolvedLink.type !== 'record') {
457 throw Error(t`Expected uri to resolve to a record`)
458 }
459 return resolvedLink.record
460}
461
462// The built-in hashing functions from multiformats (`multiformats/hashes/sha2`)
463// are meant for Node.js, this is the cross-platform equivalent.
464const mf_sha256 = Hasher.from({
465 name: 'sha2-256',
466 code: 0x12,
467 encode: input => {
468 const digest = sha256.arrayBuffer(input)
469 return new Uint8Array(digest)
470 },
471})
472
473async function computeCid(record: AppBskyFeedPost.Record): Promise<string> {
474 // IMPORTANT: `prepareObject` prepares the record to be hashed by removing
475 // fields with undefined value, and converting BlobRef instances to the
476 // right IPLD representation.
477 const prepared = prepareForHashing(record)
478 // 1. Encode the record into DAG-CBOR format
479 const encoded = dcbor.encode(prepared)
480 // 2. Hash the record in SHA-256 (code 0x12)
481 const digest = await mf_sha256.digest(encoded)
482 // 3. Create a CIDv1, specifying DAG-CBOR as content (code 0x71)
483 const cid = CID.createV1(0x71, digest)
484 // 4. Get the Base32 representation of the CID (`b` prefix)
485 return cid.toString()
486}
487
488// Returns a transformed version of the object for use in DAG-CBOR.
489function prepareForHashing(v: any): any {
490 // IMPORTANT: BlobRef#ipld() returns the correct object we need for hashing,
491 // the API client will convert this for you but we're hashing in the client,
492 // so we need it *now*.
493 if (v instanceof BlobRef) {
494 return v.ipld()
495 }
496
497 // Walk through arrays
498 if (Array.isArray(v)) {
499 let pure = true
500 const mapped = v.map(value => {
501 if (value !== (value = prepareForHashing(value))) {
502 pure = false
503 }
504 return value
505 })
506 return pure ? v : mapped
507 }
508
509 // Walk through plain objects
510 if (isPlainObject(v)) {
511 const obj: any = {}
512 let pure = true
513 for (const key in v) {
514 let value = v[key]
515 // `value` is undefined
516 if (value === undefined) {
517 pure = false
518 continue
519 }
520 // `prepareObject` returned a value that's different from what we had before
521 if (value !== (value = prepareForHashing(value))) {
522 pure = false
523 }
524 obj[key] = value
525 }
526 // Return as is if we haven't needed to tamper with anything
527 return pure ? v : obj
528 }
529 return v
530}
531
532function isPlainObject(v: any): boolean {
533 if (typeof v !== 'object' || v === null) {
534 return false
535 }
536 const proto = Object.getPrototypeOf(v)
537 return proto === Object.prototype || proto === null
538}