Something went wrong. Try again.
A Bsky-like frontend using the atprotocol natively.
Something went wrong. Try again.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284import { Agent, CredentialSession } from '@atproto/api';import { BrowserOAuthClient, type OAuthSession,} from '@atproto/oauth-client-browser';import { buildAtprotoLoopbackClientMetadata } from '@atproto/oauth-types';import { isBlockedPost, isSuspendedPost, suspendedToThread,} from './types';import type { SessionData, GetTimelineResponse, GetPostThreadResponse, GetFeedResponse, Cursor, AtUri, Cid, Did, PostView, BlobRef, UploadBlobResponse, SearchActorsTypeaheadResponse, SearchPostsResponse, SearchActorsResponse, FeedGeneratorInfo, SavedFeedEntry, ListNotificationsResponse, UnreadCountResponse, Handle, FeedGeneratorView, GetActorFeedsResponse, SearchFeedsResponse, ProfileViewDetailed, ProfileViewBasic, SuspendedPost, EmbedView,} from './types';import { initBlobRewrite, rewriteBlobUrls } from './blobRewrite';import { resolvePdsForDids, getPdsUrlForDid } from './pdsResolver';
declare const __OAUTH_DOMAIN__: string | undefined;
const HANDLE_RESOLVERS = [ 'https://compressed.cubporn.zip/', 'https://bsky.social/',];
const DEFAULT_PUBLIC_APPVIEW_PROXIES = [ 'did:web:api.bsky.app#bsky_appview', 'did:web:api.blacksky.community#bsky_appview',];
function appViewUrlFromProxy(proxyValue: string): string { const match = proxyValue.match(/^did:web:([^#]+)/); if (match) return `https://${match[1]}`; return 'https://api.bsky.app';}
let _publicAppViewProxies: string[] = [...DEFAULT_PUBLIC_APPVIEW_PROXIES];
async function resolveWorkingHandleResolver(): Promise<string> { try { const cached = sessionStorage.getItem('foxsky_handle_resolver'); if (cached) return cached; } catch {}
for (const resolver of HANDLE_RESOLVERS) { try { const probeUrl = `${resolver}xrpc/com.atproto.identity.resolveHandle?handle=bsky.social`; const resp = await fetch(probeUrl, { method: 'GET', signal: AbortSignal.timeout(5000) }); if (resp.status >= 500 || resp.status === 0) { console.warn(`[HANDLE_RESOLVER] ${resolver} returned ${resp.status}, trying next...`); continue; } // Any non-5xx (including 4xx for bad handle) means the server is up console.info(`[HANDLE_RESOLVER] Using ${resolver}`); try { sessionStorage.setItem('foxsky_handle_resolver', resolver); } catch {} return resolver; } catch (err) { console.warn(`[HANDLE_RESOLVER] ${resolver} unreachable:`, err); } }
// All failed — fall back to bsky.social as last resort console.warn('[HANDLE_RESOLVER] All resolvers failed, falling back to bsky.social'); return HANDLE_RESOLVERS[HANDLE_RESOLVERS.length - 1];}
export class AtprotoClient { private oauthClient: BrowserOAuthClient | null = null; private oauthSession: OAuthSession | null = null; private agent: Agent | null = null; get isReady(): boolean { return this.agent != null; }
get pdsUrl(): string { if (!this.oauthSession) return ''; try { return this.oauthSession.serverMetadata.issuer; } catch { return ''; } }
get session(): SessionData | null { if (!this.oauthSession) return null; return { did: this.oauthSession.did, handle: '', accessJwt: '', refreshJwt: '', }; } get api(): Agent | null { return this.agent; } get oauth(): OAuthSession | null { return this.oauthSession; } get client(): BrowserOAuthClient | null { return this.oauthClient; }
async initialize(): Promise<void> { const { origin, hostname, port, pathname, protocol } = window.location;
const isLoopback = hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '[::1]';
const buildDomain: string | undefined = typeof __OAUTH_DOMAIN__ !== 'undefined' ? __OAUTH_DOMAIN__ : undefined;
const handleResolver = await resolveWorkingHandleResolver();
if (isLoopback && !buildDomain) { const actualHostname = hostname === 'localhost' ? '127.0.0.1' : hostname; const redirectUri = `${protocol}//${actualHostname}${port ? `:${port}` : ''}${pathname}`;
const clientMetadata = buildAtprotoLoopbackClientMetadata({ redirect_uris: [redirectUri], scope: 'atproto transition:generic', });
this.oauthClient = new BrowserOAuthClient({ clientMetadata, handleResolver, responseMode: 'fragment', }); } else { const domain = buildDomain ? `https://${buildDomain}` : origin; const clientId = `${domain}/client-metadata.json`; this.oauthClient = await BrowserOAuthClient.load({ clientId, handleResolver, }); } }
async initSession(): Promise<{ session: SessionData | null; state?: string | null; redirectUrl?: string | null; error?: string; }> { if (!this.oauthClient) { return { session: null, error: 'OAuth client not initialized' }; }
try { const result = await this.oauthClient.init();
if (!result) { return { session: null }; }
this.oauthSession = result.session; this.agent = new Agent(result.session);
// Configure atproto-proxy for app.bsky.* requests routed through the user's PDS. // The PDS uses this header to determine which AppView to forward to. // Default to the bsky.app AppView; if the user's AppView list starts with // blacksky, use that instead. this.configureAgentProxy();
if (this.pdsUrl) { initBlobRewrite(this.oauthSession.did, this.pdsUrl); }
let redirectUrl: string | null = null; try { redirectUrl = sessionStorage.getItem('foxsky_oauth_redirect'); if (redirectUrl) sessionStorage.removeItem('foxsky_oauth_redirect'); } catch {}
return { session: this.session!, state: result.state, redirectUrl, }; } catch (err: unknown) { const message = err instanceof Error ? err.message : 'Failed to initialize session'; return { session: null, error: message }; } } async restoreSession(did: string): Promise<SessionData> { if (!this.oauthClient) { throw new Error('OAuth client not initialized'); }
const session = await this.oauthClient.restore(did); this.oauthSession = session; this.agent = new Agent(session); this._isPublicAgent = false;
// Configure atproto-proxy for app.bsky.* requests routed through the user's PDS. this.configureAgentProxy();
if (this.pdsUrl) { initBlobRewrite(session.did, this.pdsUrl); }
return this.session!; } async revokeSession(did: string): Promise<void> { if (!this.oauthClient) return;
await this.oauthClient.revoke(did);
if (this.oauthSession && this.oauthSession.did === did) { this.oauthSession = null; this.agent = null; } } async signIn(handle: string): Promise<never> { if (!this.oauthClient) { throw new Error('OAuth client not initialized'); }
const currentPath = window.location.pathname + window.location.search + window.location.hash; if (currentPath !== '/' && currentPath !== '/login') { try { sessionStorage.setItem('foxsky_oauth_redirect', currentPath); } catch {} }
await this.oauthClient.signInRedirect(handle); throw new Error('Redirect did not occur'); } async logout(): Promise<void> { if (this.oauthSession) { try { await this.oauthSession.signOut(); } catch { } } this.oauthSession = null; this.agent = null; this._isPublicAgent = false; }
/** Initialize an unauthenticated Agent pointing at bsky.social * for public API access (used by demo/test accounts). */ async initPublicAgent(): Promise<void> { this.oauthSession = null; this.agent = new Agent('https://bsky.social'); this._isPublicAgent = true; }
/** Whether this agent is an unauthenticated public agent (demo mode). */ private _isPublicAgent = false;
setPublicAppViewProxies(proxies: string[]): void { _publicAppViewProxies = proxies; // Re-configure the agent proxy to match the new primary AppView this.configureAgentProxy(); }
getPublicAppViewProxies(): string[] { return _publicAppViewProxies; }
private configureAgentProxy(): void { if (!this.agent) return; const proxyValue = _publicAppViewProxies[0] ?? 'did:web:api.bsky.app#bsky_appview'; // @ts-ignore this.agent.configureProxy(proxyValue); console.info(`[AtprotoClient] Configured agent proxy: ${proxyValue}`); }
private async fetchFromAppViews<T>( xrpcPath: string, requireOk = true, ): Promise<{ data: T; appViewProxy: string } | null> { const userPdsUrl = this.pdsUrl;
for (const appViewProxy of _publicAppViewProxies) { try { const appViewUrl = userPdsUrl || appViewUrlFromProxy(appViewProxy); const url = `${appViewUrl}/${xrpcPath}`; const resp = await fetch(url, { signal: AbortSignal.timeout(10000), headers: { 'atproto-proxy': appViewProxy }, });
if (requireOk && !resp.ok) { console.warn(`[AppView] ${appViewUrl} returned ${resp.status} for ${xrpcPath}, trying next...`); continue; }
if (!resp.ok) { continue; }
const data = await resp.json() as T; // Check that we got actual data (not an empty/error object) if (data && typeof data === 'object') { console.info(`[AppView] ${appViewUrl} succeeded for ${xrpcPath}`); return { data, appViewProxy }; } } catch (err) { const failedUrl = userPdsUrl || appViewUrlFromProxy(appViewProxy); console.warn(`[AppView] ${failedUrl} failed for ${xrpcPath}:`, err); } } return null; }
/** Public "What's Hot" feed URI used for unauthenticated browsing. */ private static readonly DISCOVER_FEED_URI = 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot' as AtUri;
/** * Authenticate with an app password (bypasses OAuth). * Used for test/internal accounts that can't go through the OAuth redirect flow. */ async loginWithPassword( service: string, identifier: string, password: string, ): Promise<SessionData> { const session = new CredentialSession(new URL(service)); await session.login({ identifier, password }); this.agent = new Agent(session); this._isPublicAgent = false;
// Configure atproto-proxy for app.bsky.* requests routed through the user's PDS. this.configureAgentProxy();
const did = this.agent.did!; const handle = identifier;
this.oauthSession = null;
return { did, handle, accessJwt: '', refreshJwt: '', }; }
/** * Restore a session using app password credentials. */ async restoreWithPassword( service: string, identifier: string, password: string, ): Promise<SessionData> { return this.loginWithPassword(service, identifier, password); } async getTimeline(limit = 50, cursor?: Cursor): Promise<GetTimelineResponse> { if (!this.agent) throw new Error('AtprotoClient: not logged in');
// Unauthenticated (demo) mode: getTimeline and getFeed both require auth. // Try a raw fetch to getFeed first (bsky.social may allow it for official // generators without auth). If that fails, fall back to searchPosts which // is a guaranteed-public endpoint that returns trending/popular content. if (this._isPublicAgent) { // Use the public Bluesky API (api.bsky.app) which serves the // "What's Hot" discover feed without authentication. const params = new URLSearchParams({ feed: AtprotoClient.DISCOVER_FEED_URI, limit: '30', }); if (cursor) params.set('cursor', cursor);
const rawResp = await this.fetchFromAppViews( `xrpc/app.bsky.feed.getFeed?${params}`, ); if (!rawResp) { throw new Error('Public feed request failed: all AppViews unavailable'); } const data = rawResp.data as { cursor?: string; feed: unknown[] }; return await rewriteBlobUrls({ cursor: data.cursor, feed: data.feed as GetTimelineResponse['feed'], }); }
const resp = await this.agent.getTimeline({ limit, cursor }); return await rewriteBlobUrls({ cursor: resp.data.cursor, feed: resp.data.feed as GetTimelineResponse['feed'], }); }
async getFeed(feedUri: AtUri, limit = 50, cursor?: Cursor): Promise<GetFeedResponse> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const resp = await this.agent.app.bsky.feed.getFeed({ feed: feedUri, limit, cursor }); return await rewriteBlobUrls({ cursor: resp.data.cursor, feed: resp.data.feed as GetFeedResponse['feed'], }); }
async getSavedFeeds(): Promise<SavedFeedEntry[]> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const prefs = await this.agent.getPreferences(); return (prefs.savedFeeds ?? []).map((f) => ({ id: f.id, type: f.type, value: f.value, pinned: f.pinned, })); }
async getFeedGenerators(uris: AtUri[]): Promise<FeedGeneratorInfo[]> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); if (uris.length === 0) return []; const resp = await this.agent.app.bsky.feed.getFeedGenerators({ feeds: uris }); return await rewriteBlobUrls(resp.data.feeds.map((f) => ({ uri: f.uri, cid: f.cid, did: f.did, creator: { did: f.creator.did, handle: f.creator.handle, displayName: f.creator.displayName, avatar: f.creator.avatar, }, displayName: f.displayName, description: f.description, avatar: f.avatar, likeCount: f.likeCount, indexedAt: f.indexedAt, viewer: f.viewer ? { like: f.viewer.like } : undefined, }))); }
async getPostThread(uri: AtUri, depth = 10, parentHeight = 40): Promise<GetPostThreadResponse> { if (!this.agent) throw new Error('AtprotoClient: not logged in');
// Try PDS direct first — fetches the post directly from the post author's PDS. // This is the authoritative source and avoids routing through the logged-in user's PDS. const pdsPost = await this.getRecordPost(uri);
// If PDS direct succeeded, return it immediately. The post author's PDS is the // canonical source for the record. We only fall back to the AppView (which routes // through the logged-in user's PDS) when PDS direct has no data. if (pdsPost) { // Try to enrich with AppView thread (counts, viewer state, replies) — optional try { const resp = await this.agent.getPostThread({ uri, depth, parentHeight }); const threadNode = isSuspendedPost(resp.data.thread) ? suspendedToThread(resp.data.thread) : resp.data.thread;
if (isBlockedPost(threadNode)) { console.log('[getPostThread] Authenticated fetch returned BlockedPost, falling back to public AppView'); return this.getPostThreadPublic(uri, depth, parentHeight); }
return await rewriteBlobUrls({ thread: resp.data.thread as GetPostThreadResponse['thread'], }); } catch (appViewErr) { console.warn('[getPostThread] AppView enrichment failed (non-critical), using PDS data:', appViewErr); } return { thread: pdsPost }; }
// PDS direct returned nothing — fall back to the AppView (routed through logged-in user's PDS) try { const resp = await this.agent.getPostThread({ uri, depth, parentHeight }); const threadNode = isSuspendedPost(resp.data.thread) ? suspendedToThread(resp.data.thread) : resp.data.thread;
if (isBlockedPost(threadNode)) { console.log('[getPostThread] Authenticated fetch (fallback) returned BlockedPost, falling back to public AppView'); return this.getPostThreadPublic(uri, depth, parentHeight); }
return await rewriteBlobUrls({ thread: resp.data.thread as GetPostThreadResponse['thread'], }); } catch (appViewErr) { console.warn('[getPostThread] AppView failed:', appViewErr); }
throw new Error('Cannot fetch post thread: PDS returned no data and AppView is unavailable'); }
async getPostThreadPublic(uri: AtUri, depth = 10, parentHeight = 40): Promise<GetPostThreadResponse> { // Try PDS direct first — fetches the post directly from the post author's PDS. // This is the authoritative source and avoids routing through any intermediate server. const pdsPost = await this.getRecordPost(uri);
// If PDS direct succeeded, return it immediately. We only fall back to the public // AppView when PDS direct has no data. if (pdsPost) { // Try to enrich with public AppView thread (counts, replies) — optional try { const params = new URLSearchParams({ uri, depth: String(depth), parentHeight: String(parentHeight), }); const result = await this.fetchFromAppViews<{ thread: GetPostThreadResponse['thread'] }>( `xrpc/app.bsky.feed.getPostThread?${params}`, ); if (result) { const threadNode = isSuspendedPost(result.data.thread) ? suspendedToThread(result.data.thread) : result.data.thread;
if (isBlockedPost(threadNode)) { // Even public AppView says it's blocked? This shouldn't happen for public view // unless the whole repository is taken down or similar. return { thread: pdsPost }; }
return await rewriteBlobUrls({ thread: result.data.thread }); } } catch (appViewErr) { console.warn('[getPostThreadPublic] Public AppView enrichment failed (non-critical), using PDS data:', appViewErr); } return { thread: pdsPost }; }
// PDS direct returned nothing — fall back to the public AppViews try { const params = new URLSearchParams({ uri, depth: String(depth), parentHeight: String(parentHeight), }); const result = await this.fetchFromAppViews<{ thread: GetPostThreadResponse['thread'] }>( `xrpc/app.bsky.feed.getPostThread?${params}`, ); if (result) { const threadNode = isSuspendedPost(result.data.thread) ? suspendedToThread(result.data.thread) : result.data.thread;
if (isBlockedPost(threadNode)) { throw new Error('Post is blocked even in public AppView'); }
return await rewriteBlobUrls({ thread: result.data.thread }); } } catch (appViewErr) { console.warn('[getPostThreadPublic] Public AppView failed:', appViewErr); }
throw new Error('Cannot fetch post thread: PDS returned no data and public AppView is unavailable'); }
async like(uri: AtUri, cid: Cid): Promise<{ uri: AtUri; cid: Cid }> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); return this.agent.like(uri, cid); }
async unlike(likeUri: AtUri): Promise<void> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); await this.agent.deleteLike(likeUri); }
async repost(uri: AtUri, cid: Cid): Promise<{ uri: AtUri; cid: Cid }> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); return this.agent.repost(uri, cid); }
async unrepost(repostUri: AtUri): Promise<void> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); await this.agent.deleteRepost(repostUri); }
async postReply( text: string, parentPost: PostView, rootRef?: { uri: AtUri; cid: Cid }, embed?: unknown, facets?: unknown[], ): Promise<{ uri: AtUri; cid: Cid }> { if (!this.agent) throw new Error('AtprotoClient: not logged in');
const root = rootRef ?? { uri: parentPost.uri, cid: parentPost.cid }; const reply = { root, parent: { uri: parentPost.uri, cid: parentPost.cid }, };
const record: Record<string, unknown> = { text, reply, createdAt: new Date().toISOString(), }; if (facets && facets.length > 0) { record.facets = facets; } if (embed) { record.embed = embed; }
return this.agent.post(record as Parameters<typeof this.agent.post>[0]); }
async postQuote( text: string, quotedPost: PostView, ): Promise<{ uri: AtUri; cid: Cid }> { if (!this.agent) throw new Error('AtprotoClient: not logged in');
return this.agent.post({ text, embed: { $type: 'app.bsky.embed.record', record: { uri: quotedPost.uri, cid: quotedPost.cid }, }, createdAt: new Date().toISOString(), }); }
async getProfile() { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const resp = await this.agent.getProfile({ actor: this.agent.did! }); return await rewriteBlobUrls(resp.data); }
/** * Fetch a profile directly from the user's PDS using com.atproto.repo.getRecord. * This works even when the appView is down or the account is suspended/taken down. * Uses repo.getRecord (not sync.getRecord) because the repo endpoint reads directly * from the data store and does not respect takedown/suspension status. * Returns a ProfileViewDetailed with `suspended: true` to indicate the data source. */ async getRecordProfile(actor: string): Promise<ProfileViewDetailed | null> { // Resolve actor to DID if it's a handle let did = actor; if (!actor.startsWith('did:')) { try { const resolved = await this.resolveHandlePublic(actor); did = resolved.did; } catch { // Try authenticated resolve as fallback try { if (this.agent) { const resolved = await this.resolveHandle(actor); did = resolved.did; } } catch { return null; } } }
// Resolve PDS URL for the DID await resolvePdsForDids([did]); const pdsUrl = getPdsUrlForDid(did); if (!pdsUrl) return null;
try { const resp = await fetch( `${pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=app.bsky.actor.profile&rkey=self`, { signal: AbortSignal.timeout(10000) }, ); if (!resp.ok) return null; const data = await resp.json() as { uri: string; cid: string; value: Record<string, unknown>; };
const value = data.value; // Extract avatar and banner blob refs for URL construction const avatar = this.buildBlobUrl(did, value.avatar as BlobRef | undefined, pdsUrl); const banner = this.buildBlobUrl(did, value.banner as BlobRef | undefined, pdsUrl);
// Resolve handle from DID doc let handle = actor.startsWith('did:') ? did : actor; try { const didResp = await fetch( did.startsWith('did:plc:') ? `https://plc.directory/${encodeURIComponent(did)}` : `https://${did.slice('did:web:'.length)}/.well-known/did.json`, { signal: AbortSignal.timeout(5000) }, ); if (didResp.ok) { const didDoc = await didResp.json() as { alsoKnownAs?: string[] }; const akaHandle = didDoc.alsoKnownAs?.find((a) => a.startsWith('at://')); if (akaHandle) { handle = akaHandle.replace('at://', ''); } } } catch { /* best effort */ }
return await rewriteBlobUrls({ did, handle, displayName: value.displayName as string | undefined, description: value.description as string | undefined, avatar, banner, followsCount: undefined, followersCount: undefined, postsCount: undefined, labels: undefined, pronouns: value.pronouns as string | undefined, website: undefined, indexedAt: undefined, suspended: true, pinnedPost: undefined, viewer: {}, } as ProfileViewDetailed); } catch { return null; } }
/** * Fetch a post record directly from the author's PDS using com.atproto.repo.getRecord. * Uses repo.getRecord (not sync.getRecord) because the repo endpoint reads directly * from the data store and works even for suspended/taken-down accounts. */ async getRecordPost(postUri: AtUri): Promise<SuspendedPost | null> { // Parse the AT URI: at://did:.../app.bsky.feed.post/rkey const uriParts = postUri.replace('at://', '').split('/'); const did = uriParts[0]; const collection = uriParts[1]; const rkey = uriParts.slice(2).join('/');
if (collection !== 'app.bsky.feed.post') return null;
// Resolve PDS URL await resolvePdsForDids([did]); const pdsUrl = getPdsUrlForDid(did); if (!pdsUrl) return null;
try { const resp = await fetch( `${pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent(rkey)}`, { signal: AbortSignal.timeout(10000) }, ); if (!resp.ok) return null; const data = await resp.json() as { uri: string; cid: string; value: Record<string, unknown>; };
const value = data.value;
// Resolve author handle from DID doc const author = await this.buildProfileBasicFromDid(did, pdsUrl);
// Build embed from record-level embed data const embed = await this.buildEmbedFromRecord(did, value.embed, pdsUrl);
return await rewriteBlobUrls({ $type: 'foxsky.feed.defs#suspendedPost', uri: data.uri || postUri, cid: data.cid, author, record: { $type: 'app.bsky.feed.post', text: value.text as string, createdAt: value.createdAt as string, embed: value.embed, reply: value.reply as { root: { uri: AtUri; cid: Cid }; parent: { uri: AtUri; cid: Cid } } | undefined, langs: value.langs as string[] | undefined, facets: value.facets as unknown[], }, embed, replyCount: undefined, repostCount: undefined, likeCount: undefined, quoteCount: undefined, indexedAt: value.createdAt as string, labels: undefined, viewer: {}, } as SuspendedPost); } catch { return null; } }
/** * Fetch multiple post records directly from their authors' PDSs. * Returns an array of SuspendedPost for successfully fetched posts. */ async getRecordsPosts(uris: AtUri[]): Promise<SuspendedPost[]> { if (uris.length === 0) return [];
const results = await Promise.allSettled( uris.map((uri) => this.getRecordPost(uri)), );
return results .filter((r): r is PromiseFulfilledResult<SuspendedPost> => r.status === 'fulfilled' && r.value !== null, ) .map((r) => r.value); }
/** * Build a blob URL from a blob ref, pointing at the given PDS. */ private buildBlobUrl(did: string, blobRef: BlobRef | undefined, pdsUrl: string): string | undefined { if (!blobRef || typeof blobRef !== 'object') return undefined; const ref = blobRef.ref; if (!ref || typeof ref !== 'object' || !('$link' in ref)) { // Could be a simple CID string or a different format const cid = (blobRef as unknown as Record<string, unknown>).cid; if (typeof cid === 'string') { return `${pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`; } return undefined; } return `${pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(ref.$link)}`; }
/** * Construct a minimal ProfileViewDetailed from DID resolution alone. * Used as a last resort when both the appView and PDS repo are unavailable. * Resolves handle from DID doc but has no avatar, banner, description, etc. */ private async buildMinimalProfile(actor: string): Promise<ProfileViewDetailed | null> { // Resolve actor to DID if it's a handle let did = actor; let handle = actor; if (!actor.startsWith('did:')) { try { const resolved = await this.resolveHandlePublic(actor); did = resolved.did; } catch { try { if (this.agent) { const resolved = await this.resolveHandle(actor); did = resolved.did; } } catch { return null; } } } else { handle = did; }
// Resolve handle from DID doc try { const didResp = await fetch( did.startsWith('did:plc:') ? `https://plc.directory/${encodeURIComponent(did)}` : `https://${did.slice('did:web:'.length)}/.well-known/did.json`, { signal: AbortSignal.timeout(5000) }, ); if (didResp.ok) { const didDoc = await didResp.json() as { alsoKnownAs?: string[] }; const akaHandle = didDoc.alsoKnownAs?.find((a) => a.startsWith('at://')); if (akaHandle) { handle = akaHandle.replace('at://', ''); } } } catch { /* best effort */ }
// Try PDS to at least get the profile record (description, display name, etc.) // even if the full getRecordProfile failed upstream let displayName: string | undefined; let description: string | undefined; let avatar: string | undefined; let banner: string | undefined;
try { await resolvePdsForDids([did]); const pdsUrl = getPdsUrlForDid(did); if (pdsUrl) { const profileResp = await fetch( `${pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=app.bsky.actor.profile&rkey=self`, { signal: AbortSignal.timeout(8000) }, ); if (profileResp.ok) { const profileData = await profileResp.json() as { value: Record<string, unknown> }; const value = profileData.value; displayName = value.displayName as string | undefined; description = value.description as string | undefined; avatar = this.buildBlobUrl(did, value.avatar as BlobRef | undefined, pdsUrl); banner = this.buildBlobUrl(did, value.banner as BlobRef | undefined, pdsUrl); } } } catch { /* best effort */ }
return { did, handle, displayName, description, avatar, banner, followsCount: undefined, followersCount: undefined, postsCount: undefined, labels: undefined, indexedAt: undefined, suspended: true, viewer: {}, } as ProfileViewDetailed; }
/** * Build a basic ProfileViewBasic from a DID by resolving the DID doc * and fetching the profile record from the PDS. */ private async buildProfileBasicFromDid(did: string, pdsUrl: string): Promise<ProfileViewBasic> { let handle = did; try { // Try to get handle from DID doc via PLC directory const didResp = await fetch( did.startsWith('did:plc:') ? `https://plc.directory/${encodeURIComponent(did)}` : `https://${did.slice('did:web:'.length)}/.well-known/did.json`, { signal: AbortSignal.timeout(5000) }, ); if (didResp.ok) { const didDoc = await didResp.json() as { alsoKnownAs?: string[] }; const akaHandle = didDoc.alsoKnownAs?.find((a) => a.startsWith('at://')); if (akaHandle) { handle = akaHandle.replace('at://', ''); } } } catch { /* best effort */ }
let displayName: string | undefined; let avatar: string | undefined;
try { const profileResp = await fetch( `${pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=app.bsky.actor.profile&rkey=self`, { signal: AbortSignal.timeout(5000) }, ); if (profileResp.ok) { const profileData = await profileResp.json() as { value: Record<string, unknown> }; displayName = profileData.value.displayName as string | undefined; avatar = this.buildBlobUrl(did, profileData.value.avatar as BlobRef | undefined, pdsUrl); } } catch { /* best effort */ }
return { did, handle, displayName, avatar, viewer: {}, }; }
/** * Build an EmbedView from a raw record embed by resolving blob references * to actual URLs on the PDS. Best-effort: returns undefined if it can't. */ private async buildEmbedFromRecord( did: string, rawEmbed: unknown, pdsUrl: string, ): Promise<EmbedView | undefined> { if (!rawEmbed || typeof rawEmbed !== 'object') return undefined;
const embed = rawEmbed as Record<string, unknown>; const embedType = embed.$type as string;
// Handle images embed if (embedType === 'app.bsky.embed.images') { const images = embed.images as Array<Record<string, unknown>> | undefined; if (!images) return undefined;
return { $type: 'app.bsky.embed.images#view', images: images.map((img) => { const blobRef = img.image as BlobRef | undefined; const thumb = this.buildBlobUrl(did, blobRef, pdsUrl); return { thumb: thumb || '', fullsize: thumb || '', alt: (img.alt as string) || '', aspectRatio: img.aspectRatio as { width: number; height: number } | undefined, }; }), }; }
// Handle video embed if (embedType === 'app.bsky.embed.video') { const videoBlob = embed.video as BlobRef | undefined; const videoCid = videoBlob?.ref?.$link || (videoBlob as unknown as Record<string, unknown>)?.cid as string | undefined; const thumbBlob = embed.thumbnail as BlobRef | undefined; if (!videoCid) return undefined;
return { $type: 'app.bsky.embed.video#view', cid: videoCid, playlist: `${pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(videoCid)}`, thumbnail: this.buildBlobUrl(did, thumbBlob, pdsUrl), alt: embed.alt as string | undefined, aspectRatio: embed.aspectRatio as { width: number; height: number } | undefined, }; }
// Handle external embed if (embedType === 'app.bsky.embed.external') { const external = embed.external as Record<string, unknown> | undefined; if (!external) return undefined; const thumbBlob = external.thumb as BlobRef | undefined;
return { $type: 'app.bsky.embed.external#view', external: { uri: (external.uri as string) || '', title: (external.title as string) || '', description: (external.description as string) || '', thumb: this.buildBlobUrl(did, thumbBlob, pdsUrl), }, }; }
// Handle recordWithMedia embed — render the media part, skip the record // (record enrichment requires AppView, but media can be served from PDS blobs) if (embedType === 'app.bsky.embed.recordWithMedia') { const media = embed.media as Record<string, unknown> | undefined; if (!media) return undefined; const mediaEmbed = await this.buildEmbedFromRecord(did, media, pdsUrl); // Return only the media portion; the record (quote) part can't be hydrated // without the AppView, so it's omitted. return mediaEmbed; }
// For record embeds, the appView enriches these from just URI/CID refs // into full views. Without the appView we can't do this, so return undefined // and let the UI show the raw text only. return undefined; }
/** * Check if an error from the AppView indicates an account takedown/suspension. * The bsky AppView returns: {"error":"AccountTakedown","message":"Account has been suspended"} */ private isAccountTakedownError(err: unknown): boolean { if (!err || typeof err !== 'object') return false; const e = err as Record<string, unknown>; // Check for XRPC error shape const error = e.error as string | undefined; if (error === 'AccountTakedown') return true; // Also check the message for "suspend" keyword as a fallback const message = (e.message as string) || String(err); if (typeof message === 'string' && message.toLowerCase().includes('suspend')) return true; return false; }
async getActorProfile(actor: string): Promise<ProfileViewDetailed> { if (!this.agent) throw new Error('AtprotoClient: not logged in');
let isSuspended = false;
// 1. Try authenticated appView (fastest, richest data for normal accounts) try { const resp = await this.agent.getProfile({ actor }); const profile = await rewriteBlobUrls(resp.data) as ProfileViewDetailed; return profile; } catch (appViewErr) { if (this.isAccountTakedownError(appViewErr)) { isSuspended = true; } console.warn('[getActorProfile] Authenticated appView failed:', appViewErr); }
// 2. Try public AppViews (might still serve data for suspended/taken-down accounts) try { const result = await this.fetchFromAppViews<ProfileViewDetailed>( `xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(actor)}`, ); if (result && result.data && result.data.did) { const profile = await rewriteBlobUrls(result.data) as ProfileViewDetailed; if (isSuspended) { profile.suspended = true; } return profile; } } catch (publicErr) { console.warn('[getActorProfile] Public AppViews failed:', publicErr); }
// 3. Try PDS direct (fetches raw profile record from the user's PDS) const pdsProfile = await this.getRecordProfile(actor); if (pdsProfile) return pdsProfile;
// 4. Construct minimal profile from DID resolution (last resort) const minimalProfile = await this.buildMinimalProfile(actor); if (minimalProfile) return minimalProfile;
throw new Error(`Cannot fetch profile for ${actor}`); }
async updateProfile(fields: { displayName?: string; description?: string; avatar?: string; banner?: string; pronouns?: string; }): Promise<void> { if (!this.agent) throw new Error('AtprotoClient: not logged in');
const current = await this.agent.com.atproto.repo.getRecord({ repo: this.agent.did!, collection: 'app.bsky.actor.profile', rkey: 'self', });
const existing = (current.data as { value?: Record<string, unknown> }).value ?? {};
const updated: Record<string, unknown> = { ...existing, $type: 'app.bsky.actor.profile', };
if (fields.displayName !== undefined) updated.displayName = fields.displayName; if (fields.description !== undefined) updated.description = fields.description; if (fields.pronouns !== undefined) { if (fields.pronouns === '') { delete updated.pronouns; } else { updated.pronouns = fields.pronouns; } }
await this.agent.com.atproto.repo.putRecord({ repo: this.agent.did!, collection: 'app.bsky.actor.profile', rkey: 'self', record: updated, }); }
async getPosts(uris: AtUri[]): Promise<PostView[]> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); if (uris.length === 0) return [];
// Try PDS direct first — fetches posts directly from each post author's PDS. // This is the authoritative source and avoids routing through the logged-in user's PDS. const pdsPosts = await this.getRecordsPosts(uris);
// If all posts were fetched via PDS direct, try to enrich with AppView for counts, // viewer state, etc. — but prefer the PDS data structure when AppView fails. // If PDS direct returned all requested posts, we still try AppView enrichment // because it provides richer data (like counts, viewer state). if (pdsPosts.length === uris.length) { // All posts fetched from author PDSes — try AppView for enrichment (optional) try { const BATCH_SIZE = 25; const allAppViewPosts: PostView[] = []; for (let i = 0; i < uris.length; i += BATCH_SIZE) { const batch = uris.slice(i, i + BATCH_SIZE); const resp = await this.agent.app.bsky.feed.getPosts({ uris: batch }); allAppViewPosts.push(...(resp.data.posts as PostView[])); } // If AppView also succeeded, use the richer AppView data (has counts, viewer state) if (allAppViewPosts.length > 0) { return await rewriteBlobUrls(allAppViewPosts); } } catch (appViewErr) { console.warn('[getPosts] AppView enrichment failed (non-critical), using PDS data:', appViewErr); } // AppView failed or returned nothing — use PDS data return pdsPosts as unknown as PostView[]; }
// PDS direct returned partial or no results — try the AppView for full hydration try { const BATCH_SIZE = 25; if (uris.length <= BATCH_SIZE) { const resp = await this.agent.app.bsky.feed.getPosts({ uris }); return await rewriteBlobUrls(resp.data.posts as PostView[]); }
const allPosts: PostView[] = []; for (let i = 0; i < uris.length; i += BATCH_SIZE) { const batch = uris.slice(i, i + BATCH_SIZE); const resp = await this.agent.app.bsky.feed.getPosts({ uris: batch }); allPosts.push(...(resp.data.posts as PostView[])); } return await rewriteBlobUrls(allPosts); } catch (appViewErr) { // AppView failed — that's OK if we have PDS data console.warn('[getPosts] AppView failed (non-critical), using PDS records:', appViewErr); }
// Use PDS results if we have them if (pdsPosts.length > 0) return pdsPosts as unknown as PostView[];
throw new Error('Cannot fetch posts: PDS returned no data and AppView is unavailable'); }
async getAuthorFeed( actor: string, limit = 30, cursor?: Cursor, filter?: 'posts_with_replies' | 'posts_no_replies' | 'posts_with_media' | 'posts_with_video', ) { if (!this.agent) throw new Error('AtprotoClient: not logged in');
// Try PDS direct first (works even when appView is down or account is suspended) let pdsResult: { cursor?: string; feed: GetTimelineResponse['feed'] } | null = null; try { pdsResult = await this.getAuthorFeedViaRecords(actor, limit, cursor, filter); } catch (pdsErr) { console.warn('[getAuthorFeed] PDS direct failed:', pdsErr); }
// If PDS returned posts, try to enrich with AppView for full hydration // (counts, viewer state, etc.). Use AppView data when available because // it provides a richer experience. Fall back to PDS data only when AppView // fails — this ensures suspended/taken-down accounts still work. if (pdsResult && pdsResult.feed.length > 0) { // Try authenticated agent first, then fall through to public AppViews try { const resp = await this.agent.getAuthorFeed({ actor, limit, cursor, filter }); const appViewResult = await rewriteBlobUrls({ cursor: resp.data.cursor, feed: resp.data.feed as GetTimelineResponse['feed'], }); if (appViewResult.feed.length > 0) { return appViewResult; } } catch (agentErr) { console.warn('[getAuthorFeed] Agent enrichment failed, trying public AppViews:', agentErr); }
// Agent enrichment failed or returned empty — try public AppViews (bsky then Blacksky) try { const params = new URLSearchParams({ actor, limit: String(limit), }); if (cursor) params.set('cursor', cursor); if (filter) params.set('filter', filter);
const result = await this.fetchFromAppViews<{ cursor?: string; feed: GetTimelineResponse['feed'] }>( `xrpc/app.bsky.feed.getAuthorFeed?${params}`, ); if (result) { const appViewResult = await rewriteBlobUrls({ cursor: result.data.cursor, feed: result.data.feed as GetTimelineResponse['feed'], }); if (appViewResult.feed.length > 0) { return appViewResult; } } } catch (appViewErr) { console.warn('[getAuthorFeed] Public AppView enrichment failed, using PDS data:', appViewErr); } // All AppView enrichment failed or returned empty — use PDS data as fallback return pdsResult; }
// PDS returned no results — try the authenticated agent first, then public AppViews try { const resp = await this.agent.getAuthorFeed({ actor, limit, cursor, filter }); const appViewResult = await rewriteBlobUrls({ cursor: resp.data.cursor, feed: resp.data.feed as GetTimelineResponse['feed'], }); if (appViewResult.feed.length > 0) { return appViewResult; } console.warn('[getAuthorFeed] Agent returned empty feed'); } catch (agentErr) { console.warn('[getAuthorFeed] Agent failed (non-critical), trying public AppViews:', agentErr); }
// Agent failed or returned empty — try public AppViews try { const params = new URLSearchParams({ actor, limit: String(limit), }); if (cursor) params.set('cursor', cursor); if (filter) params.set('filter', filter);
const result = await this.fetchFromAppViews<{ cursor?: string; feed: GetTimelineResponse['feed'] }>( `xrpc/app.bsky.feed.getAuthorFeed?${params}`, ); if (result) { const appViewResult = await rewriteBlobUrls({ cursor: result.data.cursor, feed: result.data.feed as GetTimelineResponse['feed'], }); if (appViewResult.feed.length > 0) { return appViewResult; } } console.warn('[getAuthorFeed] Public AppViews returned empty feed'); } catch (appViewErr) { console.warn('[getAuthorFeed] Public AppViews failed (non-critical):', appViewErr); }
// Use PDS result even if empty (it may have a cursor for pagination) if (pdsResult) { return pdsResult; }
throw new Error('Cannot fetch author feed: PDS returned no data and AppView is unavailable'); }
async getAuthorFeedPublic( actor: string, limit = 30, cursor?: Cursor, filter?: 'posts_with_replies' | 'posts_no_replies' | 'posts_with_media' | 'posts_with_video', ) { // Try PDS direct first (works even when appView is down or account is suspended) let pdsResult: { cursor?: string; feed: GetTimelineResponse['feed'] } | null = null; try { pdsResult = await this.getAuthorFeedViaRecords(actor, limit, cursor, filter); } catch (pdsErr) { console.warn('[getAuthorFeedPublic] PDS direct failed:', pdsErr); }
// If PDS returned posts, try to enrich with public AppViews for full hydration // (counts, viewer state, etc.). Use AppView data when available because // it provides a richer experience. Fall back to PDS data only when AppView fails. if (pdsResult && pdsResult.feed.length > 0) { try { const params = new URLSearchParams({ actor, limit: String(limit), }); if (cursor) params.set('cursor', cursor); if (filter) params.set('filter', filter);
const result = await this.fetchFromAppViews<{ cursor?: string; feed: GetTimelineResponse['feed'] }>( `xrpc/app.bsky.feed.getAuthorFeed?${params}`, ); if (result) { const appViewResult = await rewriteBlobUrls({ cursor: result.data.cursor, feed: result.data.feed as GetTimelineResponse['feed'], }); if (appViewResult.feed.length > 0) { return appViewResult; } } } catch (appViewErr) { console.warn('[getAuthorFeedPublic] Public AppView enrichment failed, using PDS data:', appViewErr); } // AppView failed or returned empty — use PDS data as fallback return pdsResult; }
// PDS returned no results — try the public AppViews for full hydration try { const params = new URLSearchParams({ actor, limit: String(limit), }); if (cursor) params.set('cursor', cursor); if (filter) params.set('filter', filter);
const result = await this.fetchFromAppViews<{ cursor?: string; feed: GetTimelineResponse['feed'] }>( `xrpc/app.bsky.feed.getAuthorFeed?${params}`, ); if (result) { const appViewResult = await rewriteBlobUrls({ cursor: result.data.cursor, feed: result.data.feed as GetTimelineResponse['feed'], }); if (appViewResult.feed.length > 0) { return appViewResult; } } console.warn('[getAuthorFeedPublic] Public AppViews returned empty feed'); } catch (appViewErr) { // AppView failed — that's OK if we have PDS data console.warn('[getAuthorFeedPublic] Public AppViews failed (non-critical):', appViewErr); }
// Use PDS result even if empty (it may have a cursor for pagination) if (pdsResult) { return pdsResult; }
throw new Error('Cannot fetch author feed: PDS returned no data and public AppView is unavailable'); }
/** * Check whether a raw post record matches the given filter. * Used by getAuthorFeedViaRecords to apply tab-level filtering locally, * since the PDS listRecords endpoint has no filter parameter. */ private postMatchesFilter(value: Record<string, unknown>, filter: string | undefined): boolean { if (!filter) return true; // 'posts_with_replies' or no filter → show everything
switch (filter) { case 'posts_no_replies': // Exclude posts that are replies (have a reply field) return !value.reply; case 'posts_with_media': { // Only posts with image or video embeds const embed = value.embed as Record<string, unknown> | undefined; if (!embed) return false; const type = embed.$type as string; return type === 'app.bsky.embed.images' || type === 'app.bsky.embed.video' || type === 'app.bsky.embed.recordWithMedia'; } case 'posts_with_video': { // Only posts with video embeds const embed = value.embed as Record<string, unknown> | undefined; if (!embed) return false; const type = embed.$type as string; return type === 'app.bsky.embed.video' || (type === 'app.bsky.embed.recordWithMedia' && !!(embed.media as Record<string, unknown> | undefined)?.$type?.toString().includes('video')); } default: return true; } }
/** * Fetch a user's feed directly from their PDS repo using listRecords. * This is a fallback when the appView is down or the account is suspended. * Note: Without the appView, we can only return the raw posts (no hydration * of likes, reposts, reply counts, etc.). * The filter parameter is applied locally on the fetched records. */ private async getAuthorFeedViaRecords( actor: string, limit = 30, cursor?: Cursor, filter?: string, ): Promise<{ cursor?: string; feed: GetTimelineResponse['feed'] }> { // Resolve actor to DID let did = actor; if (!actor.startsWith('did:')) { try { const resolved = await this.resolveHandlePublic(actor); did = resolved.did; } catch { try { const resolved = await this.resolveHandle(actor); did = resolved.did; } catch { throw new Error(`Cannot resolve handle: ${actor}`); } } }
// Resolve PDS URL await resolvePdsForDids([did]); const pdsUrl = getPdsUrlForDid(did); if (!pdsUrl) throw new Error(`Cannot resolve PDS for DID: ${did}`);
// Fetch more records than needed when filtering, since some will be excluded const fetchLimit = filter && filter !== 'posts_with_replies' ? Math.min(limit * 3, 100) : Math.min(limit, 50);
const params = new URLSearchParams({ repo: did, collection: 'app.bsky.feed.post', limit: String(fetchLimit), }); if (cursor) params.set('cursor', cursor);
const resp = await fetch( `${pdsUrl}/xrpc/com.atproto.repo.listRecords?${params}`, { signal: AbortSignal.timeout(15000) }, ); if (!resp.ok) { throw new Error(`listRecords (posts) failed: ${resp.status}`); } const data = await resp.json() as { cursor?: string; records: Array<{ uri: string; cid: string; value: Record<string, unknown>; }>; };
// Build SuspendedPost objects from raw records, applying filter const author = await this.buildProfileBasicFromDid(did, pdsUrl); const feed: GetTimelineResponse['feed'] = [];
for (const record of data.records) { if (feed.length >= limit) break;
const value = record.value;
// Apply local filter if (!this.postMatchesFilter(value, filter)) continue;
const embed = await this.buildEmbedFromRecord(did, value.embed, pdsUrl); const suspendedPost: SuspendedPost = await rewriteBlobUrls({ $type: 'foxsky.feed.defs#suspendedPost', uri: record.uri, cid: record.cid, author, record: { $type: 'app.bsky.feed.post', text: value.text as string, createdAt: value.createdAt as string, embed: value.embed, reply: value.reply as { root: { uri: AtUri; cid: Cid }; parent: { uri: AtUri; cid: Cid } } | undefined, langs: value.langs as string[] | undefined, facets: value.facets as unknown[], }, embed, indexedAt: value.createdAt as string, viewer: {}, } as SuspendedPost); feed.push({ post: suspendedPost }); }
return { cursor: data.cursor, feed }; }
/** * Resolve a handle to a DID without requiring authentication. * Uses the PLC directory / handle DNS/DID web resolution. */ async resolveHandlePublic(handle: string): Promise<{ did: string }> { // Try com.atproto.identity.resolveHandle on a public resolver first try { const resp = await fetch( `https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`, { signal: AbortSignal.timeout(5000) }, ); if (resp.ok) { const data = await resp.json() as { did: string }; return { did: data.did }; } } catch { /* try next */ }
// Try plc.directory via handle → DID resolution try { const resp = await fetch( `https://plc.directory/handle/${encodeURIComponent(handle)}`, { signal: AbortSignal.timeout(5000) }, ); if (resp.ok) { const data = await resp.json() as { did: string }; return { did: data.did }; } } catch { /* try next */ }
// Try did:web resolution try { const resp = await fetch( `https://${handle}/.well-known/atproto-did`, { signal: AbortSignal.timeout(5000) }, ); if (resp.ok) { const text = await resp.text(); const didMatch = text.match(/did:plc:[a-zA-Z0-9]+|did:web:[a-zA-Z0-9.%-]+/); if (didMatch) return { did: didMatch[0] }; } } catch { /* give up */ }
throw new Error(`Cannot resolve handle: ${handle}`); }
async getPostsPublic(uris: AtUri[]): Promise<PostView[]> { if (uris.length === 0) return [];
// Try PDS direct first — fetches posts directly from each post author's PDS. // This is the authoritative source and avoids routing through any intermediate server. const pdsPosts = await this.getRecordsPosts(uris);
// If all posts were fetched via PDS direct, try to enrich with public AppView // for counts, viewer state, etc. — but prefer the PDS data when AppView fails. if (pdsPosts.length === uris.length) { try { const BATCH_SIZE = 25; const allAppViewPosts: PostView[] = [];
for (let i = 0; i < uris.length; i += BATCH_SIZE) { const batch = uris.slice(i, i + BATCH_SIZE); const params = new URLSearchParams(); for (const u of batch) params.append('uris', u);
const result = await this.fetchFromAppViews<{ posts: PostView[] }>( `xrpc/app.bsky.feed.getPosts?${params}`, ); if (result) { allAppViewPosts.push(...(result.data.posts as PostView[])); } }
if (allAppViewPosts.length > 0) { return await rewriteBlobUrls(allAppViewPosts); } } catch (appViewErr) { console.warn('[getPostsPublic] Public AppView enrichment failed (non-critical), using PDS records:', appViewErr); } return pdsPosts as unknown as PostView[]; }
// PDS direct returned partial or no results — try the public AppViews for full hydration try { const BATCH_SIZE = 25; const allPosts: PostView[] = [];
for (let i = 0; i < uris.length; i += BATCH_SIZE) { const batch = uris.slice(i, i + BATCH_SIZE); const params = new URLSearchParams(); for (const u of batch) params.append('uris', u);
const result = await this.fetchFromAppViews<{ posts: PostView[] }>( `xrpc/app.bsky.feed.getPosts?${params}`, ); if (result) { allPosts.push(...(result.data.posts as PostView[])); } }
return await rewriteBlobUrls(allPosts); } catch (appViewErr) { // AppView failed — that's OK if we have PDS data console.warn('[getPostsPublic] Public AppView failed (non-critical), using PDS records:', appViewErr); }
// Use PDS results if we have them if (pdsPosts.length > 0) return pdsPosts as unknown as PostView[];
throw new Error('Cannot fetch posts: PDS returned no data and public AppView is unavailable'); }
async getActorLikes(actor: string, limit = 30, cursor?: Cursor) { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const resp = await this.agent.app.bsky.feed.getActorLikes({ actor, limit, cursor }); return await rewriteBlobUrls({ cursor: resp.data.cursor, feed: resp.data.feed as GetTimelineResponse['feed'], }); }
/** * Fetch another user's liked posts using the public listRecords endpoint. * This works for any user (not just the authenticated user) by: * 1. Resolving the user's DID to their PDS URL * 2. Calling com.atproto.repo.listRecords with collection=app.bsky.feed.like * 3. Extracting the liked post URIs from each like record's subject.uri * 4. Batch-fetching the actual posts via app.bsky.feed.getPosts (25 per batch) * * Returns the same shape as getActorLikes for compatibility. */ async getActorLikesViaRecords( did: string, limit = 25, cursor?: Cursor, ): Promise<{ cursor?: string; feed: GetTimelineResponse['feed'] }> { if (!this.agent) throw new Error('AtprotoClient: not logged in');
// Resolve the DID → PDS URL await resolvePdsForDids([did]); const pdsUrl = getPdsUrlForDid(did); const baseUrl = pdsUrl || 'https://bsky.social';
// Fetch like records from the user's repo const params = new URLSearchParams({ repo: did, collection: 'app.bsky.feed.like', limit: String(limit), }); if (cursor) params.set('cursor', cursor);
const resp = await fetch( `${baseUrl}/xrpc/com.atproto.repo.listRecords?${params}`, ); if (!resp.ok) { throw new Error(`listRecords failed: ${resp.status}`); } const data = await resp.json() as { cursor?: string; records: Array<{ uri: string; cid: string; value: { subject: { uri: string; cid: string }; createdAt: string; }; }>; };
// Extract post URIs from the like records const postUris: AtUri[] = []; for (const record of data.records) { const subjectUri = record.value?.subject?.uri; if (subjectUri) { postUris.push(subjectUri as AtUri); } }
if (postUris.length === 0) { return { cursor: data.cursor, feed: [] }; }
// Batch-fetch the actual posts (getPosts already tries PDS first, then AppView enrichment) let posts: (PostView | SuspendedPost)[] = []; try { posts = await this.getPosts(postUris); } catch { // AppView getPosts failed — try PDS direct for each post console.warn('[getActorLikesViaRecords] getPosts failed, trying PDS records'); posts = await this.getRecordsPosts(postUris); }
// Build a URI→PostView map for ordering (preserve listRecords order) const postMap = new Map(posts.map((p) => [p.uri, p]));
// Construct FeedViewPost items — no reply/reason context for likes const feed: GetTimelineResponse['feed'] = []; for (const uri of postUris) { const post = postMap.get(uri); if (post) { feed.push({ post }); } }
return await rewriteBlobUrls({ cursor: data.cursor, feed, }); }
async getFollowers(actor: string, limit = 50, cursor?: Cursor) { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const resp = await this.agent.app.bsky.graph.getFollowers({ actor, limit, cursor }); return await rewriteBlobUrls({ cursor: resp.data.cursor, subject: resp.data.subject, followers: resp.data.followers, }); }
async getFollows(actor: string, limit = 50, cursor?: Cursor) { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const resp = await this.agent.app.bsky.graph.getFollows({ actor, limit, cursor }); return await rewriteBlobUrls({ cursor: resp.data.cursor, subject: resp.data.subject, follows: resp.data.follows, }); } async follow(subjectDid: Did): Promise<{ uri: AtUri; cid: Cid }> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); return this.agent.follow(subjectDid); } async unfollow(followUri: AtUri): Promise<void> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); await this.agent.deleteFollow(followUri); } async block(subjectDid: Did): Promise<{ uri: AtUri; cid: Cid }> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const resp = await this.agent.com.atproto.repo.createRecord({ repo: this.agent.did!, collection: 'app.bsky.graph.block', record: { $type: 'app.bsky.graph.block', subject: subjectDid, createdAt: new Date().toISOString(), }, }); return { uri: resp.data.uri, cid: resp.data.cid }; } async unblock(blockUri: AtUri): Promise<void> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const uriParts = blockUri.replace('at://', '').split('/'); const rkey = uriParts.slice(2).join('/'); await this.agent.com.atproto.repo.deleteRecord({ repo: this.agent.did!, collection: 'app.bsky.graph.block', rkey, }); }
async uploadBlob(data: Uint8Array, encoding: string): Promise<UploadBlobResponse> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const resp = await this.agent.uploadBlob(data, { encoding }); return { blob: resp.data.blob as unknown as BlobRef }; }
async createPost(opts: { text: string; facets?: unknown[]; embed?: unknown; langs?: string[]; }): Promise<{ uri: AtUri; cid: Cid }> { if (!this.agent) throw new Error('AtprotoClient: not logged in');
const record: Record<string, unknown> = { $type: 'app.bsky.feed.post', text: opts.text, createdAt: new Date().toISOString(), }; if (opts.facets && opts.facets.length > 0) { record.facets = opts.facets; } if (opts.embed) { record.embed = opts.embed; } if (opts.langs) { record.langs = opts.langs; }
return this.agent.post(record as Parameters<typeof this.agent.post>[0]); }
async editPost(opts: { uri: AtUri; text: string; facets?: unknown[]; embed?: unknown; originalRecord: Record<string, unknown>; }): Promise<{ uri: AtUri; cid: Cid }> { if (!this.agent) throw new Error('AtprotoClient: not logged in');
const uriParts = opts.uri.replace('at://', '').split('/'); const repo = uriParts[0]; const collection = uriParts[1]; const rkey = uriParts.slice(2).join('/');
const record: Record<string, unknown> = { ...opts.originalRecord, $type: 'app.bsky.feed.post', text: opts.text, };
if (opts.facets && opts.facets.length > 0) { record.facets = opts.facets; } else { delete record.facets; }
if (opts.embed) { record.embed = opts.embed; }
const resp = await this.agent.com.atproto.repo.putRecord({ repo, collection, rkey, record, });
return { uri: resp.data.uri, cid: resp.data.cid }; }
async searchActorsTypeahead(query: string, limit = 8): Promise<SearchActorsTypeaheadResponse> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const resp = await this.agent.searchActorsTypeahead({ q: query, limit }); return await rewriteBlobUrls({ actors: resp.data.actors as SearchActorsTypeaheadResponse['actors'] }); }
async searchActors(query: string, limit = 25, cursor?: Cursor): Promise<SearchActorsResponse> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const resp = await this.agent.searchActors({ q: query, limit, cursor }); return await rewriteBlobUrls({ cursor: resp.data.cursor, actors: resp.data.actors as SearchActorsResponse['actors'], }); }
async searchPosts( query: string, limit = 25, cursor?: Cursor, sort?: 'top' | 'latest', ): Promise<SearchPostsResponse> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const params: Record<string, unknown> = { q: query, limit }; if (cursor) params.cursor = cursor; if (sort) params.sort = sort; const resp = await this.agent.app.bsky.feed.searchPosts(params as Parameters<typeof this.agent.app.bsky.feed.searchPosts>[0]); return await rewriteBlobUrls({ cursor: resp.data.cursor, hitsTotal: resp.data.hitsTotal, posts: resp.data.posts as PostView[], }); }
async searchGifs(query: string, limit = 20): Promise<Array<{ id: string; url: string; preview: string; title: string; width: number; height: number; }>> { const GIPHY_KEY = 'GlVGYHkr3WSBnllca54iNt0yFbjz7L65'; const resp = await fetch( `https://api.giphy.com/v1/gifs/search?api_key=${GIPHY_KEY}&q=${encodeURIComponent(query)}&limit=${limit}&rating=pg-13`, ); if (!resp.ok) throw new Error('GIF search failed'); const json = await resp.json() as { data: Array<{ id: string; title: string; images: { fixed_height_small: { url: string; width: string; height: string }; original: { url: string }; }; }>; }; return json.data.map((gif) => ({ id: gif.id, url: gif.images.original.url, preview: gif.images.fixed_height_small.url, title: gif.title, width: parseInt(gif.images.fixed_height_small.width, 10) || 200, height: parseInt(gif.images.fixed_height_small.height, 10) || 200, })); }
async resolveHandle(handle: string): Promise<{ did: string }> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const resp = await this.agent.com.atproto.identity.resolveHandle({ handle }); return { did: resp.data.did }; }
async listNotifications(limit = 50, cursor?: Cursor): Promise<ListNotificationsResponse> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const resp = await this.agent.app.bsky.notification.listNotifications({ limit, cursor }); return await rewriteBlobUrls({ cursor: resp.data.cursor, notifications: resp.data.notifications as ListNotificationsResponse['notifications'], priority: resp.data.priority ?? false, }); }
async getUnreadCount(): Promise<UnreadCountResponse> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const resp = await this.agent.app.bsky.notification.getUnreadCount({}); return { count: resp.data.count }; }
async updateSeen(): Promise<void> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); await this.agent.app.bsky.notification.updateSeen({ seenAt: new Date().toISOString(), }); }
async getBlocks(limit = 50, cursor?: Cursor): Promise<{ cursor?: Cursor; blocks: Array<{ did: Did; handle: Handle; displayName?: string; avatar?: string; }>; }> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const resp = await this.agent.app.bsky.graph.getBlocks({ limit, cursor }); return await rewriteBlobUrls({ cursor: resp.data.cursor, blocks: resp.data.blocks.map((b) => ({ did: b.did, handle: b.handle, displayName: b.displayName, avatar: b.avatar, })), }); }
async getMutes(limit = 50, cursor?: Cursor): Promise<{ cursor?: Cursor; mutes: Array<{ did: Did; handle: Handle; displayName?: string; avatar?: string; }>; }> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const resp = await this.agent.app.bsky.graph.getMutes({ limit, cursor }); return await rewriteBlobUrls({ cursor: resp.data.cursor, mutes: resp.data.mutes.map((m) => ({ did: m.did, handle: m.handle, displayName: m.displayName, avatar: m.avatar, })), }); }
async muteActor(actor: Did | Handle): Promise<void> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); await this.agent.app.bsky.graph.muteActor({ actor }); }
async unmuteActor(actor: Did | Handle): Promise<void> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); await this.agent.app.bsky.graph.unmuteActor({ actor }); }
async muteThread(root: AtUri): Promise<void> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); await this.agent.app.bsky.graph.muteThread({ root }); }
async unmuteThread(root: AtUri): Promise<void> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); await this.agent.app.bsky.graph.unmuteThread({ root }); }
async createBookmark(postUri: AtUri, postCid: Cid): Promise<void> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); await this.agent.app.bsky.bookmark.createBookmark({ uri: postUri, cid: postCid, }); }
async deleteBookmark(postUri: AtUri): Promise<void> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); await this.agent.app.bsky.bookmark.deleteBookmark({ uri: postUri, }); }
async getBookmarks(limit = 50, cursor?: Cursor): Promise<{ cursor?: Cursor; bookmarks: Array<{ subject: { uri: AtUri; cid: Cid }; createdAt: string; item: unknown; }>; }> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const resp = await this.agent.app.bsky.bookmark.getBookmarks({ limit, cursor, }); return await rewriteBlobUrls({ cursor: resp.data.cursor, bookmarks: resp.data.bookmarks as Array<{ subject: { uri: AtUri; cid: Cid }; createdAt: string; item: unknown; }>, }); }
async updateSavedFeeds(feeds: SavedFeedEntry[]): Promise<void> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); await this.agent.overwriteSavedFeeds( feeds.map((f) => ({ id: f.id, type: f.type, value: f.value, pinned: f.pinned, })), ); }
async getActorFeeds(actor: string, limit = 50, cursor?: Cursor): Promise<GetActorFeedsResponse> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const resp = await this.agent.app.bsky.feed.getActorFeeds({ actor, limit, cursor }); return await rewriteBlobUrls({ cursor: resp.data.cursor, feeds: resp.data.feeds.map((f: any) => ({ uri: f.uri, cid: f.cid, did: f.did, creator: { did: f.creator.did, handle: f.creator.handle, displayName: f.creator.displayName, avatar: f.creator.avatar, }, displayName: f.displayName, description: f.description, descriptionFacets: f.descriptionFacets, avatar: f.avatar, likeCount: f.likeCount, acceptsInteractions: f.acceptsInteractions, labels: f.labels, indexedAt: f.indexedAt, viewer: f.viewer ? { like: f.viewer.like } : undefined, })), }); }
async searchFeeds(query: string, limit = 25, cursor?: Cursor): Promise<SearchFeedsResponse> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const params: Record<string, unknown> = { q: query, limit }; if (cursor) params.cursor = cursor; const resp = await this.agent.app.bsky.feed.searchPosts(params as any); return await rewriteBlobUrls({ cursor: (resp.data as any).cursor, feeds: (resp.data as any).feeds.map((f: any) => ({ uri: f.uri, cid: f.cid, did: f.did, creator: { did: f.creator.did, handle: f.creator.handle, displayName: f.creator.displayName, avatar: f.creator.avatar, }, displayName: f.displayName, description: f.description, descriptionFacets: f.descriptionFacets, avatar: f.avatar, likeCount: f.likeCount, acceptsInteractions: f.acceptsInteractions, labels: f.labels, indexedAt: f.indexedAt, viewer: f.viewer ? { like: f.viewer.like } : undefined, })), }); }
async getFeedGenerator(uri: AtUri): Promise<FeedGeneratorView> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); const resp = await this.agent.app.bsky.feed.getFeedGenerator({ feed: uri }); const f = resp.data.view as any; return await rewriteBlobUrls({ uri: f.uri, cid: f.cid, did: f.did, creator: { did: f.creator.did, handle: f.creator.handle, displayName: f.creator.displayName, avatar: f.creator.avatar, }, displayName: f.displayName, description: f.description, descriptionFacets: f.descriptionFacets, avatar: f.avatar, likeCount: f.likeCount, acceptsInteractions: f.acceptsInteractions, labels: f.labels, indexedAt: f.indexedAt, viewer: f.viewer ? { like: f.viewer.like } : undefined, }); }
async likeFeedGenerator(uri: AtUri, cid: Cid): Promise<{ uri: AtUri; cid: Cid }> { return this.like(uri, cid); }
async unlikeFeedGenerator(likeUri: AtUri): Promise<void> { return this.unlike(likeUri); }
/** * Report a post or account for moderation. */ async createReport(opts: { reasonType: string; reason?: string; subject: { $type: string; uri: string; cid: string } | { $type: string; did: string }; }): Promise<void> { if (!this.agent) throw new Error('AtprotoClient: not logged in'); await this.agent.com.atproto.moderation.createReport(opts); }}
export const atprotoClient = new AtprotoClient();