import { useState, useCallback, useMemo } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Zap, Activity, Shield, Award, RefreshCw, AlertCircle } from 'lucide-react'
import { discoverRelays, measureLatency, type RelayStats } from './nostr'
import './App.css'
function App() {
const [relays, setRelays] = useState<RelayStats[]>([])
const [isTesting, setIsTesting] = useState(false)
const [error, setError] = useState<string | null>(null)
const startSpeedTest = useCallback(async () => {
setIsTesting(true)
setError(null)
setRelays([])
try {
const discovered = await discoverRelays()
setRelays(discovered)
// Measure one by one but update state immediately
for (let i = 0; i < discovered.length; i++) {
const relay = discovered[i]
setRelays((prev: RelayStats[]) => prev.map((r: RelayStats) =>
r.url === relay.url ? { ...r, status: 'measuring' } : r
))
try {
const latency = await measureLatency(relay.url)
setRelays((prev: RelayStats[]) => prev.map((r: RelayStats) =>
r.url === relay.url ? { ...r, latency, status: 'done' } : r
))
} catch (err) {
setRelays((prev: RelayStats[]) => prev.map((r: RelayStats) =>
r.url === relay.url ? { ...r, status: 'error', lastError: 'Failed to connect' } : r
))
}
}
} catch (err) {
setError('Failed to discover relays. Please check your connection.')
} finally {
setIsTesting(false)
}
}, [])
const sortedRelays = useMemo(() => {
return [...relays].sort((a, b) => {
// Done comes first
if (a.status === 'done' && b.status !== 'done') return -1
if (a.status !== 'done' && b.status === 'done') return 1
// If both done, sort by latency
if (a.status === 'done' && b.status === 'done') {
return (a.latency || 9999) - (b.latency || 9999)
}
return 0
})
}, [relays])
const getLatencyClass = (l?: number) => {
if (!l) return ''
if (l < 150) return 'fast'
if (l < 400) return 'medium'
return 'slow'
}
return (
<div className="container">
<header className="header">
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.5 }}
>
<Zap size={64} color="#bb86fc" style={{ marginBottom: '10px' }} />
</motion.div>
<h1>Nostr Speedtest</h1>
<p>Made by <a href="https://github.com/GigaProjects" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--primary)', textDecoration: 'none', fontWeight: 'bold' }}>GigaProjects</a></p>
</header>
<div className="controls">
<button
id="run-test-button"
className="btn-primary"
onClick={startSpeedTest}
disabled={isTesting}
>
{isTesting ? (
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<RefreshCw className="spin" size={20} /> Testing...
</span>
) : 'Run Speed Test'}
</button>
</div>
{error && (
<div style={{ color: 'var(--accent)', textAlign: 'center', marginBottom: '20px' }}>
<AlertCircle size={20} style={{ verticalAlign: 'middle', marginRight: '8px' }} />
{error}
</div>
)}
{relays.length > 0 && (
<div className="leaderboard">
<AnimatePresence>
{sortedRelays.map((relay: RelayStats, index: number) => (
<motion.div
key={relay.url}
layout
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{
type: 'spring',
stiffness: 300,
damping: 30,
opacity: { duration: 0.2 }
}}
className={`relay-item ${index < 3 ? 'top-3' : ''}`}
>
<div className="rank">
{relay.status === 'done' ? (
index === 0 ? <Award size={24} /> : `#${index + 1}`
) : '-'}
</div>
<div className="url">
<span className={`status-badge status-${relay.status}`}></span>
{relay.url.replace('wss://', '')}
</div>
<div className={`latency ${getLatencyClass(relay.latency)}`}>
{relay.status === 'done' ? `${relay.latency}ms` :
relay.status === 'measuring' ? '...' :
relay.status === 'error' ? 'Offline' : '-'}
</div>
<div className="reported">
{relay.reportedLatency ? (
<div title="Reported by NIP-66">
<Shield size={12} style={{ marginRight: '4px' }} />
{relay.reportedLatency}ms
</div>
) : (
<div style={{ opacity: 0.3 }}>
<Activity size={12} style={{ marginRight: '4px' }} />
Unreported
</div>
)}
</div>
</motion.div>
))}
</AnimatePresence>
</div>
)}
<footer className="footer">
<p>Built with NIP-66 Relay Metadata • Measuring from your browser</p>
</footer>
</div>
)
}
export default App