From 4d18c84243e2cd631cb4fe00a2c6f3a482f514e8 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 15 Nov 2025 20:26:37 +0000 Subject: [PATCH 1/4] feat: improve relay hint selection to exclude non-content relays - Add NON_CONTENT_RELAYS list and isContentRelay helper to classify relays - Update ContentPanel to filter out non-content relays (e.g., relay.nsec.app) from naddr hints - Update HighlightItem to prefer publishedRelays/seenOnRelays and filter using isContentRelay - Ensures relay.nsec.app and other auth/utility relays are never suggested as content hints --- src/components/ContentPanel.tsx | 9 ++++++--- src/components/HighlightItem.tsx | 21 +++++++++++++++++---- src/config/relays.ts | 17 +++++++++++++++++ 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/src/components/ContentPanel.tsx b/src/components/ContentPanel.tsx index 3bbc45b8..61017d0d 100644 --- a/src/components/ContentPanel.tsx +++ b/src/components/ContentPanel.tsx @@ -12,6 +12,8 @@ import { nip19 } from 'nostr-tools' import { getNostrUrl, getSearchUrl } from '../config/nostrGateways' import { RelayPool } from 'applesauce-relay' import { getActiveRelayUrls } from '../services/relayManager' +import { isContentRelay } from '../config/relays' +import { isLocalRelay } from '../utils/helpers' import { IAccount } from 'applesauce-accounts' import { NostrEvent } from 'nostr-tools' import { Highlight } from '../types/highlights' @@ -432,9 +434,10 @@ const ContentPanel: React.FC = ({ const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1] || '' const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : [] - const relayHints = activeRelays.filter(r => - !r.includes('localhost') && !r.includes('127.0.0.1') - ).slice(0, 3) + const relayHints = activeRelays + .filter(url => !isLocalRelay(url)) + .filter(url => isContentRelay(url)) + .slice(0, 3) const naddr = nip19.naddrEncode({ kind: 30023, diff --git a/src/components/HighlightItem.tsx b/src/components/HighlightItem.tsx index 90d4e9e5..eaf1e4e5 100644 --- a/src/components/HighlightItem.tsx +++ b/src/components/HighlightItem.tsx @@ -10,6 +10,7 @@ import { Hooks } from 'applesauce-react' import { onSyncStateChange, isEventSyncing, isEventOfflineCreated } from '../services/offlineSyncService' import { areAllRelaysLocal, isLocalRelay } from '../utils/helpers' import { getActiveRelayUrls } from '../services/relayManager' +import { isContentRelay } from '../config/relays' import { nip19 } from 'nostr-tools' import { formatDateCompact } from '../utils/bookmarkUtils' import { createDeletionRequest } from '../services/deletionService' @@ -224,11 +225,23 @@ export const HighlightItem: React.FC = ({ const getHighlightLinks = () => { // Encode the highlight event itself (kind 9802) as a nevent - // Get non-local relays for the hint + // Prefer relays we actually published to or saw the event on + const publishedRelays = highlight.publishedRelays || [] + const seenOnRelays = highlight.seenOnRelays || [] + + // Use published relays if available, else seen relays, else fall back to active relays + const baseRelays = publishedRelays.length > 0 + ? publishedRelays + : (seenOnRelays.length > 0 ? seenOnRelays : []) + const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : [] - const relayHints = activeRelays.filter(r => - !r.includes('localhost') && !r.includes('127.0.0.1') - ).slice(0, 3) // Include up to 3 relay hints + const candidates = baseRelays.length > 0 ? baseRelays : activeRelays + + // Filter to content-capable remote relays + const relayHints = candidates + .filter(url => !isLocalRelay(url)) + .filter(url => isContentRelay(url)) + .slice(0, 3) // Include up to 3 relay hints const nevent = nip19.neventEncode({ id: highlight.id, diff --git a/src/config/relays.ts b/src/config/relays.ts index a09165fa..6abc96cf 100644 --- a/src/config/relays.ts +++ b/src/config/relays.ts @@ -19,3 +19,20 @@ export const RELAYS = [ 'wss://proxy.nostr-relay.app/5d0d38afc49c4b84ca0da951a336affa18438efed302aeedfa92eb8b0d3fcb87', ] +/** + * Relays that should NOT be used as content hints (auth/signer, etc.) + * These relays are fine for connection and other purposes, but shouldn't + * be suggested as places where posts/highlights are likely to be found. + */ +export const NON_CONTENT_RELAYS = [ + 'wss://relay.nsec.app', +] + +/** + * Check if a relay URL is suitable for use as a content hint + * Returns true for remote relays that are reasonable for posts/highlights + */ +export function isContentRelay(url: string): boolean { + return !NON_CONTENT_RELAYS.includes(url) +} + From bca1ee2b2eddc3eaaa05be4221ee690ec03fe645 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 16 Nov 2025 18:30:39 +0000 Subject: [PATCH 2/4] refactor(relays): unify relay config with typed registry and improve hint usage - Create typed RelayRole and RelayConfig interface in relays.ts - Add centralized RELAY_CONFIGS registry with role annotations - Add helper getters: getLocalRelays(), getDefaultRelays(), getContentRelays(), getFallbackContentRelays() - Maintain backwards compatibility with RELAYS and NON_CONTENT_RELAYS constants - Refactor relayManager to use new registry helpers - Harden applyRelaySetToPool with consistent normalization and local relay preservation - Add RelaySetChangeSummary return type for debugging - Improve articleService to prioritize and filter relay hints from naddr - Use centralized fallback content relay helpers instead of hard-coded arrays --- src/config/relays.ts | 101 ++++++++++++++++++++++++++------- src/services/articleService.ts | 72 ++++++++++++++++------- src/services/relayManager.ts | 90 ++++++++++++++++++----------- 3 files changed, 187 insertions(+), 76 deletions(-) diff --git a/src/config/relays.ts b/src/config/relays.ts index 6abc96cf..00b71ad8 100644 --- a/src/config/relays.ts +++ b/src/config/relays.ts @@ -3,36 +3,95 @@ * Single set of relays used throughout the application */ -// All relays including local relays -export const RELAYS = [ - 'ws://localhost:10547', - 'ws://localhost:4869', - 'wss://relay.nsec.app', - 'wss://relay.damus.io', - 'wss://nos.lol', - 'wss://relay.nostr.band', - 'wss://wot.dergigi.com', - 'wss://relay.snort.social', - 'wss://nostr-pub.wellorder.net', - 'wss://purplepag.es', - 'wss://relay.primal.net', - 'wss://proxy.nostr-relay.app/5d0d38afc49c4b84ca0da951a336affa18438efed302aeedfa92eb8b0d3fcb87', +export type RelayRole = 'local-cache' | 'default' | 'fallback' | 'non-content' | 'bunker' + +export interface RelayConfig { + url: string + roles: RelayRole[] +} + +/** + * Central relay registry with role annotations + */ +const RELAY_CONFIGS: RelayConfig[] = [ + { url: 'ws://localhost:10547', roles: ['local-cache'] }, + { url: 'ws://localhost:4869', roles: ['local-cache'] }, + { url: 'wss://relay.nsec.app', roles: ['default', 'non-content'] }, + { url: 'wss://relay.damus.io', roles: ['default', 'fallback'] }, + { url: 'wss://nos.lol', roles: ['default', 'fallback'] }, + { url: 'wss://relay.nostr.band', roles: ['default', 'fallback'] }, + { url: 'wss://wot.dergigi.com', roles: ['default'] }, + { url: 'wss://relay.snort.social', roles: ['default'] }, + { url: 'wss://nostr-pub.wellorder.net', roles: ['default'] }, + { url: 'wss://purplepag.es', roles: ['default'] }, + { url: 'wss://relay.primal.net', roles: ['default', 'fallback'] }, + { url: 'wss://proxy.nostr-relay.app/5d0d38afc49c4b84ca0da951a336affa18438efed302aeedfa92eb8b0d3fcb87', roles: ['default'] }, ] /** - * Relays that should NOT be used as content hints (auth/signer, etc.) - * These relays are fine for connection and other purposes, but shouldn't - * be suggested as places where posts/highlights are likely to be found. + * Get all local cache relays (localhost relays) */ -export const NON_CONTENT_RELAYS = [ - 'wss://relay.nsec.app', +export function getLocalRelays(): string[] { + return RELAY_CONFIGS + .filter(config => config.roles.includes('local-cache')) + .map(config => config.url) +} + +/** + * Get all default relays (main public relays) + */ +export function getDefaultRelays(): string[] { + return RELAY_CONFIGS + .filter(config => config.roles.includes('default')) + .map(config => config.url) +} + +/** + * Get fallback content relays (last resort public relays for content fetching) + * These are reliable public relays that should be tried when other methods fail + */ +export function getFallbackContentRelays(): string[] { + return RELAY_CONFIGS + .filter(config => config.roles.includes('fallback')) + .map(config => config.url) +} + +/** + * Get relays suitable for content fetching (excludes non-content relays like auth/signer relays) + */ +export function getContentRelays(): string[] { + return RELAY_CONFIGS + .filter(config => !config.roles.includes('non-content')) + .map(config => config.url) +} + +/** + * Get relays that should NOT be used as content hints + */ +export function getNonContentRelays(): string[] { + return RELAY_CONFIGS + .filter(config => config.roles.includes('non-content')) + .map(config => config.url) +} + +/** + * All relays including local relays (backwards compatibility) + */ +export const RELAYS = [ + ...getLocalRelays(), + ...getDefaultRelays(), ] +/** + * Relays that should NOT be used as content hints (backwards compatibility) + */ +export const NON_CONTENT_RELAYS = getNonContentRelays() + /** * Check if a relay URL is suitable for use as a content hint - * Returns true for remote relays that are reasonable for posts/highlights + * Returns true for relays that are reasonable for posts/highlights */ export function isContentRelay(url: string): boolean { - return !NON_CONTENT_RELAYS.includes(url) + return !getNonContentRelays().includes(url) } diff --git a/src/services/articleService.ts b/src/services/articleService.ts index 994b4329..8582fbe3 100644 --- a/src/services/articleService.ts +++ b/src/services/articleService.ts @@ -4,7 +4,7 @@ import { nip19 } from 'nostr-tools' import { AddressPointer } from 'nostr-tools/nip19' import { NostrEvent } from 'nostr-tools' import { Helpers } from 'applesauce-core' -import { RELAYS } from '../config/relays' +import { getContentRelays, getFallbackContentRelays, isContentRelay } from '../config/relays' import { prioritizeLocalRelays, partitionRelays, createParallelReqStreams } from '../utils/helpers' import { merge, toArray as rxToArray } from 'rxjs' import { UserSettings } from './settingsService' @@ -138,13 +138,6 @@ export async function fetchArticleByNaddr( const pointer = decoded.data as AddressPointer - // Define relays to query - use union of relay hints from naddr and configured relays - // This avoids failures when naddr contains stale/unreachable relay hints - const hintedRelays = (pointer.relays && pointer.relays.length > 0) ? pointer.relays : [] - const baseRelays = Array.from(new Set([...hintedRelays, ...RELAYS])) - const orderedRelays = prioritizeLocalRelays(baseRelays) - const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays) - // Fetch the article event const filter = { kinds: [pointer.kind], @@ -152,24 +145,59 @@ export async function fetchArticleByNaddr( '#d': [pointer.identifier] } - // Parallel local+remote, stream immediate, collect up to first from each - const { local$, remote$ } = createParallelReqStreams(relayPool, localRelays, remoteRelays, filter, 1200, 6000) - const collected = await lastValueFrom(merge(local$.pipe(take(1)), remote$.pipe(take(1))).pipe(rxToArray())) - let events = collected as NostrEvent[] + let events: NostrEvent[] = [] - // Fallback: if nothing found, try a second round against a set of reliable public relays + // First, try relay hints from naddr (primary source) + // Filter to only content relays to avoid using auth/signer relays + const hintedRelays = (pointer.relays && pointer.relays.length > 0) + ? pointer.relays.filter(isContentRelay) + : [] + + if (hintedRelays.length > 0) { + const orderedHintedRelays = prioritizeLocalRelays(hintedRelays) + const { local: localHinted, remote: remoteHinted } = partitionRelays(orderedHintedRelays) + + const { local$, remote$ } = createParallelReqStreams( + relayPool, + localHinted, + remoteHinted, + filter, + 1200, + 6000 + ) + const collected = await lastValueFrom( + merge(local$.pipe(take(1)), remote$.pipe(take(1))).pipe(rxToArray()) + ) + events = collected as NostrEvent[] + } + + // Fallback: if no hints or nothing found from hints, try default content relays if (events.length === 0) { - const reliableRelays = Array.from(new Set([ - 'wss://relay.nostr.band', - 'wss://relay.primal.net', - 'wss://relay.damus.io', - 'wss://nos.lol', - ...remoteRelays // keep any configured remote relays - ])) + const defaultContentRelays = getContentRelays() + const orderedDefault = prioritizeLocalRelays(defaultContentRelays) + const { local: localDefault, remote: remoteDefault } = partitionRelays(orderedDefault) + + const { local$, remote$ } = createParallelReqStreams( + relayPool, + localDefault, + remoteDefault, + filter, + 1200, + 6000 + ) + const collected = await lastValueFrom( + merge(local$.pipe(take(1)), remote$.pipe(take(1))).pipe(rxToArray()) + ) + events = collected as NostrEvent[] + } + + // Last resort: try fallback content relays (most reliable public relays) + if (events.length === 0) { + const fallbackRelays = getFallbackContentRelays() const { remote$: fallback$ } = createParallelReqStreams( relayPool, - [], // no local - reliableRelays, + [], // no local for fallback + fallbackRelays, filter, 1500, 12000 diff --git a/src/services/relayManager.ts b/src/services/relayManager.ts index 6f491aae..1aad6b33 100644 --- a/src/services/relayManager.ts +++ b/src/services/relayManager.ts @@ -1,16 +1,14 @@ import { RelayPool } from 'applesauce-relay' import { prioritizeLocalRelays } from '../utils/helpers' +import { getLocalRelays, getDefaultRelays } from '../config/relays' /** * Local relays that are always included */ -export const ALWAYS_LOCAL_RELAYS = [ - 'ws://localhost:10547', - 'ws://localhost:4869' -] +export const ALWAYS_LOCAL_RELAYS = getLocalRelays() /** - * Hardcoded relays that are always included + * Hardcoded relays that are always included (minimal reliable set) */ export const HARDCODED_RELAYS = [ 'wss://relay.nostr.band' @@ -28,7 +26,7 @@ export function getActiveRelayUrls(relayPool: RelayPool): string[] { * Normalizes a relay URL to match what applesauce-relay stores internally * Adds trailing slash for URLs without a path */ -function normalizeRelayUrl(url: string): string { +export function normalizeRelayUrl(url: string): string { try { const parsed = new URL(url) // If the pathname is empty or just "/", ensure it ends with "/" @@ -42,58 +40,84 @@ function normalizeRelayUrl(url: string): string { } } +export interface RelaySetChangeSummary { + added: string[] + removed: string[] +} + /** * Applies a new relay set to the pool: adds missing relays, removes extras + * Always preserves local relays even if not in finalUrls + * @returns Summary of changes for debugging */ export function applyRelaySetToPool( relayPool: RelayPool, - finalUrls: string[] -): void { - // Normalize all URLs to match pool's internal format - const currentUrls = new Set(Array.from(relayPool.relays.keys())) - const normalizedTargetUrls = new Set(finalUrls.map(normalizeRelayUrl)) - + finalUrls: string[], + options?: { preserveAlwaysLocal?: boolean } +): RelaySetChangeSummary { + const preserveLocal = options?.preserveAlwaysLocal !== false // default true - - // Add new relays (use original URLs for adding, not normalized) - const toAdd = finalUrls.filter(url => !currentUrls.has(normalizeRelayUrl(url))) + // Ensure local relays are always included + const urlsWithLocal = preserveLocal + ? Array.from(new Set([...finalUrls, ...ALWAYS_LOCAL_RELAYS])) + : finalUrls - if (toAdd.length > 0) { - relayPool.group(toAdd) + // Normalize all URLs consistently for comparison + const normalizedCurrent = new Set( + Array.from(relayPool.relays.keys()).map(normalizeRelayUrl) + ) + const normalizedTarget = new Set(urlsWithLocal.map(normalizeRelayUrl)) + + // Map normalized URLs back to original for adding + const normalizedToOriginal = new Map() + for (const url of urlsWithLocal) { + normalizedToOriginal.set(normalizeRelayUrl(url), url) } - - // Remove relays not in target (but always keep local relays) + + // Find relays to add (not in current pool) + const toAdd: string[] = [] + for (const normalizedUrl of normalizedTarget) { + if (!normalizedCurrent.has(normalizedUrl)) { + const originalUrl = normalizedToOriginal.get(normalizedUrl) || normalizedUrl + toAdd.push(originalUrl) + } + } + + // Find relays to remove (not in target, but preserve local relays) + const normalizedLocal = new Set(ALWAYS_LOCAL_RELAYS.map(normalizeRelayUrl)) const toRemove: string[] = [] - for (const url of currentUrls) { - // Check if this normalized URL is in the target set - if (!normalizedTargetUrls.has(url)) { - // Also check if it's a local relay (check both normalized and original forms) - const isLocal = ALWAYS_LOCAL_RELAYS.some(localUrl => - normalizeRelayUrl(localUrl) === url || localUrl === url - ) - if (!isLocal) { - toRemove.push(url) + for (const currentUrl of relayPool.relays.keys()) { + const normalizedCurrentUrl = normalizeRelayUrl(currentUrl) + if (!normalizedTarget.has(normalizedCurrentUrl)) { + // Always preserve local relays + if (!preserveLocal || !normalizedLocal.has(normalizedCurrentUrl)) { + toRemove.push(currentUrl) } } } - + // Apply changes + if (toAdd.length > 0) { + relayPool.group(toAdd) + } + for (const url of toRemove) { const relay = relayPool.relays.get(url) if (relay) { try { - // Only close if relay is actually connected or attempting to connect - // This helps avoid WebSocket warnings for connections that never started relay.close() } catch (error) { // Suppress errors when closing relays that haven't fully connected yet - // This can happen when switching relay sets before connections establish - // Silently ignore } relayPool.relays.delete(url) } } + // Return summary for debugging (useful for understanding relay churn) + if (import.meta.env.DEV && (toAdd.length > 0 || toRemove.length > 0)) { + console.debug('[relay-pool] Changes:', { added: toAdd, removed: toRemove }) + } + return { added: toAdd, removed: toRemove } } From cc2252446694b903083bf2b2b34fb0604fa72e83 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 22 Nov 2025 00:29:31 +0100 Subject: [PATCH 3/4] fix: remove unused getDefaultRelays import --- src/services/relayManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/relayManager.ts b/src/services/relayManager.ts index 1aad6b33..9ad8ccd5 100644 --- a/src/services/relayManager.ts +++ b/src/services/relayManager.ts @@ -1,6 +1,6 @@ import { RelayPool } from 'applesauce-relay' import { prioritizeLocalRelays } from '../utils/helpers' -import { getLocalRelays, getDefaultRelays } from '../config/relays' +import { getLocalRelays } from '../config/relays' /** * Local relays that are always included From efb6b56c3b08da89fe88b9218a01fde07e8f9285 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 22 Nov 2025 01:16:10 +0100 Subject: [PATCH 4/4] refactor: improve relay hint selection and relay management - Extract updateKeepAlive and updateAddressLoader helpers in App.tsx for better code reuse - Improve relay hint selection in HighlightItem with priority: published > seen > configured relays - Add URL normalization for consistent relay comparison across services - Unify relay set approach in articleService (hints + configured relays together) - Improve relay deduplication in relayListService using normalized URLs - Move normalizeRelayUrl to helpers.ts for shared use - Update isContentRelay to use normalized URLs for comparison - Use getFallbackContentRelays for HARDCODED_RELAYS in relayManager --- src/App.tsx | 72 +++++++++++++------------------- src/components/HighlightItem.tsx | 35 +++++++++++----- src/config/relays.ts | 6 ++- src/services/articleService.ts | 40 ++++++------------ src/services/relayListService.ts | 47 +++++++++++++++------ src/services/relayManager.ts | 27 ++---------- src/utils/helpers.ts | 18 ++++++++ 7 files changed, 126 insertions(+), 119 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index b79598cd..09869104 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -576,6 +576,31 @@ function App() { } }) + // Helper to update keep-alive subscription based on current active relays + const updateKeepAlive = (relayUrls?: string[]) => { + const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } } + if (poolWithSub._keepAliveSubscription) { + poolWithSub._keepAliveSubscription.unsubscribe() + } + const targetRelays = relayUrls || getActiveRelayUrls(pool) + const newKeepAliveSub = pool.subscription(targetRelays, { kinds: [0], limit: 0 }).subscribe({ + next: () => {}, + error: () => {} + }) + poolWithSub._keepAliveSubscription = newKeepAliveSub + } + + // Helper to update address loader based on current active relays + const updateAddressLoader = (relayUrls?: string[]) => { + const targetRelays = relayUrls || getActiveRelayUrls(pool) + const addressLoader = createAddressLoader(pool, { + eventStore: store, + lookupRelays: targetRelays + }) + store.addressableLoader = addressLoader + store.replaceableLoader = addressLoader + } + // Handle user relay list and blocked relays when account changes const userRelaysSub = accounts.active$.subscribe((account) => { if (account) { @@ -604,20 +629,6 @@ function App() { // Apply initial set immediately applyRelaySetToPool(pool, initialRelays) - // Prepare keep-alive helper - const updateKeepAlive = () => { - const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } } - if (poolWithSub._keepAliveSubscription) { - poolWithSub._keepAliveSubscription.unsubscribe() - } - const activeRelays = getActiveRelayUrls(pool) - const newKeepAliveSub = pool.subscription(activeRelays, { kinds: [0], limit: 0 }).subscribe({ - next: () => {}, - error: () => {} - }) - poolWithSub._keepAliveSubscription = newKeepAliveSub - } - // Begin loading blocked relays in background const blockedPromise = loadBlockedRelays(pool, pubkey) @@ -649,43 +660,16 @@ function App() { applyRelaySetToPool(pool, finalRelays) updateKeepAlive() - - // Update address loader with new relays - const activeRelays = getActiveRelayUrls(pool) - const addressLoader = createAddressLoader(pool, { - eventStore: store, - lookupRelays: activeRelays - }) - store.addressableLoader = addressLoader - store.replaceableLoader = addressLoader + updateAddressLoader() }).catch((error) => { console.error('[relay-init] Failed to load user relay list (continuing with initial set):', error) // Continue with initial relay set on error - no need to change anything }) } else { // User logged out - reset to hardcoded relays - applyRelaySetToPool(pool, RELAYS) - - - // Update keep-alive subscription - const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } } - if (poolWithSub._keepAliveSubscription) { - poolWithSub._keepAliveSubscription.unsubscribe() - } - const newKeepAliveSub = pool.subscription(RELAYS, { kinds: [0], limit: 0 }).subscribe({ - next: () => {}, - error: () => {} - }) - poolWithSub._keepAliveSubscription = newKeepAliveSub - - // Reset address loader - const addressLoader = createAddressLoader(pool, { - eventStore: store, - lookupRelays: RELAYS - }) - store.addressableLoader = addressLoader - store.replaceableLoader = addressLoader + updateKeepAlive(RELAYS) + updateAddressLoader(RELAYS) } }) diff --git a/src/components/HighlightItem.tsx b/src/components/HighlightItem.tsx index eaf1e4e5..752e38e1 100644 --- a/src/components/HighlightItem.tsx +++ b/src/components/HighlightItem.tsx @@ -10,7 +10,7 @@ import { Hooks } from 'applesauce-react' import { onSyncStateChange, isEventSyncing, isEventOfflineCreated } from '../services/offlineSyncService' import { areAllRelaysLocal, isLocalRelay } from '../utils/helpers' import { getActiveRelayUrls } from '../services/relayManager' -import { isContentRelay } from '../config/relays' +import { isContentRelay, getContentRelays, getFallbackContentRelays } from '../config/relays' import { nip19 } from 'nostr-tools' import { formatDateCompact } from '../utils/bookmarkUtils' import { createDeletionRequest } from '../services/deletionService' @@ -225,23 +225,36 @@ export const HighlightItem: React.FC = ({ const getHighlightLinks = () => { // Encode the highlight event itself (kind 9802) as a nevent - // Prefer relays we actually published to or saw the event on + // Relay hint selection priority: + // 1. Published relays (where we successfully published the event) + // 2. Seen relays (where we observed the event) + // 3. Configured content relays (deterministic fallback) + // All candidates are deduplicated, filtered to content-capable remote relays, and limited to 3 + const publishedRelays = highlight.publishedRelays || [] const seenOnRelays = highlight.seenOnRelays || [] - // Use published relays if available, else seen relays, else fall back to active relays - const baseRelays = publishedRelays.length > 0 - ? publishedRelays - : (seenOnRelays.length > 0 ? seenOnRelays : []) + // Determine base candidates: prefer published, then seen, then configured relays + let candidates: string[] + if (publishedRelays.length > 0) { + // Prefer published relays, but include seen relays as backup + candidates = Array.from(new Set([...publishedRelays, ...seenOnRelays])) + .sort((a, b) => a.localeCompare(b)) + } else if (seenOnRelays.length > 0) { + candidates = seenOnRelays + } else { + // Fallback to deterministic configured content relays + const contentRelays = getContentRelays() + const fallbackRelays = getFallbackContentRelays() + candidates = Array.from(new Set([...contentRelays, ...fallbackRelays])) + } - const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : [] - const candidates = baseRelays.length > 0 ? baseRelays : activeRelays - - // Filter to content-capable remote relays + // Filter to content-capable remote relays (exclude local and non-content relays) + // Then take up to 3 for relay hints const relayHints = candidates .filter(url => !isLocalRelay(url)) .filter(url => isContentRelay(url)) - .slice(0, 3) // Include up to 3 relay hints + .slice(0, 3) const nevent = nip19.neventEncode({ id: highlight.id, diff --git a/src/config/relays.ts b/src/config/relays.ts index 00b71ad8..cd55986c 100644 --- a/src/config/relays.ts +++ b/src/config/relays.ts @@ -1,3 +1,5 @@ +import { normalizeRelayUrl } from '../utils/helpers' + /** * Centralized relay configuration * Single set of relays used throughout the application @@ -92,6 +94,8 @@ export const NON_CONTENT_RELAYS = getNonContentRelays() * Returns true for relays that are reasonable for posts/highlights */ export function isContentRelay(url: string): boolean { - return !getNonContentRelays().includes(url) + const normalized = normalizeRelayUrl(url) + const nonContentRelays = getNonContentRelays().map(normalizeRelayUrl) + return !nonContentRelays.includes(normalized) } diff --git a/src/services/articleService.ts b/src/services/articleService.ts index 8582fbe3..7638f5c9 100644 --- a/src/services/articleService.ts +++ b/src/services/articleService.ts @@ -147,40 +147,26 @@ export async function fetchArticleByNaddr( let events: NostrEvent[] = [] - // First, try relay hints from naddr (primary source) - // Filter to only content relays to avoid using auth/signer relays + // Build unified relay set: hints + configured content relays + // Filter hinted relays to only content-capable relays const hintedRelays = (pointer.relays && pointer.relays.length > 0) ? pointer.relays.filter(isContentRelay) : [] - if (hintedRelays.length > 0) { - const orderedHintedRelays = prioritizeLocalRelays(hintedRelays) - const { local: localHinted, remote: remoteHinted } = partitionRelays(orderedHintedRelays) + // Get configured content relays + const contentRelays = getContentRelays() + + // Union of hinted and configured relays (deduplicated) + const unifiedRelays = Array.from(new Set([...hintedRelays, ...contentRelays])) + + if (unifiedRelays.length > 0) { + const orderedUnified = prioritizeLocalRelays(unifiedRelays) + const { local: localUnified, remote: remoteUnified } = partitionRelays(orderedUnified) const { local$, remote$ } = createParallelReqStreams( relayPool, - localHinted, - remoteHinted, - filter, - 1200, - 6000 - ) - const collected = await lastValueFrom( - merge(local$.pipe(take(1)), remote$.pipe(take(1))).pipe(rxToArray()) - ) - events = collected as NostrEvent[] - } - - // Fallback: if no hints or nothing found from hints, try default content relays - if (events.length === 0) { - const defaultContentRelays = getContentRelays() - const orderedDefault = prioritizeLocalRelays(defaultContentRelays) - const { local: localDefault, remote: remoteDefault } = partitionRelays(orderedDefault) - - const { local$, remote$ } = createParallelReqStreams( - relayPool, - localDefault, - remoteDefault, + localUnified, + remoteUnified, filter, 1200, 6000 diff --git a/src/services/relayListService.ts b/src/services/relayListService.ts index 991f9b49..3dec6450 100644 --- a/src/services/relayListService.ts +++ b/src/services/relayListService.ts @@ -1,6 +1,7 @@ import { RelayPool } from 'applesauce-relay' import { NostrEvent } from 'nostr-tools' import { queryEvents } from './dataFetch' +import { normalizeRelayUrl } from '../utils/helpers' export interface UserRelayInfo { url: string @@ -144,35 +145,55 @@ export function computeRelaySet(params: { alwaysIncludeLocal } = params + // Normalize all URLs for consistent comparison and deduplication + const normalizedBlocked = new Set(blocked.map(normalizeRelayUrl)) + const normalizedLocal = new Set(alwaysIncludeLocal.map(normalizeRelayUrl)) + const relaySet = new Set() - const blockedSet = new Set(blocked) + const normalizedRelaySet = new Set() - // Helper to check if relay should be included - const shouldInclude = (url: string): boolean => { + // Helper to check if relay should be included (using normalized URLs) + const shouldInclude = (normalizedUrl: string): boolean => { // Always include local relays - if (alwaysIncludeLocal.includes(url)) return true + if (normalizedLocal.has(normalizedUrl)) return true // Otherwise check if blocked - return !blockedSet.has(url) + return !normalizedBlocked.has(normalizedUrl) } - // Add hardcoded relays + // Add hardcoded relays (normalized) for (const url of hardcoded) { - if (shouldInclude(url)) relaySet.add(url) + const normalized = normalizeRelayUrl(url) + if (shouldInclude(normalized) && !normalizedRelaySet.has(normalized)) { + normalizedRelaySet.add(normalized) + relaySet.add(url) // Keep original URL for output + } } - // Add bunker relays + // Add bunker relays (normalized) for (const url of bunker) { - if (shouldInclude(url)) relaySet.add(url) + const normalized = normalizeRelayUrl(url) + if (shouldInclude(normalized) && !normalizedRelaySet.has(normalized)) { + normalizedRelaySet.add(normalized) + relaySet.add(url) // Keep original URL for output + } } - // Add user relays (treating 'both' and 'read' as applicable for queries) + // Add user relays (normalized) for (const relay of userList) { - if (shouldInclude(relay.url)) relaySet.add(relay.url) + const normalized = normalizeRelayUrl(relay.url) + if (shouldInclude(normalized) && !normalizedRelaySet.has(normalized)) { + normalizedRelaySet.add(normalized) + relaySet.add(relay.url) // Keep original URL for output + } } - // Always ensure local relays are present + // Always ensure local relays are present (normalized check) for (const url of alwaysIncludeLocal) { - relaySet.add(url) + const normalized = normalizeRelayUrl(url) + if (!normalizedRelaySet.has(normalized)) { + normalizedRelaySet.add(normalized) + relaySet.add(url) // Keep original URL for output + } } return Array.from(relaySet) diff --git a/src/services/relayManager.ts b/src/services/relayManager.ts index 9ad8ccd5..41a29cdf 100644 --- a/src/services/relayManager.ts +++ b/src/services/relayManager.ts @@ -1,6 +1,6 @@ import { RelayPool } from 'applesauce-relay' -import { prioritizeLocalRelays } from '../utils/helpers' -import { getLocalRelays } from '../config/relays' +import { prioritizeLocalRelays, normalizeRelayUrl } from '../utils/helpers' +import { getLocalRelays, getFallbackContentRelays } from '../config/relays' /** * Local relays that are always included @@ -9,10 +9,9 @@ export const ALWAYS_LOCAL_RELAYS = getLocalRelays() /** * Hardcoded relays that are always included (minimal reliable set) + * Derived from RELAY_CONFIGS fallback relays */ -export const HARDCODED_RELAYS = [ - 'wss://relay.nostr.band' -] +export const HARDCODED_RELAYS = getFallbackContentRelays() /** * Gets active relay URLs from the relay pool @@ -22,24 +21,6 @@ export function getActiveRelayUrls(relayPool: RelayPool): string[] { return prioritizeLocalRelays(urls) } -/** - * Normalizes a relay URL to match what applesauce-relay stores internally - * Adds trailing slash for URLs without a path - */ -export function normalizeRelayUrl(url: string): string { - try { - const parsed = new URL(url) - // If the pathname is empty or just "/", ensure it ends with "/" - if (parsed.pathname === '' || parsed.pathname === '/') { - return url.endsWith('/') ? url : url + '/' - } - return url - } catch { - // If URL parsing fails, return as-is - return url - } -} - export interface RelaySetChangeSummary { added: string[] removed: string[] diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 738ab475..c930ad76 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -39,6 +39,24 @@ export const classifyUrl = (url: string | undefined): UrlClassification => { return { type: 'article' } } +/** + * Normalizes a relay URL to match what applesauce-relay stores internally + * Adds trailing slash for URLs without a path + */ +export function normalizeRelayUrl(url: string): string { + try { + const parsed = new URL(url) + // If the pathname is empty or just "/", ensure it ends with "/" + if (parsed.pathname === '' || parsed.pathname === '/') { + return url.endsWith('/') ? url : url + '/' + } + return url + } catch { + // If URL parsing fails, return as-is + return url + } +} + /** * Checks if a relay URL is a local relay (localhost or 127.0.0.1) */