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