From e72354a2544b5e3f52415e49fe663f2572a24daa Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 4 Feb 2026 12:15:47 +0100 Subject: [PATCH] bug-fixes --- README.md | 4 +- public/healthz.json | 4 +- .../components/content/EmbeddedEvent.svelte | 1 + .../components/content/QuotedContext.svelte | 1 + .../components/content/ReplyContext.svelte | 1 + src/lib/components/relay/RelayInfo.svelte | 342 ++++++++++++++++-- src/lib/modules/comments/CommentForm.svelte | 25 +- src/lib/services/nostr/auth-handler.ts | 12 +- src/lib/services/nostr/nostr-client.ts | 193 ++++++++-- src/lib/services/nostr/relay-manager.ts | 105 +++++- src/lib/services/user-data.ts | 24 +- .../{explore/relays => }/[relay]/+page.svelte | 30 +- src/routes/rss/[pubkey]/+page.server.ts | 2 +- 13 files changed, 642 insertions(+), 102 deletions(-) rename src/routes/feed/relay/{explore/relays => }/[relay]/+page.svelte (62%) diff --git a/README.md b/README.md index 11768f4..bb5cb05 100644 --- a/README.md +++ b/README.md @@ -450,7 +450,7 @@ aitherboard/ | Category | Relays | Purpose | |----------|--------|---------| -| **Default Relays** | `wss://theforest.nostr1.com`
`wss://nostr21.com`
`wss://nostr.land`
`wss://nostr.wine`
`wss://nostr.sovbit.host` | Base relays for all operations | +| **Default Relays** | `wss://theforest.nostr1.com`
`wss://nostr21.com`
`wss://nostr.land`
`wss://nostr.sovbit.host`
`wss://orly-relay.imwald.eu`
`wss://nostr.wine` | Base relays for all operations | | **Profile Relays** | `wss://relay.damus.io`
`wss://aggr.nostr.land`
`wss://profiles.nostr1.com` | Additional relays for profile/kind 1 content | ### Relay Selection by Operation @@ -857,7 +857,7 @@ aitherboard/ | Variable | Type | Default | Validation | |----------|------|---------|------------| -| `VITE_DEFAULT_RELAYS` | Comma-separated URLs | `wss://theforest.nostr1.com,wss://nostr21.com,wss://nostr.land,wss://nostr.wine,wss://nostr.sovbit.host` | Empty/invalid falls back to defaults | +| `VITE_DEFAULT_RELAYS` | Comma-separated URLs | `wss://theforest.nostr1.com,wss://nostr21.com,wss://nostr.land,wss://nostr.sovbit.host,wss://orly-relay.imwald.eu` | Empty/invalid falls back to defaults | | `VITE_ZAP_THRESHOLD` | Integer | `1` | Must be 0 or positive, invalid defaults to 1 | | `VITE_THREAD_TIMEOUT_DAYS` | Integer | `30` | - | | `VITE_PWA_ENABLED` | Boolean | `true` | - | diff --git a/public/healthz.json b/public/healthz.json index ee82a9c..2b71ff3 100644 --- a/public/healthz.json +++ b/public/healthz.json @@ -2,7 +2,7 @@ "status": "ok", "service": "aitherboard", "version": "0.1.0", - "buildTime": "2026-02-04T09:46:20.554Z", + "buildTime": "2026-02-04T11:12:13.164Z", "gitCommit": "unknown", - "timestamp": 1770198380554 + "timestamp": 1770203533165 } \ No newline at end of file diff --git a/src/lib/components/content/EmbeddedEvent.svelte b/src/lib/components/content/EmbeddedEvent.svelte index 0bd8653..6587b84 100644 --- a/src/lib/components/content/EmbeddedEvent.svelte +++ b/src/lib/components/content/EmbeddedEvent.svelte @@ -6,6 +6,7 @@ import type { NostrEvent } from '../../types/nostr.js'; import { stripMarkdown } from '../../services/text-utils.js'; import ProfileBadge from '../layout/ProfileBadge.svelte'; + import { KIND } from '../../types/kind-lookup.js'; interface Props { eventId: string; // Can be hex, note, nevent, naddr diff --git a/src/lib/components/content/QuotedContext.svelte b/src/lib/components/content/QuotedContext.svelte index 970f31a..b4afa22 100644 --- a/src/lib/components/content/QuotedContext.svelte +++ b/src/lib/components/content/QuotedContext.svelte @@ -3,6 +3,7 @@ import { nostrClient } from '../../services/nostr/nostr-client.js'; import { relayManager } from '../../services/nostr/relay-manager.js'; import { stripMarkdown } from '../../services/text-utils.js'; + import { KIND } from '../../types/kind-lookup.js'; interface Props { quotedEvent?: NostrEvent; // Optional - if not provided, will load by quotedEventId diff --git a/src/lib/components/content/ReplyContext.svelte b/src/lib/components/content/ReplyContext.svelte index 8240fce..57a20f8 100644 --- a/src/lib/components/content/ReplyContext.svelte +++ b/src/lib/components/content/ReplyContext.svelte @@ -3,6 +3,7 @@ import { nostrClient } from '../../services/nostr/nostr-client.js'; import { relayManager } from '../../services/nostr/relay-manager.js'; import { stripMarkdown } from '../../services/text-utils.js'; + import { KIND } from '../../types/kind-lookup.js'; interface Props { parentEvent?: NostrEvent; // Optional - if not provided, will load by parentEventId diff --git a/src/lib/components/relay/RelayInfo.svelte b/src/lib/components/relay/RelayInfo.svelte index 00fca93..cd60a3b 100644 --- a/src/lib/components/relay/RelayInfo.svelte +++ b/src/lib/components/relay/RelayInfo.svelte @@ -2,6 +2,11 @@ import { nostrClient } from '../../services/nostr/nostr-client.js'; import { Relay } from 'nostr-tools'; import { onMount } from 'svelte'; + import ProfileBadge from '../layout/ProfileBadge.svelte'; + import { KIND } from '../../types/kind-lookup.js'; + import { relayManager } from '../../services/nostr/relay-manager.js'; + import type { NostrEvent } from '../../types/nostr.js'; + import { fetchProfiles } from '../../services/user-data.js'; interface Props { relayUrl: string; @@ -17,6 +22,7 @@ supported_nips?: number[]; software?: string; version?: string; + icon?: string; limitation?: { max_message_length?: number; max_subscriptions?: number; @@ -38,53 +44,122 @@ let error = $state(null); let connectionStatus = $state<'connecting' | 'connected' | 'disconnected'>('connecting'); let eventCount = $state(null); + let relayIcon = $state(null); + let favoritedBy = $state([]); // Array of pubkeys who favorited this relay + let favoritedByProfiles = $state>(new Map()); 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'; + // Fetch NIP-11 metadata via HTTP GET first (this doesn't require WebSocket connection) + 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; + + // Set relay icon from NIP-11 metadata, or fallback to favicon.ico + if (info.icon) { + relayIcon = info.icon; + } else { + // Fallback to favicon.ico from the relay's HTTP endpoint + relayIcon = `${httpUrl}/favicon.ico`; + } + } } else { - connectionStatus = 'disconnected'; + // If NIP-11 fails, still try favicon.ico + relayIcon = `${httpUrl}/favicon.ico`; } - - // Fetch NIP-11 metadata via HTTP GET + } catch (err) { + console.debug(`[RelayInfo] Could not fetch NIP-11 metadata for ${relayUrl}:`, err); + // Try favicon.ico as fallback 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' + relayIcon = `${httpUrl}/favicon.ico`; + } catch { + // Ignore favicon fetch errors + } + } + + // Get relay connection status from nostr-client + // Try to get the relay, but don't wait too long + const relay = await nostrClient.getRelay(relayUrl); + + // Check connection status more accurately + if (relay) { + try { + const ws = (relay as any).ws; + if (ws) { + // Check WebSocket readyState directly + // 0 = CONNECTING, 1 = OPEN, 2 = CLOSING, 3 = CLOSED + if (ws.readyState === WebSocket.OPEN) { + connectionStatus = 'connected'; + } else if (ws.readyState === WebSocket.CONNECTING) { + connectionStatus = 'connecting'; + // Wait a bit and check again + setTimeout(() => { + if (ws.readyState === WebSocket.OPEN) { + connectionStatus = 'connected'; + } else if (ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) { + connectionStatus = 'disconnected'; + } + }, 1000); + } else { + connectionStatus = 'disconnected'; } - }); - - if (response.ok) { - const info = await response.json(); - if (info) { - metadata = info as RelayMetadata; + } else { + // No WebSocket yet, check status property + const status = (relay as any).status; + if (status === 1) { + connectionStatus = 'connected'; + } else if (status === 0) { + connectionStatus = 'connecting'; + } else { + connectionStatus = 'disconnected'; } } } 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 + // If we can't check status, assume connecting + connectionStatus = 'connecting'; } } else { - connectionStatus = 'disconnected'; - error = 'Failed to connect to relay'; + // Relay not in map yet - might still be connecting + // Try to add it and check again after a short delay + connectionStatus = 'connecting'; + setTimeout(async () => { + const relayAfterDelay = await nostrClient.getRelay(relayUrl); + if (relayAfterDelay) { + try { + const ws = (relayAfterDelay as any).ws; + if (ws && ws.readyState === WebSocket.OPEN) { + connectionStatus = 'connected'; + } else if (ws && (ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING)) { + connectionStatus = 'disconnected'; + } + } catch { + // Ignore errors checking status + } + } else { + connectionStatus = 'disconnected'; + } + }, 2000); } } catch (err) { console.error(`[RelayInfo] Error fetching relay info for ${relayUrl}:`, err); @@ -112,9 +187,88 @@ } } + async function fetchFavoritedBy() { + try { + // Fetch kind 10012 (FAVORITE_RELAYS) events + // These events contain 'r' tags with relay URLs + const favoriteRelayEvents = await nostrClient.fetchEvents( + [{ kinds: [KIND.FAVORITE_RELAYS], limit: 100 }], + relayManager.getProfileReadRelays(), + { useCache: true, cacheResults: true } + ); + + console.debug(`[RelayInfo] Fetched ${favoriteRelayEvents.length} favorite relay events for ${relayUrl}`); + + // Normalize the relay URL for comparison (remove trailing slashes, convert to lowercase) + const normalizedRelayUrl = relayUrl.trim().replace(/\/$/, '').toLowerCase(); + console.debug(`[RelayInfo] Looking for normalized URL: ${normalizedRelayUrl}`); + + // Debug: Show structure of first few events + if (favoriteRelayEvents.length > 0) { + const firstEvent = favoriteRelayEvents[0]; + console.debug(`[RelayInfo] First event structure:`, { + pubkey: firstEvent.pubkey, + kind: firstEvent.kind, + tags: firstEvent.tags, + tagTypes: firstEvent.tags.map(t => t[0]) + }); + } + + // Filter events that contain this relay URL in their tags + // Kind 10012 uses 'relay' tags (not 'r' tags like relay lists) + const pubkeys = new Set(); + + for (const event of favoriteRelayEvents) { + for (const tag of event.tags) { + // Check for 'relay' tags (kind 10012 uses 'relay' tags, not 'r' tags) + if (tag[0] === 'relay' && tag[1]) { + // Normalize tag URL for comparison + const tagUrl = tag[1].trim().replace(/\/$/, '').toLowerCase(); + + if (tagUrl === normalizedRelayUrl) { + pubkeys.add(event.pubkey); + console.debug(`[RelayInfo] Found favorite match: ${event.pubkey} for ${relayUrl}`); + break; // Found this relay in this event, no need to check other tags + } + } + } + } + + favoritedBy = Array.from(pubkeys); + console.debug(`[RelayInfo] Found ${favoritedBy.length} users who favorited ${relayUrl}`); + + // Fetch profiles for the users who favorited this relay + if (favoritedBy.length > 0) { + const profiles = await fetchProfiles(favoritedBy); + favoritedByProfiles = profiles; + } + + // If no matches found, show some sample data from the events for debugging + if (favoritedBy.length === 0 && favoriteRelayEvents.length > 0) { + const sampleRelays = new Set(); + const allTagTypes = new Set(); + for (const event of favoriteRelayEvents.slice(0, 5)) { + for (const tag of event.tags) { + allTagTypes.add(tag[0]); + if (tag[0] === 'relay' && tag[1]) { + sampleRelays.add(tag[1]); + } + } + } + console.debug(`[RelayInfo] Sample relay URLs from favorite events:`, Array.from(sampleRelays)); + console.debug(`[RelayInfo] All tag types found in favorite events:`, Array.from(allTagTypes)); + } + } catch (err) { + console.error(`[RelayInfo] Could not fetch favorite relays for ${relayUrl}:`, err); + favoritedBy = []; + favoritedByProfiles = new Map(); + } + } + onMount(async () => { await fetchRelayMetadata(); await getEventCount(); + await fetchFavoritedBy(); }); function getStatusColor(status: string): string { @@ -156,7 +310,12 @@ {:else}
-

