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
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495import { parseUri } from "/js/dataHelpers.js";import { RefreshTokenError } from "/js/auth.js";import { TokenRefreshError as OauthRefreshTokenError } from "/js/oauth.js";import { batch, buildQueryString, getCurrentTimestamp, unique,} from "/js/utils.js";import { linkToLogin } from "/js/navigation.js";import { PUBLIC_SERVICE_ENDPOINT_URL, BSKY_APPVIEW_SERVICE_DID, BSKY_CHAT_SERVICE_DID, BSKY_LABELER_DID, VIDEO_SERVICE_URL, VIDEO_SERVICE_DID, GIF_SERVICE_URL,} from "/js/config.js";
export class ApiError extends Error { constructor(res) { const message = `${res.status} ${res.statusText}`; super(message); this.status = res.status; this.statusText = res.statusText; this.data = res.data; this.headers = res.headers; this.url = res.url; }}
export function isRecordNotFoundError(error) { return error instanceof ApiError && error.data?.error === "RecordNotFound";}
export function isInvalidSwapError(error) { return error instanceof ApiError && error.data?.error === "InvalidSwap";}
class PublicSession { constructor() { this.serviceEndpoint = PUBLIC_SERVICE_ENDPOINT_URL; } async fetch(url, options) { return fetch(url, options); } get did() { throw new Error("Public session does not have a DID"); }}
// Matches the header format in @atproto/apifunction buildAcceptLabelersHeader(labelerDids) { return labelerDids .map((did) => (did === BSKY_LABELER_DID ? `${did};redact` : did)) .join(", ");}
// Used for merging provided headers with defaultsfunction parseAcceptLabelersHeader(header) { return (header ?? "") .split(",") .map((entry) => entry.split(";")[0].trim()) .filter(Boolean);}
export class Api { constructor( session, { onTokenRefreshError = null, bskyAppViewServiceDid = BSKY_APPVIEW_SERVICE_DID, chatAppViewServiceDid = BSKY_CHAT_SERVICE_DID, getLabelerDids = null, } = {}, ) { this.isAuthenticated = !!session; this.session = session ?? new PublicSession(); this.onTokenRefreshError = onTokenRefreshError; this.bskyAppViewServiceDid = bskyAppViewServiceDid; this.chatAppViewServiceDid = chatAppViewServiceDid; this.getLabelerDids = getLabelerDids; }
async appViewRequest(path, options = {}) { const headers = { "atproto-proxy": this.bskyAppViewServiceDid, ...options.headers, }; if (this.getLabelerDids) { const providedDids = await this.getLabelerDids(); // Merge provided labeler dids into existing labelers header if present const headerDids = headers["atproto-accept-labelers"] ? parseAcceptLabelersHeader(headers["atproto-accept-labelers"]) : []; const allDids = unique([...providedDids, ...headerDids]); if (allDids.length) { headers["atproto-accept-labelers"] = buildAcceptLabelersHeader(allDids); } } return this.request(path, { ...options, headers }); }
chatRequest(path, options = {}) { return this.request(path, { ...options, headers: { "atproto-proxy": this.chatAppViewServiceDid, ...options.headers, }, }); }
async request(path, options = {}) { const { body, query, method, headers = {}, parseJson = true, stringifyBody = true, ...restOptions } = options; let queryString = ""; if (query) { queryString = "?" + buildQueryString(query); } let res = null; try { const fetchOptions = { ...restOptions, method: method ?? "GET", headers: { "Content-Type": "application/json", ...headers, }, }; if (body) { if (stringifyBody) { fetchOptions.body = JSON.stringify(body); } else { fetchOptions.body = body; } } res = await this.session.fetch( `${this.session.serviceEndpoint}/xrpc/${path}${queryString}`, fetchOptions, ); } catch (error) { // Handle token refresh error if ( error instanceof RefreshTokenError || error instanceof OauthRefreshTokenError ) { console.error("Token refresh error", error); const did = this.isAuthenticated ? (this.session.did ?? null) : null; await this.onTokenRefreshError?.(did); window.location.href = linkToLogin(); await new Promise(() => {}); } throw error; } let data = null; if (parseJson) { // If body was already consumed by the oauth library, use that data = res.data ?? (await res.json()); } res.data = data; if (!res.ok) { throw new ApiError(res); } return res; }
async createLikeRecord(post) { const res = await this.request("com.atproto.repo.createRecord", { method: "POST", body: { repo: this.session.did, collection: "app.bsky.feed.like", record: { createdAt: getCurrentTimestamp(), subject: { uri: post.uri, cid: post.cid }, }, }, }); return res.data; }
async deleteLikeRecord(post) { const like = post.viewer.like; const rkey = like.split("/").pop(); const res = await this.request("com.atproto.repo.deleteRecord", { method: "POST", body: { repo: this.session.did, collection: "app.bsky.feed.like", rkey, }, }); return res.data; }
async createRepostRecord(post) { const res = await this.request("com.atproto.repo.createRecord", { method: "POST", body: { repo: this.session.did, collection: "app.bsky.feed.repost", record: { createdAt: getCurrentTimestamp(), subject: { uri: post.uri, cid: post.cid }, }, }, }); return res.data; }
async deleteRepostRecord(post) { const repost = post.viewer.repost; const rkey = repost.split("/").pop(); const res = await this.request("com.atproto.repo.deleteRecord", { method: "POST", body: { repo: this.session.did, collection: "app.bsky.feed.repost", rkey, }, }); return res.data; }
async createBookmark(post) { const res = await this.appViewRequest("app.bsky.bookmark.createBookmark", { method: "POST", body: { uri: post.uri, cid: post.cid, }, parseJson: false, }); return res.data; }
async deleteBookmark(post) { const res = await this.appViewRequest("app.bsky.bookmark.deleteBookmark", { method: "POST", body: { uri: post.uri, }, parseJson: false, }); return res.data; }
async getDrafts({ cursor = "", limit } = {}) { const query = {}; if (cursor) { query.cursor = cursor; } if (limit) { query.limit = limit; } const res = await this.appViewRequest("app.bsky.draft.getDrafts", { query, }); return res.data; }
async createDraft(draft) { const res = await this.appViewRequest("app.bsky.draft.createDraft", { method: "POST", body: { draft }, }); return res.data; }
async updateDraft(id, draft) { const res = await this.appViewRequest("app.bsky.draft.updateDraft", { method: "POST", body: { draft: { id, draft } }, parseJson: false, }); return res.data; }
async deleteDraft(id) { const res = await this.appViewRequest("app.bsky.draft.deleteDraft", { method: "POST", body: { id }, parseJson: false, }); return res.data; }
async createFollowRecord(profile) { const res = await this.request(`com.atproto.repo.createRecord`, { method: "POST", body: { repo: this.session.did, collection: "app.bsky.graph.follow", record: { createdAt: getCurrentTimestamp(), subject: profile.did, }, }, }); return res.data; }
async deleteFollowRecord(profile) { const follow = profile.viewer.following; const rkey = follow.split("/").pop(); const res = await this.request("com.atproto.repo.deleteRecord", { method: "POST", body: { repo: this.session.did, collection: "app.bsky.graph.follow", rkey, }, }); return res.data; }
async createListItemRecord(listUri, subjectDid) { const res = await this.request("com.atproto.repo.createRecord", { method: "POST", body: { repo: this.session.did, collection: "app.bsky.graph.listitem", record: { createdAt: getCurrentTimestamp(), subject: subjectDid, list: listUri, }, }, }); return res.data; }
async deleteListItemRecord(listItemUri) { const rkey = listItemUri.split("/").pop(); const res = await this.request("com.atproto.repo.deleteRecord", { method: "POST", body: { repo: this.session.did, collection: "app.bsky.graph.listitem", rkey, }, }); return res.data; }
async getListItems({ limit = 100, cursor = "" } = {}) { const query = { repo: this.session.did, collection: "app.bsky.graph.listitem", limit, }; if (cursor) { query.cursor = cursor; } const res = await this.request("com.atproto.repo.listRecords", { query }); return res.data; }
async getPostThread(postUri, { depth = 6 } = {}) { const res = await this.appViewRequest(`app.bsky.feed.getPostThread`, { query: { uri: postUri, depth, parentHeight: 1000, // max height, just so we don't set the wrong reply root by accident. This should be really rare - the default is 80. }, }); return res.data.thread; }
async getPostThreadOther(postUri) { const res = await this.appViewRequest( `app.bsky.unspecced.getPostThreadOtherV2`, { query: { anchor: postUri }, }, ); return res.data.thread; }
async getFeed(feedURI, { limit = 31, cursor = "" } = {}) { const res = await this.appViewRequest(`app.bsky.feed.getFeed`, { query: { feed: feedURI, limit, cursor, }, }); return res.data; }
async getFeedGenerator(feedURI) { const res = await this.appViewRequest(`app.bsky.feed.getFeedGenerator`, { query: { feed: feedURI }, }); return res.data.view; // note- returning the view object. }
async getFeedGenerators(feedURIs) { const res = await this.appViewRequest(`app.bsky.feed.getFeedGenerators`, { query: { feeds: feedURIs }, }); return res.data.feeds; }
async getStarterPack(starterPackURI) { const res = await this.appViewRequest(`app.bsky.graph.getStarterPack`, { query: { starterPack: starterPackURI }, }); return res.data.starterPack; }
async getList(listURI, { limit = 1, cursor = "" } = {}) { const query = { list: listURI, limit }; if (cursor) { query.cursor = cursor; } const res = await this.appViewRequest(`app.bsky.graph.getList`, { query, }); return res.data; }
async getListFeed(listURI, { limit = 31, cursor = "" } = {}) { const query = { list: listURI, limit }; if (cursor) { query.cursor = cursor; } const res = await this.appViewRequest(`app.bsky.feed.getListFeed`, { query, }); return res.data; }
async getActorFeeds(did, { limit = 50, cursor = "" } = {}) { const query = { actor: did, limit }; if (cursor) { query.cursor = cursor; } const res = await this.appViewRequest(`app.bsky.feed.getActorFeeds`, { query, }); return res.data; }
async getListsWithMembership(actor, { limit = 50, cursor = "" } = {}) { const query = { actor, limit }; if (cursor) { query.cursor = cursor; } const res = await this.appViewRequest( "app.bsky.graph.getListsWithMembership", { query, }, ); return res.data; }
async getActorLists(did, { limit = 50, cursor = "" } = {}) { const query = { actor: did, limit }; if (cursor) { query.cursor = cursor; } const res = await this.appViewRequest(`app.bsky.graph.getLists`, { query, }); return res.data; }
async searchFeedGenerators(query, { limit = 15, cursor = "" } = {}) { const queryParams = { limit, query }; if (cursor) { queryParams.cursor = cursor; } const res = await this.appViewRequest( `app.bsky.unspecced.getPopularFeedGenerators`, { query: queryParams, }, ); return res.data; }
async getTrends({ limit = 5 } = {}) { const res = await this.appViewRequest(`app.bsky.unspecced.getTrends`, { query: { limit }, }); return res.data; }
async getFollowingFeed({ limit = 31, cursor = "" } = {}) { const res = await this.appViewRequest(`app.bsky.feed.getTimeline`, { query: { limit, cursor }, }); return res.data; }
// Returns the posts in request order. The result may still be a subset // because the app view omits deleted and blocked posts async getPosts(postURIs) { const batches = batch(postURIs, 25); let posts = []; for (const batch of batches) { const res = await this.appViewRequest(`app.bsky.feed.getPosts`, { query: { uris: batch }, }); posts.push(...res.data.posts); } const orderByUri = new Map(postURIs.map((uri, index) => [uri, index])); posts.sort( (a, b) => (orderByUri.get(a.uri) ?? Infinity) - (orderByUri.get(b.uri) ?? Infinity), ); return posts; }
async getPost(postUri) { const posts = await this.getPosts([postUri]); if (posts.length === 0) { throw new Error(`Post not found: ${postUri}`); } return posts[0]; }
async getRecord(uri) { const { repo, rkey, collection } = parseUri(uri); const res = await this.request(`com.atproto.repo.getRecord`, { query: { repo, collection, rkey, }, }); return res.data; }
async getRepost(repostUri) { return this.getRecord(repostUri); }
async getReposts(repostUris) { const reposts = []; // Batch to avoid rate limiting const batches = batch(repostUris, 5); for (const batch of batches) { const results = await Promise.allSettled( batch.map((repostUri) => this.getRepost(repostUri)), ); // Only keep successful responses. This is similar to getPosts() const successfulResponses = results .filter((result) => result.status === "fulfilled") .map((result) => result.value); reposts.push(...successfulResponses); } return reposts; }
async getProfile(did) { const res = await this.appViewRequest(`app.bsky.actor.getProfile`, { query: { actor: did }, }); return res.data; }
async getProfiles(dids) { const results = await Promise.all( batch(dids, 25).map((chunk) => this.appViewRequest(`app.bsky.actor.getProfiles`, { query: { actors: chunk }, }), ), ); return results.flatMap((res) => res.data.profiles); }
async searchProfiles(query, { limit = 10, cursor = "" } = {}) { const queryParams = { q: query, limit }; if (cursor) { queryParams.cursor = cursor; } const res = await this.appViewRequest(`app.bsky.actor.searchActors`, { query: queryParams, }); return res.data; }
async searchProfilesTypeahead(query, { limit = 12 } = {}) { const res = await this.appViewRequest( `app.bsky.actor.searchActorsTypeahead`, { query: { q: query, limit }, }, ); return res.data; }
async searchPosts(query, { limit = 25, sort = "top", cursor = "" } = {}) { const queryParams = { q: query, limit, sort }; if (cursor) { queryParams.cursor = cursor; } const res = await this.appViewRequest(`app.bsky.feed.searchPosts`, { query: queryParams, }); return res.data; }
async sendInteractions(interactions, feedProxyUrl) { // Interactions are only useful to the feed generator that served the // posts, so callers must route to one. if (!feedProxyUrl) { throw new Error("sendInteractions requires a feedProxyUrl"); } await this.request(`app.bsky.feed.sendInteractions`, { method: "POST", body: { interactions }, headers: { "atproto-proxy": feedProxyUrl, }, parseJson: false, // third-party feed might not return JSON }); }
async getAuthorFeed( did, { limit = 31, cursor = "", filter = "posts_and_author_threads", includePins = false, } = {}, ) { const res = await this.appViewRequest(`app.bsky.feed.getAuthorFeed`, { query: { actor: did, limit, cursor, filter, includePins }, }); return res.data; }
async getActorLikes(did, { limit = 31, cursor = "" } = {}) { const query = { actor: did, limit }; if (cursor) { query.cursor = cursor; } const res = await this.appViewRequest(`app.bsky.feed.getActorLikes`, { query, }); return res.data; }
async getPreferences() { const res = await this.request(`app.bsky.actor.getPreferences`); // note - no atproto-proxy for this endpoint return res.data.preferences; }
async updatePreferences(preferencesObj) { const res = await this.appViewRequest(`app.bsky.actor.putPreferences`, { method: "POST", body: { preferences: preferencesObj }, parseJson: false, }); return res; }
async getLabelers(labelerDids) { const res = await this.appViewRequest(`app.bsky.labeler.getServices`, { query: { dids: labelerDids, detailed: true }, }); return res.data.views; }
async getLabeler(labelerDid) { const labelers = await this.getLabelers([labelerDid]); return labelers[0]; }
async getSession() { const res = await this.request("com.atproto.server.getSession", { method: "GET", }); return res.data; }
async getNumNotifications() { const res = await this.appViewRequest( "app.bsky.notification.getUnreadCount", ); return res.data.count; }
async registerPush({ serviceDid, token, platform, appId }) { await this.request("app.bsky.notification.registerPush", { method: "POST", body: { serviceDid, token, platform, appId }, headers: { "atproto-proxy": `${serviceDid}#bsky_notif`, }, parseJson: false, }); }
async unregisterPush({ serviceDid, token, platform, appId }) { await this.request("app.bsky.notification.unregisterPush", { method: "POST", body: { serviceDid, token, platform, appId }, headers: { "atproto-proxy": `${serviceDid}#bsky_notif`, }, parseJson: false, }); }
async getNotifications({ cursor, limit = 31, reasons } = {}) { const query = { cursor: cursor ?? "", limit }; if (reasons?.length) { query.reasons = reasons; } const res = await this.appViewRequest( "app.bsky.notification.listNotifications", { query, }, ); return res.data; }
async markNotificationsAsRead() { await this.appViewRequest("app.bsky.notification.updateSeen", { method: "POST", body: { seenAt: getCurrentTimestamp() }, parseJson: false, }); }
async listConvos({ cursor, limit = 30, readState, status } = {}) { const query = { limit }; if (cursor) { query.cursor = cursor; } if (readState) { query.readState = readState; } if (status) { query.status = status; } const res = await this.chatRequest("chat.bsky.convo.listConvos", { query, }); return res.data; }
async getConvo(convoId) { const res = await this.chatRequest("chat.bsky.convo.getConvo", { query: { convoId }, }); return res.data; }
async getConvoMembers(convoId, { cursor, limit = 50 } = {}) { const query = { convoId, limit }; if (cursor) { query.cursor = cursor; } const res = await this.chatRequest("chat.bsky.convo.getConvoMembers", { query, }); return res.data; }
async getMessages(convoId, { cursor, limit = 50 } = {}) { const query = { convoId, limit }; if (cursor) { query.cursor = cursor; } const res = await this.chatRequest("chat.bsky.convo.getMessages", { query, }); return res.data; }
async sendMessage(convoId, { text, facets, replyTo, embed }) { const message = { text, facets }; if (replyTo) { message.replyTo = replyTo; } if (embed) { message.embed = embed; } const res = await this.chatRequest("chat.bsky.convo.sendMessage", { method: "POST", body: { convoId, message, }, }); return res.data; }
async acceptConvo(convoId) { const res = await this.chatRequest("chat.bsky.convo.acceptConvo", { method: "POST", body: { convoId, }, }); return res.data; }
async leaveConvo(convoId) { const res = await this.chatRequest("chat.bsky.convo.leaveConvo", { method: "POST", body: { convoId, }, }); return res.data; }
async muteConvo(convoId) { const res = await this.chatRequest("chat.bsky.convo.muteConvo", { method: "POST", body: { convoId, }, }); return res.data; }
async unmuteConvo(convoId) { const res = await this.chatRequest("chat.bsky.convo.unmuteConvo", { method: "POST", body: { convoId, }, }); return res.data; }
async getConvoAvailability(memberDids) { const res = await this.chatRequest("chat.bsky.convo.getConvoAvailability", { query: { members: memberDids }, }); return res.data; }
async createGroupChat(name, memberDids) { const res = await this.chatRequest("chat.bsky.group.createGroup", { method: "POST", body: { name, members: memberDids }, }); return res.data; }
async getChatActorStatus() { const res = await this.chatRequest("chat.bsky.actor.getStatus"); return res.data; }
async getConvoForMembers(memberDids) { const res = await this.chatRequest("chat.bsky.convo.getConvoForMembers", { query: { members: memberDids }, }); return res.data; }
async getChatLogs({ cursor }) { const res = await this.chatRequest("chat.bsky.convo.getLog", { query: { cursor }, }); return res.data; }
async markConvoAsRead(convoId) { await this.chatRequest("chat.bsky.convo.updateRead", { method: "POST", body: { convoId, }, }); }
async getChatUnreadCounts({ includeGroupChats = true } = {}) { const res = await this.chatRequest("chat.bsky.convo.getUnreadCounts", { query: { includeGroupChats }, }); return res.data; }
async addMessageReaction(convoId, messageId, emoji) { const res = await this.chatRequest("chat.bsky.convo.addReaction", { method: "POST", body: { convoId, messageId, value: emoji, }, }); return res.data.message; }
async removeMessageReaction(convoId, messageId, emoji) { const res = await this.chatRequest("chat.bsky.convo.removeReaction", { method: "POST", body: { convoId, messageId, value: emoji, }, }); return res.data.message; }
async getJoinLinkPreviews(codes) { const res = await this.chatRequest("chat.bsky.group.getJoinLinkPreviews", { query: { codes }, }); return res.data; }
async requestJoinGroupChat(code) { const res = await this.chatRequest("chat.bsky.group.requestJoin", { method: "POST", body: { code }, }); return res.data; }
async getLikes(postUri, { limit = 50, cursor } = {}) { const query = { uri: postUri, limit }; if (cursor) { query.cursor = cursor; } const res = await this.appViewRequest("app.bsky.feed.getLikes", { query, }); return res.data; }
async getQuotes(postUri, { limit = 50, cursor } = {}) { const query = { uri: postUri, limit }; if (cursor) { query.cursor = cursor; } const res = await this.appViewRequest("app.bsky.feed.getQuotes", { query, }); return res.data; }
async getRepostedBy(postUri, { limit = 50, cursor } = {}) { const query = { uri: postUri, limit }; if (cursor) { query.cursor = cursor; } const res = await this.appViewRequest("app.bsky.feed.getRepostedBy", { query, }); return res.data; }
async getBookmarks({ limit = 31, cursor } = {}) { const query = { limit }; if (cursor) { query.cursor = cursor; } const res = await this.appViewRequest("app.bsky.bookmark.getBookmarks", { query, }); return res.data; }
async getFollowers(actor, { limit = 50, cursor } = {}) { const query = { actor, limit }; if (cursor) { query.cursor = cursor; } const res = await this.appViewRequest("app.bsky.graph.getFollowers", { query, }); return res.data; }
async getKnownFollowers(actor, { limit = 50, cursor } = {}) { const query = { actor, limit }; if (cursor) { query.cursor = cursor; } const res = await this.appViewRequest("app.bsky.graph.getKnownFollowers", { query, }); return res.data; }
async getFollows(actor, { limit = 50, cursor } = {}) { const query = { actor, limit }; if (cursor) { query.cursor = cursor; } const res = await this.appViewRequest("app.bsky.graph.getFollows", { query, }); return res.data; }
async putActivitySubscription(did, activitySubscription) { const res = await this.appViewRequest( "app.bsky.notification.putActivitySubscription", { method: "POST", body: { subject: did, activitySubscription, }, }, ); return res.data; }
async muteActor(did) { const res = await this.appViewRequest("app.bsky.graph.muteActor", { method: "POST", body: { actor: did, }, parseJson: false, }); return res; }
async unmuteActor(did) { const res = await this.appViewRequest("app.bsky.graph.unmuteActor", { method: "POST", body: { actor: did, }, parseJson: false, }); return res; }
async blockActor(profile) { const res = await this.request("com.atproto.repo.createRecord", { method: "POST", body: { repo: this.session.did, collection: "app.bsky.graph.block", record: { createdAt: getCurrentTimestamp(), subject: profile.did, }, }, }); return res.data; }
async getBlocks({ limit = 50, cursor } = {}) { const query = { limit }; if (cursor) { query.cursor = cursor; } const res = await this.appViewRequest("app.bsky.graph.getBlocks", { query, }); return res.data; }
async getMutes({ limit = 50, cursor } = {}) { const query = { limit }; if (cursor) { query.cursor = cursor; } const res = await this.appViewRequest("app.bsky.graph.getMutes", { query, }); return res.data; }
async unblockActor(profile) { const block = profile.viewer.blocking; const rkey = block.split("/").pop(); const res = await this.request("com.atproto.repo.deleteRecord", { method: "POST", body: { repo: this.session.did, collection: "app.bsky.graph.block", rkey, }, }); return res.data; }
async muteModList(listUri) { const res = await this.appViewRequest("app.bsky.graph.muteActorList", { method: "POST", body: { list: listUri, }, parseJson: false, }); return res; }
async unmuteModList(listUri) { const res = await this.appViewRequest("app.bsky.graph.unmuteActorList", { method: "POST", body: { list: listUri, }, parseJson: false, }); return res; }
async blockModList(listUri) { const res = await this.request("com.atproto.repo.createRecord", { method: "POST", body: { repo: this.session.did, collection: "app.bsky.graph.listblock", record: { createdAt: getCurrentTimestamp(), subject: listUri, }, }, }); return res.data; }
async unblockModList(blockUri) { const rkey = blockUri.split("/").pop(); const res = await this.request("com.atproto.repo.deleteRecord", { method: "POST", body: { repo: this.session.did, collection: "app.bsky.graph.listblock", rkey, }, }); return res.data; }
async applyWrites(writes) { const res = await this.request("com.atproto.repo.applyWrites", { method: "POST", body: { repo: this.session.did, writes, validate: true, }, }); return res.data; }
async deletePost(post) { const { rkey } = parseUri(post.uri); await this.request("com.atproto.repo.deleteRecord", { method: "POST", body: { repo: this.session.did, collection: "app.bsky.feed.post", rkey, }, }); }
async uploadBlob(blob, { signal = null } = {}) { const res = await this.request("com.atproto.repo.uploadBlob", { method: "POST", headers: { "Content-Type": blob.type, }, body: blob, stringifyBody: false, signal, }); return res.data.blob; }
async getServiceAuthToken({ aud, lxm, exp, signal = null }) { const res = await this.request("com.atproto.server.getServiceAuth", { query: { aud, lxm, exp: exp ?? Math.floor(Date.now() / 1000) + 60 }, signal, }); return res.data.token; }
async serviceRequest( url, { token, method = "GET", query, body, headers = {}, signal = null } = {}, ) { let queryString = ""; if (query) { queryString = "?" + buildQueryString(query); } const res = await fetch(`${url}${queryString}`, { method, headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...headers, }, body, signal, }); const data = await res.json(); res.data = data; if (!res.ok) { throw new ApiError(res); } return res; }
async getVideoUploadLimits({ signal = null } = {}) { const token = await this.getServiceAuthToken({ aud: VIDEO_SERVICE_DID, lxm: "app.bsky.video.getUploadLimits", signal, }); const res = await this.serviceRequest( `${VIDEO_SERVICE_URL}/xrpc/app.bsky.video.getUploadLimits`, { token, signal }, ); return res.data; }
async uploadVideoBlob(file, { signal = null } = {}) { const pdsHostname = new URL(this.session.serviceEndpoint).hostname; const token = await this.getServiceAuthToken({ aud: `did:web:${pdsHostname}`, lxm: "com.atproto.repo.uploadBlob", exp: Math.floor(Date.now() / 1000) + 60 * 30, signal, }); try { const res = await this.serviceRequest( `${VIDEO_SERVICE_URL}/xrpc/app.bsky.video.uploadVideo`, { token, method: "POST", query: { did: this.session.did, name: file.name }, headers: { "Content-Type": file.type }, body: file, signal, }, ); return res.data; } catch (error) { // If the same video has been uploaded before, the service returns 409 // Treat this as a success if ( error instanceof ApiError && error.data?.error === "already_exists" && error.data.jobId ) { return error.data; } throw error; } }
async getVideoJobStatus(jobId, { signal = null } = {}) { const res = await this.serviceRequest( `${VIDEO_SERVICE_URL}/xrpc/app.bsky.video.getJobStatus`, { query: { jobId }, signal }, ); return res.data.jobStatus; }
// The gifs.bsky.app proxy normalizes KLIPY responses into the Tenor Gif // shape: { next, results }. async _gifServiceRequest(path, { query = "", limit, cursor, signal }) { const params = { client_key: "impro-web", limit: String(limit), contentfilter: "low", }; if (query) { params.q = query; } if (cursor) { params.pos = String(cursor); } const res = await this.serviceRequest( `${GIF_SERVICE_URL}/klipy/v2/${path}`, { query: params, signal, }, ); return res.data; }
async searchGifs(query, { limit = 30, cursor = "", signal = null } = {}) { return this._gifServiceRequest("search", { query, limit, cursor, signal }); }
async getFeaturedGifs({ limit = 30, cursor = "", signal = null } = {}) { return this._gifServiceRequest("featured", { limit, cursor, signal }); }
async getProfileRecord() { const res = await this.request("com.atproto.repo.getRecord", { query: { repo: this.session.did, collection: "app.bsky.actor.profile", rkey: "self", }, }); return res.data; }
async putProfileRecord(record, swapRecord) { const res = await this.request("com.atproto.repo.putRecord", { method: "POST", body: { repo: this.session.did, collection: "app.bsky.actor.profile", rkey: "self", record: { $type: "app.bsky.actor.profile", ...record, }, swapRecord: swapRecord ?? null, }, }); return res.data; }
async getStatusRecord() { const res = await this.request("com.atproto.repo.getRecord", { query: { repo: this.session.did, collection: "app.bsky.actor.status", rkey: "self", }, }); return res.data; }
async putStatusRecord(record, swapRecord) { const res = await this.request("com.atproto.repo.putRecord", { method: "POST", body: { repo: this.session.did, collection: "app.bsky.actor.status", rkey: "self", record: { $type: "app.bsky.actor.status", ...record, }, swapRecord: swapRecord ?? null, }, }); return res.data; }
async deleteStatusRecord() { const res = await this.request("com.atproto.repo.deleteRecord", { method: "POST", body: { repo: this.session.did, collection: "app.bsky.actor.status", rkey: "self", }, }); return res.data; }
async createListRecord(record) { const res = await this.request("com.atproto.repo.createRecord", { method: "POST", body: { repo: this.session.did, collection: "app.bsky.graph.list", record: { $type: "app.bsky.graph.list", ...record, }, }, }); return res.data; }
async getListRecord(rkey) { const res = await this.request("com.atproto.repo.getRecord", { query: { repo: this.session.did, collection: "app.bsky.graph.list", rkey, }, }); return res.data; }
async putListRecord(rkey, record, swapRecord) { const res = await this.request("com.atproto.repo.putRecord", { method: "POST", body: { repo: this.session.did, collection: "app.bsky.graph.list", rkey, record: { $type: "app.bsky.graph.list", ...record, }, swapRecord: swapRecord ?? null, }, }); return res.data; }
async getThreadgateRecord(rkey) { const res = await this.request("com.atproto.repo.getRecord", { query: { repo: this.session.did, collection: "app.bsky.feed.threadgate", rkey, }, }); return res.data; }
async putThreadgateRecord(rkey, record, swapRecord) { const res = await this.request("com.atproto.repo.putRecord", { method: "POST", body: { repo: this.session.did, collection: "app.bsky.feed.threadgate", rkey, record: { $type: "app.bsky.feed.threadgate", ...record, }, swapRecord: swapRecord ?? null, }, }); return res.data; }
async getPostgateRecord(rkey) { const res = await this.request("com.atproto.repo.getRecord", { query: { repo: this.session.did, collection: "app.bsky.feed.postgate", rkey, }, }); return res.data; }
async putPostgateRecord(rkey, record, swapRecord) { const res = await this.request("com.atproto.repo.putRecord", { method: "POST", body: { repo: this.session.did, collection: "app.bsky.feed.postgate", rkey, record: { $type: "app.bsky.feed.postgate", ...record, }, swapRecord: swapRecord ?? null, }, }); return res.data; }
async createModerationReport({ reasonType, reason, subject, labelerDid }) { const body = { reasonType, subject, }; // Reason is optional if (reason) { body.reason = reason; } const res = await this.request("com.atproto.moderation.createReport", { method: "POST", body, headers: { "atproto-proxy": `${labelerDid}#atproto_labeler`, }, }); return res.data; }}