Something went wrong. Try again.
FlockOff is a mass-block tool for the Atmosphere flockoff.app
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227import { Agent } from '@atproto/api'import { requireSession } from '@/lib/session'import { restoreAgent } from '@/lib/oauth'import { requireListScopes } from '@/lib/scope-gate'import { extractAtUri, extractListRef } from '@/lib/utils'import { APPVIEW_URL } from '@/lib/appview'import { fetchAllListMembers, getListSubscriptionRkey, createBlockList, populateList, subscribeToList,} from '@/lib/lexicon'import type { EngagementAccount, RelationshipStatus } from '@/types'
// ─── GET /api/list?url=... ────────────────────────────────────────────────────// Fetch list metadata + all members with relationship info + subscription status.
export async function GET(request: Request) { const { searchParams } = new URL(request.url) const url = searchParams.get('url')
if (!url) return Response.json({ error: 'url is required' }, { status: 400 })
let session: { did: string } try { session = await requireSession() } catch { return Response.json({ error: 'Not authenticated' }, { status: 401 }) }
try { const parsed = extractListRef(url) if (!parsed) { return Response.json( { error: "Couldn't parse that link. Paste the list's URL from any Atmosphere client, or its at:// URI — aturi.to can convert links between clients." }, { status: 400 } ) }
const publicAgent = new Agent(APPVIEW_URL)
// Resolve handle → DID (skip if the input already contains a DID) let creatorDid: string if (parsed.actor.startsWith('did:')) { creatorDid = parsed.actor } else { // eslint-disable-next-line @typescript-eslint/no-explicit-any const handleRes: any = await publicAgent.resolveHandle({ handle: parsed.actor }) creatorDid = handleRes.data.did }
const listUri = `at://${creatorDid}/app.bsky.graph.list/${parsed.rkey}`
// Fetch all list members using the public agent const { listMeta, profiles } = await fetchAllListMembers(publicAgent, listUri)
// Check relationships (auth agent needed) const agent = await restoreAgent(session.did)
const dids = profiles.map((p) => p.did) const relationshipMap = new Map<string, RelationshipStatus>()
// Batch relationship checks (50 at a time) const BATCH = 50 for (let i = 0; i < dids.length; i += BATCH) { const batch = dids.slice(i, i + BATCH) try { // eslint-disable-next-line @typescript-eslint/no-explicit-any const relRes: any = await (agent as any).app.bsky.graph.getRelationships({ actor: session.did, others: batch, }) // eslint-disable-next-line @typescript-eslint/no-explicit-any for (const rel of (relRes.data.relationships as any[])) { if (!rel.did) continue const following = !!rel.following const followedBy = !!rel.followedBy let status: RelationshipStatus = 'none' if (following && followedBy) status = 'mutual' else if (following) status = 'following' else if (followedBy) status = 'follower' relationshipMap.set(rel.did, status) } } catch { // Relationship check is best-effort } }
const accounts: EngagementAccount[] = profiles.map((profile) => ({ profile, engagementTypes: [], // list members have no engagement type tag relationship: relationshipMap.get(profile.did) ?? 'none', }))
// Check if user is already subscribed to this list const subscriptionRkey = await getListSubscriptionRkey(agent, session.did, listUri)
return Response.json({ list: listMeta, accounts, isSubscribed: !!subscriptionRkey, subscriptionRkey: subscriptionRkey ?? undefined, }) } catch (err) { const message = err instanceof Error ? err.message : 'Failed to fetch list' console.error('[api/list GET]', message) return Response.json({ error: message }, { status: 400 }) }}
// ─── POST /api/list ────────────────────────────────────────────────────────────// Create a curated copy of a list and subscribe to it.
export async function POST(request: Request) { let session: { did: string; handle?: string } try { session = await requireSession() } catch { return Response.json({ error: 'Not authenticated' }, { status: 401 }) }
try { const scopeGate = await requireListScopes(session.did) if (scopeGate) return scopeGate
const body = await request.json() as { sourceListUri: string selectedDids: string[] listName: string listDescription?: string }
if (!body.sourceListUri || !body.selectedDids?.length || !body.listName) { return Response.json({ error: 'sourceListUri, selectedDids, and listName are required' }, { status: 400 }) }
// sourceListUri is embedded in the created list's public description, so // it must be exactly a list AT URI — not arbitrary caller-supplied text. const sourceRef = extractAtUri(body.sourceListUri, 'app.bsky.graph.list') const canonicalSourceUri = sourceRef ? `at://${sourceRef.actor}/app.bsky.graph.list/${sourceRef.rkey}` : null if (canonicalSourceUri !== body.sourceListUri.trim()) { return Response.json({ error: 'sourceListUri must be an app.bsky.graph.list AT URI' }, { status: 400 }) }
const agent = await restoreAgent(session.did)
// 1. Create the new list const newListUri = await createBlockList( agent, session.did, body.listName, body.listDescription ?? `Curated block list created via FlockOff. Source: ${canonicalSourceUri}` ) const newListRkey = newListUri.split('/').pop()!
// 2. Populate with selected members (batched) const { failed } = await populateList(agent, session.did, newListUri, body.selectedDids) if (failed.length > 0) { let cleanedUp = false try { await agent.com.atproto.repo.deleteRecord({ repo: session.did, collection: 'app.bsky.graph.list', rkey: newListRkey, }) cleanedUp = true } catch { // Best-effort cleanup. If deletion fails, return the URI so the user can // find/remove the partial list manually rather than creating mystery state. }
return Response.json( { error: cleanedUp ? `Couldn't add ${failed.length} account${failed.length === 1 ? '' : 's'}, so the partial list was removed. Try again later.` : `Created list, but failed to add ${failed.length} account${failed.length === 1 ? '' : 's'}. Delete the partial list before trying again.`, ...(cleanedUp ? {} : { listUri: newListUri }), }, { status: 502 } ) }
// 3. Subscribe to the new list let subscriptionRkey: string try { subscriptionRkey = await subscribeToList(agent, session.did, newListUri) } catch { let cleanedUp = false try { await agent.com.atproto.repo.deleteRecord({ repo: session.did, collection: 'app.bsky.graph.list', rkey: newListRkey, }) cleanedUp = true } catch { // Return the URI below when cleanup fails so the user can remove the // unsubscribed list manually rather than creating a duplicate on retry. }
return Response.json( { error: cleanedUp ? "Couldn't subscribe to the new list, so it was removed. Try again later." : "Created the list, but couldn't subscribe to it. Delete the list before trying again.", ...(cleanedUp ? {} : { listUri: newListUri }), }, { status: 502 } ) }
return Response.json({ listUri: newListUri, listRkey: newListRkey, subscriptionRkey, total: body.selectedDids.length, }) } catch (err) { const message = err instanceof Error ? err.message : 'Failed to create list' console.error('[api/list POST]', message) return Response.json({ error: message }, { status: 500 }) }}