mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import { 2 $Typed, 3 AppBskyEmbedExternal, 4 AppBskyEmbedImages, 5 AppBskyEmbedRecord, 6 AppBskyEmbedRecordWithMedia, 7 AppBskyEmbedVideo, 8 AppBskyFeedPost, 9 AtUri, 10 BlobRef, 11 BskyAgent, 12 ComAtprotoLabelDefs, 13 ComAtprotoRepoApplyWrites, 14 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 {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 EmbedDraft, 39 PostDraft, 40 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 return { 337 $type: 'app.bsky.embed.video', 338 video: videoDraft.pendingPublish.blobRef, 339 alt: videoDraft.altText || undefined, 340 captions: captions.length === 0 ? undefined : captions, 341 aspectRatio: { 342 width: videoDraft.asset.width, 343 height: videoDraft.asset.height, 344 }, 345 } 346 } 347 if (embedDraft.media?.type === 'gif') { 348 const gifDraft = embedDraft.media 349 const resolvedGif = await fetchResolveGifQuery( 350 queryClient, 351 agent, 352 gifDraft.gif, 353 ) 354 let blob: BlobRef | undefined 355 if (resolvedGif.thumb) { 356 onStateChange?.(t`Uploading link thumbnail...`) 357 const {path, mime} = resolvedGif.thumb.source 358 const response = await uploadBlob(agent, path, mime) 359 blob = response.data.blob 360 } 361 return { 362 $type: 'app.bsky.embed.external', 363 external: { 364 uri: resolvedGif.uri, 365 title: resolvedGif.title, 366 description: createGIFDescription(resolvedGif.title, gifDraft.alt), 367 thumb: blob, 368 }, 369 } 370 } 371 if (embedDraft.link) { 372 const resolvedLink = await fetchResolveLinkQuery( 373 queryClient, 374 agent, 375 embedDraft.link.uri, 376 ) 377 if (resolvedLink.type === 'external') { 378 let blob: BlobRef | undefined 379 if (resolvedLink.thumb) { 380 onStateChange?.(t`Uploading link thumbnail...`) 381 const {path, mime} = resolvedLink.thumb.source 382 const response = await uploadBlob(agent, path, mime) 383 blob = response.data.blob 384 } 385 return { 386 $type: 'app.bsky.embed.external', 387 external: { 388 uri: resolvedLink.uri, 389 title: resolvedLink.title, 390 description: resolvedLink.description, 391 thumb: blob, 392 }, 393 } 394 } 395 } 396 return undefined 397} 398 399async function resolveRecord( 400 agent: BskyAgent, 401 queryClient: QueryClient, 402 uri: string, 403): Promise<ComAtprotoRepoStrongRef.Main> { 404 const resolvedLink = await fetchResolveLinkQuery(queryClient, agent, uri) 405 if (resolvedLink.type !== 'record') { 406 throw Error(t`Expected uri to resolve to a record`) 407 } 408 return resolvedLink.record 409} 410 411// The built-in hashing functions from multiformats (`multiformats/hashes/sha2`) 412// are meant for Node.js, this is the cross-platform equivalent. 413const mf_sha256 = Hasher.from({ 414 name: 'sha2-256', 415 code: 0x12, 416 encode: input => { 417 const digest = sha256.arrayBuffer(input) 418 return new Uint8Array(digest) 419 }, 420}) 421 422async function computeCid(record: AppBskyFeedPost.Record): Promise<string> { 423 // IMPORTANT: `prepareObject` prepares the record to be hashed by removing 424 // fields with undefined value, and converting BlobRef instances to the 425 // right IPLD representation. 426 const prepared = prepareForHashing(record) 427 // 1. Encode the record into DAG-CBOR format 428 const encoded = dcbor.encode(prepared) 429 // 2. Hash the record in SHA-256 (code 0x12) 430 const digest = await mf_sha256.digest(encoded) 431 // 3. Create a CIDv1, specifying DAG-CBOR as content (code 0x71) 432 const cid = CID.createV1(0x71, digest) 433 // 4. Get the Base32 representation of the CID (`b` prefix) 434 return cid.toString() 435} 436 437// Returns a transformed version of the object for use in DAG-CBOR. 438function prepareForHashing(v: any): any { 439 // IMPORTANT: BlobRef#ipld() returns the correct object we need for hashing, 440 // the API client will convert this for you but we're hashing in the client, 441 // so we need it *now*. 442 if (v instanceof BlobRef) { 443 return v.ipld() 444 } 445 446 // Walk through arrays 447 if (Array.isArray(v)) { 448 let pure = true 449 const mapped = v.map(value => { 450 if (value !== (value = prepareForHashing(value))) { 451 pure = false 452 } 453 return value 454 }) 455 return pure ? v : mapped 456 } 457 458 // Walk through plain objects 459 if (isPlainObject(v)) { 460 const obj: any = {} 461 let pure = true 462 for (const key in v) { 463 let value = v[key] 464 // `value` is undefined 465 if (value === undefined) { 466 pure = false 467 continue 468 } 469 // `prepareObject` returned a value that's different from what we had before 470 if (value !== (value = prepareForHashing(value))) { 471 pure = false 472 } 473 obj[key] = value 474 } 475 // Return as is if we haven't needed to tamper with anything 476 return pure ? v : obj 477 } 478 return v 479} 480 481function isPlainObject(v: any): boolean { 482 if (typeof v !== 'object' || v === null) { 483 return false 484 } 485 const proto = Object.getPrototypeOf(v) 486 return proto === Object.prototype || proto === null 487}