import { 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() // 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 }) } }