You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

579 lines
17 KiB

<script lang="ts">
import Header from '../../lib/components/layout/Header.svelte';
import { nostrClient } from '../../lib/services/nostr/nostr-client.js';
import { relayManager } from '../../lib/services/nostr/relay-manager.js';
import { config } from '../../lib/services/nostr/config.js';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { sessionManager } from '../../lib/services/auth/session-manager.js';
import { KIND } from '../../lib/types/kind-lookup.js';
import type { NostrEvent } from '../../lib/types/nostr.js';
interface RelayInfo {
url: string;
categories: string[];
connected: boolean;
}
let relays = $state<RelayInfo[]>([]);
let favoriteRelays = $state<RelayInfo[]>([]);
let loading = $state(true);
let customRelayUrl = $state('');
function categorizeRelay(url: string): string[] {
const categories: string[] = [];
// Check all categories - a relay can belong to multiple
if (config.defaultRelays.includes(url)) {
categories.push('Default');
}
if (config.profileRelays.includes(url)) {
categories.push('Profile');
}
if (config.threadPublishRelays.includes(url)) {
categories.push('Thread Publish');
}
if (config.gifRelays.includes(url)) {
categories.push('GIF');
}
// If no categories found, mark as Other
if (categories.length === 0) {
categories.push('Other');
}
return categories;
}
async function loadRelays() {
loading = true;
// Collect all unique relays from all categories
const allRelays = new Set<string>();
// Add default relays
config.defaultRelays.forEach(r => allRelays.add(r));
// Add profile relays
config.profileRelays.forEach(r => allRelays.add(r));
// Add thread publish relays
config.threadPublishRelays.forEach(r => allRelays.add(r));
// Add gif relays
config.gifRelays.forEach(r => allRelays.add(r));
// Get connection status from nostrClient
const connectedRelays = nostrClient.getConnectedRelays();
const relayList: RelayInfo[] = Array.from(allRelays).map(url => ({
url,
categories: categorizeRelay(url),
connected: connectedRelays.includes(url)
}));
// Sort by first category, then by URL
relayList.sort((a, b) => {
const categoryA = a.categories[0] || '';
const categoryB = b.categories[0] || '';
if (categoryA !== categoryB) {
return categoryA.localeCompare(categoryB);
}
return a.url.localeCompare(b.url);
});
relays = relayList;
// Load favorite relays if user is logged in (non-blocking - show page immediately)
loadFavoriteRelays().catch(err => {
console.debug('Error loading favorite relays in background:', err);
});
loading = false;
}
function processFavoriteRelayEvent(event: NostrEvent): void {
// Extract relay URLs from 'relay' tags (kind 10012 uses 'relay' tags)
const favoriteRelayUrls = new Set<string>();
for (const tag of event.tags) {
if (tag[0] === 'relay' && tag[1]) {
// Normalize URL: trim, remove trailing slash, ensure protocol
let url = tag[1].trim();
// Remove trailing slash
url = url.replace(/\/$/, '');
// If no protocol, assume wss://
if (!url.startsWith('ws://') && !url.startsWith('wss://')) {
url = `wss://${url}`;
}
favoriteRelayUrls.add(url);
}
}
// Get connection status - normalize URLs for comparison
const connectedRelays = nostrClient.getConnectedRelays();
const normalizeUrlForComparison = (url: string): string => {
return url.replace(/\/$/, '').toLowerCase();
};
const favoriteRelayList: RelayInfo[] = Array.from(favoriteRelayUrls).map(url => {
const normalizedUrl = normalizeUrlForComparison(url);
const isConnected = connectedRelays.some(connected =>
normalizeUrlForComparison(connected) === normalizedUrl
);
return {
url,
categories: ['Favorite'],
connected: isConnected
};
});
// Sort by URL
favoriteRelayList.sort((a, b) => a.url.localeCompare(b.url));
favoriteRelays = favoriteRelayList;
}
async function loadFavoriteRelays() {
const currentPubkey = sessionManager.getCurrentPubkey();
if (!currentPubkey) {
favoriteRelays = [];
return;
}
// Load from cache first (fast - instant display)
try {
const { getRecentCachedEvents } = await import('../../lib/services/cache/event-cache.js');
const cachedEvents = await getRecentCachedEvents([KIND.FAVORITE_RELAYS], 60 * 60 * 1000, 10); // 1 hour cache
const cachedFavoriteEvent = cachedEvents.find(e => e.pubkey === currentPubkey);
if (cachedFavoriteEvent) {
// Process cached event immediately
processFavoriteRelayEvent(cachedFavoriteEvent);
}
} catch (error) {
console.debug('Error loading cached favorite relays:', error);
}
try {
// Fetch kind 10012 (FAVORITE_RELAYS) event for current user
// Fetch multiple in case there are multiple replaceable events
const favoriteEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.FAVORITE_RELAYS], authors: [currentPubkey], limit: 10 }],
relayManager.getProfileReadRelays(),
{
useCache: 'cache-first', // Already shown cache above, now stream updates
cacheResults: true,
timeout: config.standardTimeout
}
);
if (favoriteEvents.length === 0) {
if (favoriteRelays.length === 0) {
favoriteRelays = [];
}
return;
}
// Get the latest event (replaceable event, so should be only one, but take latest just in case)
const latestEvent = favoriteEvents.sort((a, b) => b.created_at - a.created_at)[0];
// Process the event (update favorite relays with fresh data)
processFavoriteRelayEvent(latestEvent);
} catch (err) {
console.error('Error loading favorite relays:', err);
// Don't clear favoriteRelays if we have cached data
if (favoriteRelays.length === 0) {
favoriteRelays = [];
}
}
}
function handleRelayClick(url: string) {
// Extract hostname from relay URL (e.g., wss://thecitadel.nostr1.com -> thecitadel.nostr1.com)
// The route expects just the domain without protocol or port
let relayPath = url;
try {
const urlObj = new URL(url);
relayPath = urlObj.hostname;
} catch {
// If URL parsing fails, try to extract hostname manually
// Remove protocol (wss:// or ws://) and trailing slash
relayPath = url.replace(/^wss?:\/\//, '').replace(/\/$/, '');
// Remove port if present (route doesn't support ports in the parameter)
relayPath = relayPath.split(':')[0];
}
// Navigate to feed page with relay filter
goto(`/feed/relay/${relayPath}`);
}
function handleCustomRelaySubmit() {
const url = customRelayUrl.trim();
if (!url) return;
// Validate URL format
try {
// Try to parse as URL, add protocol if missing
let fullUrl = url;
if (!url.startsWith('ws://') && !url.startsWith('wss://')) {
fullUrl = `wss://${url}`;
}
new URL(fullUrl); // Validate URL format
// Navigate to the relay
handleRelayClick(fullUrl);
customRelayUrl = ''; // Clear input after navigation
} catch {
alert('Please enter a valid relay URL (e.g., relay.example.com or wss://relay.example.com)');
}
}
onMount(async () => {
await nostrClient.initialize();
// Ensure session is restored before loading favorite relays
if (!sessionManager.isLoggedIn()) {
try {
await sessionManager.restoreSession();
} catch (error) {
console.error('Failed to restore session in relay page:', error);
}
}
await loadRelays();
});
</script>
<Header />
<main class="container mx-auto px-4 py-8">
<div class="relay-page">
<h1 class="font-bold text-fog-text dark:text-fog-dark-text font-mono mb-6" style="font-size: 1.5em;">/Relay</h1>
<!-- Custom Relay Input -->
<section class="relay-category custom-relay-section">
<h2 class="category-title">Custom Relay</h2>
<div class="custom-relay-input">
<input
type="text"
placeholder="Enter relay URL (e.g., relay.example.com or wss://relay.example.com)"
bind:value={customRelayUrl}
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleCustomRelaySubmit();
}
}}
class="custom-relay-text-input"
/>
<button
onclick={handleCustomRelaySubmit}
class="custom-relay-button"
disabled={!customRelayUrl.trim()}
>
Go
</button>
</div>
</section>
{#if loading}
<p class="text-fog-text dark:text-fog-dark-text">Loading relays...</p>
{:else}
<div class="relay-categories">
<!-- Favorite Relays Section -->
{#if favoriteRelays.length > 0}
<section class="relay-category">
<h2 class="category-title">Favorite</h2>
<div class="relay-list">
{#each favoriteRelays as relay}
<div
class="relay-item"
onclick={() => handleRelayClick(relay.url)}
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleRelayClick(relay.url);
}
}}
>
<div class="relay-info">
<span class="relay-url">{relay.url}</span>
<div class="relay-meta">
<span class="relay-status" class:connected={relay.connected} class:disconnected={!relay.connected}>
{relay.connected ? '● Connected' : '○ Disconnected'}
</span>
</div>
</div>
<span class="relay-arrow"></span>
</div>
{/each}
</div>
</section>
{/if}
{#each ['Default', 'Profile', 'Thread Publish', 'GIF', 'Other'] as category}
{@const categoryRelays = relays.filter(r => r.categories.includes(category))}
{#if categoryRelays.length > 0}
<section class="relay-category">
<h2 class="category-title">{category}</h2>
<div class="relay-list">
{#each categoryRelays as relay}
<div
class="relay-item"
onclick={() => handleRelayClick(relay.url)}
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleRelayClick(relay.url);
}
}}
>
<div class="relay-info">
<span class="relay-url">{relay.url}</span>
<div class="relay-meta">
<span class="relay-status" class:connected={relay.connected} class:disconnected={!relay.connected}>
{relay.connected ? '● Connected' : '○ Disconnected'}
</span>
{#if relay.categories.length > 1}
{@const otherCategories = relay.categories.filter(c => c !== category)}
{#if otherCategories.length > 0}
{#each otherCategories as cat}
<span class="relay-category-badge">{cat}</span>
{/each}
{/if}
{/if}
</div>
</div>
<span class="relay-arrow"></span>
</div>
{/each}
</div>
</section>
{/if}
{/each}
</div>
{/if}
</div>
</main>
<style>
.relay-page {
max-width: var(--content-width);
margin: 0 auto;
padding: 0 1rem;
}
.relay-categories {
display: flex;
flex-direction: column;
gap: 2rem;
}
.relay-category {
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
padding: 1.5rem;
background: var(--fog-post, #ffffff);
}
:global(.dark) .relay-category {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.category-title {
margin: 0 0 1rem 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--fog-text, #475569);
font-family: monospace;
text-transform: uppercase;
letter-spacing: 0.05em;
}
:global(.dark) .category-title {
color: var(--fog-dark-text, #cbd5e1);
}
.relay-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.relay-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-highlight, #f3f4f6);
cursor: pointer;
transition: all 0.2s;
font-family: monospace;
}
.relay-item > .relay-info {
flex: 1;
}
:global(.dark) .relay-item {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-highlight, #374151);
}
.relay-item:hover {
background: var(--fog-accent, #64748b);
color: white;
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .relay-item:hover {
background: var(--fog-dark-accent, #94a3b8);
border-color: var(--fog-dark-accent, #94a3b8);
}
.relay-item:focus {
outline: 2px solid var(--fog-accent, #64748b);
outline-offset: 2px;
}
.relay-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
min-width: 0;
}
.relay-meta {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.relay-status {
font-size: 0.75rem;
opacity: 0.7;
flex-shrink: 0;
}
.relay-status.connected {
color: #10b981;
}
.relay-status.disconnected {
color: #ef4444;
}
.relay-item:hover .relay-status {
color: white;
opacity: 1;
}
.relay-category-badge {
font-size: 0.7rem;
padding: 0.125rem 0.375rem;
border-radius: 0.125rem;
background: var(--fog-border, #e5e7eb);
color: var(--fog-text-light, #6b7280);
font-family: monospace;
}
:global(.dark) .relay-category-badge {
background: var(--fog-dark-border, #475569);
color: var(--fog-dark-text-light, #9ca3af);
}
.relay-item:hover .relay-category-badge {
background: rgba(255, 255, 255, 0.2);
color: white;
}
.relay-url {
font-size: 0.875rem;
color: var(--fog-text, #475569);
word-break: break-all;
}
:global(.dark) .relay-url {
color: var(--fog-dark-text, #cbd5e1);
}
.relay-item:hover .relay-url {
color: white;
}
.relay-arrow {
font-size: 1.25rem;
opacity: 0.5;
flex-shrink: 0;
margin-top: 0.125rem;
}
.relay-item:hover .relay-arrow {
opacity: 1;
}
.custom-relay-section {
margin-bottom: 2rem;
}
.custom-relay-input {
display: flex;
gap: 0.5rem;
align-items: center;
}
.custom-relay-text-input {
flex: 1;
padding: 0.75rem 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #475569);
font-family: monospace;
font-size: 0.875rem;
}
:global(.dark) .custom-relay-text-input {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #cbd5e1);
}
.custom-relay-text-input:focus {
outline: 2px solid var(--fog-accent, #64748b);
outline-offset: 2px;
}
.custom-relay-button {
padding: 0.75rem 1.5rem;
border: 1px solid var(--fog-accent, #64748b);
border-radius: 0.25rem;
background: var(--fog-accent, #64748b);
color: var(--fog-text, #f1f5f9);
font-family: monospace;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.custom-relay-button:hover:not(:disabled) {
background: var(--fog-dark-accent, #94a3b8);
border-color: var(--fog-dark-accent, #94a3b8);
}
.custom-relay-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.custom-relay-button:focus {
outline: 2px solid var(--fog-accent, #64748b);
outline-offset: 2px;
}
</style>