/** * Shared comment-fetching logic for beads. * * Fetches comments and likes from the Hypergoat GraphQL indexer, * resolves Bluesky profiles, builds threaded comment trees, and * detects claim info. Used by both the React hook (useBeadsComments) * and the public API routes (/api/v1/*). */ // ============================================================================ // Types // ============================================================================ export interface BeadsLike { did: string; handle: string; displayName?: string; avatar?: string; createdAt: string; uri: string; // AT-URI of the like record rkey: string; } export interface BeadsComment { did: string; handle: string; displayName?: string; avatar?: string; text: string; createdAt: string; uri: string; // AT-URI of the comment record rkey: string; nodeId: string; // The beads issue ID this comment targets replyTo?: string; // AT-URI of parent comment (for threading) likes: BeadsLike[]; // Likes on this comment replies: BeadsComment[]; // Nested child comments (built during assembly) } export interface ClaimInfo { handle: string; did: string; displayName?: string; avatar?: string; claimedAt: string; } export interface FetchCommentsResult { /** Map from node ID -> threaded comment trees (root comments with nested replies) */ commentsByNode: Map; /** Map from node ID -> total comment count (including replies) */ commentedNodeIds: Map; /** Flat list of ALL comments (including replies), newest-first */ allComments: BeadsComment[]; } // ============================================================================ // Profile resolution (cached, deduplicated) // ============================================================================ interface ResolvedProfile { did: string; handle: string; displayName?: string; avatar?: string; } const profileCache = new Map(); const profileInflight = new Map>(); async function resolveProfile(did: string): Promise { const cached = profileCache.get(did); if (cached) return cached; const inflight = profileInflight.get(did); if (inflight) return inflight; const promise = (async () => { try { const res = await fetch( `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}` ); if (!res.ok) throw new Error(`Profile fetch failed: ${res.status}`); const data = await res.json(); const profile: ResolvedProfile = { did, handle: data.handle || did, displayName: data.displayName || undefined, avatar: data.avatar || undefined, }; profileCache.set(did, profile); return profile; } catch { // Fallback: use DID as handle const fallback: ResolvedProfile = { did, handle: did.slice(0, 20) + "...", }; profileCache.set(did, fallback); return fallback; } finally { profileInflight.delete(did); } })(); profileInflight.set(did, promise); return promise; } // ============================================================================ // Hypergoat GraphQL fetching // ============================================================================ const INDEXER_URL = process.env.INDEXER_URL || "https://hypergoat-app-production.up.railway.app/graphql"; const FETCH_RECORDS_QUERY = ` query FetchRecords($collection: String!, $first: Int, $after: String) { records(collection: $collection, first: $first, after: $after) { edges { node { cid collection did rkey uri value } } pageInfo { hasNextPage endCursor } } } `; interface IndexerRecord { cid: string; collection: string; did: string; rkey: string; uri: string; value: Record; } interface IndexerResponse { data?: { records?: { edges: Array<{ node: IndexerRecord }>; pageInfo: { hasNextPage: boolean; endCursor?: string }; }; }; errors?: Array<{ message: string }>; } async function fetchRecordsByCollection( collection: string ): Promise { const allRecords: IndexerRecord[] = []; let cursor: string | undefined; let hasMore = true; let pages = 0; const MAX_PAGES = 5; // Safety limit while (hasMore && pages < MAX_PAGES) { const res = await fetch(INDEXER_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query: FETCH_RECORDS_QUERY, variables: { collection, first: 100, after: cursor, }, }), }); if (!res.ok) { throw new Error(`Indexer fetch failed: ${res.status}`); } const json: IndexerResponse = await res.json(); if (json.errors?.length) { throw new Error(json.errors[0].message); } const records = json.data?.records; if (!records) break; for (const edge of records.edges) { allRecords.push(edge.node); } hasMore = records.pageInfo.hasNextPage; cursor = records.pageInfo.endCursor; pages++; } return allRecords; } // ============================================================================ // Core: fetch and process comments // ============================================================================ /** * Fetch all beads comments and likes from Hypergoat, resolve profiles, * build threaded trees, and group by node. * * This is the pure data-fetching function used by both: * - `hooks/useBeadsComments.ts` (client-side React hook) * - `app/api/v1/*` routes (server-side API) */ export async function fetchBeadsComments(): Promise { // 1. Fetch comments and likes in parallel from Hypergoat const [commentRecords, likeRecords] = await Promise.all([ fetchRecordsByCollection("org.impactindexer.review.comment"), fetchRecordsByCollection("org.impactindexer.review.like"), ]); // 2. Filter to only beads-targeted comments const beadsCommentRecords = commentRecords.filter((r) => { const value = r.value as Record; const subject = value.subject as | { uri?: string; type?: string } | undefined; return ( subject?.uri && typeof subject.uri === "string" && subject.uri.startsWith("beads:") ); }); // 3. Build likes-by-comment-URI map const likesByCommentUri = new Map(); for (const likeRecord of likeRecords) { const value = likeRecord.value as Record; const subject = value.subject as | { uri?: string; type?: string } | undefined; if ( subject?.uri && typeof subject.uri === "string" && subject.uri.startsWith("at://") ) { const existing = likesByCommentUri.get(subject.uri) || []; existing.push(likeRecord); likesByCommentUri.set(subject.uri, existing); } } // 4. Resolve profiles for all unique DIDs (commenters + likers) const allDids = new Set(); for (const r of beadsCommentRecords) allDids.add(r.did); for (const r of likeRecords) allDids.add(r.did); const profiles = await Promise.all( [...allDids].map((did) => resolveProfile(did)) ); const profileMap = new Map(profiles.map((p) => [p.did, p])); // 5. Build flat comment list with likes attached const allFlat: BeadsComment[] = []; for (const record of beadsCommentRecords) { const value = record.value as Record; const subject = value.subject as { uri: string }; const nodeId = subject.uri.replace(/^beads:/, ""); const text = (value.text as string) || ""; const createdAt = (value.createdAt as string) || ""; const replyTo = (value.replyTo as string) || undefined; const profile = profileMap.get(record.did); // Build likes for this comment const likeRecordsForComment = likesByCommentUri.get(record.uri) || []; const likes: BeadsLike[] = likeRecordsForComment.map((lr) => { const lp = profileMap.get(lr.did); const lv = lr.value as Record; return { did: lr.did, handle: lp?.handle || lr.did.slice(0, 20) + "...", displayName: lp?.displayName, avatar: lp?.avatar, createdAt: (lv.createdAt as string) || "", uri: lr.uri, rkey: lr.rkey, }; }); const comment: BeadsComment = { did: record.did, handle: profile?.handle || record.did.slice(0, 20) + "...", displayName: profile?.displayName, avatar: profile?.avatar, text, createdAt, uri: record.uri, rkey: record.rkey, nodeId, replyTo, likes, replies: [], // Will be filled during tree assembly }; allFlat.push(comment); } // 6. Build thread trees grouped by node // Index all comments by URI for tree assembly const commentByUri = new Map(); for (const comment of allFlat) { commentByUri.set(comment.uri, comment); } // Assemble threads: attach replies to parents const rootComments: BeadsComment[] = []; for (const comment of allFlat) { if (comment.replyTo) { const parent = commentByUri.get(comment.replyTo); if (parent) { parent.replies.push(comment); continue; // Don't add as root } // Parent not found — treat as root comment } rootComments.push(comment); } // Sort: root comments newest-first, replies oldest-first (chronological) rootComments.sort( (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() ); function sortRepliesRecursive(comments: BeadsComment[]) { for (const c of comments) { c.replies.sort( (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() ); sortRepliesRecursive(c.replies); } } sortRepliesRecursive(rootComments); // Group root comments by nodeId const commentsByNode = new Map(); for (const comment of rootComments) { const existing = commentsByNode.get(comment.nodeId) || []; existing.push(comment); commentsByNode.set(comment.nodeId, existing); } // 7. Build counts (total comments including replies per node) const commentedNodeIds = new Map(); for (const comment of allFlat) { commentedNodeIds.set( comment.nodeId, (commentedNodeIds.get(comment.nodeId) || 0) + 1 ); } // 8. Build allComments flat list (newest-first) const allComments = [...allFlat].sort( (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() ); return { commentsByNode, commentedNodeIds, allComments }; } // ============================================================================ // Claim detection // ============================================================================ /** * Extract claim info from comments. * A claim comment has text starting with "@" and no spaces (e.g. "@alice.bsky.social"). * First claim per node wins. */ export function getClaimedNodes( allComments: BeadsComment[] ): Map { const claims = new Map(); // Sort oldest-first so the first claim wins const sorted = [...allComments].sort( (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() ); for (const comment of sorted) { if (claims.has(comment.nodeId)) continue; const text = comment.text.trim(); if (text.startsWith("@") && text.indexOf(" ") === -1) { claims.set(comment.nodeId, { handle: comment.handle, did: comment.did, displayName: comment.displayName, avatar: comment.avatar, claimedAt: comment.createdAt, }); } } return claims; }