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) */