Chess on the ATmosphere checkmate.blue
chess
13
fork

Configure Feed

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

Polish UX and clean up for production readiness

- Make login box prominent at top of page when opponent visits a game link
- Simplify firehose logger to append bare DIDs instead of full records
- Remove console.log/error statements from jetstream and atproto modules
- Profile page now uses GameCard component instead of raw rkey strings
- Add try-catch to findGameRecordByParent for consistent error handling
- Fix opponent handle check on waiting screen to use DID (resolves immediately)
- Homepage live feed empty state links to /play as CTA

authored by

Scott Hadfield and committed by tangled.org 88907421 c9d76087

+73 -80
+6 -21
scripts/firehose-logger.ts
··· 4 4 import { mkdir } from 'fs/promises'; 5 5 6 6 const JETSTREAM_URL = 'wss://jetstream2.us-east.bsky.network/subscribe'; 7 - const COLLECTIONS = ['blue.checkmate.game', 'blue.checkmate.challenge']; 7 + const COLLECTION = 'blue.checkmate.game'; 8 8 9 9 const __dirname = dirname(fileURLToPath(import.meta.url)); 10 10 const logDir = join(__dirname, '..', 'data'); 11 - const logFile = join(logDir, 'firehose.jsonl'); 11 + const logFile = join(logDir, 'players.log'); 12 12 13 13 await mkdir(logDir, { recursive: true }); 14 14 const stream = createWriteStream(logFile, { flags: 'a' }); ··· 18 18 19 19 function connect() { 20 20 const url = new URL(JETSTREAM_URL); 21 - for (const c of COLLECTIONS) { 22 - url.searchParams.append('wantedCollections', c); 23 - } 21 + url.searchParams.set('wantedCollections', COLLECTION); 24 22 if (cursor) url.searchParams.set('cursor', String(cursor)); 25 23 26 24 console.log(`[firehose] connecting...${cursor ? ` (cursor: ${cursor})` : ''}`); ··· 35 33 const data = JSON.parse(String(event.data)); 36 34 if (data.time_us) cursor = data.time_us; 37 35 if (data.kind !== 'commit') return; 36 + if (data.commit.collection !== COLLECTION) return; 38 37 39 - const line = JSON.stringify({ 40 - time: new Date().toISOString(), 41 - time_us: data.time_us, 42 - did: data.did, 43 - operation: data.commit.operation, 44 - collection: data.commit.collection, 45 - rkey: data.commit.rkey, 46 - record: data.commit.record ?? null, 47 - }); 48 - 49 - stream.write(line + '\n'); 50 - console.log( 51 - `[firehose] ${data.commit.operation} ${data.commit.collection} ${data.did.slice(0, 24)}...` 52 - ); 38 + stream.write(data.did + '\n'); 53 39 }; 54 40 55 41 ws.onclose = (event: CloseEvent) => { ··· 65 51 }; 66 52 } 67 53 68 - console.log(`[firehose] logging to ${logFile}`); 69 - console.log(`[firehose] watching: ${COLLECTIONS.join(', ')}`); 54 + console.log(`[firehose] logging DIDs to ${logFile}`); 70 55 connect();
+17 -15
src/lib/atproto.ts
··· 68 68 $type: 'blue.checkmate.game', 69 69 }; 70 70 71 - console.log('[updateGame]', { did, rkey, collection: COLLECTIONS.game }); 72 - 73 71 await agent.com.atproto.repo.putRecord({ 74 72 repo: did, 75 73 collection: COLLECTIONS.game, ··· 239 237 did: string, 240 238 parentGameUri: string 241 239 ): Promise<{ rkey: string; record: GameRecord } | null> { 242 - const readAgent = did === agent.assertDid 243 - ? agent 244 - : await getPublicAgent(did); 240 + try { 241 + const readAgent = did === agent.assertDid 242 + ? agent 243 + : await getPublicAgent(did); 245 244 246 - const response = await readAgent.com.atproto.repo.listRecords({ 247 - repo: did, 248 - collection: COLLECTIONS.game, 249 - limit: 100, 250 - }); 245 + const response = await readAgent.com.atproto.repo.listRecords({ 246 + repo: did, 247 + collection: COLLECTIONS.game, 248 + limit: 100, 249 + }); 251 250 252 - for (const rec of response.data.records) { 253 - const value = rec.value as unknown as GameRecord; 254 - if (value.parentGameUri === parentGameUri) { 255 - return { rkey: rec.uri.split('/').pop()!, record: value }; 251 + for (const rec of response.data.records) { 252 + const value = rec.value as unknown as GameRecord; 253 + if (value.parentGameUri === parentGameUri) { 254 + return { rkey: rec.uri.split('/').pop()!, record: value }; 255 + } 256 256 } 257 + return null; 258 + } catch { 259 + return null; 257 260 } 258 - return null; 259 261 } 260 262 261 263 export { COLLECTIONS };
+2 -7
src/lib/jetstream.ts
··· 57 57 url.searchParams.set('cursor', String(this.cursor)); 58 58 } 59 59 60 - console.log('[jetstream] connecting to', url.toString()); 61 60 this.ws = new WebSocket(url.toString()); 62 61 63 62 this.ws.onopen = () => { 64 - console.log('[jetstream] connected'); 65 63 this.reconnectAttempts = 0; 66 64 this.stopPolling(); 67 65 this.options.onConnectionChange?.(true); ··· 74 72 } 75 73 if (data.kind !== 'commit') return; 76 74 const evt = data as JetstreamEvent; 77 - console.log('[jetstream]', evt.commit.operation, evt.commit.collection, evt.did); 78 75 if ( 79 76 (evt.commit.operation === 'update' || evt.commit.operation === 'create') && 80 77 evt.commit.collection === COLLECTIONS.game && ··· 87 84 } 88 85 }; 89 86 90 - this.ws.onclose = (event) => { 91 - console.log('[jetstream] closed:', event.code, event.reason); 87 + this.ws.onclose = () => { 92 88 this.options.onConnectionChange?.(false); 93 89 if (!this.destroyed) { 94 90 this.startPolling(); ··· 96 92 } 97 93 }; 98 94 99 - this.ws.onerror = (event) => { 100 - console.error('[jetstream] error:', event); 95 + this.ws.onerror = () => { 101 96 this.ws?.close(); 102 97 }; 103 98 }
+1 -1
src/routes/+page.svelte
··· 194 194 </div> 195 195 {:else} 196 196 <p class="text-sm text-text-secondary"> 197 - {liveConnected ? 'No active games right now. Be the first!' : 'Connecting to live feed...'} 197 + {liveConnected ? 'No active games right now. ' : 'Connecting to live feed...'}{#if liveConnected}<a href="/play" class="text-accent-blue hover:underline">Start one!</a>{/if} 198 198 </p> 199 199 {/if} 200 200 </section>
+16 -18
src/routes/game/[did]/[rkey]/+page.svelte
··· 679 679 <a href="/" class="text-accent-blue hover:underline">Go home</a> 680 680 </div> 681 681 {:else} 682 - <div class="flex min-h-screen flex-col items-center justify-center gap-4 p-4"> 682 + <div class="flex min-h-screen flex-col items-center gap-4 p-4 {isSpectator && isWaiting && !auth.isLoggedIn ? 'pt-12' : 'justify-center'}"> 683 + {#if isSpectator && isWaiting && !auth.isLoggedIn} 684 + <div class="w-full max-w-md rounded-lg border border-accent-blue bg-bg-secondary p-6 text-center"> 685 + <h2 class="text-lg font-semibold">You've been challenged!</h2> 686 + <p class="mt-2 text-sm text-text-secondary">Sign in with your Bluesky account to join this game</p> 687 + <div class="mt-4"> 688 + <LoginButton /> 689 + </div> 690 + </div> 691 + {/if} 692 + 683 693 {#if isWaiting && !isSpectator} 684 694 <div class="w-full max-w-md rounded-lg border border-border bg-bg-secondary p-6 text-center"> 685 695 <p class="text-lg font-semibold">Waiting for opponent</p> ··· 698 708 {copied ? 'Copied!' : 'Copy'} 699 709 </button> 700 710 </div> 701 - {#if opponentHandle && opponentHandle !== 'Waiting...'} 711 + {#if game.myColor === 'white' ? game.blackDid : game.whiteDid} 702 712 <div class="mt-4 flex flex-col items-center gap-2"> 703 713 <div class="flex gap-2"> 704 714 {#if posted} ··· 727 737 </div> 728 738 {/if} 729 739 730 - {#if isSpectator && isWaiting} 731 - {#if !auth.isLoggedIn} 732 - <div class="w-full max-w-sm rounded-lg border border-border bg-bg-secondary p-4 text-center"> 733 - <p class="mb-3 text-sm text-text-secondary">Sign in to join this game</p> 734 - <LoginButton /> 735 - </div> 736 - {:else} 737 - <div class="rounded-lg bg-bg-secondary px-4 py-2 text-sm text-text-secondary"> 738 - Waiting for game to start 739 - </div> 740 - {/if} 740 + {#if isSpectator && isWaiting && auth.isLoggedIn} 741 + <div class="rounded-lg bg-bg-secondary px-4 py-2 text-sm text-text-secondary"> 742 + Waiting for game to start 743 + </div> 741 744 {/if} 742 745 743 746 <PlayerBar ··· 772 775 Flip board 773 776 </button> 774 777 </div> 775 - {#if !auth.isLoggedIn && !game.result} 776 - <div class="w-full max-w-sm"> 777 - <LoginButton /> 778 - </div> 779 - {/if} 780 778 {/if} 781 779 782 780 {#if game.result}
+31 -18
src/routes/profile/[handle]/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { page } from '$app/stores'; 3 3 import { onMount } from 'svelte'; 4 - import { resolveIdentity, findGamesForPlayer, type ConstellationLink, type SlingshotProfile } from '$lib/microcosm'; 4 + import { resolveIdentity, type SlingshotProfile } from '$lib/microcosm'; 5 + import { listGamesForDid } from '$lib/atproto'; 6 + import type { GameRecord } from '$lib/types'; 7 + import GameCard from '$lib/components/GameCard.svelte'; 5 8 6 9 const handle = $derived($page.params.handle!); 7 10 11 + interface GameEntry { 12 + did: string; 13 + rkey: string; 14 + record: GameRecord; 15 + } 16 + 8 17 let profile: SlingshotProfile | null = $state(null); 9 - let games: ConstellationLink[] = $state([]); 18 + let games: GameEntry[] = $state([]); 10 19 let loading = $state(true); 11 20 let error = $state(''); 12 21 22 + function parseAtUri(uri: string): { did: string; rkey: string } | null { 23 + const match = uri.match(/^at:\/\/([^/]+)\/[^/]+\/(.+)$/); 24 + if (!match) return null; 25 + return { did: match[1], rkey: match[2] }; 26 + } 27 + 13 28 onMount(async () => { 14 29 const resolved = await resolveIdentity(handle); 15 30 if (!resolved) { ··· 18 33 return; 19 34 } 20 35 profile = resolved; 21 - games = await findGamesForPlayer(resolved.did); 36 + 37 + const allGames = await listGamesForDid(resolved.did); 38 + games = allGames 39 + .map((g) => { 40 + // For child records (player is black), use the parent URI for canonical navigation 41 + if (g.record.parentGameUri) { 42 + const parsed = parseAtUri(g.record.parentGameUri); 43 + if (parsed) return { did: parsed.did, rkey: parsed.rkey, record: g.record }; 44 + } 45 + return g; 46 + }) 47 + .sort((a, b) => new Date(b.record.createdAt).getTime() - new Date(a.record.createdAt).getTime()); 48 + 22 49 loading = false; 23 50 }); 24 - 25 - function parseAtUri(uri: string): { did: string; rkey: string } | null { 26 - const match = uri.match(/^at:\/\/([^/]+)\/[^/]+\/(.+)$/); 27 - if (!match) return null; 28 - return { did: match[1], rkey: match[2] }; 29 - } 30 51 </script> 31 52 32 53 {#if loading} ··· 55 76 <h2 class="mb-3 text-lg font-semibold">Games ({games.length})</h2> 56 77 <div class="flex flex-col gap-2"> 57 78 {#each games as game} 58 - {@const parsed = parseAtUri(game.uri)} 59 - {#if parsed} 60 - <a 61 - href="/game/{parsed.did}/{parsed.rkey}" 62 - class="rounded-lg border border-border bg-bg-secondary px-4 py-3 transition-colors hover:border-accent-blue" 63 - > 64 - <span class="font-mono text-sm text-text-secondary">{parsed.rkey}</span> 65 - </a> 66 - {/if} 79 + <GameCard did={game.did} rkey={game.rkey} record={game.record} /> 67 80 {/each} 68 81 </div> 69 82 </div>