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