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

Configure Feed

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

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