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