9 changed files with 669 additions and 40 deletions
@ -0,0 +1,417 @@
@@ -0,0 +1,417 @@
|
||||
<script lang="ts"> |
||||
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
||||
import { Relay } from 'nostr-tools'; |
||||
import { onMount } from 'svelte'; |
||||
|
||||
interface Props { |
||||
relayUrl: string; |
||||
} |
||||
|
||||
let { relayUrl }: Props = $props(); |
||||
|
||||
interface RelayMetadata { |
||||
name?: string; |
||||
description?: string; |
||||
pubkey?: string; |
||||
contact?: string; |
||||
supported_nips?: number[]; |
||||
software?: string; |
||||
version?: string; |
||||
limitation?: { |
||||
max_message_length?: number; |
||||
max_subscriptions?: number; |
||||
max_filters?: number; |
||||
max_limit?: number; |
||||
max_subid_length?: number; |
||||
min_prefix?: number; |
||||
max_event_tags?: number; |
||||
max_content_length?: number; |
||||
min_pow_difficulty?: number; |
||||
auth_required?: boolean; |
||||
payment_required?: boolean; |
||||
restricted_writes?: boolean; |
||||
}; |
||||
} |
||||
|
||||
let metadata = $state<RelayMetadata | null>(null); |
||||
let loading = $state(true); |
||||
let error = $state<string | null>(null); |
||||
let connectionStatus = $state<'connecting' | 'connected' | 'disconnected'>('connecting'); |
||||
let eventCount = $state<number | null>(null); |
||||
|
||||
async function fetchRelayMetadata() { |
||||
loading = true; |
||||
error = null; |
||||
|
||||
try { |
||||
// Get relay connection status from nostr-client |
||||
const relay = await nostrClient.getRelay(relayUrl); |
||||
if (relay) { |
||||
// Check connection status |
||||
const status = (relay as any).status; |
||||
if (status === 1) { |
||||
connectionStatus = 'connected'; |
||||
} else if (status === 0) { |
||||
connectionStatus = 'connecting'; |
||||
} else { |
||||
connectionStatus = 'disconnected'; |
||||
} |
||||
|
||||
// Fetch NIP-11 metadata via HTTP GET |
||||
try { |
||||
// Convert ws:// or wss:// to http:// or https:// |
||||
const httpUrl = relayUrl.replace(/^wss?:\/\//, (match) => { |
||||
return match === 'wss://' ? 'https://' : 'http://'; |
||||
}); |
||||
const nip11Url = `${httpUrl}/.well-known/nostr.json`; |
||||
|
||||
const response = await fetch(nip11Url, { |
||||
method: 'GET', |
||||
headers: { |
||||
'Accept': 'application/nostr+json' |
||||
} |
||||
}); |
||||
|
||||
if (response.ok) { |
||||
const info = await response.json(); |
||||
if (info) { |
||||
metadata = info as RelayMetadata; |
||||
} |
||||
} |
||||
} catch (err) { |
||||
console.debug(`[RelayInfo] Could not fetch NIP-11 metadata for ${relayUrl}:`, err); |
||||
// Not all relays support NIP-11, so this is not a critical error |
||||
} |
||||
} else { |
||||
connectionStatus = 'disconnected'; |
||||
error = 'Failed to connect to relay'; |
||||
} |
||||
} catch (err) { |
||||
console.error(`[RelayInfo] Error fetching relay info for ${relayUrl}:`, err); |
||||
error = err instanceof Error ? err.message : 'Unknown error'; |
||||
connectionStatus = 'disconnected'; |
||||
} finally { |
||||
loading = false; |
||||
} |
||||
} |
||||
|
||||
async function getEventCount() { |
||||
try { |
||||
// Fetch a small sample to estimate activity |
||||
const events = await nostrClient.fetchEvents( |
||||
[{ kinds: [1], limit: 1 }], |
||||
[relayUrl], |
||||
{ useCache: false, cacheResults: false, timeout: 5000 } |
||||
); |
||||
// This is just a connectivity check, not a real count |
||||
// Real event count would require a COUNT query which not all relays support |
||||
eventCount = events.length > 0 ? -1 : 0; // -1 means "has events" but we don't know the count |
||||
} catch (err) { |
||||
// Silently fail - this is just informational |
||||
eventCount = null; |
||||
} |
||||
} |
||||
|
||||
onMount(async () => { |
||||
await fetchRelayMetadata(); |
||||
await getEventCount(); |
||||
}); |
||||
|
||||
function getStatusColor(status: string): string { |
||||
switch (status) { |
||||
case 'connected': |
||||
return 'text-green-600 dark:text-green-400'; |
||||
case 'connecting': |
||||
return 'text-yellow-600 dark:text-yellow-400'; |
||||
case 'disconnected': |
||||
return 'text-red-600 dark:text-red-400'; |
||||
default: |
||||
return 'text-gray-600 dark:text-gray-400'; |
||||
} |
||||
} |
||||
|
||||
function getStatusIcon(status: string): string { |
||||
switch (status) { |
||||
case 'connected': |
||||
return '●'; |
||||
case 'connecting': |
||||
return '○'; |
||||
case 'disconnected': |
||||
return '○'; |
||||
default: |
||||
return '○'; |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<div class="relay-info-card"> |
||||
{#if loading} |
||||
<div class="relay-info-loading"> |
||||
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading relay information...</p> |
||||
</div> |
||||
{:else if error} |
||||
<div class="relay-info-error"> |
||||
<p class="text-fog-text dark:text-fog-dark-text">Error: {error}</p> |
||||
</div> |
||||
{:else} |
||||
<div class="relay-info-header"> |
||||
<div class="relay-info-title-row"> |
||||
<h3 class="relay-info-title">Relay Information</h3> |
||||
<div class="relay-status"> |
||||
<span class="relay-status-icon {getStatusColor(connectionStatus)}">{getStatusIcon(connectionStatus)}</span> |
||||
<span class="relay-status-text {getStatusColor(connectionStatus)}"> |
||||
{connectionStatus.charAt(0).toUpperCase() + connectionStatus.slice(1)} |
||||
</span> |
||||
</div> |
||||
</div> |
||||
<code class="relay-url">{relayUrl}</code> |
||||
</div> |
||||
|
||||
{#if metadata} |
||||
<div class="relay-info-content"> |
||||
{#if metadata.name} |
||||
<div class="relay-info-item"> |
||||
<span class="relay-info-label">Name:</span> |
||||
<span class="relay-info-value">{metadata.name}</span> |
||||
</div> |
||||
{/if} |
||||
|
||||
{#if metadata.description} |
||||
<div class="relay-info-item"> |
||||
<span class="relay-info-label">Description:</span> |
||||
<span class="relay-info-value">{metadata.description}</span> |
||||
</div> |
||||
{/if} |
||||
|
||||
{#if metadata.software} |
||||
<div class="relay-info-item"> |
||||
<span class="relay-info-label">Software:</span> |
||||
<span class="relay-info-value">{metadata.software}{metadata.version ? ` ${metadata.version}` : ''}</span> |
||||
</div> |
||||
{/if} |
||||
|
||||
{#if metadata.contact} |
||||
<div class="relay-info-item"> |
||||
<span class="relay-info-label">Contact:</span> |
||||
<span class="relay-info-value"> |
||||
{#if metadata.contact.startsWith('mailto:')} |
||||
<a href="{metadata.contact}" class="relay-info-link">{metadata.contact.replace('mailto:', '')}</a> |
||||
{:else if metadata.contact.startsWith('http')} |
||||
<a href="{metadata.contact}" target="_blank" rel="noopener noreferrer" class="relay-info-link">{metadata.contact}</a> |
||||
{:else} |
||||
{metadata.contact} |
||||
{/if} |
||||
</span> |
||||
</div> |
||||
{/if} |
||||
|
||||
{#if metadata.pubkey} |
||||
<div class="relay-info-item"> |
||||
<span class="relay-info-label">Pubkey:</span> |
||||
<code class="relay-info-value relay-pubkey">{metadata.pubkey.substring(0, 16)}...</code> |
||||
</div> |
||||
{/if} |
||||
|
||||
{#if metadata.supported_nips && metadata.supported_nips.length > 0} |
||||
<div class="relay-info-item"> |
||||
<span class="relay-info-label">Supported NIPs:</span> |
||||
<span class="relay-info-value"> |
||||
{metadata.supported_nips.slice(0, 20).join(', ')}{metadata.supported_nips.length > 20 ? '...' : ''} |
||||
</span> |
||||
</div> |
||||
{/if} |
||||
|
||||
{#if metadata.limitation} |
||||
<div class="relay-info-limitations"> |
||||
<span class="relay-info-label">Limitations:</span> |
||||
<div class="relay-limitations-list"> |
||||
{#if metadata.limitation.max_message_length} |
||||
<span class="relay-limitation-item">Max message: {metadata.limitation.max_message_length} bytes</span> |
||||
{/if} |
||||
{#if metadata.limitation.max_subscriptions} |
||||
<span class="relay-limitation-item">Max subscriptions: {metadata.limitation.max_subscriptions}</span> |
||||
{/if} |
||||
{#if metadata.limitation.max_filters} |
||||
<span class="relay-limitation-item">Max filters: {metadata.limitation.max_filters}</span> |
||||
{/if} |
||||
{#if metadata.limitation.max_limit} |
||||
<span class="relay-limitation-item">Max limit: {metadata.limitation.max_limit}</span> |
||||
{/if} |
||||
{#if metadata.limitation.auth_required} |
||||
<span class="relay-limitation-item">Auth required</span> |
||||
{/if} |
||||
{#if metadata.limitation.payment_required} |
||||
<span class="relay-limitation-item">Payment required</span> |
||||
{/if} |
||||
{#if metadata.limitation.restricted_writes} |
||||
<span class="relay-limitation-item">Restricted writes</span> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
{:else} |
||||
<div class="relay-info-no-metadata"> |
||||
<p class="text-fog-text-light dark:text-fog-dark-text-light">No NIP-11 metadata available</p> |
||||
</div> |
||||
{/if} |
||||
{/if} |
||||
</div> |
||||
|
||||
<style> |
||||
.relay-info-card { |
||||
background: var(--fog-post, #ffffff); |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.5rem; |
||||
padding: 1.5rem; |
||||
margin-bottom: 1.5rem; |
||||
} |
||||
|
||||
:global(.dark) .relay-info-card { |
||||
background: var(--fog-dark-post, #1f2937); |
||||
border-color: var(--fog-dark-border, #374151); |
||||
} |
||||
|
||||
.relay-info-header { |
||||
margin-bottom: 1rem; |
||||
padding-bottom: 1rem; |
||||
border-bottom: 1px solid var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
:global(.dark) .relay-info-header { |
||||
border-bottom-color: var(--fog-dark-border, #374151); |
||||
} |
||||
|
||||
.relay-info-title-row { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
margin-bottom: 0.5rem; |
||||
} |
||||
|
||||
.relay-info-title { |
||||
margin: 0; |
||||
font-size: 1.25rem; |
||||
font-weight: 600; |
||||
color: var(--fog-text, #1f2937); |
||||
} |
||||
|
||||
:global(.dark) .relay-info-title { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.relay-status { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 0.5rem; |
||||
} |
||||
|
||||
.relay-status-icon { |
||||
font-size: 0.75rem; |
||||
} |
||||
|
||||
.relay-status-text { |
||||
font-size: 0.875rem; |
||||
font-weight: 500; |
||||
} |
||||
|
||||
.relay-url { |
||||
display: block; |
||||
font-family: monospace; |
||||
font-size: 0.875rem; |
||||
color: var(--fog-text-light, #9ca3af); |
||||
word-break: break-all; |
||||
} |
||||
|
||||
:global(.dark) .relay-url { |
||||
color: var(--fog-dark-text-light, #6b7280); |
||||
} |
||||
|
||||
.relay-info-content { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 0.75rem; |
||||
} |
||||
|
||||
.relay-info-item { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 0.25rem; |
||||
} |
||||
|
||||
.relay-info-label { |
||||
font-size: 0.875rem; |
||||
font-weight: 600; |
||||
color: var(--fog-text, #1f2937); |
||||
} |
||||
|
||||
:global(.dark) .relay-info-label { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.relay-info-value { |
||||
font-size: 0.875rem; |
||||
color: var(--fog-text-light, #6b7280); |
||||
word-break: break-word; |
||||
} |
||||
|
||||
:global(.dark) .relay-info-value { |
||||
color: var(--fog-dark-text-light, #9ca3af); |
||||
} |
||||
|
||||
.relay-pubkey { |
||||
font-family: monospace; |
||||
} |
||||
|
||||
.relay-info-link { |
||||
color: var(--fog-accent, #64748b); |
||||
text-decoration: underline; |
||||
} |
||||
|
||||
.relay-info-link:hover { |
||||
color: var(--fog-text, #1f2937); |
||||
} |
||||
|
||||
:global(.dark) .relay-info-link { |
||||
color: var(--fog-dark-accent, #94a3b8); |
||||
} |
||||
|
||||
:global(.dark) .relay-info-link:hover { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.relay-info-limitations { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 0.5rem; |
||||
margin-top: 0.5rem; |
||||
} |
||||
|
||||
.relay-limitations-list { |
||||
display: flex; |
||||
flex-wrap: wrap; |
||||
gap: 0.5rem; |
||||
} |
||||
|
||||
.relay-limitation-item { |
||||
font-size: 0.75rem; |
||||
padding: 0.25rem 0.5rem; |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
color: var(--fog-text, #1f2937); |
||||
} |
||||
|
||||
:global(.dark) .relay-limitation-item { |
||||
background: var(--fog-dark-highlight, #374151); |
||||
border-color: var(--fog-dark-border, #475569); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.relay-info-loading, |
||||
.relay-info-error, |
||||
.relay-info-no-metadata { |
||||
padding: 1rem; |
||||
text-align: center; |
||||
} |
||||
</style> |
||||
@ -0,0 +1,91 @@
@@ -0,0 +1,91 @@
|
||||
<script lang="ts"> |
||||
import Header from '../../../../../../lib/components/layout/Header.svelte'; |
||||
import FeedPage from '../../../../../../lib/modules/feed/FeedPage.svelte'; |
||||
import SearchBox from '../../../../../../lib/components/layout/SearchBox.svelte'; |
||||
import RelayInfo from '../../../../../../lib/components/relay/RelayInfo.svelte'; |
||||
import { nostrClient } from '../../../../../../lib/services/nostr/nostr-client.js'; |
||||
import { onMount } from 'svelte'; |
||||
import { page } from '$app/stores'; |
||||
|
||||
let decodedRelay = $state<string | null>(null); |
||||
let error = $state<string | null>(null); |
||||
|
||||
function decodeRelayUrl(encoded: string): string | null { |
||||
try { |
||||
// Decode the URL-encoded relay URI |
||||
const decoded = decodeURIComponent(encoded); |
||||
|
||||
// Validate it's a websocket URI |
||||
if (!decoded.startsWith('ws://') && !decoded.startsWith('wss://')) { |
||||
return null; |
||||
} |
||||
|
||||
return decoded; |
||||
} catch (e) { |
||||
console.error('Error decoding relay URL:', e); |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
onMount(async () => { |
||||
await nostrClient.initialize(); |
||||
|
||||
if ($page.params.relay) { |
||||
const decoded = decodeRelayUrl($page.params.relay); |
||||
if (decoded) { |
||||
decodedRelay = decoded; |
||||
} else { |
||||
error = 'Invalid relay URL. Must be a ws:// or wss:// URI.'; |
||||
} |
||||
} else { |
||||
error = 'No relay specified.'; |
||||
} |
||||
}); |
||||
|
||||
$effect(() => { |
||||
if ($page.params.relay) { |
||||
const decoded = decodeRelayUrl($page.params.relay); |
||||
if (decoded) { |
||||
decodedRelay = decoded; |
||||
error = null; |
||||
} else { |
||||
error = 'Invalid relay URL. Must be a ws:// or wss:// URI.'; |
||||
decodedRelay = null; |
||||
} |
||||
} |
||||
}); |
||||
</script> |
||||
|
||||
<Header /> |
||||
|
||||
<main class="container mx-auto px-4 py-8"> |
||||
<div class="search-section mb-6"> |
||||
<SearchBox /> |
||||
</div> |
||||
|
||||
{#if error} |
||||
<div class="error-state"> |
||||
<p class="text-fog-text dark:text-fog-dark-text">{error}</p> |
||||
</div> |
||||
{:else if decodedRelay} |
||||
<RelayInfo relayUrl={decodedRelay} /> |
||||
<FeedPage singleRelay={decodedRelay} /> |
||||
{:else} |
||||
<div class="loading-state"> |
||||
<p class="text-fog-text dark:text-fog-dark-text">Loading relay feed...</p> |
||||
</div> |
||||
{/if} |
||||
</main> |
||||
|
||||
<style> |
||||
main { |
||||
max-width: var(--content-width); |
||||
margin: 0 auto; |
||||
} |
||||
|
||||
.error-state, |
||||
.loading-state { |
||||
padding: 2rem; |
||||
text-align: center; |
||||
} |
||||
</style> |
||||
Loading…
Reference in new issue