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

Configure Feed

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

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