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