Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/improsocial/impro
An extensible Bluesky client for web impro.social
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297import { getPostLangs, isNil, wait } from "/js/utils.js";import { computeRecordCid, generateTid } from "/js/atproto.js";import { ImageCompressor } from "/js/imageCompressor.js";import { fetchAndCompressImage } from "/js/embedHelpers.js";import { getUnresolvedFacetsFromText, resolveFacets,} from "/js/facetHelpers.js";
// Matches social-app - strip leading + trailing whitespace and collapse runs of 3+ newlines to 2const excessNewlinesRegex = /[\r\n]([\s]*[\r\n]){2,}/g;
function trimPostText(text) { if (!text) return ""; return text .replace(/^(\s*\n)+/, "") .trimEnd() .replace(excessNewlinesRegex, "\n\n");}
export class PostCreator { constructor(api, identityResolver, imageCompressor = new ImageCompressor()) { this.api = api; this.identityResolver = identityResolver; this.imageCompressor = imageCompressor; }
async createThread({ posts, replyTo, replyRoot, threadgateAllow = null, postgateEmbeddingRules = null, signal = null, }) { if (!posts || posts.length === 0) { throw new Error("createThread requires at least one post"); } let reply = null; if (replyTo) { if (!replyRoot) { throw new Error("replyRoot is required when replyTo is provided"); } reply = { root: { uri: replyRoot.uri, cid: replyRoot.cid }, parent: { uri: replyTo.uri, cid: replyTo.cid }, }; }
const did = this.api.session.did; const langs = getPostLangs(); const writes = []; const uris = []; // Sort order for posts sharing a createdAt is undefined, so each post // gets a +1ms bump like social-app does. const baseTime = Date.now();
for (let i = 0; i < posts.length; i++) { signal?.throwIfAborted(); const { text, facets, embed } = await this._buildPostContent(posts[i], { signal, }); const rkey = generateTid(); const uri = `at://${did}/app.bsky.feed.post/${rkey}`; const createdAt = new Date(baseTime + i).toISOString(); const record = { $type: "app.bsky.feed.post", text, facets, createdAt, langs, }; if (embed) { record.embed = embed; } if (reply) { record.reply = reply; } if (posts[i].labels) { record.labels = posts[i].labels; }
writes.push({ $type: "com.atproto.repo.applyWrites#create", collection: "app.bsky.feed.post", rkey, value: record, }); uris.push(uri);
if (i === 0 && !isNil(threadgateAllow)) { writes.push({ $type: "com.atproto.repo.applyWrites#create", collection: "app.bsky.feed.threadgate", rkey, value: { $type: "app.bsky.feed.threadgate", post: uri, allow: threadgateAllow, createdAt, }, }); } if (postgateEmbeddingRules?.length > 0) { writes.push({ $type: "com.atproto.repo.applyWrites#create", collection: "app.bsky.feed.postgate", rkey, value: { $type: "app.bsky.feed.postgate", post: uri, embeddingRules: postgateEmbeddingRules, createdAt, }, }); }
if (i < posts.length - 1) { const ref = { uri, cid: await computeRecordCid(record) }; reply = { root: reply?.root ?? ref, parent: ref }; } }
// Last chance to cancel: the commit itself is deliberately not abortable signal?.throwIfAborted(); await this.api.applyWrites(writes);
// Attempt to get the full posts from the app view, null on failure. const maxRetries = 5; let fullPosts = null; for (let tries = 0; tries < maxRetries; tries++) { try { const fetched = await this.api.getPosts(uris); if (fetched.length === uris.length) { fullPosts = fetched; break; } } catch (e) {} if (tries < maxRetries - 1) { await wait(1000); } }
return { uris, posts: fullPosts }; }
async _buildPostContent( { postText, external, quotedRecord, images, video }, { signal = null } = {}, ) { const trimmedText = trimPostText(postText); const unresolvedFacets = getUnresolvedFacetsFromText(trimmedText); const facets = await resolveFacets(unresolvedFacets, this.identityResolver); signal?.throwIfAborted(); const externalEmbed = await this._prepareExternalEmbed(external, { signal, }); const imagesEmbed = await this._prepareImagesEmbed(images, { signal }); const videoEmbed = this._prepareVideoEmbed(video);
let quotedRecordEmbed = null; if (quotedRecord) { quotedRecordEmbed = { $type: "app.bsky.embed.record", record: { uri: quotedRecord.uri, cid: quotedRecord.cid, }, }; }
// Prioritize video > images > external link (these are mutually exclusive) const mediaEmbed = videoEmbed || imagesEmbed || externalEmbed;
let embed = null; if (mediaEmbed && quotedRecordEmbed) { embed = { $type: "app.bsky.embed.recordWithMedia", media: mediaEmbed, record: quotedRecordEmbed, }; } else if (mediaEmbed) { embed = mediaEmbed; } else if (quotedRecordEmbed) { embed = quotedRecordEmbed; }
return { text: trimmedText, facets, embed }; }
async _prepareImagesEmbed(images, { signal = null } = {}) { if (!images || images.length === 0) { return null; }
const uploadedImages = []; for (const img of images) { signal?.throwIfAborted(); const compressedImage = await this.imageCompressor.compressImage( img.dataUrl, ); const blob = await this.api.uploadBlob(compressedImage.blob, { signal });
uploadedImages.push({ $type: "app.bsky.embed.images#image", alt: img.alt || "", image: { $type: "blob", ref: { $link: blob.ref.$link, }, mimeType: blob.mimeType, size: blob.size, }, aspectRatio: { $type: "app.bsky.embed.defs#aspectRatio", width: compressedImage.width, height: compressedImage.height, }, }); }
return { $type: "app.bsky.embed.images", images: uploadedImages, }; }
_prepareVideoEmbed(video) { if (!video || !video.blob) { return null; } const embed = { $type: "app.bsky.embed.video", video: { $type: "blob", ref: { $link: video.blob.ref.$link }, mimeType: video.blob.mimeType, size: video.blob.size, }, }; if (video.alt) { embed.alt = video.alt; } if ( video.aspectRatio && video.aspectRatio.width > 0 && video.aspectRatio.height > 0 ) { embed.aspectRatio = { $type: "app.bsky.embed.defs#aspectRatio", width: video.aspectRatio.width, height: video.aspectRatio.height, }; } return embed; }
async _prepareExternalEmbed(external, { signal = null } = {}) { if (!external) { return null; } const externalImage = external.image; const externalEmbed = { $type: "app.bsky.embed.external", external: { title: external.title, description: external.description, uri: external.url, // note - renaming url to uri }, }; // If there's an external link, upload the preview image if (externalImage) { try { const compressed = await fetchAndCompressImage(externalImage, { signal, imageCompressor: this.imageCompressor, }); const blob = await this.api.uploadBlob(compressed.blob, { signal }); externalEmbed.external.thumb = { $type: "blob", mimeType: blob.mimeType, ref: { $link: blob.ref.$link }, size: blob.size, }; } catch (error) { if (error.name === "AbortError") { throw error; } // Don't fail the post creation if the image can't be uploaded console.warn("Error uploading external link image: ", error); } } return externalEmbed; }}