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