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

Configure Feed

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

at 8c3553cd66ad07ef8c8c4e760b495cf6ce08cc8d 538 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 {pdsAgent} from '#/state/session/agent' 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 pdsAgent(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`Skeet 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 {text: parsedText, facets: markdownFacets} = 205 parseMarkdownLinks(trimmedText) 206 207 let rt = new RichText({text: parsedText}) 208 await rt.detectFacets(agent) 209 210 if (markdownFacets.length > 0) { 211 const nonOverlapping = (rt.facets || []).filter(f => { 212 return !markdownFacets.some(mf => { 213 return ( 214 (f.index.byteStart >= mf.index.byteStart && 215 f.index.byteStart < mf.index.byteEnd) || 216 (f.index.byteEnd > mf.index.byteStart && 217 f.index.byteEnd <= mf.index.byteEnd) || 218 (mf.index.byteStart >= f.index.byteStart && 219 mf.index.byteStart < f.index.byteEnd) 220 ) 221 }) 222 }) 223 rt.facets = [...nonOverlapping, ...markdownFacets].sort( 224 (a, b) => a.index.byteStart - b.index.byteStart, 225 ) 226 } 227 228 rt = shortenLinks(rt) 229 rt = stripInvalidMentions(rt) 230 return rt 231} 232 233async function resolveReply(agent: BskyAgent, replyTo: string) { 234 const replyToUrip = new AtUri(replyTo) 235 const parentPost = await agent.getPost({ 236 repo: replyToUrip.host, 237 rkey: replyToUrip.rkey, 238 }) 239 if (parentPost) { 240 const parentRef = { 241 uri: parentPost.uri, 242 cid: parentPost.cid, 243 } 244 return { 245 root: parentPost.value.reply?.root || parentRef, 246 parent: parentRef, 247 } 248 } 249} 250 251async function resolveEmbed( 252 agent: BskyAgent, 253 queryClient: QueryClient, 254 draft: PostDraft, 255 onStateChange: ((state: string) => void) | undefined, 256): Promise< 257 | $Typed<AppBskyEmbedImages.Main> 258 | $Typed<AppBskyEmbedVideo.Main> 259 | $Typed<AppBskyEmbedExternal.Main> 260 | $Typed<AppBskyEmbedRecord.Main> 261 | $Typed<AppBskyEmbedRecordWithMedia.Main> 262 | undefined 263> { 264 if (draft.embed.quote) { 265 const [resolvedMedia, resolvedQuote] = await Promise.all([ 266 resolveMedia(agent, queryClient, draft.embed, onStateChange), 267 resolveRecord(agent, queryClient, draft.embed.quote.uri), 268 ]) 269 if (resolvedMedia) { 270 return { 271 $type: 'app.bsky.embed.recordWithMedia', 272 record: { 273 $type: 'app.bsky.embed.record', 274 record: resolvedQuote, 275 }, 276 media: resolvedMedia, 277 } 278 } 279 return { 280 $type: 'app.bsky.embed.record', 281 record: resolvedQuote, 282 } 283 } 284 const resolvedMedia = await resolveMedia( 285 agent, 286 queryClient, 287 draft.embed, 288 onStateChange, 289 ) 290 if (resolvedMedia) { 291 return resolvedMedia 292 } 293 if (draft.embed.link) { 294 const resolvedLink = await fetchResolveLinkQuery( 295 queryClient, 296 agent, 297 draft.embed.link.uri, 298 ) 299 if (resolvedLink.type === 'record') { 300 return { 301 $type: 'app.bsky.embed.record', 302 record: resolvedLink.record, 303 } 304 } 305 } 306 return undefined 307} 308 309async function resolveMedia( 310 agent: BskyAgent, 311 queryClient: QueryClient, 312 embedDraft: EmbedDraft, 313 onStateChange: ((state: string) => void) | undefined, 314): Promise< 315 | $Typed<AppBskyEmbedExternal.Main> 316 | $Typed<AppBskyEmbedImages.Main> 317 | $Typed<AppBskyEmbedVideo.Main> 318 | undefined 319> { 320 if (embedDraft.media?.type === 'images') { 321 const imagesDraft = embedDraft.media.images 322 logger.debug(`Uploading images`, { 323 count: imagesDraft.length, 324 }) 325 onStateChange?.(t`Uploading images...`) 326 const images: AppBskyEmbedImages.Image[] = await Promise.all( 327 imagesDraft.map(async (image, i) => { 328 if (image.blobRef) { 329 logger.debug(`Reusing existing blob for image #${i}`) 330 return { 331 image: image.blobRef, 332 alt: image.alt, 333 aspectRatio: { 334 width: image.source.width, 335 height: image.source.height, 336 }, 337 } 338 } 339 logger.debug(`Compressing image #${i}`) 340 const {path, width, height, mime} = await compressImage(image) 341 logger.debug(`Uploading image #${i}`) 342 const res = await uploadBlob(agent, path, mime) 343 return { 344 image: res.data.blob, 345 alt: image.alt, 346 aspectRatio: {width, height}, 347 } 348 }), 349 ) 350 return { 351 $type: 'app.bsky.embed.images', 352 images, 353 } 354 } 355 if ( 356 embedDraft.media?.type === 'video' && 357 embedDraft.media.video.status === 'done' 358 ) { 359 const videoDraft = embedDraft.media.video 360 const captions = await Promise.all( 361 videoDraft.captions 362 .filter(caption => caption.lang !== '') 363 .map(async caption => { 364 const {data} = await agent.uploadBlob(caption.file, { 365 encoding: 'text/vtt', 366 }) 367 return {lang: caption.lang, file: data.blob} 368 }), 369 ) 370 371 const width = Math.round( 372 videoDraft.asset?.width || 373 ('redraftDimensions' in videoDraft ? videoDraft.redraftDimensions.width : 1000) 374 ) 375 const height = Math.round( 376 videoDraft.asset?.height || 377 ('redraftDimensions' in videoDraft ? videoDraft.redraftDimensions.height : 1000) 378 ) 379 380 // aspect ratio values must be >0 - better to leave as unset otherwise 381 // posting will fail if aspect ratio is set to 0 382 const aspectRatio = width > 0 && height > 0 ? {width, height} : undefined 383 384 if (!aspectRatio) { 385 logger.error( 386 `Invalid aspect ratio - got { width: ${width}, height: ${height} }`, 387 ) 388 } 389 390 return { 391 $type: 'app.bsky.embed.video', 392 video: videoDraft.pendingPublish.blobRef, 393 alt: videoDraft.altText || undefined, 394 captions: captions.length === 0 ? undefined : captions, 395 aspectRatio, 396 } 397 } 398 if (embedDraft.media?.type === 'gif') { 399 const gifDraft = embedDraft.media 400 const resolvedGif = await fetchResolveGifQuery( 401 queryClient, 402 agent, 403 gifDraft.gif, 404 ) 405 let blob: BlobRef | undefined 406 if (resolvedGif.thumb) { 407 onStateChange?.(t`Uploading link thumbnail...`) 408 const {path, mime} = resolvedGif.thumb.source 409 const response = await uploadBlob(agent, path, mime) 410 blob = response.data.blob 411 } 412 return { 413 $type: 'app.bsky.embed.external', 414 external: { 415 uri: resolvedGif.uri, 416 title: resolvedGif.title, 417 description: createGIFDescription(resolvedGif.title, gifDraft.alt), 418 thumb: blob, 419 }, 420 } 421 } 422 if (embedDraft.link) { 423 const resolvedLink = await fetchResolveLinkQuery( 424 queryClient, 425 agent, 426 embedDraft.link.uri, 427 ) 428 if (resolvedLink.type === 'external') { 429 let blob: BlobRef | undefined 430 if (resolvedLink.thumb) { 431 onStateChange?.(t`Uploading link thumbnail...`) 432 const {path, mime} = resolvedLink.thumb.source 433 const response = await uploadBlob(agent, path, mime) 434 blob = response.data.blob 435 } 436 return { 437 $type: 'app.bsky.embed.external', 438 external: { 439 uri: resolvedLink.uri, 440 title: resolvedLink.title, 441 description: resolvedLink.description, 442 thumb: blob, 443 }, 444 } 445 } 446 } 447 return undefined 448} 449 450async function resolveRecord( 451 agent: BskyAgent, 452 queryClient: QueryClient, 453 uri: string, 454): Promise<ComAtprotoRepoStrongRef.Main> { 455 const resolvedLink = await fetchResolveLinkQuery(queryClient, agent, uri) 456 if (resolvedLink.type !== 'record') { 457 throw Error(t`Expected uri to resolve to a record`) 458 } 459 return resolvedLink.record 460} 461 462// The built-in hashing functions from multiformats (`multiformats/hashes/sha2`) 463// are meant for Node.js, this is the cross-platform equivalent. 464const mf_sha256 = Hasher.from({ 465 name: 'sha2-256', 466 code: 0x12, 467 encode: input => { 468 const digest = sha256.arrayBuffer(input) 469 return new Uint8Array(digest) 470 }, 471}) 472 473async function computeCid(record: AppBskyFeedPost.Record): Promise<string> { 474 // IMPORTANT: `prepareObject` prepares the record to be hashed by removing 475 // fields with undefined value, and converting BlobRef instances to the 476 // right IPLD representation. 477 const prepared = prepareForHashing(record) 478 // 1. Encode the record into DAG-CBOR format 479 const encoded = dcbor.encode(prepared) 480 // 2. Hash the record in SHA-256 (code 0x12) 481 const digest = await mf_sha256.digest(encoded) 482 // 3. Create a CIDv1, specifying DAG-CBOR as content (code 0x71) 483 const cid = CID.createV1(0x71, digest) 484 // 4. Get the Base32 representation of the CID (`b` prefix) 485 return cid.toString() 486} 487 488// Returns a transformed version of the object for use in DAG-CBOR. 489function prepareForHashing(v: any): any { 490 // IMPORTANT: BlobRef#ipld() returns the correct object we need for hashing, 491 // the API client will convert this for you but we're hashing in the client, 492 // so we need it *now*. 493 if (v instanceof BlobRef) { 494 return v.ipld() 495 } 496 497 // Walk through arrays 498 if (Array.isArray(v)) { 499 let pure = true 500 const mapped = v.map(value => { 501 if (value !== (value = prepareForHashing(value))) { 502 pure = false 503 } 504 return value 505 }) 506 return pure ? v : mapped 507 } 508 509 // Walk through plain objects 510 if (isPlainObject(v)) { 511 const obj: any = {} 512 let pure = true 513 for (const key in v) { 514 let value = v[key] 515 // `value` is undefined 516 if (value === undefined) { 517 pure = false 518 continue 519 } 520 // `prepareObject` returned a value that's different from what we had before 521 if (value !== (value = prepareForHashing(value))) { 522 pure = false 523 } 524 obj[key] = value 525 } 526 // Return as is if we haven't needed to tamper with anything 527 return pure ? v : obj 528 } 529 return v 530} 531 532function isPlainObject(v: any): boolean { 533 if (typeof v !== 'object' || v === null) { 534 return false 535 } 536 const proto = Object.getPrototypeOf(v) 537 return proto === Object.prototype || proto === null 538}