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