Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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