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