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