feat: Nostr profile enrichment with caching

Fetch full kind:0 metadata (display_name, about, banner, nip05) from
relays with a 5-min TTL cache. Show Nostr banner, display name, and
NIP-05 on bot profile pages. Expose ownerPubkey in stats API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-08 20:07:09 +00:00
co-authored by Claude Opus 4.6
parent 7761f713d7
commit d1fe4d6e83
3 changed files with 70 additions and 14 deletions
+28 -3
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter, RouterLink } from 'vue-router'
import { useNostr } from '../composables/useNostr'
import { useNostr, type NostrProfile } from '../composables/useNostr'
import SpritePreview from '../components/SpritePreview.vue'
import HumanPreview from '../components/HumanPreview.vue'
import WalletConnect from '../components/WalletConnect.vue'
@@ -10,7 +10,7 @@ import type { SpriteCustomization } from '../game/sprites'
const route = useRoute()
const router = useRouter()
const { bot: nostrBot, pubkey, isLoggedIn, logout, updateCustomization, updateWebhook } = useNostr()
const { bot: nostrBot, pubkey, isLoggedIn, logout, updateCustomization, updateWebhook, fetchNostrProfile } = useNostr()
const botName = route.params.name as string
interface BotCustomization {
@@ -29,6 +29,7 @@ interface BotStats {
archetype: string
customization: BotCustomization | null
profilePicUrl: string | null
ownerPubkey: string | null
eloRating: number
wins: number
losses: number
@@ -86,6 +87,7 @@ const showChoose = ref(false)
const waitingFighters = ref<QueueEntry[]>([])
let pollHandle: ReturnType<typeof setInterval> | null = null
const nostrProfile = ref<NostrProfile | null>(null)
const loadError = ref('')
const fightError = ref('')
const showCustomize = ref(false)
@@ -276,6 +278,13 @@ onMounted(async () => {
}
isLoading.value = false
// Fetch Nostr profile for the bot owner (non-blocking)
if (stats.value?.ownerPubkey) {
fetchNostrProfile(stats.value.ownerPubkey).then(p => {
nostrProfile.value = p
}).catch(() => {})
}
// Poll queue for "choose your fight"
pollQueue()
pollHandle = setInterval(pollQueue, 4000)
@@ -360,7 +369,14 @@ const tierClass = (t: number) => `tier-${t}`
<!-- LEFT COLUMN: Character + Stats + Actions -->
<div class="lg:w-[380px] lg:shrink-0 flex flex-col">
<div class="border border-border bg-surface-raised/30 p-6 overflow-hidden">
<!-- Nostr banner -->
<div v-if="nostrProfile?.banner" class="relative h-24 -mx-0 mb-0 overflow-hidden border border-border border-b-0">
<img :src="nostrProfile.banner" alt="" class="w-full h-full object-cover opacity-60" />
<div class="absolute inset-0 bg-gradient-to-t from-surface-base to-transparent" />
</div>
<div class="border border-border bg-surface-raised/30 p-6 overflow-hidden"
:class="{ 'border-t-0': nostrProfile?.banner }">
<!-- Human player: just the human avatar, centered -->
<div v-if="stats.archetype === 'human'" class="flex justify-center mb-4">
<HumanPreview
@@ -409,6 +425,15 @@ const tierClass = (t: number) => `tier-${t}`
<p class="font-mono text-text-muted text-[10px] mt-0.5">
#{{ stats.rank }} of {{ stats.totalBots }}
</p>
<!-- Nostr identity -->
<div v-if="nostrProfile?.displayName || nostrProfile?.nip05" class="mt-2 space-y-0.5">
<p v-if="nostrProfile.displayName" class="font-mono text-xs text-text-secondary">
{{ nostrProfile.displayName }}
</p>
<p v-if="nostrProfile.nip05" class="font-mono text-[10px] text-neon-purple">
{{ nostrProfile.nip05 }}
</p>
</div>
<button
v-if="isOwner && stats.archetype !== 'human'"
class="mt-2 font-mono text-[10px] text-neon-cyan/60 hover:text-neon-cyan transition-colors"