Browse Source

bug-fixes

master
Silberengel 1 month ago
parent
commit
e72354a254
  1. 4
      README.md
  2. 4
      public/healthz.json
  3. 1
      src/lib/components/content/EmbeddedEvent.svelte
  4. 1
      src/lib/components/content/QuotedContext.svelte
  5. 1
      src/lib/components/content/ReplyContext.svelte
  6. 342
      src/lib/components/relay/RelayInfo.svelte
  7. 25
      src/lib/modules/comments/CommentForm.svelte
  8. 12
      src/lib/services/nostr/auth-handler.ts
  9. 193
      src/lib/services/nostr/nostr-client.ts
  10. 105
      src/lib/services/nostr/relay-manager.ts
  11. 24
      src/lib/services/user-data.ts
  12. 30
      src/routes/feed/relay/[relay]/+page.svelte
  13. 2
      src/routes/rss/[pubkey]/+page.server.ts

4
README.md

@ -450,7 +450,7 @@ aitherboard/ @@ -450,7 +450,7 @@ aitherboard/
| Category | Relays | Purpose |
|----------|--------|---------|
| **Default Relays** | `wss://theforest.nostr1.com`<br>`wss://nostr21.com`<br>`wss://nostr.land`<br>`wss://nostr.wine`<br>`wss://nostr.sovbit.host` | Base relays for all operations |
| **Default Relays** | `wss://theforest.nostr1.com`<br>`wss://nostr21.com`<br>`wss://nostr.land`<br>`wss://nostr.sovbit.host`<br>`wss://orly-relay.imwald.eu`<br>`wss://nostr.wine` | Base relays for all operations |
| **Profile Relays** | `wss://relay.damus.io`<br>`wss://aggr.nostr.land`<br>`wss://profiles.nostr1.com` | Additional relays for profile/kind 1 content |
### Relay Selection by Operation
@ -857,7 +857,7 @@ aitherboard/ @@ -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` | - |

4
public/healthz.json

@ -2,7 +2,7 @@ @@ -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
}

1
src/lib/components/content/EmbeddedEvent.svelte

@ -6,6 +6,7 @@ @@ -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

1
src/lib/components/content/QuotedContext.svelte

@ -3,6 +3,7 @@ @@ -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

1
src/lib/components/content/ReplyContext.svelte

@ -3,6 +3,7 @@ @@ -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

342
src/lib/components/relay/RelayInfo.svelte

@ -2,6 +2,11 @@ @@ -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 @@ @@ -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 @@ @@ -38,53 +44,122 @@
let error = $state<string | null>(null);
let connectionStatus = $state<'connecting' | 'connected' | 'disconnected'>('connecting');
let eventCount = $state<number | null>(null);
let relayIcon = $state<string | null>(null);
let favoritedBy = $state<string[]>([]); // Array of pubkeys who favorited this relay
let favoritedByProfiles = $state<Map<string, { picture?: string; name?: string }>>(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 @@ @@ -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<string>();
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<string>();
const allTagTypes = new Set<string>();
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 @@ @@ -156,7 +310,12 @@
{:else}
<div class="relay-info-header">
<div class="relay-info-title-row">
<h3 class="relay-info-title">Relay Information</h3>
<div class="relay-info-title-with-icon">
{#if relayIcon}
<img src={relayIcon} alt="Relay icon" class="relay-icon" onerror={() => relayIcon = null} />
{/if}
<h3 class="relay-info-title">Relay Information</h3>
</div>
<div class="relay-status">
<span class="relay-status-icon {getStatusColor(connectionStatus)}">{getStatusIcon(connectionStatus)}</span>
<span class="relay-status-text {getStatusColor(connectionStatus)}">
@ -207,8 +366,10 @@ @@ -207,8 +366,10 @@
{#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>
<span class="relay-info-label">Owner:</span>
<div class="relay-info-value">
<ProfileBadge pubkey={metadata.pubkey} inline={true} />
</div>
</div>
{/if}
@ -255,6 +416,32 @@ @@ -255,6 +416,32 @@
<p class="text-fog-text-light dark:text-fog-dark-text-light">No NIP-11 metadata available</p>
</div>
{/if}
<div class="relay-info-favorited">
<span class="relay-info-label">Favorited by:</span>
{#if favoritedBy.length > 0}
<div class="relay-favorited-list">
{#each favoritedBy as pubkey}
{@const profile = favoritedByProfiles.get(pubkey)}
<a href="/profile/{pubkey}" class="relay-favorite-avatar" title={profile?.name || pubkey.substring(0, 16) + '...'}>
{#if profile?.picture}
<img src={profile.picture} alt={profile.name || 'User'} class="relay-favorite-pic" onerror={(e) => {
const img = e.currentTarget as HTMLImageElement;
img.style.display = 'none';
const fallback = img.nextElementSibling as HTMLElement;
if (fallback) fallback.classList.remove('hidden');
}} />
{/if}
<div class="relay-favorite-fallback {profile?.picture ? 'hidden' : ''}">
{profile?.name ? profile.name.charAt(0).toUpperCase() : pubkey.substring(0, 2).toUpperCase()}
</div>
</a>
{/each}
</div>
{:else}
<span class="relay-info-value">No favorites yet</span>
{/if}
</div>
{/if}
</div>
@ -289,6 +476,24 @@ @@ -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 @@ @@ -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);
}
</style>

25
src/lib/modules/comments/CommentForm.svelte

@ -138,8 +138,22 @@ @@ -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 @@ @@ -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;
}
</style>

12
src/lib/services/nostr/auth-handler.ts

@ -10,7 +10,7 @@ import { @@ -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<string> { @@ -36,6 +36,11 @@ export async function authenticateWithNIP07(): Promise<string> {
// 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( @@ -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;
}

193
src/lib/services/nostr/nostr-client.ts

@ -49,6 +49,7 @@ class NostrClient { @@ -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<string> = new Set();
@ -132,21 +133,48 @@ class NostrClient { @@ -132,21 +133,48 @@ class NostrClient {
async addRelay(url: string): Promise<void> {
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<never>((_, 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 { @@ -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 { @@ -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 { @@ -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<Relay | null> {
// 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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -840,13 +968,18 @@ class NostrClient {
): Promise<NostrEvent[]> {
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 { @@ -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))
);
}

105
src/lib/services/nostr/relay-manager.ts

@ -7,21 +7,82 @@ import { fetchRelayLists } from '../user-data.js'; @@ -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<string> = new Set();
/**
* Load user relay preferences
*/
async loadUserPreferences(pubkey: string): Promise<void> {
// 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 { @@ -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 { @@ -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 { @@ -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 { @@ -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;
}

24
src/lib/services/user-data.ts

@ -67,6 +67,7 @@ export function parseProfile(event: NostrEvent): ProfileData { @@ -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( @@ -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

30
src/routes/feed/relay/explore/relays/[relay]/+page.svelte → src/routes/feed/relay/[relay]/+page.svelte

@ -1,9 +1,9 @@ @@ -1,9 +1,9 @@
<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 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';
@ -12,15 +12,21 @@ @@ -12,15 +12,21 @@
function decodeRelayUrl(encoded: string): string | null {
try {
// Decode the URL-encoded relay URI
const decoded = decodeURIComponent(encoded);
// The relay parameter is just the domain (e.g., "nostr.wine" or "theforest.nostr1.com")
// Construct the full wss:// URL
const domain = encoded.trim();
// Validate it's a websocket URI
if (!decoded.startsWith('ws://') && !decoded.startsWith('wss://')) {
// Validate it looks like a domain
if (!domain || domain.includes('/') || domain.includes(':')) {
return null;
}
return decoded;
// Construct wss:// URL (assume wss:// unless it's localhost)
const relayUrl = domain.startsWith('localhost') || domain.startsWith('127.0.0.1')
? `ws://${domain}`
: `wss://${domain}`;
return relayUrl;
} catch (e) {
console.error('Error decoding relay URL:', e);
return null;
@ -35,7 +41,7 @@ @@ -35,7 +41,7 @@
if (decoded) {
decodedRelay = decoded;
} else {
error = 'Invalid relay URL. Must be a ws:// or wss:// URI.';
error = 'Invalid relay domain.';
}
} else {
error = 'No relay specified.';
@ -49,7 +55,7 @@ @@ -49,7 +55,7 @@
decodedRelay = decoded;
error = null;
} else {
error = 'Invalid relay URL. Must be a ws:// or wss:// URI.';
error = 'Invalid relay domain.';
decodedRelay = null;
}
}

2
src/routes/rss/[pubkey]/+page.server.ts

@ -2,7 +2,7 @@ import { nostrClient } from '../../../lib/services/nostr/nostr-client.js'; @@ -2,7 +2,7 @@ import { nostrClient } from '../../../lib/services/nostr/nostr-client.js';
import { relayManager } from '../../../lib/services/nostr/relay-manager.js';
import { getEventsByPubkey } from '../../../lib/services/cache/event-cache.js';
import { stripMarkdown } from '../../../lib/services/text-utils.js';
import type { RequestHandler } from './$types';
import type { RequestHandler } from '@sveltejs/kit';
import { KIND } from '../../../lib/types/kind-lookup.js';
const RSS_FEED_KIND = 10015;

Loading…
Cancel
Save