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

Configure Feed

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

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