Relay Information

+
+ {#if relayIcon} + Relay icon relayIcon = null} /> + {/if} +

Relay Information

+
{getStatusIcon(connectionStatus)} @@ -207,8 +366,10 @@ {#if metadata.pubkey}
- Pubkey: - {metadata.pubkey.substring(0, 16)}... + Owner: +
+ +
{/if} @@ -255,6 +416,32 @@

No NIP-11 metadata available

{/if} + + {/if}
@@ -289,6 +476,24 @@ margin-bottom: 0.5rem; } + .relay-info-title-with-icon { + display: flex; + align-items: center; + gap: 0.75rem; + } + + .relay-icon { + width: 2rem; + height: 2rem; + border-radius: 0.375rem; + object-fit: cover; + border: 1px solid var(--fog-border, #e5e7eb); + } + + :global(.dark) .relay-icon { + border-color: var(--fog-dark-border, #374151); + } + .relay-info-title { margin: 0; font-size: 1.25rem; @@ -414,4 +619,77 @@ padding: 1rem; text-align: center; } + + .relay-info-favorited { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--fog-border, #e5e7eb); + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + :global(.dark) .relay-info-favorited { + border-top-color: var(--fog-dark-border, #374151); + } + + .relay-favorited-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + } + + .relay-favorite-avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + border-radius: 50%; + overflow: hidden; + border: 2px solid var(--fog-border, #e5e7eb); + background: var(--fog-highlight, #f3f4f6); + text-decoration: none; + transition: transform 0.2s, border-color 0.2s; + } + + .relay-favorite-avatar:hover { + transform: scale(1.1); + border-color: var(--fog-accent, #64748b); + } + + :global(.dark) .relay-favorite-avatar { + border-color: var(--fog-dark-border, #374151); + background: var(--fog-dark-highlight, #374151); + } + + :global(.dark) .relay-favorite-avatar:hover { + border-color: var(--fog-dark-accent, #94a3b8); + } + + .relay-favorite-pic { + width: 100%; + height: 100%; + object-fit: cover; + } + + .relay-favorite-fallback { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.875rem; + font-weight: 600; + color: var(--fog-text, #1f2937); + } + + .relay-favorite-fallback.hidden { + display: none; + } + + :global(.dark) .relay-favorite-fallback { + color: var(--fog-dark-text, #f9fafb); + } diff --git a/src/lib/modules/comments/CommentForm.svelte b/src/lib/modules/comments/CommentForm.svelte index 80c0f11..ac41544 100644 --- a/src/lib/modules/comments/CommentForm.svelte +++ b/src/lib/modules/comments/CommentForm.svelte @@ -138,8 +138,22 @@ } } + // Extract relay hints from the event being replied to (r tags) + // Check parentEvent first (if replying to a comment), then rootEvent + const replyTargetEvent = parentEvent || rootEvent; + let replyRelayHints: string[] | undefined; + if (replyTargetEvent) { + replyRelayHints = replyTargetEvent.tags + .filter(tag => tag[0] === 'r' && tag[1]) + .map(tag => tag[1]) + .filter((url): url is string => { + // Validate it's a websocket URL + return typeof url === 'string' && (url.startsWith('ws://') || url.startsWith('wss://')); + }); + } + // Use proper relay selection for comments - const publishRelays = relayManager.getCommentPublishRelays(targetInbox); + const publishRelays = relayManager.getCommentPublishRelays(targetInbox, replyRelayHints); const result = await signAndPublish(event, publishRelays); // Show publication status modal @@ -308,13 +322,4 @@ background: var(--fog-dark-highlight, #374151); border-color: var(--fog-dark-accent, #64748b); } - - .client-tag-checkbox { - opacity: 0.7; - cursor: pointer; - } - - .client-tag-checkbox:hover { - opacity: 0.9; - } diff --git a/src/lib/services/nostr/auth-handler.ts b/src/lib/services/nostr/auth-handler.ts index fae2103..8cdd318 100644 --- a/src/lib/services/nostr/auth-handler.ts +++ b/src/lib/services/nostr/auth-handler.ts @@ -10,7 +10,7 @@ import { } from '../auth/anonymous-signer.js'; import { decryptPrivateKey } from '../security/key-management.js'; import { sessionManager, type AuthMethod } from '../auth/session-manager.js'; -import { fetchRelayLists } from '../user-data.js'; +import { fetchRelayLists, fetchProfile } from '../user-data.js'; import { nostrClient } from './nostr-client.js'; import { relayManager } from './relay-manager.js'; import type { NostrEvent } from '../../types/nostr.js'; @@ -36,6 +36,11 @@ export async function authenticateWithNIP07(): Promise { // Fetch user relay lists and mute list await loadUserPreferences(pubkey); + // Fetch and cache user's own profile (background-update if already cached) + fetchProfile(pubkey).catch(() => { + // Silently fail - profile fetch errors shouldn't break login + }); + return pubkey; } @@ -61,6 +66,11 @@ export async function authenticateWithNsec( await loadUserPreferences(pubkey); + // Fetch and cache user's own profile (background-update if already cached) + fetchProfile(pubkey).catch(() => { + // Silently fail - profile fetch errors shouldn't break login + }); + return pubkey; } diff --git a/src/lib/services/nostr/nostr-client.ts b/src/lib/services/nostr/nostr-client.ts index 24bcf2a..5fcb613 100644 --- a/src/lib/services/nostr/nostr-client.ts +++ b/src/lib/services/nostr/nostr-client.ts @@ -49,6 +49,7 @@ class NostrClient { private readonly INITIAL_RETRY_DELAY = 5000; // 5 seconds private readonly MAX_RETRY_DELAY = 300000; // 5 minutes private readonly MAX_FAILURE_COUNT = 10; // After 10 failures, wait max delay + private readonly PERMANENT_FAILURE_THRESHOLD = 20; // After 20 failures, skip relay for this session // Track authenticated relays to avoid re-authenticating private authenticatedRelays: Set = new Set(); @@ -132,21 +133,48 @@ class NostrClient { async addRelay(url: string): Promise { if (this.relays.has(url)) return; - // Check if this relay has failed recently and we should wait + // Check if this relay has failed too many times - skip permanently for this session const failureInfo = this.failedRelays.get(url); + if (failureInfo && failureInfo.failureCount >= this.PERMANENT_FAILURE_THRESHOLD) { + console.debug(`[nostr-client] Relay ${url} has failed ${failureInfo.failureCount} times, skipping for this session`); + throw new Error(`Relay has failed too many times (${failureInfo.failureCount}), skipping for this session`); + } + + // Check if this relay has failed recently and we should wait if (failureInfo) { const timeSinceFailure = Date.now() - failureInfo.lastFailure; if (timeSinceFailure < failureInfo.retryAfter) { const waitTime = failureInfo.retryAfter - timeSinceFailure; - console.log(`[nostr-client] Relay ${url} failed recently, waiting ${Math.round(waitTime / 1000)}s before retry`); + console.debug(`[nostr-client] Relay ${url} failed recently, waiting ${Math.round(waitTime / 1000)}s before retry`); throw new Error(`Relay failed recently, retry after ${Math.round(waitTime / 1000)}s`); } } try { - const relay = await Relay.connect(url); + // Use connection timeout similar to jumble's approach (5 seconds) + const relay = await Promise.race([ + Relay.connect(url), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Connection timeout')), 5000) + ) + ]); + this.relays.set(url, relay); + // Add connection close handler to automatically clean up closed relays + try { + const ws = (relay as any).ws; + if (ws) { + ws.addEventListener('close', () => { + console.debug(`[nostr-client] Relay ${url} connection closed, removing from active relays`); + this.relays.delete(url); + this.authenticatedRelays.delete(url); + }); + } + } catch (error) { + // Ignore errors accessing WebSocket + } + // Don't proactively authenticate - only authenticate when: // 1. Relay sends an AUTH challenge (handled by nostr-tools automatically) // 2. An operation fails with 'auth-required' error (handled in publish/subscribe methods) @@ -156,7 +184,7 @@ class NostrClient { // Log successful connection at debug level to reduce console noise console.debug(`[nostr-client] Successfully connected to relay: ${url}`); } catch (error) { - // Track the failure + // Track the failure but don't throw - allow graceful degradation like jumble const existingFailure = this.failedRelays.get(url) || { lastFailure: 0, retryAfter: this.INITIAL_RETRY_DELAY, failureCount: 0 }; const failureCount = existingFailure.failureCount + 1; @@ -184,7 +212,12 @@ class NostrClient { if (failureCount > 3) { console.debug(`[nostr-client] Relay ${url} connection failed (failure #${failureCount}), will retry after ${Math.round(retryAfter / 1000)}s`); } - throw error; + // Warn if approaching permanent failure threshold + if (failureCount >= this.PERMANENT_FAILURE_THRESHOLD) { + console.warn(`[nostr-client] Relay ${url} has failed ${failureCount} times, will be skipped for this session`); + } + // Don't throw - allow graceful degradation like jumble does + // The caller can check if relay was added by checking this.relays.has(url) } } @@ -205,25 +238,47 @@ class NostrClient { private checkAndCleanupRelay(relayUrl: string): boolean { const relay = this.relays.get(relayUrl); if (!relay) return false; + + // Check relay connection status + // Status values: 0 = CONNECTING, 1 = OPEN, 2 = CLOSING, 3 = CLOSED const status = (relay as any).status; - if (status === 3) { + if (status === 3 || status === 2) { + // Relay is closed or closing, remove it + console.debug(`[nostr-client] Relay ${relayUrl} is closed (status: ${status}), removing from active relays`); this.relays.delete(relayUrl); + this.authenticatedRelays.delete(relayUrl); return false; } + + // Check if relay has a connection property and if it's closed + try { + const ws = (relay as any).ws; + if (ws && (ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING)) { + console.debug(`[nostr-client] Relay ${relayUrl} WebSocket is closed, removing from active relays`); + this.relays.delete(relayUrl); + this.authenticatedRelays.delete(relayUrl); + return false; + } + } catch (error) { + // If we can't check the WebSocket, assume it's still valid + } + return true; } /** * Get a relay instance by URL * Will connect if not already connected + * Returns null if connection fails (graceful degradation like jumble) */ async getRelay(url: string): Promise { // Ensure relay is connected if (!this.relays.has(url)) { - try { - await this.addRelay(url); - } catch (error) { - console.debug(`[nostr-client] Failed to connect to relay ${url}:`, error); + // addRelay doesn't throw on failure, it just doesn't add the relay + await this.addRelay(url); + // Check if relay was actually added + if (!this.relays.has(url)) { + console.debug(`[nostr-client] Failed to connect to relay ${url}, skipping gracefully`); return null; } } @@ -425,14 +480,14 @@ class NostrClient { for (const url of relays) { const relay = this.relays.get(url); if (!relay) { - try { - await this.addRelay(url); - const newRelay = this.relays.get(url); - if (newRelay) { - try { - await newRelay.publish(event); - results.success.push(url); - } catch (error) { + // addRelay doesn't throw on failure, it just doesn't add the relay (graceful degradation like jumble) + await this.addRelay(url); + const newRelay = this.relays.get(url); + if (newRelay && this.checkAndCleanupRelay(url)) { + try { + await newRelay.publish(event); + results.success.push(url); + } catch (error) { // Check if error is auth-required if (error instanceof Error && error.message.startsWith('auth-required')) { // Try to authenticate and retry @@ -454,20 +509,36 @@ class NostrClient { }); } } else { + // Check if it's a closed connection error + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes('SendingOnClosedConnection') || errorMessage.includes('closed connection')) { + console.debug(`[nostr-client] Relay ${url} connection closed during publish, removing from active relays`); + this.relays.delete(url); + this.authenticatedRelays.delete(url); + } results.failed.push({ relay: url, error: error instanceof Error ? error.message : 'Unknown error' }); } } + } else { + // Relay connection failed (addRelay didn't add it) + results.failed.push({ + relay: url, + error: 'Failed to connect' + }); } - } catch (error) { + } else { + // Check relay status before publishing + if (!this.checkAndCleanupRelay(url)) { results.failed.push({ relay: url, - error: 'Failed to connect' + error: 'Relay connection closed' }); + continue; } - } else { + try { await relay.publish(event); results.success.push(url); @@ -493,6 +564,13 @@ class NostrClient { }); } } else { + // Check if it's a closed connection error + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes('SendingOnClosedConnection') || errorMessage.includes('closed connection')) { + console.debug(`[nostr-client] Relay ${url} connection closed during publish, removing from active relays`); + this.relays.delete(url); + this.authenticatedRelays.delete(url); + } results.failed.push({ relay: url, error: error instanceof Error ? error.message : 'Unknown error' @@ -515,13 +593,19 @@ class NostrClient { for (const url of relays) { if (!this.relays.has(url)) { + // Check if relay should be skipped before attempting connection + const failureInfo = this.failedRelays.get(url); + if (failureInfo && failureInfo.failureCount >= this.PERMANENT_FAILURE_THRESHOLD) { + console.debug(`[nostr-client] Skipping permanently failed relay ${url} for subscription`); + continue; + } + + // addRelay doesn't throw on failure, it just doesn't add the relay (graceful degradation like jumble) this.addRelay(url).then(() => { const newRelay = this.relays.get(url); if (newRelay) { this.setupSubscription(newRelay, url, subId, filters, onEvent, onEose); } - }).catch(() => { - // Silently fail }); continue; } @@ -529,10 +613,22 @@ class NostrClient { const relay = this.relays.get(url); if (!relay) continue; + // Check relay status before setting up subscription + if (!this.checkAndCleanupRelay(url)) { + console.debug(`[nostr-client] Relay ${url} is closed, skipping subscription`); + continue; + } + try { this.setupSubscription(relay, url, subId, filters, onEvent, onEose); } catch (error) { - // Handle errors + // Handle errors - setupSubscription already handles closed connection errors + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes('SendingOnClosedConnection') || errorMessage.includes('closed connection')) { + console.debug(`[nostr-client] Relay ${url} connection closed, removing from active relays`); + this.relays.delete(url); + this.authenticatedRelays.delete(url); + } } } @@ -549,11 +645,23 @@ class NostrClient { ): void { if (!this.relays.has(url)) return; + // Check relay status before setting up subscription + if (!this.checkAndCleanupRelay(url)) { + console.debug(`[nostr-client] Relay ${url} is closed, skipping subscription setup`); + return; + } + try { const client = this; let hasAuthed = this.authenticatedRelays.has(url); const startSub = () => { + // Check relay status again before subscribing + if (!client.checkAndCleanupRelay(url)) { + console.debug(`[nostr-client] Relay ${url} closed before subscription, aborting`); + return; + } + const sub = relay.subscribe(filters, { onevent: (event: NostrEvent) => { try { @@ -599,7 +707,13 @@ class NostrClient { startSub(); } catch (error) { - // Handle errors + // Handle SendingOnClosedConnection and other errors + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes('SendingOnClosedConnection') || errorMessage.includes('closed connection')) { + console.debug(`[nostr-client] Relay ${url} connection closed during subscription setup, removing from active relays`); + this.relays.delete(url); + this.authenticatedRelays.delete(url); + } } } @@ -696,6 +810,13 @@ class NostrClient { const startSub = () => { try { + // Check relay status before subscribing + if (!this.checkAndCleanupRelay(relayUrl)) { + console.debug(`[nostr-client] Relay ${relayUrl} is closed, skipping subscription`); + finish(); + return; + } + const client = this; const sub = relay.subscribe(filters, { onevent: (event: NostrEvent) => { @@ -749,6 +870,13 @@ class NostrClient { if (!resolved) finish(); }, timeout); } catch (error) { + // Handle SendingOnClosedConnection and other errors + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes('SendingOnClosedConnection') || errorMessage.includes('closed connection')) { + console.debug(`[nostr-client] Relay ${relayUrl} connection closed, removing from active relays`); + this.relays.delete(relayUrl); + this.authenticatedRelays.delete(relayUrl); + } finish(); } }; @@ -840,13 +968,18 @@ class NostrClient { ): Promise { const timeout = options.timeout || config.relayTimeout; - // Filter out relays that have failed recently + // Filter out relays that have failed recently or permanently const now = Date.now(); const availableRelays = relays.filter(url => { if (this.relays.has(url)) return true; // Already connected const failureInfo = this.failedRelays.get(url); if (failureInfo) { + // Skip permanently failed relays + if (failureInfo.failureCount >= this.PERMANENT_FAILURE_THRESHOLD) { + return false; // Skip this relay, it has failed too many times + } + // Skip relays that failed recently (still in backoff period) const timeSinceFailure = now - failureInfo.lastFailure; if (timeSinceFailure < failureInfo.retryAfter) { return false; // Skip this relay, it failed recently @@ -856,15 +989,11 @@ class NostrClient { }); // Try to connect to relays that aren't already connected + // Like jumble, we gracefully handle failures - addRelay doesn't throw, it just doesn't add failed relays const relaysToConnect = availableRelays.filter(url => !this.relays.has(url)); if (relaysToConnect.length > 0) { await Promise.allSettled( - relaysToConnect.map(url => - this.addRelay(url).catch((error) => { - // Error already logged in addRelay - return null; - }) - ) + relaysToConnect.map(url => this.addRelay(url)) ); } diff --git a/src/lib/services/nostr/relay-manager.ts b/src/lib/services/nostr/relay-manager.ts index 1eeed45..00a74f1 100644 --- a/src/lib/services/nostr/relay-manager.ts +++ b/src/lib/services/nostr/relay-manager.ts @@ -7,21 +7,82 @@ import { fetchRelayLists } from '../user-data.js'; import { getBlockedRelays } from '../nostr/auth-handler.js'; import { config } from './config.js'; import { sessionManager } from '../auth/session-manager.js'; +import { nostrClient } from './nostr-client.js'; +import { KIND } from '../../types/kind-lookup.js'; class RelayManager { private userInbox: string[] = []; private userOutbox: string[] = []; + private userLocalRelaysRead: string[] = []; // Local relays (kind 10432) marked read or unmarked + private userLocalRelaysWrite: string[] = []; // Local relays (kind 10432) marked write or unmarked private blockedRelays: Set = new Set(); /** * Load user relay preferences */ async loadUserPreferences(pubkey: string): Promise { - // Fetch relay lists + // Fetch relay lists (includes both kind 10002 and 10432) const { inbox, outbox } = await fetchRelayLists(pubkey); this.userInbox = inbox; this.userOutbox = outbox; + // Also fetch local relays separately to track read/write indicators + // Local relays are used as external cache + const relayList = [ + ...config.defaultRelays, + ...config.profileRelays + ]; + const localRelayEvents = await nostrClient.fetchEvents( + [{ kinds: [KIND.LOCAL_RELAYS], authors: [pubkey], limit: 1 }], + relayList, + { useCache: true, cacheResults: true } + ); + + const localRelaysRead: string[] = []; + const localRelaysWrite: string[] = []; + + for (const event of localRelayEvents) { + for (const tag of event.tags) { + if (tag[0] === 'r' && tag[1]) { + const url = tag[1].trim(); + if (!url) continue; + + const markers = tag.slice(2); + + // If no markers, relay is both read and write + if (markers.length === 0) { + if (!localRelaysRead.includes(url)) { + localRelaysRead.push(url); + } + if (!localRelaysWrite.includes(url)) { + localRelaysWrite.push(url); + } + continue; + } + + // Check for explicit markers + const hasRead = markers.includes('read'); + const hasWrite = markers.includes('write'); + + // Determine read/write permissions + // If only 'read' marker: read=true, write=false + // If only 'write' marker: read=false, write=true + // If both or neither: both true (default behavior) + const read = hasRead || (!hasRead && !hasWrite); + const write = hasWrite || (!hasRead && !hasWrite); + + if (read && !localRelaysRead.includes(url)) { + localRelaysRead.push(url); + } + if (write && !localRelaysWrite.includes(url)) { + localRelaysWrite.push(url); + } + } + } + } + this.userLocalRelaysRead = localRelaysRead; + this.userLocalRelaysWrite = localRelaysWrite; + // Get blocked relays this.blockedRelays = getBlockedRelays(); } @@ -32,6 +93,8 @@ class RelayManager { clearUserPreferences(): void { this.userInbox = []; this.userOutbox = []; + this.userLocalRelaysRead = []; + this.userLocalRelaysWrite = []; this.blockedRelays.clear(); } @@ -71,6 +134,11 @@ class RelayManager { relays = [...relays, ...this.userInbox]; } + // Add local relays marked for read (used as external cache) + if (includeUserInbox && sessionManager.isLoggedIn() && this.userLocalRelaysRead.length > 0) { + relays = [...relays, ...this.userLocalRelaysRead]; + } + // Normalize and deduplicate relays = this.normalizeRelays(relays); @@ -89,6 +157,11 @@ class RelayManager { relays = [...relays, ...this.userOutbox]; } + // Add local relays marked for write (used as external cache) + if (includeUserOutbox && sessionManager.isLoggedIn() && this.userLocalRelaysWrite.length > 0) { + relays = [...relays, ...this.userLocalRelaysWrite]; + } + // Normalize and deduplicate relays = this.normalizeRelays(relays); @@ -176,35 +249,49 @@ class RelayManager { /** * Get relays for publishing comments (kind 1111) - * If replying, include target's inbox + * If replying, include target's inbox and relay hints from the event being replied to */ - getCommentPublishRelays(targetInbox?: string[]): string[] { + getCommentPublishRelays(targetInbox?: string[], replyRelayHints?: string[]): string[] { let relays = this.getPublishRelays(config.defaultRelays); + // If replying to an event with relay hints, add them + if (replyRelayHints && replyRelayHints.length > 0) { + relays = [...relays, ...replyRelayHints]; + } + // If replying, add target's inbox if (targetInbox && targetInbox.length > 0) { relays = [...relays, ...targetInbox]; - relays = this.normalizeRelays(relays); - relays = this.filterBlocked(relays); } + // Normalize and filter after combining all relays + relays = this.normalizeRelays(relays); + relays = this.filterBlocked(relays); + return relays; } /** * Get relays for publishing kind 1 posts - * If replying, include target's inbox + * If replying, include target's inbox and relay hints from the event being replied to */ - getFeedPublishRelays(targetInbox?: string[]): string[] { + getFeedPublishRelays(targetInbox?: string[], replyRelayHints?: string[]): string[] { let relays = this.getPublishRelays(config.defaultRelays); + // If replying to an event with relay hints, add them + if (replyRelayHints && replyRelayHints.length > 0) { + relays = [...relays, ...replyRelayHints]; + } + // If replying, add target's inbox if (targetInbox && targetInbox.length > 0) { relays = [...relays, ...targetInbox]; - relays = this.normalizeRelays(relays); - relays = this.filterBlocked(relays); } + // Normalize and filter after combining all relays + relays = this.normalizeRelays(relays); + relays = this.filterBlocked(relays); + return relays; } diff --git a/src/lib/services/user-data.ts b/src/lib/services/user-data.ts index 566c6fb..1270042 100644 --- a/src/lib/services/user-data.ts +++ b/src/lib/services/user-data.ts @@ -67,6 +67,7 @@ export function parseProfile(event: NostrEvent): ProfileData { /** * Fetch profile for a pubkey + * Returns cached profile immediately if available, then background-refreshes */ export async function fetchProfile( pubkey: string, @@ -75,10 +76,31 @@ export async function fetchProfile( // Try cache first const cached = await getProfile(pubkey); if (cached) { + // Return cached immediately, then background-refresh + const relayList = relays || [ + ...config.defaultRelays, + ...config.profileRelays + ]; + + // Background refresh - don't await, just fire and forget + nostrClient.fetchEvents( + [{ kinds: [KIND.METADATA], authors: [pubkey], limit: 1 }], + relayList, + { useCache: false, cacheResults: true } // Don't use cache, but cache results + ).then((events) => { + if (events.length > 0) { + cacheProfile(events[0]).catch(() => { + // Silently fail - caching errors shouldn't break the app + }); + } + }).catch(() => { + // Silently fail - background refresh errors shouldn't break the app + }); + return parseProfile(cached.event); } - // Fetch from relays + // No cache - fetch from relays const relayList = relays || [ ...config.defaultRelays, ...config.profileRelays diff --git a/src/routes/feed/relay/explore/relays/[relay]/+page.svelte b/src/routes/feed/relay/[relay]/+page.svelte similarity index 62% rename from src/routes/feed/relay/explore/relays/[relay]/+page.svelte rename to src/routes/feed/relay/[relay]/+page.svelte index 6480742..fe20afe 100644 --- a/src/routes/feed/relay/explore/relays/[relay]/+page.svelte +++ b/src/routes/feed/relay/[relay]/+page.svelte @@ -1,9 +1,9 @@