Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137// Thin atproto AppView client. Everything here hits the PUBLIC, unauthenticated// AppView (CORS-enabled), so the whole app runs from a static page with no// backend and no login. See follow-context / sort-friends for the same pattern.
const PUBLIC_API = 'https://public.api.bsky.app';
async function xrpc(method, params) { const url = new URL(`${PUBLIC_API}/xrpc/${method}`); for (const [k, v] of Object.entries(params || {})) { if (v !== undefined && v !== null) url.searchParams.set(k, v); } const r = await fetch(url, { headers: { accept: 'application/json' } }); if (!r.ok) { let detail = ''; try { detail = (await r.json()).message || ''; } catch {} throw new Error(`${method} ${r.status}${detail ? `: ${detail}` : ''}`); } return r.json();}
// Typeahead search for the handle input. Returns basic profile views// (did, handle, displayName, avatar).export async function searchActors(query, { limit = 6 } = {}) { const q = query.trim().replace(/^@/, ''); if (!q) return []; const { actors } = await xrpc('app.bsky.actor.searchActorsTypeahead', { q, limit }); return actors || [];}
// handle (or did) -> didexport async function resolveActor(input) { const handle = input.trim().replace(/^@/, '').replace(/^https?:\/\/(bsky\.app\/profile\/)?/, ''); if (handle.startsWith('did:')) return handle; const { did } = await xrpc('com.atproto.identity.resolveHandle', { handle }); return did;}
// Walk a paginated graph endpoint to the END (until the cursor runs out),// returning the `key` array flattened. The high page cap is only a runaway// guard — we want EVERY record, not a sample.async function paginate(method, actor, key, { maxPages = 1000, onProgress } = {}) { let cursor; const out = []; for (let page = 0; page < maxPages; page++) { const data = await xrpc(method, { actor, limit: 100, cursor }); out.push(...(data[key] || [])); onProgress?.(out.length); cursor = data.cursor; if (!cursor) break; } return out;}
// Run async `fn` over items with bounded concurrency (polite, but parallel).async function mapLimit(items, limit, fn) { let i = 0; const worker = async () => { while (i < items.length) { const idx = i++; await fn(items[idx]); } }; await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));}
// Mutuals = the people you follow who ALSO follow you back.//// Mutuals are a subset of your follows, so we enumerate ALL of your follows// (you control that count) and then batch-check the reverse edge with// getRelationships (30 at a time). This is correct even when your FOLLOWER list// is huge — paginating both sides and intersecting would silently truncate the// bigger side and drop real mutuals.export async function getMutuals(did, { onProgress } = {}) { const follows = await paginate('app.bsky.graph.getFollows', did, 'follows', { onProgress });
// batch the reverse-edge check; keep those who follow `did` back const chunks = []; for (let i = 0; i < follows.length; i += 30) chunks.push(follows.slice(i, i + 30).map((f) => f.did));
const mutualDids = new Set(); await mapLimit(chunks, 6, async (others) => { const url = new URL(`${PUBLIC_API}/xrpc/app.bsky.graph.getRelationships`); url.searchParams.set('actor', did); for (const o of others) url.searchParams.append('others', o); try { const r = await fetch(url, { headers: { accept: 'application/json' } }); if (!r.ok) return; for (const rel of (await r.json()).relationships || []) { if (rel.followedBy) mutualDids.add(rel.did); } } catch { /* skip this chunk on error */ } });
return follows.filter((f) => mutualDids.has(f.did));}
// Batch profiles (25 actors/call) -> map of did -> profile view.// NOTE: `actors` is a repeated query param (actors=a&actors=b), NOT comma-joined// — the AppView 400s on a comma list.export async function getProfiles(dids) { const byDid = {}; for (let i = 0; i < dids.length; i += 25) { const chunk = dids.slice(i, i + 25); const url = new URL(`${PUBLIC_API}/xrpc/app.bsky.actor.getProfiles`); for (const d of chunk) url.searchParams.append('actors', d); const r = await fetch(url, { headers: { accept: 'application/json' } }); if (!r.ok) throw new Error(`getProfiles ${r.status}`); const { profiles } = await r.json(); for (const p of profiles) byDid[p.did] = p; } return byDid;}
// Batch-resolve post URIs (25/call) -> map of uri -> post view. Used to show// WHAT someone liked/reposted (the firehose like record only carries the// subject URI, not its text).export async function getPosts(uris) { const byUri = {}; const unique = [...new Set(uris)].filter(Boolean); for (let i = 0; i < unique.length; i += 25) { const chunk = unique.slice(i, i + 25); const url = new URL(`${PUBLIC_API}/xrpc/app.bsky.feed.getPosts`); for (const u of chunk) url.searchParams.append('uris', u); const r = await fetch(url, { headers: { accept: 'application/json' } }); if (!r.ok) continue; // best-effort: one bad URI 400s the whole call otherwise const { posts } = await r.json(); for (const p of posts || []) byUri[p.uri] = p; } return byUri;}
// One author feed pull (up to `limit` recent items, no replies-of-others noise// removed — we want replies too, they're a signal). Returns the raw feed array.export async function getAuthorFeed(did, { limit = 100 } = {}) { const { feed } = await xrpc('app.bsky.feed.getAuthorFeed', { actor: did, limit, filter: 'posts_with_replies', }); return feed || [];}