extremely claude-assisted go game based on atproto! working on cleaning up and giving a more unique design, still has a bit of a slop vibe to it.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Fix reactions: fetch records from PDS after Constellation lookup

Constellation backlinks only return references (did, collection, rkey),
not actual record values. Now we:
1. Get backlink refs from Constellation
2. Fetch actual records from each author's PDS
3. Group reactions by move URI

Also:
- Add uri field to MoveRecord type for proper reaction matching
- Include move URIs when fetching from PDS
- Add granular loading states (game, moves, reactions)
- Show "Loading reactions..." with pulse animation
- Load reactions async to not block page render

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+116 -72
+63 -25
src/lib/atproto-client.ts
··· 236 236 const data = await moveRes.json(); 237 237 for (const rec of data.records || []) { 238 238 if (rec.value?.game === gameAtUri) { 239 - moves.push(rec.value as MoveRecord); 239 + moves.push({ 240 + ...(rec.value as MoveRecord), 241 + uri: rec.uri, // Include the AT URI 242 + }); 240 243 } 241 244 } 242 245 } ··· 302 305 authorHandle?: string; 303 306 } 304 307 308 + interface ConstellationBacklinkRef { 309 + did: string; 310 + collection: string; 311 + rkey: string; 312 + } 313 + 305 314 /** Fetch all reactions for a game from Constellation backlinks. Returns a map of moveUri -> reactions. */ 306 315 export async function fetchGameReactions( 307 316 gameAtUri: string 308 317 ): Promise<Map<string, ReactionWithAuthor[]>> { 309 318 const reactionsByMove = new Map<string, ReactionWithAuthor[]>(); 319 + const backlinkRefs: ConstellationBacklinkRef[] = []; 310 320 let cursor: string | undefined; 311 321 312 322 try { 323 + // Step 1: Get backlink references from Constellation 313 324 do { 314 325 const params = new URLSearchParams({ 315 326 subject: gameAtUri, ··· 325 336 326 337 if (!res.ok) break; 327 338 328 - const body: ConstellationBacklinksResponse = await res.json(); 339 + const body = await res.json(); 329 340 for (const rec of body.records || []) { 330 - // Skip records without URI or value 331 - if (!rec.uri || !rec.value) continue; 341 + if (rec.did && rec.collection && rec.rkey) { 342 + backlinkRefs.push(rec as ConstellationBacklinkRef); 343 + } 344 + } 345 + cursor = body.cursor ?? undefined; 346 + } while (cursor); 332 347 333 - const reaction = rec.value as ReactionRecord; 334 - const moveUri = reaction.move; 348 + // Step 2: Fetch actual records from each author's PDS 349 + const fetchPromises = backlinkRefs.map(async (ref) => { 350 + try { 351 + const pds = await resolvePdsHost(ref.did); 352 + if (!pds) return null; 335 353 336 - // Parse author DID from the URI (at://did/collection/rkey) 337 - const uriParts = rec.uri.split('/'); 338 - const author = uriParts[2] || ''; 354 + const params = new URLSearchParams({ 355 + repo: ref.did, 356 + collection: ref.collection, 357 + rkey: ref.rkey, 358 + }); 339 359 340 - const reactionWithAuthor: ReactionWithAuthor = { 341 - ...reaction, 342 - uri: rec.uri, 343 - author, 344 - }; 360 + const res = await fetch( 361 + `${pds}/xrpc/com.atproto.repo.getRecord?${params}` 362 + ); 345 363 346 - // Group by move URI 347 - const existing = reactionsByMove.get(moveUri) || []; 348 - existing.push(reactionWithAuthor); 349 - reactionsByMove.set(moveUri, existing); 364 + if (!res.ok) return null; 365 + 366 + const data = await res.json(); 367 + if (!data.value) return null; 368 + 369 + const reaction = data.value as ReactionRecord; 370 + const uri = data.uri || `at://${ref.did}/${ref.collection}/${ref.rkey}`; 371 + 372 + return { 373 + ...reaction, 374 + uri, 375 + author: ref.did, 376 + } as ReactionWithAuthor; 377 + } catch { 378 + return null; 350 379 } 351 - cursor = body.cursor ?? undefined; 352 - } while (cursor); 380 + }); 381 + 382 + const reactions = (await Promise.all(fetchPromises)).filter((r): r is ReactionWithAuthor => r !== null); 383 + 384 + // Step 3: Group by move URI 385 + for (const reaction of reactions) { 386 + const moveUri = reaction.move; 387 + const existing = reactionsByMove.get(moveUri) || []; 388 + existing.push(reaction); 389 + reactionsByMove.set(moveUri, existing); 390 + } 391 + 392 + // Sort reactions within each move by creation time, newest first 393 + for (const [, reacts] of reactionsByMove) { 394 + reacts.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); 395 + } 353 396 } catch (err) { 354 397 console.error('Failed to fetch reactions from Constellation:', err); 355 - } 356 - 357 - // Sort reactions within each move by creation time, newest first 358 - for (const [moveUri, reactions] of reactionsByMove) { 359 - reactions.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); 360 398 } 361 399 362 400 return reactionsByMove;
+1
src/lib/types.ts
··· 16 16 17 17 export interface MoveRecord { 18 18 $type: 'boo.sky.go.move'; 19 + uri?: string; // AT URI of this move record (populated when fetching) 19 20 game: string; // AT URI of the game 20 21 player: string; 21 22 moveNumber: number;
+52 -47
src/routes/game/[id]/+page.svelte
··· 35 35 let isSubmittingReaction = $state(false); 36 36 let reactionHandles = $state<Record<string, string>>({}); 37 37 38 - // Client-side loaded data 39 - let loading = $state(true); 38 + // Client-side loaded data with granular loading states 39 + let loadingGame = $state(true); 40 + let loadingMoves = $state(true); 41 + let loadingReactions = $state(true); 40 42 let gameRecord = $state<GameRecord | null>(null); 41 43 let moves = $state<MoveRecord[]>([]); 42 44 let passes = $state<PassRecord[]>([]); 43 45 let resigns = $state<ResignRecord[]>([]); 44 46 let playerOneHandle = $state<string>(data.creatorDid); 45 47 let playerTwoHandle = $state<string | null>(data.playerTwoDid); 48 + 49 + // Combined loading state for initial render 50 + const loading = $derived(loadingGame && loadingMoves); 46 51 47 52 // DB index is authoritative for status and playerTwo (set by join endpoint, 48 53 // which can't write to player one's PDS repo). PDS is authoritative for ··· 96 101 reviewMoveIndex !== null ? moves[reviewMoveIndex] : (moves.length > 0 ? moves[moves.length - 1] : null) 97 102 ); 98 103 99 - // Build move URI for reactions (format: at://did/boo.sky.go.move/rkey) 104 + // Get move URI for reactions - use actual URI if available, fallback to synthetic 100 105 function getMoveUri(move: MoveRecord): string { 101 - // The move URI is constructed from player DID and move details 102 - // Since we don't have the rkey directly, we use a compound key 106 + if (move.uri) { 107 + return move.uri; 108 + } 109 + // Fallback: construct synthetic URI (shouldn't be needed normally) 103 110 return `at://${move.player}/boo.sky.go.move/${move.moveNumber}`; 104 111 } 105 112 106 113 // Get reactions for the selected move 107 114 const selectedMoveReactions = $derived(() => { 108 115 if (!selectedMove) return []; 109 - // Try to find reactions using multiple URI formats 110 - for (const [uri, reacts] of reactions) { 111 - if (uri.includes(selectedMove.player) && uri.includes(`/${selectedMove.moveNumber}`)) { 112 - return reacts; 113 - } 114 - } 115 - return []; 116 + const moveUri = getMoveUri(selectedMove); 117 + return reactions.get(moveUri) || []; 116 118 }); 117 119 118 120 // Count reactions per move for badges 119 121 function getReactionCount(move: MoveRecord): number { 120 - for (const [uri, reacts] of reactions) { 121 - if (uri.includes(move.player) && uri.includes(`/${move.moveNumber}`)) { 122 - return reacts.length; 123 - } 124 - } 125 - return 0; 122 + const moveUri = getMoveUri(move); 123 + return reactions.get(moveUri)?.length || 0; 126 124 } 127 125 128 126 // Get unique emojis for a move (for badges) 129 127 function getMoveEmojis(move: MoveRecord): string[] { 128 + const moveUri = getMoveUri(move); 129 + const moveReactions = reactions.get(moveUri) || []; 130 130 const emojis: string[] = []; 131 - for (const [uri, reacts] of reactions) { 132 - if (uri.includes(move.player) && uri.includes(`/${move.moveNumber}`)) { 133 - for (const r of reacts) { 134 - if (r.emoji && !emojis.includes(r.emoji)) { 135 - emojis.push(r.emoji); 136 - } 137 - } 131 + for (const r of moveReactions) { 132 + if (r.emoji && !emojis.includes(r.emoji)) { 133 + emojis.push(r.emoji); 138 134 } 139 135 } 140 136 return emojis.slice(0, 3); // Max 3 emojis for badge display 141 137 } 142 138 143 139 async function loadGameData() { 144 - loading = true; 145 - 146 - // Resolve handles 140 + // Resolve handles (fire and forget) 147 141 resolveDidToHandle(data.creatorDid).then((h) => { 148 142 playerOneHandle = h; 149 143 }); ··· 164 158 }); 165 159 } 166 160 } 161 + loadingGame = false; 167 162 168 163 // Fetch moves, passes, and resigns from both players' PDS repos. 169 - // Constellation backlinks don't embed record values, so we use 170 - // listRecords on each player's PDS and filter by game URI. 171 164 const result = await fetchGameActionsFromPds( 172 165 data.creatorDid, 173 166 data.playerTwoDid, ··· 176 169 moves = result.moves; 177 170 passes = result.passes; 178 171 resigns = result.resigns; 172 + loadingMoves = false; 179 173 180 - // Fetch reactions for the game (single query via game backlink) 181 - const reactionsMap = await fetchGameReactions(data.gameAtUri); 182 - reactions = reactionsMap; 174 + // Fetch reactions for the game (async, don't block) 175 + fetchGameReactions(data.gameAtUri).then((reactionsMap) => { 176 + reactions = reactionsMap; 177 + loadingReactions = false; 183 178 184 - // Resolve handles for reaction authors 185 - const authorDids = new Set<string>(); 186 - for (const reacts of reactionsMap.values()) { 187 - for (const r of reacts) { 188 - authorDids.add(r.author); 179 + // Resolve handles for reaction authors 180 + const authorDids = new Set<string>(); 181 + for (const reacts of reactionsMap.values()) { 182 + for (const r of reacts) { 183 + authorDids.add(r.author); 184 + } 185 + } 186 + for (const did of authorDids) { 187 + resolveDidToHandle(did).then((h) => { 188 + reactionHandles = { ...reactionHandles, [did]: h }; 189 + }); 189 190 } 190 - } 191 - for (const did of authorDids) { 192 - resolveDidToHandle(did).then((h) => { 193 - reactionHandles = { ...reactionHandles, [did]: h }; 194 - }); 195 - } 196 - 197 - loading = false; 191 + }); 198 192 } 199 193 200 194 async function handleMove(x: number, y: number, captures: number) { ··· 879 873 {/if} 880 874 881 875 <div class="reactions-list"> 882 - {#if selectedMoveReactions().length === 0} 876 + {#if loadingReactions} 877 + <p class="no-reactions loading-text">Loading reactions...</p> 878 + {:else if selectedMoveReactions().length === 0} 883 879 <p class="no-reactions">No reactions yet. {data.session ? 'Be the first to comment!' : 'Login to add a reaction.'}</p> 884 880 {:else} 885 881 {#each selectedMoveReactions() as reaction} ··· 1769 1765 color: var(--sky-gray); 1770 1766 font-style: italic; 1771 1767 padding: 1rem; 1768 + } 1769 + 1770 + .no-reactions.loading-text { 1771 + animation: pulse 1.5s ease-in-out infinite; 1772 + } 1773 + 1774 + @keyframes pulse { 1775 + 0%, 100% { opacity: 0.5; } 1776 + 50% { opacity: 1; } 1772 1777 } 1773 1778 1774 1779 .reaction-item {