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