diff --git a/src/index.css b/src/index.css index 78a8649..b4ed267 100644 --- a/src/index.css +++ b/src/index.css @@ -120,4 +120,85 @@ body { @apply bg-background text-foreground; } -} \ No newline at end of file +} + +@layer utilities { + .relay-grid { + background-image: + linear-gradient(rgba(255, 255, 255, 0.08) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.08) 1px, transparent 1px), + linear-gradient(rgba(251, 191, 36, 0.08) 1px, transparent 1px), + linear-gradient(90deg, rgba(251, 191, 36, 0.08) 1px, transparent 1px); + background-size: 44px 44px, 44px 44px, 176px 176px, 176px 176px; + } + + .relay-scanline { + animation: relay-scan 5.5s linear infinite; + background: linear-gradient(to bottom, transparent, rgba(251, 191, 36, 0.18), transparent); + } + + .event-packet { + animation: event-packet-pop 1.6s ease-out both; + box-shadow: 0 0 22px currentColor; + } + + .event-packet-live { + animation: event-packet-pop 1.6s ease-out both, event-packet-breathe 2.4s ease-in-out infinite; + } + + .live-row-new { + animation: live-row-arrive 1.4s ease-out both; + } +} + +@keyframes relay-scan { + 0% { + transform: translateY(-7rem); + } + + 100% { + transform: translateY(28rem); + } +} + +@keyframes event-packet-pop { + 0% { + opacity: 0; + transform: scale(0.25); + } + + 55% { + opacity: 1; + transform: scale(1.7); + } + + 100% { + opacity: 0.92; + transform: scale(1); + } +} + +@keyframes event-packet-breathe { + 0%, + 100% { + filter: brightness(1); + } + + 50% { + filter: brightness(1.45); + } +} + +@keyframes live-row-arrive { + 0% { + border-color: rgba(190, 242, 100, 0.72); + box-shadow: 0 0 0 1px rgba(190, 242, 100, 0.22), 0 0 34px rgba(190, 242, 100, 0.16); + transform: translateY(-4px); + } + + 100% { + border-color: rgba(255, 255, 255, 0.1); + box-shadow: none; + transform: translateY(0); + } +} diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index fb16a26..b321954 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -1,237 +1,551 @@ import { useSeoMeta } from '@unhead/react'; -import { useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import type { NostrEvent } from '@nostrify/nostrify'; +import { useNostr } from '@nostrify/react'; +import { Link } from 'react-router-dom'; +import { + Activity, + ArrowUpRight, + CheckCircle2, + CircleDot, + Copy, + Gauge, + Globe2, + RadioTower, + Server, + ShieldCheck, + Sparkles, + Zap, +} from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { Card, CardContent } from '@/components/ui/card'; -import { CheckCircle2, Copy, Server, Gift, Users, Globe } from 'lucide-react'; -import { useToast } from '@/hooks/useToast'; +import { Badge } from '@/components/ui/badge'; import { LoginArea } from '@/components/auth/LoginArea'; +import { useToast } from '@/hooks/useToast'; +import { useAppContext } from '@/hooks/useAppContext'; +import { genUserName } from '@/lib/genUserName'; +import { cn } from '@/lib/utils'; + +type LiveEventSource = 'seed' | 'live'; + +interface LiveRelayEvent { + event: NostrEvent; + receivedAt: number; + source: LiveEventSource; +} + +const RELAY_URL = 'wss://relay.layer.systems'; +const LIVE_KINDS = [0, 1, 3, 6, 7, 9735, 30023]; +const MAX_EVENTS = 28; + +function useLiveRelayEvents() { + const { nostr } = useNostr(); + const { config } = useAppContext(); + const [events, setEvents] = useState([]); + const [totalSeen, setTotalSeen] = useState(0); + const [status, setStatus] = useState<'connecting' | 'live' | 'quiet' | 'error'>('connecting'); + + const readRelays = useMemo( + () => config.relayMetadata.relays.filter((relay) => relay.read).map((relay) => relay.url), + [config.relayMetadata.relays], + ); + + useEffect(() => { + if (import.meta.env.MODE === 'test') { + return; + } + + const controller = new AbortController(); + let isActive = true; + + const appendEvent = (event: NostrEvent, source: LiveEventSource) => { + setEvents((current) => { + if (current.some((item) => item.event.id === event.id)) { + return current; + } + + return [ + { + event, + receivedAt: Date.now(), + source, + }, + ...current, + ] + .sort((a, b) => b.event.created_at - a.event.created_at) + .slice(0, MAX_EVENTS); + }); + setTotalSeen((count) => count + 1); + }; + + const run = async () => { + const now = Math.floor(Date.now() / 1000); + const filters = [{ kinds: LIVE_KINDS, since: now - 60 * 60, limit: MAX_EVENTS }]; + + try { + setStatus('connecting'); + + const recentEvents = await nostr.query(filters, { signal: controller.signal }); + if (!isActive) return; + + recentEvents + .sort((a, b) => b.created_at - a.created_at) + .slice(0, MAX_EVENTS) + .forEach((event) => appendEvent(event, 'seed')); + + setStatus('live'); + + const liveFilters = [{ kinds: LIVE_KINDS, since: Math.floor(Date.now() / 1000) }]; + for await (const msg of nostr.req(liveFilters, { signal: controller.signal })) { + if (!isActive) break; + + if (msg[0] === 'EVENT') { + appendEvent(msg[2], 'live'); + setStatus('live'); + } else if (msg[0] === 'CLOSED') { + setStatus('quiet'); + } + } + } catch (error) { + if (!isActive || controller.signal.aborted) return; + console.error('[Index] live relay subscription failed:', error); + setStatus('error'); + } + }; + + void run(); + + return () => { + isActive = false; + controller.abort(); + }; + }, [nostr]); + + return { + events, + status, + totalSeen, + readRelays, + lastEventAt: events[0]?.receivedAt, + }; +} + +function shortKey(pubkey: string) { + return `${pubkey.slice(0, 6)}:${pubkey.slice(-4)}`; +} + +function getKindMeta(kind: number) { + switch (kind) { + case 0: + return { label: 'Profile', accent: 'bg-cyan-300', text: 'text-cyan-100', ring: 'ring-cyan-300/30' }; + case 1: + return { label: 'Note', accent: 'bg-amber-300', text: 'text-amber-100', ring: 'ring-amber-300/30' }; + case 3: + return { label: 'Relay list', accent: 'bg-lime-300', text: 'text-lime-100', ring: 'ring-lime-300/30' }; + case 6: + return { label: 'Repost', accent: 'bg-sky-300', text: 'text-sky-100', ring: 'ring-sky-300/30' }; + case 7: + return { label: 'Reaction', accent: 'bg-fuchsia-300', text: 'text-fuchsia-100', ring: 'ring-fuchsia-300/30' }; + case 9735: + return { label: 'Zap', accent: 'bg-emerald-300', text: 'text-emerald-100', ring: 'ring-emerald-300/30' }; + case 30023: + return { label: 'Article', accent: 'bg-rose-300', text: 'text-rose-100', ring: 'ring-rose-300/30' }; + default: + return { label: `Kind ${kind}`, accent: 'bg-white', text: 'text-white', ring: 'ring-white/20' }; + } +} + +function getTagValue(event: NostrEvent, tagName: string) { + return event.tags.find(([name]) => name === tagName)?.[1]; +} + +function getEventPreview(event: NostrEvent) { + if (event.kind === 0) { + try { + const metadata = JSON.parse(event.content) as { name?: string; display_name?: string; about?: string }; + const name = metadata.display_name || metadata.name || genUserName(event.pubkey); + return metadata.about ? `${name}: ${metadata.about}` : `${name} refreshed a profile`; + } catch { + return 'Profile metadata update'; + } + } + + if (event.kind === 1) { + return event.content.trim() || 'Empty text note'; + } + + if (event.kind === 30023) { + return getTagValue(event, 'title') || event.content.trim() || 'Long-form note published'; + } + + if (event.kind === 9735) { + return 'Lightning zap receipt settled on the relay'; + } + + if (event.kind === 7) { + return event.content.trim() ? `Reaction: ${event.content.trim()}` : 'Reaction event'; + } + + if (event.kind === 6) { + return 'Repost pointer propagated'; + } + + if (event.kind === 3) { + return 'Contact or relay graph changed'; + } + + return event.content.trim() || `Kind ${event.kind} event`; +} + +function formatEventTime(timestamp: number) { + return new Intl.DateTimeFormat('en', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }).format(new Date(timestamp * 1000)); +} + +function formatRelativeTime(timestamp?: number) { + if (!timestamp) return 'waiting'; + + const seconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000)); + if (seconds < 5) return 'just now'; + if (seconds < 60) return `${seconds}s ago`; + + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + + return `${Math.floor(minutes / 60)}h ago`; +} + +function EventConstellation({ events }: { events: LiveRelayEvent[] }) { + const plottedEvents = events.slice(0, 18); + + return ( +
+
+
+
+ + live relay field +
+ +
+ Each pulse is an event entering from a configured read relay. Notes, zaps, profiles, and reactions land as separate signal types. +
+ +
+
+
+
+ + + {plottedEvents.map(({ event, source }, index) => { + const x = 10 + (parseInt(event.id.slice(0, 4), 16) % 80); + const y = 12 + (parseInt(event.id.slice(4, 8), 16) % 76); + const meta = getKindMeta(event.kind); + + return ( + + ); + })} +
+ +
+
+ ); +} + +function LiveEventRow({ item }: { item: LiveRelayEvent }) { + const { event, receivedAt, source } = item; + const meta = getKindMeta(event.kind); + + return ( +
+
+
+ + {meta.label} + {shortKey(event.pubkey)} +
+ +
+

{getEventPreview(event)}

+
+ {source === 'live' ? 'incoming' : 'recent'} + {formatRelativeTime(receivedAt)} +
+
+ ); +} + +function LiveRelayPanel({ + events, + status, + totalSeen, + readRelays, + lastEventAt, +}: { + events: LiveRelayEvent[]; + status: 'connecting' | 'live' | 'quiet' | 'error'; + totalSeen: number; + readRelays: string[]; + lastEventAt?: number; +}) { + const statusLabel = status === 'live' ? 'streaming' : status; + + return ( +
+
+
+
+ + live ingress +
+

Incoming Nostr events from configured read relays.

+
+ + {statusLabel} + +
+ +
+
+

events

+

{totalSeen}

+
+
+

relays

+

{readRelays.length}

+
+
+

last

+

{formatRelativeTime(lastEventAt)}

+
+
+ +
+ {events.length > 0 ? ( + events.slice(0, 6).map((item) => ) + ) : ( +
+ Listening for the next event. The relay field will light up as soon as traffic arrives. +
+ )} +
+
+ ); +} + +const features = [ + { + icon: Gauge, + title: 'Low-friction relay access', + description: 'Point any Nostr client at the relay URL and start reading or publishing through the network.', + }, + { + icon: Globe2, + title: 'Built for the public graph', + description: 'Designed around open Nostr primitives, not platform lock-in or private social silos.', + }, + { + icon: ShieldCheck, + title: 'Simple by default', + description: 'A focused relay front door with account tools, dashboard paths, and live network visibility.', + }, +]; const Index = () => { const [copied, setCopied] = useState(false); const { toast } = useToast(); - const relayUrl = 'wss://relay.layer.systems'; + const { events, status, totalSeen, readRelays, lastEventAt } = useLiveRelayEvents(); useSeoMeta({ - title: 'LAYER.systems - Public Nostr Relay', - description: 'A fast, reliable, and open Nostr relay serving the decentralized social network.', + title: 'LAYER.systems - Live Nostr Relay', + description: 'A modern public Nostr relay with live incoming event visibility.', }); const copyToClipboard = async () => { try { - await navigator.clipboard.writeText(relayUrl); + await navigator.clipboard.writeText(RELAY_URL); setCopied(true); toast({ - title: 'Copied!', + title: 'Copied', description: 'Relay URL copied to clipboard', }); - setTimeout(() => setCopied(false), 2000); + window.setTimeout(() => setCopied(false), 2000); } catch { toast({ title: 'Failed to copy', - description: 'Please copy the URL manually', + description: 'Please copy the relay URL manually', variant: 'destructive', }); } }; - const features = [ - { - icon: Gift, - title: 'Free', - description: 'No cost to use - accessible for everyone', - }, - { - icon: Users, - title: 'Community driven', - description: 'Built and maintained by the Nostr community', - }, - { - icon: Globe, - title: 'Global Network', - description: 'Part of the decentralized Nostr ecosystem', - }, - { - icon: Server, - title: 'Open Access', - description: 'Free to use for all Nostr clients', - }, - ]; - return ( -
- {/* Header */} -
-
-
- -
+
+
+
+ + + + + + LAYER.systems + + + + + +
- {/* Hero Section */} -
- {/* Animated Background Elements */} -
-
-
-
+
+
+
+
-
- {/* Status Badge */} - {/*
- -
- Relay Online - -
*/} +
+
+
+ + Public Nostr relay with live event visibility +
- {/* Main Heading */} -
-

- - LAYER.systems - -

-

- Your gateway to the decentralized social network -

-

- A fast, reliable, and open Nostr relay connecting you to the future of social media -

-
+

+ Watch the relay breathe. +

+

+ LAYER.systems is a clean public gateway into Nostr. Add the relay, publish from your client, and see the network surface in real time. +

- {/* Relay URL Card */} -
- - -
-
-
-

Relay URL

- - {relayUrl} - -
- +
+
+
+

relay url

+ {RELAY_URL}
-

- Add this URL to your Nostr client to connect to LAYER.systems -

+
- - -
+
- {/* Features Grid */} -
-

Why Choose LAYER.systems?

-
- {features.map((feature, index) => ( - - -
- -
-
-

{feature.title}

-

{feature.description}

-
-
-
+
+
+

{readRelays.length}

+

read relay{readRelays.length === 1 ? '' : 's'}

+
+
+

{totalSeen}

+

events seen

+
+
+

{formatRelativeTime(lastEventAt)}

+

last pulse

+
+
+
+ +
+ + +
+
+
+ +
+
+
+ + + relay ready + +

+ A quieter front door for a busy network. +

+
+ +
+ {features.map((feature) => ( +
+ +

{feature.title}

+

{feature.description}

+
))}
-
-
+ - {/* How to Connect Section */} -
-
-

Getting Started

-
- - -
-
-
- 1 -
-
-

Choose Your Client

-

- Pick a Nostr client like Damus, Amethyst, Snort, or any other compatible application -

-
-
-
-
- 2 -
-
-

Add the Relay

-

- In your client settings, add {relayUrl} to your relay list -

-
-
-
-
- 3 -
-
-

Start Connecting

-

- You're all set! Start posting, following, and connecting with the Nostr network -

-
-
-
-
-
-
-
-
- - {/* Footer */} -