Bluesky app fork with some witchin' additions 💫 forked from jollywhoppers.com/witchsky.app
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539import { type $Typed, type AppBskyEmbedExternal, type AppBskyEmbedImages, type AppBskyEmbedRecord, type AppBskyEmbedRecordWithMedia, type AppBskyEmbedVideo, type AppBskyFeedPost, AtUri, BlobRef, type BskyAgent, type ComAtprotoLabelDefs, type ComAtprotoRepoApplyWrites, type ComAtprotoRepoStrongRef, RichText,} from '@atproto/api'import {TID} from '@atproto/common-web'import * as dcbor from '@ipld/dag-cbor'import {t} from '@lingui/macro'import {type QueryClient} from '@tanstack/react-query'import {sha256} from 'js-sha256'import {CID} from 'multiformats/cid'import * as Hasher from 'multiformats/hashes/hasher'
import {isNetworkError} from '#/lib/strings/errors'import {parseMarkdownLinks,shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'import {logger} from '#/logger'import {compressImage} from '#/state/gallery'import { fetchResolveGifQuery, fetchResolveLinkQuery,} from '#/state/queries/resolve-link'import { createThreadgateRecord, threadgateAllowUISettingToAllowRecordValue,} from '#/state/queries/threadgate'import {pdsAgent} from '#/state/session/agent'import { type EmbedDraft, type PostDraft, type ThreadDraft,} from '#/view/com/composer/state/composer'import {createGIFDescription} from '../gif-alt-text'import {uploadBlob} from './upload-blob'
export {uploadBlob}
interface PostOpts { thread: ThreadDraft replyTo?: string onStateChange?: (state: string) => void langs?: string[]}
export async function post( agent: BskyAgent, queryClient: QueryClient, opts: PostOpts,) { const thread = opts.thread opts.onStateChange?.(t`Processing...`)
let replyPromise: | Promise<AppBskyFeedPost.Record['reply']> | AppBskyFeedPost.Record['reply'] | undefined if (opts.replyTo) { // Not awaited to avoid waterfalls. replyPromise = resolveReply(agent, opts.replyTo) }
// add top 3 languages from user preferences if langs is provided let langs = opts.langs if (opts.langs) { langs = opts.langs.slice(0, 3) }
const did = agent.assertDid const writes: $Typed<ComAtprotoRepoApplyWrites.Create>[] = [] const uris: string[] = []
let now = new Date() let tid: TID | undefined
for (let i = 0; i < thread.posts.length; i++) { const draft = thread.posts[i]
// Not awaited to avoid waterfalls. const rtPromise = resolveRT(agent, draft.richtext) const embedPromise = resolveEmbed( agent, queryClient, draft, opts.onStateChange, ) let labels: $Typed<ComAtprotoLabelDefs.SelfLabels> | undefined if (draft.labels.length) { labels = { $type: 'com.atproto.label.defs#selfLabels', values: draft.labels.map(val => ({val})), } }
// The sorting behavior for multiple posts sharing the same createdAt time is // undefined, so what we'll do here is increment the time by 1 for every post now.setMilliseconds(now.getMilliseconds() + 1) tid = TID.next(tid) const rkey = tid.toString() const uri = `at://${did}/app.bsky.feed.post/${rkey}` uris.push(uri)
const rt = await rtPromise const embed = await embedPromise const reply = await replyPromise const record: AppBskyFeedPost.Record = { // IMPORTANT: $type has to exist, CID is calculated with the `$type` field // present and will produce the wrong CID if you omit it. $type: 'app.bsky.feed.post', createdAt: now.toISOString(), text: rt.text, facets: rt.facets, reply, embed, langs, labels, } writes.push({ $type: 'com.atproto.repo.applyWrites#create', collection: 'app.bsky.feed.post', rkey: rkey, value: record, })
if (i === 0 && thread.threadgate.some(tg => tg.type !== 'everybody')) { writes.push({ $type: 'com.atproto.repo.applyWrites#create', collection: 'app.bsky.feed.threadgate', rkey: rkey, value: createThreadgateRecord({ createdAt: now.toISOString(), post: uri, allow: threadgateAllowUISettingToAllowRecordValue(thread.threadgate), }), }) }
if ( thread.postgate.embeddingRules?.length || thread.postgate.detachedEmbeddingUris?.length ) { writes.push({ $type: 'com.atproto.repo.applyWrites#create', collection: 'app.bsky.feed.postgate', rkey: rkey, value: { ...thread.postgate, $type: 'app.bsky.feed.postgate', createdAt: now.toISOString(), post: uri, }, }) }
// Prepare a ref to the current post for the next post in the thread. const ref = { cid: await computeCid(record), uri, } replyPromise = { root: reply?.root ?? ref, parent: ref, } }
try { await pdsAgent(agent).com.atproto.repo.applyWrites({ repo: agent.assertDid, writes: writes, validate: true, }) } catch (e: any) { logger.error(`Failed to create post`, { safeMessage: e.message, }) if (isNetworkError(e)) { throw new Error( t`Skeet failed to upload. Please check your Internet connection and try again.`, ) } else { throw e } }
return {uris}}
async function resolveRT(agent: BskyAgent, richtext: RichText) { const trimmedText = richtext.text // Trim leading whitespace-only lines (but don't break ASCII art). .replace(/^(\s*\n)+/, '') // Trim any trailing whitespace. .trimEnd()
const {text: parsedText, facets: markdownFacets} = parseMarkdownLinks(trimmedText)
let rt = new RichText({text: parsedText}) await rt.detectFacets(agent)
if (markdownFacets.length > 0) { const nonOverlapping = (rt.facets || []).filter(f => { return !markdownFacets.some(mf => { return ( (f.index.byteStart >= mf.index.byteStart && f.index.byteStart < mf.index.byteEnd) || (f.index.byteEnd > mf.index.byteStart && f.index.byteEnd <= mf.index.byteEnd) || (mf.index.byteStart >= f.index.byteStart && mf.index.byteStart < f.index.byteEnd) ) }) }) rt.facets = [...nonOverlapping, ...markdownFacets].sort( (a, b) => a.index.byteStart - b.index.byteStart, ) }
rt = shortenLinks(rt) rt = stripInvalidMentions(rt) return rt}
async function resolveReply(agent: BskyAgent, replyTo: string) { const replyToUrip = new AtUri(replyTo) const parentPost = await agent.getPost({ repo: replyToUrip.host, rkey: replyToUrip.rkey, }) if (parentPost) { const parentRef = { uri: parentPost.uri, cid: parentPost.cid, } return { root: parentPost.value.reply?.root || parentRef, parent: parentRef, } }}
async function resolveEmbed( agent: BskyAgent, queryClient: QueryClient, draft: PostDraft, onStateChange: ((state: string) => void) | undefined,): Promise< | $Typed<AppBskyEmbedImages.Main> | $Typed<AppBskyEmbedVideo.Main> | $Typed<AppBskyEmbedExternal.Main> | $Typed<AppBskyEmbedRecord.Main> | $Typed<AppBskyEmbedRecordWithMedia.Main> | undefined> { if (draft.embed.quote) { const [resolvedMedia, resolvedQuote] = await Promise.all([ resolveMedia(agent, queryClient, draft.embed, onStateChange), resolveRecord(agent, queryClient, draft.embed.quote.uri), ]) if (resolvedMedia) { return { $type: 'app.bsky.embed.recordWithMedia', record: { $type: 'app.bsky.embed.record', record: resolvedQuote, }, media: resolvedMedia, } } return { $type: 'app.bsky.embed.record', record: resolvedQuote, } } const resolvedMedia = await resolveMedia( agent, queryClient, draft.embed, onStateChange, ) if (resolvedMedia) { return resolvedMedia } if (draft.embed.link) { const resolvedLink = await fetchResolveLinkQuery( queryClient, agent, draft.embed.link.uri, ) if (resolvedLink.type === 'record') { return { $type: 'app.bsky.embed.record', record: resolvedLink.record, } } } return undefined}
async function resolveMedia( agent: BskyAgent, queryClient: QueryClient, embedDraft: EmbedDraft, onStateChange: ((state: string) => void) | undefined,): Promise< | $Typed<AppBskyEmbedExternal.Main> | $Typed<AppBskyEmbedImages.Main> | $Typed<AppBskyEmbedVideo.Main> | undefined> { if (embedDraft.media?.type === 'images') { const imagesDraft = embedDraft.media.images logger.debug(`Uploading images`, { count: imagesDraft.length, }) onStateChange?.(t`Uploading images...`) const images: AppBskyEmbedImages.Image[] = await Promise.all( imagesDraft.map(async (image, i) => { if (image.blobRef) { logger.debug(`Reusing existing blob for image #${i}`) return { image: image.blobRef, alt: image.alt, aspectRatio: { width: image.source.width, height: image.source.height, }, } } logger.debug(`Compressing image #${i}`) const {path, width, height, mime} = await compressImage(image) logger.debug(`Uploading image #${i}`) const res = await uploadBlob(agent, path, mime) return { image: res.data.blob, alt: image.alt, aspectRatio: {width, height}, } }), ) return { $type: 'app.bsky.embed.images', images, } } if ( embedDraft.media?.type === 'video' && embedDraft.media.video.status === 'done' ) { const videoDraft = embedDraft.media.video const captions = await Promise.all( videoDraft.captions .filter(caption => caption.lang !== '') .map(async caption => { const {data} = await agent.uploadBlob(caption.file, { encoding: 'text/vtt', }) return {lang: caption.lang, file: data.blob} }), )
const width = Math.round( videoDraft.asset?.width || ('redraftDimensions' in videoDraft ? videoDraft.redraftDimensions.width : 1000) ) const height = Math.round( videoDraft.asset?.height || ('redraftDimensions' in videoDraft ? videoDraft.redraftDimensions.height : 1000) )
// aspect ratio values must be >0 - better to leave as unset otherwise // posting will fail if aspect ratio is set to 0 const aspectRatio = width > 0 && height > 0 ? {width, height} : undefined
if (!aspectRatio) { logger.error( `Invalid aspect ratio - got { width: ${width}, height: ${height} }`, ) }
return { $type: 'app.bsky.embed.video', video: videoDraft.pendingPublish.blobRef, alt: videoDraft.altText || undefined, captions: captions.length === 0 ? undefined : captions, aspectRatio, } } if (embedDraft.media?.type === 'gif') { const gifDraft = embedDraft.media const resolvedGif = await fetchResolveGifQuery( queryClient, agent, gifDraft.gif, ) let blob: BlobRef | undefined if (resolvedGif.thumb) { onStateChange?.(t`Uploading link thumbnail...`) const {path, mime} = resolvedGif.thumb.source const response = await uploadBlob(agent, path, mime) blob = response.data.blob } return { $type: 'app.bsky.embed.external', external: { uri: resolvedGif.uri, title: resolvedGif.title, description: createGIFDescription(resolvedGif.title, gifDraft.alt), thumb: blob, }, } } if (embedDraft.link) { const resolvedLink = await fetchResolveLinkQuery( queryClient, agent, embedDraft.link.uri, ) if (resolvedLink.type === 'external') { let blob: BlobRef | undefined if (resolvedLink.thumb) { onStateChange?.(t`Uploading link thumbnail...`) const {path, mime} = resolvedLink.thumb.source const response = await uploadBlob(agent, path, mime) blob = response.data.blob } return { $type: 'app.bsky.embed.external', external: { uri: resolvedLink.uri, title: resolvedLink.title, description: resolvedLink.description, thumb: blob, }, } } } return undefined}
async function resolveRecord( agent: BskyAgent, queryClient: QueryClient, uri: string,): Promise<ComAtprotoRepoStrongRef.Main> { const resolvedLink = await fetchResolveLinkQuery(queryClient, agent, uri) if (resolvedLink.type !== 'record') { throw Error(t`Expected uri to resolve to a record`) } return resolvedLink.record}
// The built-in hashing functions from multiformats (`multiformats/hashes/sha2`)// are meant for Node.js, this is the cross-platform equivalent.const mf_sha256 = Hasher.from({ name: 'sha2-256', code: 0x12, encode: input => { const digest = sha256.arrayBuffer(input) return new Uint8Array(digest) },})
async function computeCid(record: AppBskyFeedPost.Record): Promise<string> { // IMPORTANT: `prepareObject` prepares the record to be hashed by removing // fields with undefined value, and converting BlobRef instances to the // right IPLD representation. const prepared = prepareForHashing(record) // 1. Encode the record into DAG-CBOR format const encoded = dcbor.encode(prepared) // 2. Hash the record in SHA-256 (code 0x12) const digest = await mf_sha256.digest(encoded) // 3. Create a CIDv1, specifying DAG-CBOR as content (code 0x71) const cid = CID.createV1(0x71, digest) // 4. Get the Base32 representation of the CID (`b` prefix) return cid.toString()}
// Returns a transformed version of the object for use in DAG-CBOR.function prepareForHashing(v: any): any { // IMPORTANT: BlobRef#ipld() returns the correct object we need for hashing, // the API client will convert this for you but we're hashing in the client, // so we need it *now*. if (v instanceof BlobRef) { return v.ipld() }
// Walk through arrays if (Array.isArray(v)) { let pure = true const mapped = v.map(value => { if (value !== (value = prepareForHashing(value))) { pure = false } return value }) return pure ? v : mapped }
// Walk through plain objects if (isPlainObject(v)) { const obj: any = {} let pure = true for (const key in v) { let value = v[key] // `value` is undefined if (value === undefined) { pure = false continue } // `prepareObject` returned a value that's different from what we had before if (value !== (value = prepareForHashing(value))) { pure = false } obj[key] = value } // Return as is if we haven't needed to tamper with anything return pure ? v : obj } return v}
function isPlainObject(v: any): boolean { if (typeof v !== 'object' || v === null) { return false } const proto = Object.getPrototypeOf(v) return proto === Object.prototype || proto === null}