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