diff --git a/src/PageManager.tsx b/src/PageManager.tsx index e3783d5e..2248e5d9 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -7,7 +7,12 @@ import { RefreshButton } from '@/components/RefreshButton' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' import logger from '@/lib/logger' -import { useMobileSwipeBackOnElement } from '@/lib/mobile-swipe-back' +import { + MOBILE_SWIPE_BACK_DOMINANCE, + MOBILE_SWIPE_BACK_EDGE_PX, + MOBILE_SWIPE_BACK_MIN_PX, + useMobileSwipeBackOnElement +} from '@/lib/mobile-swipe-back' import { preventRadixSheetCloseForPortaledOverlay } from '@/lib/sheet-dismiss-guard' import { ChevronLeft } from 'lucide-react' import { NavigationService } from '@/services/navigation.service' @@ -42,7 +47,6 @@ import { useState } from 'react' import { useEventCallback } from '@/hooks/use-event-callback' -import { flushSync } from 'react-dom' import { useTranslation } from 'react-i18next' import { KeyboardShortcutsHelpProvider } from '@/components/KeyboardShortcutsHelp' import { @@ -1151,7 +1155,11 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { }, [currentPrimaryPage]) const navigationCounterRef = useRef(0) const goBackRef = useRef<() => void>(() => {}) + const drawerOpenRef = useRef(drawerOpen) const [mobilePrimarySwipeRoot, setMobilePrimarySwipeRoot] = useState(null) + useLayoutEffect(() => { + drawerOpenRef.current = drawerOpen + }, [drawerOpen]) const primaryPanelRefreshRef = useRef<(() => void) | null>(null) const registerPrimaryPanelRefresh = useCallback((fn: (() => void) | null) => { primaryPanelRefreshRef.current = fn @@ -1988,6 +1996,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { return } if (isCurrentPage(secondaryStackRef.current, url)) { + const top = secondaryStackRef.current[secondaryStackRef.current.length - 1] + if (isSmallScreen && top) { + window.history.pushState({ index: top.index, url }, '', url) + } logger.component('PageManager', 'pushSecondaryPage skipped (already on stack)', { url }) return } @@ -2032,8 +2044,11 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { } if (isCurrentPage(prevStack, url)) { + const top = prevStack[prevStack.length - 1] + if (isSmallScreen && top) { + window.history.pushState({ index: top.index, url }, '', url) + } logger.component('PageManager', 'Page already exists, not scrolling') - // NEVER scroll to top - maintain scroll position return prevStack } @@ -2055,16 +2070,56 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { }) } + const restorePrimaryTabAfterSecondaryClose = () => { + const page = currentPrimaryPageRef.current + const savedFeedState = savedFeedStateRef.current.get(page) + if (savedFeedState?.tab) { + window.dispatchEvent( + new CustomEvent('restorePageTab', { + detail: { page, tab: savedFeedState.tab } + }) + ) + currentTabStateRef.current.set(page, savedFeedState.tab) + } + } + + const hardCloseSecondaryPanel = () => { + if (secondaryStackRef.current.length === 0 && !drawerOpenRef.current) { + return + } + if (drawerOpenRef.current) setDrawerOpen(false) + setSinglePaneSheetOpen(false) + secondaryStackRef.current = [] + queueMicrotask(() => { + setSecondaryStack([]) + }) + replaceHistoryWithPrimaryPageUrl( + currentPrimaryPageRef.current, + primaryPagePropsRef.current.get(currentPrimaryPageRef.current) as { spell?: string } | undefined + ) + restorePrimaryTabAfterSecondaryClose() + } + const popSecondaryPage = () => { const stackLen = secondaryStackRef.current.length + // Mobile / single-pane: one code path — drawer + stack share the same close behavior + if (isSmallScreen || panelMode === 'single') { + if (stackLen > 1) { + window.history.back() + } else { + hardCloseSecondaryPanel() + } + return + } + // In double-pane mode, never open drawer - just pop from stack if (panelMode === 'double' && !isSmallScreen) { if (stackLen === 1) { - flushSync(() => { + secondaryStackRef.current = [] + queueMicrotask(() => { setSecondaryStack([]) }) - secondaryStackRef.current = [] replaceHistoryWithPrimaryPageUrl( currentPrimaryPage, primaryPagePropsRef.current.get(currentPrimaryPage) as { spell?: string } | undefined @@ -2095,83 +2150,51 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { } return } - - // Single-pane mode or mobile: check if drawer is open and stack is empty - close drawer instead - if (drawerOpen && stackLen === 0) { - // Close drawer and reveal the background page - setDrawerOpen(false) - return - } - - // On mobile or single-pane: if stack has 1 item and drawer is open, close drawer and clear stack - if ((isSmallScreen || panelMode === 'single') && stackLen === 1 && drawerOpen) { - setDrawerOpen(false) - flushSync(() => { - setSecondaryStack([]) - }) - secondaryStackRef.current = [] - replaceHistoryWithPrimaryPageUrl( - currentPrimaryPage, - primaryPagePropsRef.current.get(currentPrimaryPage) as { spell?: string } | undefined - ) + } - const savedFeedState = savedFeedStateRef.current.get(currentPrimaryPage) + const clearSecondaryPages = () => { + hardCloseSecondaryPanel() + } - if (savedFeedState?.tab) { - logger.info('PageManager: Mobile/Single-pane - Restoring tab state', { page: currentPrimaryPage, tab: savedFeedState.tab }) - window.dispatchEvent(new CustomEvent('restorePageTab', { - detail: { page: currentPrimaryPage, tab: savedFeedState.tab } - })) - currentTabStateRef.current.set(currentPrimaryPage, savedFeedState.tab) - } - return - } - - if (stackLen === 1) { - flushSync(() => { - setSecondaryStack([]) - }) - secondaryStackRef.current = [] - replaceHistoryWithPrimaryPageUrl( - currentPrimaryPage, - primaryPagePropsRef.current.get(currentPrimaryPage) as { spell?: string } | undefined - ) + const mobileSecondaryOpen = isSmallScreen && (drawerOpen || secondaryStack.length > 0) + useEffect(() => { + if (!mobileSecondaryOpen) return - const savedFeedState = savedFeedStateRef.current.get(currentPrimaryPage) + let grab: { x: number; y: number; pointerId: number } | null = null - if (savedFeedState?.tab) { - logger.info('PageManager: Desktop - Restoring tab state', { page: currentPrimaryPage, tab: savedFeedState.tab }) - window.dispatchEvent(new CustomEvent('restorePageTab', { - detail: { page: currentPrimaryPage, tab: savedFeedState.tab } - })) - currentTabStateRef.current.set(currentPrimaryPage, savedFeedState.tab) + const onPointerDown = (e: PointerEvent) => { + if (e.button !== 0 || e.clientX > MOBILE_SWIPE_BACK_EDGE_PX) return + grab = { x: e.clientX, y: e.clientY, pointerId: e.pointerId } + } + + const onPointerUp = (e: PointerEvent) => { + if (!grab || grab.pointerId !== e.pointerId) return + const dx = e.clientX - grab.x + const dy = e.clientY - grab.y + grab = null + const ax = Math.abs(dx) + const ay = Math.abs(dy) + if (dx < MOBILE_SWIPE_BACK_MIN_PX || ax < ay * MOBILE_SWIPE_BACK_DOMINANCE) return + if (secondaryStackRef.current.length > 1) { + window.history.back() + } else { + hardCloseSecondaryPanel() } - } else if (stackLen > 1) { - // Same as double-pane: let popstate shrink the stack so it matches history. - window.history.back() - } else { - replaceHistoryWithPrimaryPageUrl( - currentPrimaryPage, - primaryPagePropsRef.current.get(currentPrimaryPage) as { spell?: string } | undefined - ) } - } - const hardCloseSecondaryPanel = useCallback(() => { - if (drawerOpen) setDrawerOpen(false) - setSinglePaneSheetOpen(false) - setSecondaryStack((prev) => (prev.length ? [] : prev)) - secondaryStackRef.current = [] - const page = currentPrimaryPageRef.current - replaceHistoryWithPrimaryPageUrl( - page, - primaryPagePropsRef.current.get(page) as { spell?: string } | undefined - ) - }, [drawerOpen]) + const onPointerCancel = () => { + grab = null + } - const clearSecondaryPages = () => { - hardCloseSecondaryPanel() - } + document.addEventListener('pointerdown', onPointerDown, { capture: true }) + document.addEventListener('pointerup', onPointerUp, { capture: true }) + document.addEventListener('pointercancel', onPointerCancel, { capture: true }) + return () => { + document.removeEventListener('pointerdown', onPointerDown, { capture: true }) + document.removeEventListener('pointerup', onPointerUp, { capture: true }) + document.removeEventListener('pointercancel', onPointerCancel, { capture: true }) + } + }, [mobileSecondaryOpen]) useEffect(() => { const shouldBeOpen = @@ -2312,7 +2335,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { setDrawerOpen(true) return } - popSecondaryPage() + hardCloseSecondaryPanel() }} noteId={drawerNoteId} /> diff --git a/src/components/Embedded/EmbeddedNote.tsx b/src/components/Embedded/EmbeddedNote.tsx index 558bc161..e2ba9be6 100644 --- a/src/components/Embedded/EmbeddedNote.tsx +++ b/src/components/Embedded/EmbeddedNote.tsx @@ -1,11 +1,6 @@ import { Skeleton } from '@/components/ui/skeleton' import ExternalLink from '@/components/ExternalLink' -import { - FAST_READ_RELAY_URLS, - PROFILE_RELAY_URLS, - SEARCHABLE_RELAY_URLS, - ExtendedKind -} from '@/constants' +import { FAST_READ_RELAY_URLS, PROFILE_RELAY_URLS, ExtendedKind } from '@/constants' import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' import { LIVE_ACTIVITY_KINDS } from '@/lib/live-activities' import { isCalendarEventKind } from '@/lib/calendar-event' @@ -19,6 +14,11 @@ import nip66Service from '@/services/nip66.service' import { navigationEventStore } from '@/services/navigation-event-store' import { useViewerInboxRelayUrls } from '@/hooks/useViewerInboxRelayUrls' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' +import { + getAggrAwareSearchRelayUrls, + syncViewerRelayStackNostrLandAggrEligible, + urlsForViewerNostrLandAggrEligibilitySync +} from '@/lib/nostr-land-relay-eligibility' import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal' import { useFavoriteRelays } from '@/providers/favorite-relays-context' import { useDeletedEvent } from '@/providers/DeletedEventProvider' @@ -234,6 +234,15 @@ function EmbeddedNoteFetched({ .filter((url): url is string => Boolean(url)), [favoriteRelays, blockedRelays] ) + useEffect(() => { + syncViewerRelayStackNostrLandAggrEligible( + urlsForViewerNostrLandAggrEligibilitySync({ + favoriteRelayUrls: favoriteRelays, + relayListRead: inboxRelayUrls + }) + ) + }, [favoriteRelays, inboxRelayUrls]) + const wideRelaysStatic = useMemo( () => buildEmbedWideRelayUrlsStatic( @@ -303,27 +312,31 @@ function EmbeddedNoteFetched({ } const runParallelFetch = async () => { - const { fetchRelayOpts: opts } = embedFetchCtxRef.current + const { fetchRelayOpts: opts, wideRelaysStatic: wideUrls } = embedFetchCtxRef.current const hex = hexEventIdFromNoteId(noteKey) const isUsable = (e: Event) => !isEventDeletedRef.current(e) && !shouldDropEventOnIngest(e) - const chosen = await firstResolvedUsableEmbedEvent( - [ - () => client.fetchEvent(noteKey, opts), - () => - hex && /^[0-9a-f]{64}$/i.test(hex) - ? indexedDb - .getEventFromPublicationStore(hex.toLowerCase()) - .catch(() => undefined) - : Promise.resolve(undefined) - ], - isUsable - ) - if (cancelled) return - if (chosen) { - resolve(chosen) + try { + const chosen = await firstResolvedUsableEmbedEvent( + [ + () => promiseWithTimeout(client.fetchEvent(noteKey, opts), 12_000), + () => + hex && /^[0-9a-f]{64}$/i.test(hex) + ? indexedDb + .getEventFromPublicationStore(hex.toLowerCase()) + .catch(() => undefined) + : Promise.resolve(undefined), + () => runWidePass(wideUrls) + ], + isUsable + ) + if (cancelled) return + if (chosen) { + resolve(chosen) + } + } finally { + if (!cancelled) setIsFetching(false) } - setIsFetching(false) } if (tryShortcuts()) { @@ -348,6 +361,7 @@ function EmbeddedNoteFetched({ ) if (cancelled || !ev) return resolve(ev) + if (!cancelled) setIsFetching(false) })() if (eventRef.current) { @@ -543,10 +557,10 @@ function buildEmbedWideRelayUrlsStatic( source: 'fallback', urls: preferPublicIndexRelaysFirst( dedupeRelayUrls([ + ...getAggrAwareSearchRelayUrls(), ...relayHintsFromParent, ...viewerInboxRelayUrls, ...nip66Service.getSearchableRelayUrls(), - ...SEARCHABLE_RELAY_URLS, ...FAST_READ_RELAY_URLS, ...PROFILE_RELAY_URLS, ...menuRelayUrls @@ -626,6 +640,15 @@ async function loadAsyncEmbedRelayHints(noteId: string, containingEvent?: Event) return dedupeRelayUrls(hintRelays) } +function promiseWithTimeout(promise: Promise, ms: number): Promise { + return Promise.race([ + promise.catch(() => undefined), + new Promise((resolve) => { + setTimeout(() => resolve(undefined), ms) + }) + ]) +} + /** Resolve as soon as any fetch path returns a usable event (do not wait for slow wide-relay fan-out). */ function firstResolvedUsableEmbedEvent( tasks: Array<() => Promise>, diff --git a/src/components/ParentNotePreview/index.tsx b/src/components/ParentNotePreview/index.tsx index 6e7bd274..46ee72fb 100644 --- a/src/components/ParentNotePreview/index.tsx +++ b/src/components/ParentNotePreview/index.tsx @@ -1,6 +1,7 @@ import { Skeleton } from '@/components/ui/skeleton' -import { SEARCHABLE_RELAY_URLS } from '@/constants' import { useFetchEvent } from '@/hooks' +import { getAggrAwareSearchRelayUrls } from '@/lib/nostr-land-relay-eligibility' +import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal' import { cn } from '@/lib/utils' import client from '@/services/client.service' import { useTranslation } from 'react-i18next' @@ -42,7 +43,10 @@ export default function ParentNotePreview({ setIsFetchingFallback(true) try { - const foundEvent = await client.fetchEventWithExternalRelays(eventId, SEARCHABLE_RELAY_URLS) + const foundEvent = await client.fetchEventWithExternalRelays( + eventId, + sanitizeRelayUrlsForFetch(getAggrAwareSearchRelayUrls()) + ) if (foundEvent) { client.addEventToCache(foundEvent) setFallbackEvent(foundEvent) diff --git a/src/components/RelayIcon/index.tsx b/src/components/RelayIcon/index.tsx index 2d82b15f..9af94e91 100644 --- a/src/components/RelayIcon/index.tsx +++ b/src/components/RelayIcon/index.tsx @@ -1,6 +1,10 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { useFetchRelayInfo } from '@/hooks' -import { getRelayIconOverrideSrc, relayUrlFingerprintColors } from '@/lib/relay-icon-source' +import { + getRelayIconFallbackGlyph, + getRelayIconOverrideSrc, + relayUrlFingerprintColors +} from '@/lib/relay-icon-source' import { cn } from '@/lib/utils' import type { TRelayInfo } from '@/types' import { Server } from 'lucide-react' @@ -72,6 +76,7 @@ export default function RelayIcon({ }, [url, relayInfo]) const fallbackColors = useMemo(() => relayUrlFingerprintColors(url), [url]) + const fallbackGlyph = useMemo(() => getRelayIconFallbackGlyph(url), [url]) return ( @@ -86,7 +91,17 @@ export default function RelayIcon({ className="bg-transparent" style={{ backgroundColor: fallbackColors.background, color: fallbackColors.color }} > - + {fallbackGlyph ? ( + + {fallbackGlyph} + + ) : ( + + )} ) diff --git a/src/components/SearchBar/index.tsx b/src/components/SearchBar/index.tsx index b28f1c2a..7b2e2b8a 100644 --- a/src/components/SearchBar/index.tsx +++ b/src/components/SearchBar/index.tsx @@ -4,7 +4,7 @@ import { toNote, toNoteList } from '@/lib/link' import client from '@/services/client.service' import { eventService } from '@/services/client.service' import { randomString } from '@/lib/random' -import { isHttpRelayUrl, isWebsocketUrl, normalizeAnyRelayUrl } from '@/lib/url' +import { isKind10243HttpRelayTagUrl, isWebsocketUrl, normalizeAnyRelayUrl, normalizeHttpRelayUrl } from '@/lib/url' import { normalizeToDTag } from '@/lib/search-parser' import { cn } from '@/lib/utils' import { useSmartNoteNavigation, useSmartHashtagNavigation } from '@/PageManager' @@ -56,8 +56,8 @@ const SearchBar = forwardRef< return undefined } try { - const n = normalizeAnyRelayUrl(input) - if (!n || (!isHttpRelayUrl(n) && !isWebsocketUrl(n))) return undefined + const n = normalizeAnyRelayUrl(input) || normalizeHttpRelayUrl(input) + if (!n || (!isWebsocketUrl(n) && !isKind10243HttpRelayTagUrl(n))) return undefined return n } catch { return undefined diff --git a/src/components/SessionRelaysTab/index.tsx b/src/components/SessionRelaysTab/index.tsx index 83e375a6..ff6e0baf 100644 --- a/src/components/SessionRelaysTab/index.tsx +++ b/src/components/SessionRelaysTab/index.tsx @@ -4,7 +4,7 @@ import { isRelayStrikeEntryActive, type RelayStrikeDebugSnapshot } from '@/lib/relay-strikes' -import { isHttpRelayUrl } from '@/lib/url' +import { isKind10243HttpRelayTagUrl } from '@/lib/url' import { useTranslation } from 'react-i18next' import { useCallback, useEffect, useMemo, useState } from 'react' import { RefreshCw, CheckCircle2, Zap, AlertTriangle } from 'lucide-react' @@ -145,16 +145,13 @@ export default function SessionRelaysTab() { for (const tag of httpRelayListEvent.tags) { if (tag[0] !== 'r' || !tag[1]) continue const raw = tag[1].trim() - if (!isHttpRelayUrl(raw)) continue + if (!isKind10243HttpRelayTagUrl(raw)) continue out.add(formatRelayAddress(raw).toLowerCase()) } return out }, [httpRelayListEvent]) const isHttpRelayEntry = (url: string): boolean => { - if (isHttpRelayUrl(url)) return true - const infoUrl = relayInfoByUrl[url]?.url - if (infoUrl && isHttpRelayUrl(infoUrl)) return true return configuredHttpRelayAddresses.has(formatRelayAddress(url).toLowerCase()) } diff --git a/src/features/feed/descriptor.ts b/src/features/feed/descriptor.ts index ab2adac6..1002b1df 100644 --- a/src/features/feed/descriptor.ts +++ b/src/features/feed/descriptor.ts @@ -1,4 +1,4 @@ -import { normalizeAnyRelayUrl } from '@/lib/url' +import { canonicalRelaySessionKey, normalizeAnyRelayUrl } from '@/lib/url' import type { TFeedSubRequest, TSubRequestFilter } from '@/types' import type { Filter } from 'nostr-tools' @@ -92,12 +92,7 @@ export function canonicalFeedFilter(filter: Omit | TS export function canonicalRelayUrls(urls: readonly string[]): string[] { return Array.from( - new Set( - urls - .map((u) => normalizeAnyRelayUrl(u) || u.trim()) - .filter(Boolean) - .map((u) => u.toLowerCase()) - ) + new Set(urls.map((u) => canonicalRelaySessionKey(u)).filter(Boolean)) ).sort((a, b) => a.localeCompare(b)) } diff --git a/src/features/feed/relay-policy.ts b/src/features/feed/relay-policy.ts index f7890c6a..d51fb6ce 100644 --- a/src/features/feed/relay-policy.ts +++ b/src/features/feed/relay-policy.ts @@ -11,7 +11,7 @@ import { relayUrlsStripExtendedTagReqBlocked } from '@/lib/relay-extended-tag-req-blocks' import { isRelayBlockedByUser } from '@/lib/relay-blocked' -import { isLocalNetworkUrl, normalizeAnyRelayUrl } from '@/lib/url' +import { isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeHttpRelayUrl } from '@/lib/url' import type { TSubRequestFilter } from '@/types' export type FeedRelayOperation = 'read' | 'write' | 'publish-picker' | 'favorites-feed' @@ -92,16 +92,17 @@ export type FeedRelayPolicyResult = { dropped: FeedRelayDrop[] } -function canonicalRelayUrl(url: string | undefined | null): string { - return (normalizeAnyRelayUrl(url ?? '') || (url ?? '').trim()).toLowerCase() +function canonicalRelayUrl(url: string | undefined | null, layerSource?: FeedRelayLayerSource | string): string { + return normalizedRelayUrl(url ?? '', layerSource).toLowerCase() } -function normalizedRelayUrl(url: string): string { +function normalizedRelayUrl(url: string, layerSource?: FeedRelayLayerSource | string): string { + if (layerSource === 'http-index') return normalizeHttpRelayUrl(url) || url.trim() return normalizeAnyRelayUrl(url) || url.trim() } function normalizedSet(urls: readonly string[] | undefined): Set { - return new Set((urls ?? []).map(canonicalRelayUrl).filter(Boolean)) + return new Set((urls ?? []).map((u) => canonicalRelayUrl(u)).filter(Boolean)) } function shouldApplySocialFilter(ctx: FeedRelayPolicyContext): boolean { @@ -172,8 +173,8 @@ export function applyFeedRelayPolicy( for (const layer of layers) { for (const raw of layer.urls) { - const normalized = normalizedRelayUrl(raw) - const key = canonicalRelayUrl(normalized) + const normalized = normalizedRelayUrl(raw, layer.source) + const key = canonicalRelayUrl(normalized, layer.source) if (!normalized || !key) { addDrop(dropped, raw, layer.source, 'invalid') continue diff --git a/src/hooks/useFetchThreadContextEvent.tsx b/src/hooks/useFetchThreadContextEvent.tsx index 3b636e26..c769ea57 100644 --- a/src/hooks/useFetchThreadContextEvent.tsx +++ b/src/hooks/useFetchThreadContextEvent.tsx @@ -1,4 +1,5 @@ -import { SEARCHABLE_RELAY_URLS, THREAD_CONTEXT_EVENT_FETCH_GLOBAL_TIMEOUT_MS } from '@/constants' +import { THREAD_CONTEXT_EVENT_FETCH_GLOBAL_TIMEOUT_MS } from '@/constants' +import { getAggrAwareSearchRelayUrls } from '@/lib/nostr-land-relay-eligibility' import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider' @@ -124,13 +125,10 @@ export function useFetchThreadContextEvent( }) ]) - if ( - !fetchedEvent && - !searchableAttemptedRef.current && - SEARCHABLE_RELAY_URLS.length > 0 - ) { + const aggrAwareSearch = getAggrAwareSearchRelayUrls() + if (!fetchedEvent && !searchableAttemptedRef.current && aggrAwareSearch.length > 0) { searchableAttemptedRef.current = true - const searchable = sanitizeRelayUrlsForFetch([...SEARCHABLE_RELAY_URLS]) + const searchable = sanitizeRelayUrlsForFetch(aggrAwareSearch) fetchedEvent = await Promise.race([ client.fetchEventWithExternalRelays(eventId, searchable), new Promise((resolve) => { diff --git a/src/layouts/SecondaryPageLayout/index.tsx b/src/layouts/SecondaryPageLayout/index.tsx index e01ff3fc..03ee63ee 100644 --- a/src/layouts/SecondaryPageLayout/index.tsx +++ b/src/layouts/SecondaryPageLayout/index.tsx @@ -44,7 +44,8 @@ const SecondaryPageLayout = forwardRef( const { isSmallScreen } = useScreenSize() const { currentIndex, pop } = useSecondaryPage() const [mobileSwipeRoot, setMobileSwipeRoot] = useState(null) - const mobileSwipeActive = isSmallScreen && currentIndex === index + const mobileSwipeActive = + isSmallScreen && (index === undefined || currentIndex === index) useMobileSwipeBackOnElement(mobileSwipeActive ? mobileSwipeRoot : null, pop, { enabled: mobileSwipeActive }) diff --git a/src/lib/event-metadata.ts b/src/lib/event-metadata.ts index 770134f1..50c06c82 100644 --- a/src/lib/event-metadata.ts +++ b/src/lib/event-metadata.ts @@ -7,7 +7,14 @@ import { getAmountFromInvoice, getLightningAddressFromProfile } from './lightnin import { formatPubkey, pubkeyToNpub } from './pubkey' import { generateBech32IdFromATag, generateBech32IdFromETag, getImetaInfoFromImetaTag, tagNameEquals } from './tag' import { isRelayBlockedByUser } from '@/lib/relay-blocked' -import { isHttpRelayUrl, isWebsocketUrl, normalizeAnyRelayUrl, normalizeHttpRelayUrl, normalizeHttpUrl, normalizeUrl } from './url' +import { + isKind10243HttpRelayTagUrl, + isWebsocketUrl, + normalizeAnyRelayUrl, + normalizeHttpRelayUrl, + normalizeHttpUrl, + normalizeUrl +} from './url' import { isTorBrowser } from './utils' import logger from '@/lib/logger' import { buildPaytoUri } from '@/lib/payto' @@ -168,7 +175,7 @@ export function getHttpRelayListFromEvent(event?: Event | null, blockedRelays?: const torBrowserDetected = isTorBrowser() event.tags.filter(tagNameEquals('r')).forEach(([, url, type]) => { if (!url || typeof url !== 'string' || url.trim() === '') return - if (!isHttpRelayUrl(url)) return + if (!isKind10243HttpRelayTagUrl(url)) return const normalizedUrl = normalizeHttpRelayUrl(url) if (!normalizedUrl) return diff --git a/src/lib/favorites-feed-relays.ts b/src/lib/favorites-feed-relays.ts index ff6bc6e7..aee0813e 100644 --- a/src/lib/favorites-feed-relays.ts +++ b/src/lib/favorites-feed-relays.ts @@ -19,6 +19,7 @@ import { import { feedRelayPolicyUrls, type FeedRelayLayer } from '@/features/feed/relay-policy' import { stripMailboxLocalUrlsForRemoteViewers } from '@/lib/relay-list-sanitize' import { relaySessionStrikes } from '@/lib/relay-strikes' +import { prependAggrNostrLandIfViewerEligible } from '@/lib/nostr-land-relay-eligibility' import { profileFetchRelayUrlsWithoutFastReadLayer } from '@/lib/viewer-relay-defaults' function isBlockedRelay(url: string, blockedRelays: string[]): boolean { @@ -39,7 +40,7 @@ export function userReadRelaysWithHttp( ): string[] { const http = relayList?.httpRead ?? [] const read = relayList?.read ?? [] - return dedupeNormalizeRelayUrlsOrdered([...http, ...read]) + return prependAggrNostrLandIfViewerEligible(dedupeNormalizeRelayUrlsOrdered([...http, ...read])) } export function getFavoritesFeedRelayUrls( diff --git a/src/lib/feed-full-search-relays.ts b/src/lib/feed-full-search-relays.ts index 1ce87e02..ccd7fadf 100644 --- a/src/lib/feed-full-search-relays.ts +++ b/src/lib/feed-full-search-relays.ts @@ -1,10 +1,10 @@ -import { SEARCHABLE_RELAY_URLS } from '@/constants' import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls' import { getFavoritesFeedRelayUrls, mergeRelayUrlLayers } from '@/lib/favorites-feed-relays' import { buildComprehensiveRelayList } from '@/lib/relay-list-builder' +import { getAggrAwareSearchRelayUrls } from '@/lib/nostr-land-relay-eligibility' import { normalizeUrl } from '@/lib/url' /** @@ -23,8 +23,7 @@ export async function buildFeedFullSearchRelayUrls(options: { const blocked = blockedRelays ?? [] const layers: string[][] = [] - const searchable = SEARCHABLE_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) - layers.push(searchable) + layers.push(getAggrAwareSearchRelayUrls().map((u) => normalizeUrl(u) || u).filter(Boolean)) layers.push(getFavoritesFeedRelayUrls(favoriteRelays ?? [], blocked)) diff --git a/src/lib/home-feed-relays.ts b/src/lib/home-feed-relays.ts index d119d54b..123530cd 100644 --- a/src/lib/home-feed-relays.ts +++ b/src/lib/home-feed-relays.ts @@ -2,33 +2,19 @@ import { MAX_REQ_RELAY_URLS } from '@/constants' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { getHttpRelayListFromEvent, getRelayListReadFromEventNoFastFallback } from '@/lib/event-metadata' import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' -import { AGGR_NOSTR_LAND_WSS } from '@/lib/nostr-land-aggr' -import { normalizeAnyRelayUrl } from '@/lib/url' +import { relayUrlIsAggrNostrLand } from '@/lib/nostr-land-relay-eligibility' import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults' import type { Event } from 'nostr-tools' -function relayUrlIsNostrLandAggr(url: string): boolean { - const raw = url.trim() - if (!raw) return false - const normalized = (normalizeAnyRelayUrl(raw) || raw).toLowerCase() - const aggrCanon = (normalizeAnyRelayUrl(AGGR_NOSTR_LAND_WSS) || AGGR_NOSTR_LAND_WSS).toLowerCase() - if (normalized === aggrCanon) return true - try { - const u = new URL(normalized) - return u.hostname.toLowerCase() === 'aggr.nostr.land' - } catch { - return /^wss:\/\/aggr\.nostr\.land\/?$/i.test(normalized) - } -} - /** Drop nostr.land aggregate from REQ stacks where it must not appear (e.g. home feeds). */ export function stripNostrLandAggrFromRelayUrls(urls: readonly string[]): string[] { - return urls.filter((url) => !relayUrlIsNostrLandAggr(url)) + return urls.filter((url) => !relayUrlIsAggrNostrLand(url)) } /** - * Home “Lieblings-Relays” feed must never open timeline REQs to nostr.land’s aggregate relay (reserved for - * threads / profiles / spells). Strips aggr from every shard after mapping, including trailing-slash variants. + * Home timeline REQs (Notes, Replies, and Gallery tabs on `home-all-favorites`) must never hit aggr — only + * favorites + Wisp trending (+ widened read layers on Replies/Gallery without aggr). Side-panel threads, + * reply blurbs, backlinks, embeds, and profiles use {@link feedRelayPolicyUrls} / comprehensive lists instead. */ export function stripNostrLandAggrFromTimelineSubRequests( feedSubscriptionKey: string | undefined, diff --git a/src/lib/index-relay-http.ts b/src/lib/index-relay-http.ts index 226b705c..c257f463 100644 --- a/src/lib/index-relay-http.ts +++ b/src/lib/index-relay-http.ts @@ -174,7 +174,8 @@ function handleFilterTransportFailure(endpoint: string, err?: unknown): void { maybeLogDevIndexRelayUnreachableHint() return } - warnIndexRelayHttpThrottled(endpoint, '[IndexRelayHttp] filter request error', { + // CORS / offline index relays are optional; strikes will skip after repeated failures. + logger.debug('[IndexRelayHttp] filter transport failure', { endpoint, error: err ?? 'unreachable' }) diff --git a/src/lib/nostr-land-relay-eligibility.test.ts b/src/lib/nostr-land-relay-eligibility.test.ts new file mode 100644 index 00000000..dd86e2b3 --- /dev/null +++ b/src/lib/nostr-land-relay-eligibility.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, afterEach } from 'vitest' +import { AGGR_NOSTR_LAND_WSS } from '@/lib/nostr-land-aggr' +import { + filterAggrNostrLandUnlessViewerEligible, + getAggrAwareSearchRelayUrls, + getViewerNostrLandAggrSearchRelayUrls, + prependAggrNostrLandIfViewerEligible, + relayUrlsIncludeCanonicalNostrLandRelay, + syncViewerRelayStackNostrLandAggrEligible +} from '@/lib/nostr-land-relay-eligibility' + +afterEach(() => { + syncViewerRelayStackNostrLandAggrEligible([]) +}) + +describe('nostr.land aggr eligibility', () => { + it('enables aggr only when wss://nostr.land is on favorites or relay lists', () => { + expect(relayUrlsIncludeCanonicalNostrLandRelay(['wss://nostr.land/'])).toBe(true) + expect(relayUrlsIncludeCanonicalNostrLandRelay(['wss://aggr.nostr.land/'])).toBe(false) + expect(relayUrlsIncludeCanonicalNostrLandRelay(['wss://hist.nostr.land/'])).toBe(false) + + syncViewerRelayStackNostrLandAggrEligible(['wss://nostr.land/']) + expect(getViewerNostrLandAggrSearchRelayUrls()[0]).toMatch(/^wss:\/\/aggr\.nostr\.land\/?$/) + expect(prependAggrNostrLandIfViewerEligible(['wss://inbox.example/'])[0]).toMatch( + /^wss:\/\/aggr\.nostr\.land\/?$/ + ) + + syncViewerRelayStackNostrLandAggrEligible(['wss://relay.example/']) + expect(getViewerNostrLandAggrSearchRelayUrls()).toEqual([]) + expect(prependAggrNostrLandIfViewerEligible(['wss://inbox.example/'])).toEqual([ + 'wss://inbox.example/' + ]) + }) + + it('getAggrAwareSearchRelayUrls prepends aggr when eligible', () => { + syncViewerRelayStackNostrLandAggrEligible(['wss://nostr.land/']) + const urls = getAggrAwareSearchRelayUrls() + expect(urls[0]).toMatch(/^wss:\/\/aggr\.nostr\.land\/?$/) + expect(urls.length).toBeGreaterThan(1) + + syncViewerRelayStackNostrLandAggrEligible([]) + expect(getAggrAwareSearchRelayUrls().some((u) => /aggr\.nostr\.land/i.test(u))).toBe(false) + }) + + it('strips aggr from fetch stacks when viewer does not list nostr.land', () => { + syncViewerRelayStackNostrLandAggrEligible([]) + expect( + filterAggrNostrLandUnlessViewerEligible([ + 'wss://relay.example/', + AGGR_NOSTR_LAND_WSS + ]) + ).toEqual(['wss://relay.example/']) + }) +}) diff --git a/src/lib/nostr-land-relay-eligibility.ts b/src/lib/nostr-land-relay-eligibility.ts index b2b29773..fdcac1d2 100644 --- a/src/lib/nostr-land-relay-eligibility.ts +++ b/src/lib/nostr-land-relay-eligibility.ts @@ -1,17 +1,36 @@ +import { SEARCHABLE_RELAY_URLS } from '@/constants' import { AGGR_NOSTR_LAND_WSS } from '@/lib/nostr-land-aggr' import { normalizeAnyRelayUrl } from '@/lib/url' /** - * True when any URL’s host is `nostr.land` (e.g. `wss://nostr.land`, `wss://aggr.nostr.land`). - * Used to decide whether read fetches should prepend {@link AGGR_NOSTR_LAND_WSS} (home OP / Replies / Gallery - * never prepend aggr via {@link FeedProvider}; side-panel threads, profiles, spells, and other reads still use it). + * True when the URL is `wss://aggr.nostr.land` (aggregator read/search endpoint). */ -export function relayUrlsMentionNostrLandDomain(urls: readonly string[]): boolean { +export function relayUrlIsAggrNostrLand(url: string): boolean { + const raw = url.trim() + if (!raw) return false + const normalized = (normalizeAnyRelayUrl(raw) || raw).toLowerCase() + const aggrCanon = (normalizeAnyRelayUrl(AGGR_NOSTR_LAND_WSS) || AGGR_NOSTR_LAND_WSS).toLowerCase() + if (normalized === aggrCanon) return true + try { + const u = new URL(normalized.replace(/^ws:\/\//i, 'http://').replace(/^wss:\/\//i, 'https://')) + return u.hostname.toLowerCase() === 'aggr.nostr.land' + } catch { + return /^wss:\/\/aggr\.nostr\.land\/?$/i.test(normalized) + } +} + +/** + * True when any URL is the canonical nostr.land **inbox** relay (`wss://nostr.land`), i.e. host `nostr.land` + * exactly — not `aggr.nostr.land`, `hist.nostr.land`, or other subdomains. + */ +export function relayUrlsIncludeCanonicalNostrLandRelay(urls: readonly string[]): boolean { return urls.some((url) => { const normalized = normalizeAnyRelayUrl(url) || String(url).trim() if (!normalized) return false try { - const parsed = new URL(normalized.replace(/^ws:\/\//i, 'http://').replace(/^wss:\/\//i, 'https://')) + const parsed = new URL( + normalized.replace(/^ws:\/\//i, 'http://').replace(/^wss:\/\//i, 'https://') + ) return parsed.hostname.toLowerCase() === 'nostr.land' } catch { return false @@ -19,14 +38,19 @@ export function relayUrlsMentionNostrLandDomain(urls: readonly string[]): boolea }) } +/** @deprecated Use {@link relayUrlsIncludeCanonicalNostrLandRelay}. */ +export function relayUrlsMentionNostrLandDomain(urls: readonly string[]): boolean { + return relayUrlsIncludeCanonicalNostrLandRelay(urls) +} + let viewerStackMentionsNostrLand = false /** - * Synced from the logged-in viewer’s relay stack (favorites, relay sets, NIP-65, cache, HTTP lists). - * Service-layer reads use {@link getViewerRelayStackNostrLandAggrEligible} when building REQ targets. + * Synced from the logged-in viewer’s favorites and NIP-65 / cache / HTTP relay lists. + * When true, {@link AGGR_NOSTR_LAND_WSS} is treated as a search relay and inbox-tier read relay. */ export function syncViewerRelayStackNostrLandAggrEligible(urls: readonly string[]): boolean { - viewerStackMentionsNostrLand = relayUrlsMentionNostrLandDomain(urls) + viewerStackMentionsNostrLand = relayUrlsIncludeCanonicalNostrLandRelay(urls) return viewerStackMentionsNostrLand } @@ -34,6 +58,56 @@ export function getViewerRelayStackNostrLandAggrEligible(): boolean { return viewerStackMentionsNostrLand } +/** Aggr URL to merge into NIP-50 / searchable relay sets when the viewer lists `wss://nostr.land`. */ +export function getViewerNostrLandAggrSearchRelayUrls(): string[] { + if (!viewerStackMentionsNostrLand) return [] + const n = normalizeAnyRelayUrl(AGGR_NOSTR_LAND_WSS) || AGGR_NOSTR_LAND_WSS + return n ? [n] : [] +} + +/** + * Search / wide-id fetch relays: aggr first when eligible, then {@link SEARCHABLE_RELAY_URLS}. + * Use for reply-to blurbs, thread parent/root fallback, embed wide pass, etc. — not home timeline OP REQs. + */ +export function getAggrAwareSearchRelayUrls(): string[] { + const seen = new Set() + const out: string[] = [] + const add = (raw: string) => { + const n = normalizeAnyRelayUrl(raw) || raw.trim() + if (!n) return + const k = n.toLowerCase() + if (seen.has(k)) return + seen.add(k) + out.push(n) + } + for (const u of getViewerNostrLandAggrSearchRelayUrls()) add(u) + for (const u of SEARCHABLE_RELAY_URLS) add(u) + return out +} + +/** Drop aggr unless the viewer has `wss://nostr.land` on favorites or relay lists. */ +export function filterAggrNostrLandUnlessViewerEligible(urls: readonly string[]): string[] { + if (viewerStackMentionsNostrLand) return [...urls] + return urls.filter((u) => !relayUrlIsAggrNostrLand(u)) +} + +/** URLs to pass to {@link syncViewerRelayStackNostrLandAggrEligible} (favorites + NIP-65 + cache + HTTP lists). */ +export function urlsForViewerNostrLandAggrEligibilitySync(options: { + favoriteRelayUrls?: readonly string[] + relayListRead?: readonly string[] + relayListWrite?: readonly string[] + cacheRelayRead?: readonly string[] + httpRelayRead?: readonly string[] +}): string[] { + return [ + ...(options.favoriteRelayUrls ?? []), + ...(options.relayListRead ?? []), + ...(options.relayListWrite ?? []), + ...(options.cacheRelayRead ?? []), + ...(options.httpRelayRead ?? []) + ] +} + /** Deduped prepend of aggr when the viewer opted into nostr.land relays (see sync…). */ export function prependAggrNostrLandIfViewerEligible(relayUrls: readonly string[]): string[] { if (!viewerStackMentionsNostrLand) return [...relayUrls] diff --git a/src/lib/read-only-relay-personal.test.ts b/src/lib/read-only-relay-personal.test.ts index e95d44c5..780c4173 100644 --- a/src/lib/read-only-relay-personal.test.ts +++ b/src/lib/read-only-relay-personal.test.ts @@ -1,15 +1,22 @@ -import { describe, expect, it, beforeEach } from 'vitest' +import { describe, expect, it, beforeEach, afterEach } from 'vitest' import { AGGR_NOSTR_LAND_WSS } from '@/lib/nostr-land-aggr' +import { syncViewerRelayStackNostrLandAggrEligible } from '@/lib/nostr-land-relay-eligibility' import { buildPersonalRelayKeySet, filterReadOnlyRelaysUnlessPersonal, isPersonalListRequiredReadOnlyRelay, + sanitizeRelayUrlsForFetch, setViewerPersonalRelayKeys } from './read-only-relay-personal' describe('read-only-relay-personal', () => { beforeEach(() => { setViewerPersonalRelayKeys(new Set()) + syncViewerRelayStackNostrLandAggrEligible([]) + }) + + afterEach(() => { + syncViewerRelayStackNostrLandAggrEligible([]) }) it('requires personal list only for filter.nostr.wine', () => { @@ -19,7 +26,7 @@ describe('read-only-relay-personal', () => { expect(isPersonalListRequiredReadOnlyRelay('wss://search.nos.today/')).toBe(false) }) - it('strips unlisted filter.nostr.wine but keeps aggr and search indexers', () => { + it('strips unlisted filter.nostr.wine but keeps search indexers; aggr only when nostr.land is listed', () => { const urls = [ 'wss://relay.damus.io/', 'wss://filter.nostr.wine/', @@ -31,6 +38,16 @@ describe('read-only-relay-personal', () => { AGGR_NOSTR_LAND_WSS, 'wss://search.nos.today/' ]) + expect(sanitizeRelayUrlsForFetch(urls)).toEqual([ + 'wss://relay.damus.io/', + 'wss://search.nos.today/' + ]) + syncViewerRelayStackNostrLandAggrEligible(['wss://nostr.land/']) + expect(sanitizeRelayUrlsForFetch(urls).map((u) => u.replace(/\/$/, ''))).toEqual([ + 'wss://relay.damus.io', + 'wss://aggr.nostr.land', + 'wss://search.nos.today' + ]) }) it('keeps filter.nostr.wine when on the viewer personal list', () => { diff --git a/src/lib/read-only-relay-personal.ts b/src/lib/read-only-relay-personal.ts index c90b52c7..015f9259 100644 --- a/src/lib/read-only-relay-personal.ts +++ b/src/lib/read-only-relay-personal.ts @@ -1,4 +1,5 @@ import { READ_ONLY_PERSONAL_LIST_REQUIRED_RELAY_URLS } from '@/constants' +import { filterAggrNostrLandUnlessViewerEligible } from '@/lib/nostr-land-relay-eligibility' import { urlIsNonLocalForRemoteViewer } from '@/lib/relay-list-sanitize' import { normalizeAnyRelayUrl } from '@/lib/url' @@ -81,5 +82,7 @@ export function sanitizeRelayUrlsForFetch( const key = relayUrlKey(u) return key.length > 0 && keys.has(key) }) - return filterReadOnlyRelaysUnlessPersonal(withoutThirdPartyLocals, keys) + return filterAggrNostrLandUnlessViewerEligible( + filterReadOnlyRelaysUnlessPersonal(withoutThirdPartyLocals, keys) + ) } diff --git a/src/lib/relay-icon-source.test.ts b/src/lib/relay-icon-source.test.ts new file mode 100644 index 00000000..233795be --- /dev/null +++ b/src/lib/relay-icon-source.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { getRelayIconFallbackGlyph, getRelayIconOverrideSrc } from '@/lib/relay-icon-source' + +describe('relay icon branding', () => { + it('uses favicon override for sovbit hosts', () => { + expect(getRelayIconOverrideSrc('wss://nostr.sovbit.host/')).toContain('nostr.sovbit.host') + expect(getRelayIconOverrideSrc('wss://freelay.sovbit.host/')).toContain('freelay.sovbit.host') + }) + + it('uses purple circle fallback glyph for purplepag.es', () => { + expect(getRelayIconFallbackGlyph('wss://purplepag.es/')).toBe('🟣') + expect(getRelayIconOverrideSrc('wss://purplepag.es/')).toBeUndefined() + }) +}) diff --git a/src/lib/relay-icon-source.ts b/src/lib/relay-icon-source.ts index f57eb309..9743fdce 100644 --- a/src/lib/relay-icon-source.ts +++ b/src/lib/relay-icon-source.ts @@ -53,6 +53,17 @@ export function getRelayIconOverrideSrc(url: string | undefined): string | undef return undefined } +/** + * Unicode fallback when NIP-11 / favicon is missing or failed to load (shown in {@link RelayIcon}). + * Sovbit hosts use {@link getRelayIconOverrideSrc} favicons instead; purplepag uses the purple circle. + */ +export function getRelayIconFallbackGlyph(url: string | undefined): string | undefined { + const host = parseRelayHostname(url ?? '') + if (!host) return undefined + if (host === 'purplepag.es') return '🟣' + return undefined +} + /** FNV-1a-ish fingerprint → HSL for a per-relay fallback swatch (no network). */ export function relayUrlFingerprintColors(url: string | undefined): { background: string diff --git a/src/lib/relay-list-builder.ts b/src/lib/relay-list-builder.ts index 149ca08c..9afbaf2c 100644 --- a/src/lib/relay-list-builder.ts +++ b/src/lib/relay-list-builder.ts @@ -13,7 +13,14 @@ import { FAST_READ_RELAY_URLS, PROFILE_RELAY_URLS, SEARCHABLE_RELAY_URLS } from import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { mergeRelayUrlLayers, userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' import { urlIsNonLocalForRemoteViewer } from '@/lib/relay-list-sanitize' -import { isHttpRelayUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' +import { + canonicalRelaySessionKey, + httpIndexRelayBasesInUrlBatch, + isKind10243HttpRelayTagUrl, + normalizeAnyRelayUrl, + normalizeHttpRelayUrl, + normalizeUrl +} from '@/lib/url' import { buildPersonalRelayKeySet, sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal' import { getCacheRelayUrls } from './private-relays' import { defaultFavoriteRelaysForViewer, viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults' @@ -25,7 +32,7 @@ import type { Event } from 'nostr-tools' export const AUTHOR_NIP65_RELAY_CAP = 2 function relayKey(url: string): string { - return (normalizeAnyRelayUrl(url) || url.trim()).toLowerCase() + return canonicalRelaySessionKey(url) } /** @@ -65,7 +72,7 @@ function dedupeNormalizedRelayUrls(urls: string[]): string[] { const seen = new Set() const out: string[] = [] for (const u of urls) { - if (isHttpRelayUrl(u)) continue + if (isKind10243HttpRelayTagUrl(u)) continue const n = normalizeAnyRelayUrl(u) || u.trim() if (!n || seen.has(n)) continue seen.add(n) @@ -166,8 +173,8 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio const addRelay = (url: string | undefined) => { if (!url) return - // This builder feeds WebSocket REQ/publish lists; keep HTTP relays separate. - if (isHttpRelayUrl(url)) return + // This builder feeds WebSocket REQ/publish lists; kind 10243 HTTP index relays use addHttpRelay. + if (isKind10243HttpRelayTagUrl(url)) return const normalized = normalizeAnyRelayUrl(url) if (!normalized) return // Filter blocked (case-insensitive comparison) @@ -182,8 +189,8 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio } const addHttpRelay = (url: string | undefined) => { - if (!url || !isHttpRelayUrl(url)) return - const normalized = normalizeAnyRelayUrl(url) || url.trim() + if (!url) return + const normalized = normalizeHttpRelayUrl(url) if (!normalized || normalizedBlocked.has(normalized.toLowerCase())) return if (httpRelayUrls.some((u) => relayKey(u) === relayKey(normalized))) return httpRelayUrls.push(normalized) @@ -424,8 +431,18 @@ export async function buildProfileAndUserRelayList( includeViewerHttpIndexRelays: true, blockedRelays }) - const httpPart = userStack.filter((u) => isHttpRelayUrl(u)) - const wsPart = userStack.filter((u) => !isHttpRelayUrl(u)) + let httpBases: string[] = [] + try { + const rl = await client.peekRelayListFromStorage(userPubkey) + httpBases = [...(rl?.httpRead ?? []), ...(rl?.httpWrite ?? [])] + .map((u) => normalizeHttpRelayUrl(u) || u) + .filter(Boolean) + } catch { + httpBases = [] + } + const httpPart = httpIndexRelayBasesInUrlBatch(userStack, httpBases) + const httpKeys = new Set(httpPart.map((u) => relayKey(u))) + const wsPart = userStack.filter((u) => !httpKeys.has(relayKey(u))) const seen = new Set() const mergedWs: string[] = [] for (const u of [...profileWs, ...wsPart]) { @@ -545,7 +562,7 @@ export async function buildPollResultsReadRelayUrls(options: { const pushLayer = (urls: string[]) => { for (const raw of urls) { - if (isHttpRelayUrl(raw)) continue + if (isKind10243HttpRelayTagUrl(raw)) continue const normalized = normalizeUrl(raw) || raw?.trim() if (!normalized || normalizedBlocked.has(normalized.toLowerCase())) continue if (seenNorm.has(normalized)) continue diff --git a/src/lib/relay-list-sanitize.ts b/src/lib/relay-list-sanitize.ts index 3dd62add..c05ea3ab 100644 --- a/src/lib/relay-list-sanitize.ts +++ b/src/lib/relay-list-sanitize.ts @@ -1,4 +1,4 @@ -import { isHttpRelayUrl, isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' +import { isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeHttpRelayUrl, normalizeUrl } from '@/lib/url' import type { TMailboxRelay, TMailboxRelayScope, TRelayList } from '@/types' /** True if this URL is not loopback / LAN (safe to open from another user's browser as a REQ target). */ @@ -44,17 +44,21 @@ export function stripMailboxLocalUrlsForRemoteViewers(list: { * Still use when merging **another user's** 10002 so we never open their LAN relays. */ export function stripLocalNetworkRelaysFromRelayList(list: TRelayList): TRelayList { - const keepUrl = (u: string): boolean => { - const n = isHttpRelayUrl(u) ? normalizeAnyRelayUrl(u) || u : normalizeUrl(u) || u - return Boolean(n && !isLocalNetworkUrl(isHttpRelayUrl(u) ? u : n)) + const keepWsUrl = (u: string): boolean => { + const n = normalizeUrl(u) || u + return Boolean(n && !isLocalNetworkUrl(n)) + } + const keepHttpIndexUrl = (u: string): boolean => { + const n = normalizeHttpRelayUrl(u) || u + return Boolean(n && !isLocalNetworkUrl(n)) } return { - write: list.write.filter(keepUrl), - read: list.read.filter(keepUrl), - originalRelays: list.originalRelays.filter((r) => keepUrl(r.url)), - httpWrite: (list.httpWrite ?? []).filter(keepUrl), - httpRead: (list.httpRead ?? []).filter(keepUrl), - httpOriginalRelays: (list.httpOriginalRelays ?? []).filter((r) => keepUrl(r.url)) + write: list.write.filter(keepWsUrl), + read: list.read.filter(keepWsUrl), + originalRelays: list.originalRelays.filter((r) => keepWsUrl(r.url)), + httpWrite: (list.httpWrite ?? []).filter(keepHttpIndexUrl), + httpRead: (list.httpRead ?? []).filter(keepHttpIndexUrl), + httpOriginalRelays: (list.httpOriginalRelays ?? []).filter((r) => keepHttpIndexUrl(r.url)) } } @@ -66,7 +70,7 @@ export function stripLocalNetworkRelaysForWssReq(urls: readonly string[]): strin const seen = new Set() const out: string[] = [] for (const raw of urls) { - if (isHttpRelayUrl(raw)) continue + if (/^https?:\/\//i.test(raw.trim())) continue const n = normalizeAnyRelayUrl(raw) || raw.trim() if (!n || isLocalNetworkUrl(n)) continue const key = (normalizeUrl(n) || n).toLowerCase() @@ -80,7 +84,9 @@ export function stripLocalNetworkRelaysForWssReq(urls: readonly string[]): strin const normRelayKey = (u: string): string => { const t = typeof u === 'string' ? u.trim() : '' if (!t) return '' - return (isHttpRelayUrl(t) ? normalizeAnyRelayUrl(t) : normalizeUrl(t)) || t + if (/^wss?:\/\//i.test(t)) return normalizeUrl(t) || t + if (/^https?:\/\//i.test(t)) return normalizeHttpRelayUrl(t) || t + return normalizeUrl(t) || normalizeHttpRelayUrl(t) || t } /** diff --git a/src/lib/relay-strikes.ts b/src/lib/relay-strikes.ts index ae7b3cb2..f65586f1 100644 --- a/src/lib/relay-strikes.ts +++ b/src/lib/relay-strikes.ts @@ -7,7 +7,7 @@ import { import type { Event } from 'nostr-tools' import { getRelayListFromEvent } from '@/lib/event-metadata' import logger from '@/lib/logger' -import { canonicalRelaySessionKey, isHttpRelayUrl } from '@/lib/url' +import { canonicalRelaySessionKey, httpIndexRelayBasesInUrlBatch } from '@/lib/url' import type { RelayOpTerminalRow } from '@/services/relay-operation-log.service' /** Conservative: 5 read/publish failures → skip until this many ms after last qualifying failure. */ @@ -189,7 +189,7 @@ class RelaySessionStrikes { e.readFailures += 1 if (e.readFailures >= STRIKE_FAILURES_THRESHOLD) { e.readStrikeSkipUntil = Math.max(e.readStrikeSkipUntil, now + STRIKE_COOLDOWN_MS) - logger.warn('[RelayStrikes] read path strike skip', { key, readFailures: e.readFailures }) + logger.debug('[RelayStrikes] read path strike skip', { key, readFailures: e.readFailures }) } } @@ -301,9 +301,10 @@ class RelaySessionStrikes { return out.length > 0 ? out : [...urls] } - filterReadHttpUrls(urls: readonly string[]): string[] { - const ws = urls.filter((u) => !isHttpRelayUrl(u)) - const http = urls.filter((u) => isHttpRelayUrl(u)) + filterReadHttpUrls(urls: readonly string[], httpIndexBases: readonly string[] = []): string[] { + const http = httpIndexRelayBasesInUrlBatch(urls, httpIndexBases) + const httpKeys = new Set(http.map((u) => canonicalRelaySessionKey(u))) + const ws = urls.filter((u) => !httpKeys.has(canonicalRelaySessionKey(u))) const singleWsRelay = ws.length <= 1 const wsOut = singleWsRelay ? [...ws] : ws.filter((u) => !this.isReadHttpSkipped(u)) const httpOut = http.filter((u) => !this.isReadHttpSkipped(u)) diff --git a/src/lib/relay-url-normalize.test.ts b/src/lib/relay-url-normalize.test.ts index af0661e3..e88232d6 100644 --- a/src/lib/relay-url-normalize.test.ts +++ b/src/lib/relay-url-normalize.test.ts @@ -1,20 +1,19 @@ import { describe, expect, it } from 'vitest' import { canonicalRelaySessionKey, + httpIndexRelayBasesInUrlBatch, normalizeAnyRelayUrl, normalizeHttpRelayUrl, normalizeUrl } from '@/lib/url' describe('relay URL normalization', () => { - it('keeps https index relays as https (no wss conversion)', () => { - const https = normalizeAnyRelayUrl('https://mercury-relay.imwald.eu/') - expect(https).toMatch(/^https:\/\/mercury-relay\.imwald\.eu\/?$/) - expect(https.startsWith('wss://')).toBe(false) + it('does not treat arbitrary https URLs as WebSocket relays', () => { + expect(normalizeAnyRelayUrl('https://mercury-relay.imwald.eu/')).toBe('') + expect(normalizeUrl('https://mercury-relay.imwald.eu/')).toBe('') expect(normalizeHttpRelayUrl('https://mercury-relay.imwald.eu/')).toMatch( /^https:\/\/mercury-relay\.imwald\.eu\/?$/ ) - expect(normalizeUrl('https://mercury-relay.imwald.eu/')).toBe('') }) it('keeps wss relays as wss', () => { @@ -28,6 +27,26 @@ describe('relay URL normalization', () => { expect(normalizeAnyRelayUrl('nostr.land')).toBe('') }) + it('only treats configured kind 10243 bases as HTTP index in a URL batch', () => { + const batch = [ + 'wss://nostr.land/', + 'https://mercury-relay.imwald.eu/', + 'https://profile-website.example/' + ] + const configured = ['https://mercury-relay.imwald.eu/'] + expect(httpIndexRelayBasesInUrlBatch(batch, configured)).toEqual([ + 'https://mercury-relay.imwald.eu/' + ]) + expect(httpIndexRelayBasesInUrlBatch(batch, [])).toEqual([]) + }) + + it('canonicalRelaySessionKey routes by scheme without cross-normalizing', () => { + expect(canonicalRelaySessionKey('wss://nostr.land/')).toMatch(/^wss:\/\/nostr\.land/) + expect(canonicalRelaySessionKey('https://mercury-relay.imwald.eu/')).toMatch( + /^https:\/\/mercury-relay\.imwald\.eu/ + ) + }) + it('does not alias https session keys to wss', () => { const https = canonicalRelaySessionKey('https://mercury-relay.imwald.eu/') const wss = canonicalRelaySessionKey('wss://mercury-relay.imwald.eu/') diff --git a/src/lib/url.ts b/src/lib/url.ts index 6d576b85..64152df9 100644 --- a/src/lib/url.ts +++ b/src/lib/url.ts @@ -16,12 +16,28 @@ export function isWebsocketUrl(url: string): boolean { return /^wss?:\/\/.+$/.test(url) } -/** Nostr relay over HTTPS (index relay JSON API), not WebSocket. */ -export function isHttpRelayUrl(url: string): boolean { +export function isWebSocketRelayScheme(url: string): boolean { + return /^wss?:\/\//i.test(url.trim()) +} + +export function isHttpOrHttpsScheme(url: string): boolean { + return /^https?:\/\//i.test(url.trim()) +} + +/** + * Kind **10243** `r` tag values use http(s) for the index-relay JSON API. + * Do not use this to classify arbitrary https:// URLs (profile websites, etc.). + */ +export function isKind10243HttpRelayTagUrl(url: string): boolean { const u = url.trim() return /^https?:\/\/.+/i.test(u) } +/** @deprecated Prefer {@link isKind10243HttpRelayTagUrl} only when parsing kind 10243. */ +export function isHttpRelayUrl(url: string): boolean { + return isKind10243HttpRelayTagUrl(url) +} + /** * Normalize https/http relay base URL without converting to WebSocket. * Use for kind 10243 and index-relay HTTP API calls (not for NIP-01 WS pool). @@ -54,11 +70,25 @@ export function devProxyLoopbackHttpRelayBase(normalizedBase: string): string { * In dev, `index-relay-http` rewrites the base to same-origin `/dev-cors-index-relay` (see `vite.config.ts`). * Keep this list tiny — each entry needs a matching Vite `proxy` target. */ -const DEV_HTTPS_INDEX_RELAY_CORS_PROXY_HOSTS = new Set(['nos.lol']) +const DEV_HTTPS_INDEX_RELAY_CORS_PROXY_HOSTS = new Set(['nos.lol', 'mercury-relay.imwald.eu']) + +function devIndexRelayTargetHostname(): string | null { + const raw = import.meta.env.VITE_DEV_INDEX_RELAY_TARGET + if (typeof raw !== 'string' || !raw.trim()) return null + try { + const withScheme = /^https?:\/\//i.test(raw.trim()) ? raw.trim() : `https://${raw.trim()}` + return new URL(withScheme).hostname.toLowerCase() + } catch { + return null + } +} /** - * Rewrite `https://nos.lol/...` index relay bases to the Vite dev proxy so POST /api/events/filter works. + * Rewrite HTTPS index relay bases to a same-origin Vite proxy so POST /api/events/filter works in dev. * Chain after `devProxyLoopbackHttpRelayBase`: `devProxyCorsProblematicHttpsIndexRelayBase(devProxyLoopbackHttpRelayBase(url))`. + * + * - Host matching `VITE_DEV_INDEX_RELAY_TARGET` → `/dev-index-relay` + * - Allowlisted hosts (e.g. nos.lol, mercury-relay.imwald.eu) → `/dev-cors-index-relay` */ export function devProxyCorsProblematicHttpsIndexRelayBase(normalizedBase: string): string { if (import.meta.env.PROD || typeof window === 'undefined') return normalizedBase @@ -69,7 +99,12 @@ export function devProxyCorsProblematicHttpsIndexRelayBase(normalizedBase: strin return normalizedBase } if (u.protocol !== 'https:') return normalizedBase - if (!DEV_HTTPS_INDEX_RELAY_CORS_PROXY_HOSTS.has(u.hostname.toLowerCase())) return normalizedBase + const host = u.hostname.toLowerCase() + const devTargetHost = devIndexRelayTargetHostname() + if (devTargetHost && host === devTargetHost) { + return `${window.location.origin}/dev-index-relay` + } + if (!DEV_HTTPS_INDEX_RELAY_CORS_PROXY_HOSTS.has(host)) return normalizedBase return `${window.location.origin}/dev-cors-index-relay` } @@ -78,25 +113,59 @@ export function relayUrlHasExplicitScheme(url: string): boolean { return /^(https?|wss?):\/\//i.test(url.trim()) } -/** - * Normalize a relay URL without changing its transport: `https://` stays HTTPS, `wss://` stays WebSocket. - */ +/** Normalize WebSocket relay URLs (`ws:` / `wss:`) for REQ pools and feed layers. */ export function normalizeAnyRelayUrl(url: string): string { + return normalizeUrl(url) +} + +/** Stable key for per-relay session stats (scheme preserved; no https→wss aliasing). */ +export function canonicalRelaySessionKey(url: string): string { const trimmed = url.trim() if (!trimmed) return '' - if (!relayUrlHasExplicitScheme(trimmed)) { - logger.warn('Relay URL requires http:, https:, ws:, or wss: prefix', { url: trimmed }) - return '' + if (isWebSocketRelayScheme(trimmed)) { + return (normalizeUrl(trimmed) || trimmed).toLowerCase() } - if (isHttpRelayUrl(trimmed)) return normalizeHttpRelayUrl(trimmed) || '' - if (isWebsocketUrl(trimmed)) return normalizeUrl(trimmed) || '' - logger.warn('Unsupported relay URL scheme', { url: trimmed }) - return '' + if (isHttpOrHttpsScheme(trimmed)) { + return (normalizeHttpRelayUrl(trimmed) || trimmed).toLowerCase() + } + return trimmed.toLowerCase() } -/** Stable key for per-relay session stats (scheme preserved; no https→wss aliasing). */ -export function canonicalRelaySessionKey(url: string): string { - return (normalizeAnyRelayUrl(url) || url.trim()).toLowerCase() +/** + * HTTP index relay bases present in `urls` that are also listed in kind **10243** storage + * (`httpRead` / `httpWrite`). URLs with https scheme that are not configured are ignored. + */ +export function httpIndexRelayBasesInUrlBatch( + urls: readonly string[], + configuredHttpIndexBases: readonly string[] +): string[] { + const configured = new Set( + configuredHttpIndexBases + .map((u) => normalizeHttpRelayUrl(u) || u.trim()) + .filter(Boolean) + .map((u) => u.toLowerCase()) + ) + if (configured.size === 0) return [] + const out = new Set() + for (const raw of urls) { + const n = normalizeHttpRelayUrl(raw) || raw.trim() + if (!n) continue + if (configured.has(n.toLowerCase())) out.add(n) + } + return [...out] +} + +export function urlMatchesConfiguredHttpIndexRelay( + url: string, + configuredHttpIndexBases: readonly string[] +): boolean { + const n = normalizeHttpRelayUrl(url) || url.trim() + if (!n) return false + const key = n.toLowerCase() + return configuredHttpIndexBases.some((b) => { + const nb = normalizeHttpRelayUrl(b) || b.trim() + return nb && nb.toLowerCase() === key + }) } // copy from nostr-tools/utils — WebSocket relays only (`ws:` / `wss:`); never rewrite http(s) schemes. @@ -113,7 +182,6 @@ export function normalizeUrl(url: string): string { stripTrailingCommasFromHostname(p) if (p.protocol !== 'ws:' && p.protocol !== 'wss:') { - logger.warn('normalizeUrl expects ws: or wss: (use normalizeHttpRelayUrl for http(s))', { url: trimmed }) return '' } @@ -162,13 +230,12 @@ export function normalizeHttpUrl(url: string): string { const trimmed = url.trim() if (!trimmed) return '' if (!trimmed.includes('://')) { - logger.warn('HTTP relay URL requires http: or https: prefix', { url: trimmed }) + logger.debug('HTTP URL requires http: or https: prefix', { url: trimmed }) return '' } const p = new URL(trimmed) stripTrailingCommasFromHostname(p) if (p.protocol !== 'http:' && p.protocol !== 'https:') { - logger.warn('normalizeHttpUrl expects http: or https: (use normalizeUrl for ws(s))', { url: trimmed }) return '' } p.pathname = p.pathname.replace(/\/+/g, '/') diff --git a/src/pages/primary/ExplorePage/index.tsx b/src/pages/primary/ExplorePage/index.tsx index 482239d8..3d193a59 100644 --- a/src/pages/primary/ExplorePage/index.tsx +++ b/src/pages/primary/ExplorePage/index.tsx @@ -25,7 +25,13 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { toRelay } from '@/lib/link' import { cn } from '@/lib/utils' -import { isHttpRelayUrl, isWebsocketUrl, normalizeAnyRelayUrl, simplifyUrl } from '@/lib/url' +import { + isKind10243HttpRelayTagUrl, + isWebsocketUrl, + normalizeAnyRelayUrl, + normalizeHttpRelayUrl, + simplifyUrl +} from '@/lib/url' const RELAY_SUGGESTION_LIMIT = 20 @@ -177,8 +183,8 @@ function ExploreRelaySearchSection({ const tryOpenRelay = () => { const trimmed = listFilter.trim() if (!trimmed) return - const normalized = normalizeAnyRelayUrl(trimmed) - if (!normalized || (!isHttpRelayUrl(normalized) && !isWebsocketUrl(normalized))) { + const normalized = normalizeAnyRelayUrl(trimmed) || normalizeHttpRelayUrl(trimmed) + if (!normalized || (!isWebsocketUrl(normalized) && !isKind10243HttpRelayTagUrl(normalized))) { toast.error(t('invalid relay URL')) return } diff --git a/src/providers/FavoriteRelaysProvider.tsx b/src/providers/FavoriteRelaysProvider.tsx index 77131aa5..99fbf1a2 100644 --- a/src/providers/FavoriteRelaysProvider.tsx +++ b/src/providers/FavoriteRelaysProvider.tsx @@ -5,7 +5,7 @@ import { createFavoriteRelaysDraftEvent, createBlockedRelaysDraftEvent, createRe import { getReplaceableEventIdentifier } from '@/lib/event' import { getRelaySetFromEvent } from '@/lib/event-metadata' import { randomString } from '@/lib/random' -import { isHttpRelayUrl, isWebsocketUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' +import { isWebsocketUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' import { queryService } from '@/services/client.service' import indexedDb from '@/services/indexed-db.service' import { TRelaySet } from '@/types' @@ -200,7 +200,7 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode async (relaySetName: string, relayUrls: string[] = []) => { const normalizedUrls = relayUrls .map((url) => normalizeAnyRelayUrl(url)) - .filter((url) => isWebsocketUrl(url) || isHttpRelayUrl(url)) + .filter((url) => isWebsocketUrl(url)) const id = randomString() const relaySetDraftEvent = createRelaySetDraftEvent({ id, diff --git a/src/providers/FeedProvider.tsx b/src/providers/FeedProvider.tsx index 491c1779..6a2475db 100644 --- a/src/providers/FeedProvider.tsx +++ b/src/providers/FeedProvider.tsx @@ -3,7 +3,10 @@ import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { getRelayListFromEvent, getHttpRelayListFromEvent } from '@/lib/event-metadata' import { buildAllFavoritesFeedRelayUrls, stripNostrLandAggrFromRelayUrls } from '@/lib/home-feed-relays' import logger from '@/lib/logger' -import { syncViewerRelayStackNostrLandAggrEligible } from '@/lib/nostr-land-relay-eligibility' +import { + syncViewerRelayStackNostrLandAggrEligible, + urlsForViewerNostrLandAggrEligibilitySync +} from '@/lib/nostr-land-relay-eligibility' import { normalizeAnyRelayUrl } from '@/lib/url' import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults' import { buildWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay' @@ -133,14 +136,15 @@ export function FeedProvider({ children }: { children: ReactNode }) { /** Keeps {@link getViewerRelayStackNostrLandAggrEligible} in sync for non-home reads (threads, profiles, etc.). */ useEffect(() => { - const urls = [ - ...favoriteFeedRelayUrls, - ...replyExtraRelayLayers.inboxRelayUrls, - ...replyExtraRelayLayers.outboxRelayUrls, - ...replyExtraRelayLayers.cacheRelayUrls, - ...replyExtraRelayLayers.httpRelayUrls - ] - syncViewerRelayStackNostrLandAggrEligible(urls) + syncViewerRelayStackNostrLandAggrEligible( + urlsForViewerNostrLandAggrEligibilitySync({ + favoriteRelayUrls: favoriteFeedRelayUrls, + relayListRead: replyExtraRelayLayers.inboxRelayUrls, + relayListWrite: replyExtraRelayLayers.outboxRelayUrls, + cacheRelayRead: replyExtraRelayLayers.cacheRelayUrls, + httpRelayRead: replyExtraRelayLayers.httpRelayUrls + }) + ) }, [favoriteFeedRelayUrls, replyExtraRelayLayers]) const lastHomeFeedUrlLogRef = useRef({ primary: '', reply: '' }) diff --git a/src/services/client-query.service.ts b/src/services/client-query.service.ts index ca87b8ac..52a8aa96 100644 --- a/src/services/client-query.service.ts +++ b/src/services/client-query.service.ts @@ -28,9 +28,10 @@ import { import { applyRelayNip42AckTimeout } from '@/lib/relay-nip42-tuning' import { isIndexRelayTransportFailure, queryIndexRelay } from '@/lib/index-relay-http' import logger from '@/lib/logger' +import { getViewerNostrLandAggrSearchRelayUrls } from '@/lib/nostr-land-relay-eligibility' import { canonicalRelaySessionKey, - isHttpRelayUrl, + httpIndexRelayBasesInUrlBatch, normalizeAnyRelayUrl, normalizeHttpRelayUrl, normalizeUrl @@ -230,6 +231,8 @@ export interface QueryOptions { firstRelayResultGraceMs?: number | false /** Label for {@link RelaySubscribeOpBatch} when this query opens REQs. */ relayOpSource?: string + /** Kind 10243 HTTP index bases; only matching URLs in `urls` use the index JSON API. */ + httpIndexRelayBases?: readonly string[] /** * When aborted (e.g. React effect cleanup / HMR), closes WS + HTTP index work promptly instead of waiting for * {@link globalTimeout}. Prevents overlapping NIP-50 shards from stacking until the tab OOMs. @@ -476,15 +479,11 @@ export class QueryService { ? FIRST_RELAY_RESULT_GRACE_MS : null - const httpRelayBases = Array.from( - new Set( - urls - .filter((u) => isHttpRelayUrl(u)) - .map((u) => normalizeHttpRelayUrl(u) || u) - .filter((u): u is string => Boolean(u) && !relaySessionStrikes.isReadHttpSkipped(u)) - ) + const httpRelayBases = httpIndexRelayBasesInUrlBatch(urls, options?.httpIndexRelayBases ?? []).filter( + (u) => !relaySessionStrikes.isReadHttpSkipped(u) ) - const wsQueryUrls = urls.filter((u) => !isHttpRelayUrl(u)) + const httpKeys = new Set(httpRelayBases.map((u) => canonicalRelaySessionKey(u))) + const wsQueryUrls = urls.filter((u) => !httpKeys.has(canonicalRelaySessionKey(u))) const reqId = ++queryReqSeq const source = options?.relayOpSource ?? 'QueryService.query' @@ -807,11 +806,12 @@ export class QueryService { relays = relayUrlsStripExtendedTagReqBlocked([...FAST_READ_RELAY_URLS]) } } - relays = relays.filter((url) => !isHttpRelayUrl(url)) + // WebSocket REQ only — drop https URLs (index relays use HTTP polling elsewhere). + relays = relays.filter((url) => !/^https?:\/\//i.test(url.trim())) const wsCountBeforeStrikes = relays.length if (wsCountBeforeStrikes > 1) { - relays = relaySessionStrikes.filterReadHttpUrls(relays) + relays = relaySessionStrikes.filterReadHttpUrls(relays, []) } if (relays.length === 0) { @@ -830,10 +830,11 @@ export class QueryService { const searchableSet = new Set( [ ...SEARCHABLE_RELAY_URLS, + ...getViewerNostrLandAggrSearchRelayUrls(), ...nip66Service.getSearchableRelayUrls(), ...PROFILE_RELAY_URLS ] - .map((u) => canonicalRelaySessionKey(normalizeUrl(u) || String(u).trim())) + .map((u) => canonicalRelaySessionKey(String(u).trim())) .filter((k): k is string => k.length > 0) ) diff --git a/src/services/client-replaceable-events.service.ts b/src/services/client-replaceable-events.service.ts index aa2a8bd3..e36f87a3 100644 --- a/src/services/client-replaceable-events.service.ts +++ b/src/services/client-replaceable-events.service.ts @@ -14,7 +14,7 @@ import { import { kinds, nip19 } from 'nostr-tools' import type { Event as NEvent, Filter } from 'nostr-tools' import DataLoader from 'dataloader' -import { isHttpRelayUrl, isWebsocketUrl, normalizeAnyRelayUrl, normalizeHttpUrl, normalizeUrl } from '@/lib/url' +import { isWebsocketUrl, normalizeAnyRelayUrl, normalizeHttpUrl, normalizeUrl } from '@/lib/url' import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata' import { LEGACY_PROFILE_BADGES_D_TAG } from '@/lib/nip58-profile-badges' import { formatPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib/pubkey' @@ -194,7 +194,7 @@ export class ReplaceableEventService { ...profileStack, ...hintLayer .map((u) => normalizeAnyRelayUrl(u) || normalizeUrl(u) || u.trim()) - .filter((u): u is string => !!u && !isHttpRelayUrl(u)) + .filter((u): u is string => !!u && !/^https?:\/\//i.test(u)) ]) ) } diff --git a/src/services/client.service.ts b/src/services/client.service.ts index f22bbbae..48369b80 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -149,14 +149,21 @@ import { stripLocalNetworkRelaysForWssReq, urlIsNonLocalForRemoteViewer } from '@/lib/relay-list-sanitize' +import { + getViewerNostrLandAggrSearchRelayUrls, + syncViewerRelayStackNostrLandAggrEligible +} from '@/lib/nostr-land-relay-eligibility' import { canonicalRelaySessionKey, - isHttpRelayUrl, + httpIndexRelayBasesInUrlBatch, + isKind10243HttpRelayTagUrl, isLocalNetworkUrl, + isWebsocketUrl, normalizeAnyRelayUrl, normalizeHttpRelayUrl, normalizeUrl, - simplifyUrl + simplifyUrl, + urlMatchesConfiguredHttpIndexRelay } from '@/lib/url' import { canonicalFeedFilter, canonicalRelayUrls } from '@/features/feed/descriptor' import { initRelayPoolIdle, touchRelayPoolActivity } from '@/lib/relay-pool-idle' @@ -356,6 +363,8 @@ class ClientService extends EventTarget { /** Set with signer from NostrProvider; used to skip relay AUTH when read-only (e.g. npub). */ signerType?: TSignerType pubkey?: string + /** Normalized kind **10243** index relay bases for the logged-in viewer (not arbitrary https URLs). */ + private viewerHttpIndexRelayBases: string[] = [] private pool: SimplePool // Sub-services (public for direct access) @@ -427,8 +436,8 @@ class ClientService extends EventTarget { if (!navigator.onLine && !isLocalNetworkUrl(url)) { throw new Error(`[offline] skipping non-local relay ${url}`) } - if (isHttpRelayUrl(url)) { - throw new Error(`[http-relay] ${url} uses the HTTPS index API, not WebSocket`) + if (!isWebsocketUrl(url) && isKind10243HttpRelayTagUrl(url)) { + throw new Error(`[http-index-relay] ${url} uses the HTTPS index API, not WebSocket`) } const n = normalizeUrl(url) || url const base = params?.connectionTimeout ?? RELAY_POOL_CONNECTION_TIMEOUT_MS @@ -604,12 +613,17 @@ class ClientService extends EventTarget { async syncViewerPersonalRelayKeys(pubkey?: string): Promise { const pk = pubkey?.trim() || this.pubkey?.trim() if (!pk) { + this.viewerHttpIndexRelayBases = [] setViewerPersonalRelayKeys(new Set()) + syncViewerRelayStackNostrLandAggrEligible([]) return } const urls: string[] = [] try { const rl = await this.peekRelayListFromStorage(pk) + this.viewerHttpIndexRelayBases = [...(rl.httpRead ?? []), ...(rl.httpWrite ?? [])] + .map((u) => normalizeHttpRelayUrl(u) || u) + .filter(Boolean) urls.push( ...(rl.read ?? []), ...(rl.write ?? []), @@ -617,6 +631,7 @@ class ClientService extends EventTarget { ...(rl.httpWrite ?? []) ) } catch { + this.viewerHttpIndexRelayBases = [] // ignore } try { @@ -630,6 +645,7 @@ class ClientService extends EventTarget { // ignore } setViewerPersonalRelayKeys(buildPersonalRelayKeySet(urls)) + syncViewerRelayStackNostrLandAggrEligible(urls) } /** NIP-66: fetch relay discovery events (30166) in background to supplement search/NIP support. */ @@ -1707,9 +1723,9 @@ class ClientService extends EventTarget { }, connectionTimeout + publishAckBudgetMs + 2_000) try { - if (isHttpRelayUrl(url)) { + if (urlMatchesConfiguredHttpIndexRelay(url, this.viewerHttpIndexRelayBases)) { const base = normalizeHttpRelayUrl(url) || url - logger.debug(`[PublishEvent] Publishing to HTTP index relay`, { url: base }) + logger.debug(`[PublishEvent] Publishing to kind 10243 HTTP index relay`, { url: base }) await Promise.race([ publishEventToHttpRelay(base, event), new Promise((_, reject) => @@ -1846,7 +1862,7 @@ class ClientService extends EventTarget { } } catch (error) { const softHttpDown = - isHttpRelayUrl(url) && + urlMatchesConfiguredHttpIndexRelay(url, this.viewerHttpIndexRelayBases) && (error instanceof IndexRelayTransportError || isIndexRelayTransportFailure(error)) if (softHttpDown) { logger.debug('[PublishEvent] HTTP index relay unreachable', { @@ -2421,8 +2437,13 @@ class ClientService extends EventTarget { relayReqLog?: { groupId?: string; onBatchEnd?: (rows: RelayOpTerminalRow[]) => void } ) { const originalDedupedRelays = Array.from(new Set(urls)) + const httpKeys = new Set( + httpIndexRelayBasesInUrlBatch(originalDedupedRelays, this.viewerHttpIndexRelayBases).map((u) => + canonicalRelaySessionKey(u) + ) + ) let relays = sanitizeRelayUrlsForFetch( - originalDedupedRelays.filter((url) => !isHttpRelayUrl(url)) + originalDedupedRelays.filter((url) => !httpKeys.has(canonicalRelaySessionKey(url))) ) if (navigator.onLine) { relays = stripLocalNetworkRelaysForWssReq(relays) @@ -2465,7 +2486,7 @@ class ClientService extends EventTarget { const wsRelayCountBeforeStrikes = relays.length if (wsRelayCountBeforeStrikes > 1) { - relays = relaySessionStrikes.filterReadHttpUrls(relays) + relays = relaySessionStrikes.filterReadHttpUrls(relays, this.viewerHttpIndexRelayBases) } // eslint-disable-next-line @typescript-eslint/no-this-alias @@ -2481,6 +2502,7 @@ class ClientService extends EventTarget { } const searchableSet = new Set([ ...SEARCHABLE_RELAY_URLS.map((u) => normalizeUrl(u) || u), + ...getViewerNostrLandAggrSearchRelayUrls().map((u) => normalizeUrl(u) || u), ...nip66Service.getSearchableRelayUrls().map((u) => normalizeUrl(u) || u) ]) const groupedRequests = Array.from(grouped.entries()).map(([url, f]) => { @@ -2865,14 +2887,7 @@ class ClientService extends EventTarget { let eosedAt: number | null = null let eventIds = new Set() - const httpTimelinePollBases = Array.from( - new Set( - relays - .filter((u) => isHttpRelayUrl(u)) - .map((u) => normalizeHttpRelayUrl(u) || u) - .filter(Boolean) - ) - ) + const httpTimelinePollBases = httpIndexRelayBasesInUrlBatch(relays, this.viewerHttpIndexRelayBases) let httpPollIntervalId: ReturnType | null = null let httpPollCursorUnix = 0 const clearHttpTimelinePoll = () => { @@ -3108,7 +3123,12 @@ class ClientService extends EventTarget { } // HTTP index relays are handled via httpTimelinePollBases above — never pass them to the WS subscribe path. - const wsRelays = relays.filter((u) => !isHttpRelayUrl(u)) + const httpPollKeys = new Set( + httpIndexRelayBasesInUrlBatch(relays, this.viewerHttpIndexRelayBases).map((u) => + canonicalRelaySessionKey(u) + ) + ) + const wsRelays = relays.filter((u) => !httpPollKeys.has(canonicalRelaySessionKey(u))) // When there are HTTP relays but NO WS relays, subscribe([]) would fire oneose + onBatchEnd // immediately (via microtask) — before the HTTP initial poll returns any events. That causes: @@ -3300,7 +3320,10 @@ class ClientService extends EventTarget { firstRelayResultGraceMs?: number | false } ) { - return this.queryService.query(sanitizeRelayUrlsForFetch(urls), filter, onevent, options) + return this.queryService.query(sanitizeRelayUrlsForFetch(urls), filter, onevent, { + ...options, + httpIndexRelayBases: this.viewerHttpIndexRelayBases + }) } // Legacy query implementation removed - now delegated to QueryService @@ -3330,16 +3353,13 @@ class ClientService extends EventTarget { } = {} ) { const originalDedupedRelays = Array.from(new Set(urls)) - const httpRelayBases = Array.from( - new Set( - originalDedupedRelays - .filter((u) => isHttpRelayUrl(u)) - .map((u) => normalizeHttpRelayUrl(u) || u) - .filter(Boolean) - ) + const httpRelayBases = httpIndexRelayBasesInUrlBatch( + originalDedupedRelays, + this.viewerHttpIndexRelayBases ) + const httpKeys = new Set(httpRelayBases.map((u) => canonicalRelaySessionKey(u))) const wsOriginal = sanitizeRelayUrlsForFetch( - originalDedupedRelays.filter((url) => !isHttpRelayUrl(url)) + originalDedupedRelays.filter((url) => !httpKeys.has(canonicalRelaySessionKey(url))) ) let relays = [...wsOriginal] if (relays.length === 0 && httpRelayBases.length === 0) { @@ -3367,7 +3387,8 @@ class ClientService extends EventTarget { firstRelayResultGraceMs, replaceableRace, immediateReturn, - foreground + foreground, + httpIndexRelayBases: this.viewerHttpIndexRelayBases }) if (cache) { events.forEach((evt) => { @@ -3411,8 +3432,8 @@ class ClientService extends EventTarget { }) } - if (isHttpRelayUrl(normalized)) { - // HTTP index relay: use HTTP API instead of WebSocket pool + if (urlMatchesConfiguredHttpIndexRelay(normalized, this.viewerHttpIndexRelayBases)) { + // Kind 10243 index relay: use HTTP API instead of WebSocket pool try { const events = await this.queryService.query([normalized], filter, undefined, queryOpts) return { events, connectionError: undefined } @@ -3686,6 +3707,7 @@ class ClientService extends EventTarget { ) const searchableSet = new Set([ ...SEARCHABLE_RELAY_URLS.map((u) => normalizeUrl(u) || u), + ...getViewerNostrLandAggrSearchRelayUrls().map((u) => normalizeUrl(u) || u), ...nip66Service.getSearchableRelayUrls().map((u) => normalizeUrl(u) || u), ...PROFILE_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) ]) diff --git a/src/services/relay-selection.service.ts b/src/services/relay-selection.service.ts index f98c058c..a850dc4f 100644 --- a/src/services/relay-selection.service.ts +++ b/src/services/relay-selection.service.ts @@ -9,7 +9,13 @@ import storage from '@/services/local-storage.service' import { NOSTR_URI_FOR_REPLY_PUBKEYS_REGEX } from '@/lib/content-patterns' import client from '@/services/client.service' import { eventService } from '@/services/client.service' -import { normalizeAnyRelayUrl, isLocalNetworkUrl } from '@/lib/url' +import { + canonicalRelaySessionKey, + isHttpOrHttpsScheme, + isLocalNetworkUrl, + normalizeAnyRelayUrl, + normalizeHttpRelayUrl +} from '@/lib/url' import { TRelaySet, TRelayList } from '@/types' import logger from '@/lib/logger' import indexedDb from '@/services/indexed-db.service' @@ -123,7 +129,9 @@ class RelaySelectionService { ) const relayTypes: Record = {} const httpSet = new Set( - (userHttpWriteRelays ?? []).map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean) + (userHttpWriteRelays ?? []) + .map((u) => canonicalRelaySessionKey(u)) + .filter(Boolean) ) filtered.forEach((url) => { relayTypes[url] = httpSet.has(url) ? 'http_relay_list' : 'relay_list' @@ -136,9 +144,12 @@ class RelaySelectionService { const addRelay = (url: string, type: RelaySourceType) => { if (!url) return - const normalized = normalizeAnyRelayUrl(url) - if (normalized && !seen.has(normalized)) { - seen.add(normalized) + const normalized = isHttpOrHttpsScheme(url) + ? normalizeHttpRelayUrl(url) + : normalizeAnyRelayUrl(url) + const key = normalized ? canonicalRelaySessionKey(normalized) : '' + if (key && !seen.has(key)) { + seen.add(key) order.push({ url: normalized, type }) } else if (!normalized) { logger.warn('Skipping invalid relay URL', { url }) diff --git a/vite.config.ts b/vite.config.ts index ffd3f5b2..20d3078c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -162,6 +162,12 @@ export default defineConfig(({ mode }) => { /** gc_index_relay (or compatible) HTTP API; app POSTs to /api/events/filter. HTTP 500 in the browser means this process errored, not that Vite failed. */ const devIndexRelayTarget = env.VITE_DEV_INDEX_RELAY_TARGET?.trim() || 'http://127.0.0.1:4000' + /** Target for `/dev-cors-index-relay` (allowlisted HTTPS index hosts in dev). */ + const devCorsIndexRelayTarget = + env.VITE_DEV_CORS_INDEX_RELAY_TARGET?.trim()?.replace(/\/+$/, '') || + (/^https:\/\//i.test(devIndexRelayTarget) + ? devIndexRelayTarget.replace(/\/+$/, '') + : 'https://mercury-relay.imwald.eu') /** * Desktop shell (`vite build --mode electron`): always bake public Imwald API origins into the bundle. @@ -261,7 +267,7 @@ export default defineConfig(({ mode }) => { * Same-origin proxy only — allowlisted hosts in {@link devProxyCorsProblematicHttpsIndexRelayBase}. */ '/dev-cors-index-relay': { - target: 'https://nos.lol', + target: devCorsIndexRelayTarget, changeOrigin: true, secure: true, rewrite: (p) => p.replace(/^\/dev-cors-index-relay/, '') || '/'