this repo has no description
0
fork

Configure Feed

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

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