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

Configure Feed

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

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