9 changed files with 669 additions and 40 deletions
@ -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 @@ |
|||||||
|
<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