GigaProjects

← Back to nostr-relay-speedtest

App.tsx

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