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