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

Configure Feed

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

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