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
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451import { 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";}
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 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; }}