diff --git a/src/components/Explore/ExploreRelayReviews.tsx b/src/components/Explore/ExploreRelayReviews.tsx index dd7e78ab..b4ed2427 100644 --- a/src/components/Explore/ExploreRelayReviews.tsx +++ b/src/components/Explore/ExploreRelayReviews.tsx @@ -1,13 +1,17 @@ +import RelayIcon from '@/components/RelayIcon' import RelayReviewCard from '@/components/RelayInfo/RelayReviewCard' import { Skeleton } from '@/components/ui/skeleton' import { ExtendedKind } from '@/constants' +import { useFetchRelayInfo } from '@/hooks' import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' import { getRelayUrlFromRelayReviewEvent } from '@/lib/event-metadata' import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' +import { toRelay } from '@/lib/link' import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpellFeeds' +import { useSmartRelayNavigation } from '@/PageManager' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useNostr } from '@/providers/NostrProvider' import client from '@/services/client.service' @@ -16,6 +20,31 @@ import type { Event } from 'nostr-tools' import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +function RelayGroupHeader({ url, reviewCount }: { url: string; reviewCount: number }) { + const { navigateToRelay } = useSmartRelayNavigation() + const { relayInfo } = useFetchRelayInfo(url) + return ( + + ) +} + const REVIEW_QUERY_LIMIT = 100 const SHOW_COUNT = 20 /** Fewer sockets + faster aggregate EOSE than full inbox stack; read-only mirrors prepended then capped. */ @@ -149,6 +178,18 @@ export default function ExploreRelayReviews() { }, [showCount, events.length]) const visible = events.slice(0, showCount) + + const groupedVisible = useMemo(() => { + const groups = new Map() + for (const event of visible) { + const url = getRelayUrlFromRelayReviewEvent(event) + if (!url) continue + if (!groups.has(url)) groups.set(url, []) + groups.get(url)!.push(event) + } + return Array.from(groups.entries()) + }, [visible]) + const showInitialSkeleton = loading && events.length === 0 const showEmptyAfterLoad = !loading && events.length === 0 @@ -164,11 +205,21 @@ export default function ExploreRelayReviews() {

{t('no relays found')}

) : ( <> -
- {visible.map((event) => ( - - ))} -
+ {groupedVisible.map(([relayUrl, relayEvents]) => ( +
+ +
+ {relayEvents.map((event) => ( + + ))} +
+
+ ))} {loading ? (
getStarsFromRelayReviewEvent(event), [event]) const relayUrl = useMemo(() => getRelayUrlFromRelayReviewEvent(event), [event]) + const { relayInfo } = useFetchRelayInfo(relayUrl) return (
{ - // Don't navigate if clicking on interactive elements const target = e.target as HTMLElement if (target.closest('button') || target.closest('[role="button"]') || target.closest('a') || target.closest('[data-embedded-note]') || target.closest('[data-parent-note-preview]')) { return @@ -40,6 +42,24 @@ export default function RelayReviewCard({ navigateToNote(toNote(event), event) }} > + {showRelayInfo && relayUrl && ( + + )}
@@ -58,22 +78,7 @@ export default function RelayReviewCard({
-
-
- - {relayUrl ? ( - - ) : null} +
diff --git a/src/hooks/useFetchCalendarRsvps.tsx b/src/hooks/useFetchCalendarRsvps.tsx index 6c331a60..0a0a31d0 100644 --- a/src/hooks/useFetchCalendarRsvps.tsx +++ b/src/hooks/useFetchCalendarRsvps.tsx @@ -6,7 +6,7 @@ import { queryService } from '@/services/client.service' import { useNostr } from '@/providers/NostrProvider' import { Event } from 'nostr-tools' import { useEffect, useState } from 'react' -import { normalizeUrl } from '@/lib/url' +import { normalizeAnyRelayUrl } from '@/lib/url' import { FAST_READ_RELAY_URLS } from '@/constants' import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' import { tagNameEquals } from '@/lib/tag' @@ -42,8 +42,8 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) { const coordinate = getReplaceableCoordinateFromEvent(calendarEvent) const userRead = userReadRelaysWithHttp(relayList) const baseUrls = new Set([ - ...FAST_READ_RELAY_URLS.map((url) => normalizeUrl(url) || url), - ...userRead.map((url) => normalizeUrl(url) || url) + ...FAST_READ_RELAY_URLS.map((url) => normalizeAnyRelayUrl(url) || url), + ...userRead.map((url) => normalizeAnyRelayUrl(url) || url) ].filter(Boolean) as string[]) // Include organizer's relays so RSVPs are found when viewing an attendee's profile (RSVPs are often on organizer's outbox/inbox) @@ -52,12 +52,13 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) { .fetchRelayList(organizerPubkey) .then((organizerRelays) => { if (cancelled) return - organizerRelays?.read?.forEach((url) => { - const u = normalizeUrl(url) - if (u) baseUrls.add(u) - }) - organizerRelays?.write?.forEach((url) => { - const u = normalizeUrl(url) + ;[ + ...(organizerRelays?.httpRead ?? []), + ...(organizerRelays?.read ?? []), + ...(organizerRelays?.httpWrite ?? []), + ...(organizerRelays?.write ?? []) + ].forEach((url) => { + const u = normalizeAnyRelayUrl(url) if (u) baseUrls.add(u) }) return Array.from(baseUrls) diff --git a/src/hooks/useProfileTimeline.tsx b/src/hooks/useProfileTimeline.tsx index bc1ff1c8..f128ec47 100644 --- a/src/hooks/useProfileTimeline.tsx +++ b/src/hooks/useProfileTimeline.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Event } from 'nostr-tools' import { CALENDAR_EVENT_KINDS, ExtendedKind, isSocialKindBlockedKind } from '@/constants' import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays' -import { normalizeUrl, subtractNormalizedRelayUrls } from '@/lib/url' +import { normalizeAnyRelayUrl, subtractNormalizedRelayUrls } from '@/lib/url' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' type ProfileTimelineMemoryEntry = { @@ -112,8 +112,8 @@ function postProcessEvents( } function relayListsContentKey(favoriteRelays: string[], blockedRelays: string[]): string { - const fav = [...favoriteRelays].map((u) => normalizeUrl(u) || u).filter(Boolean).sort().join('\u0001') - const blk = [...blockedRelays].map((u) => normalizeUrl(u) || u).filter(Boolean).sort().join('\u0001') + const fav = [...favoriteRelays].map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean).sort().join('\u0001') + const blk = [...blockedRelays].map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean).sort().join('\u0001') return `${fav}\u0000${blk}` } @@ -250,7 +250,9 @@ export function useProfileTimeline({ void (async () => { const authorRl = await client.fetchRelayList(pubkey).catch(() => ({ read: [] as string[], - write: [] as string[] + write: [] as string[], + httpRead: [] as string[], + httpWrite: [] as string[] })) if (cancelled) return const fullFeedUrls = buildProfilePageReadRelayUrls( diff --git a/src/lib/event-metadata.ts b/src/lib/event-metadata.ts index 20829d61..a215cd64 100644 --- a/src/lib/event-metadata.ts +++ b/src/lib/event-metadata.ts @@ -6,7 +6,7 @@ import { getReplaceableEventIdentifier } from './event' import { getAmountFromInvoice, getLightningAddressFromProfile } from './lightning' import { formatPubkey, pubkeyToNpub } from './pubkey' import { generateBech32IdFromATag, generateBech32IdFromETag, getImetaInfoFromImetaTag, tagNameEquals } from './tag' -import { isHttpRelayUrl, isWebsocketUrl, normalizeHttpRelayUrl, normalizeHttpUrl, normalizeUrl } from './url' +import { isHttpRelayUrl, isWebsocketUrl, normalizeAnyRelayUrl, normalizeHttpRelayUrl, normalizeHttpUrl, normalizeUrl } from './url' import { isTorBrowser } from './utils' import logger from '@/lib/logger' @@ -712,5 +712,5 @@ export function getStarsFromRelayReviewEvent(event: Event): number { export function getRelayUrlFromRelayReviewEvent(event: Event): string | undefined { const d = event.tags.find((t) => t[0] === 'd')?.[1]?.trim() if (!d) return undefined - return normalizeUrl(d) || d + return normalizeAnyRelayUrl(d) || d } diff --git a/src/lib/favorites-feed-relays.ts b/src/lib/favorites-feed-relays.ts index 69b195f3..0ce28332 100644 --- a/src/lib/favorites-feed-relays.ts +++ b/src/lib/favorites-feed-relays.ts @@ -5,7 +5,7 @@ import { relayFilterIncludesSocialKindBlockedKind } from '@/constants' import type { TFeedSubRequest } from '@/types' -import { normalizeUrl } from '@/lib/url' +import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' import { buildPrioritizedReadRelayUrls, buildReadRelayPriorityLayers, @@ -14,10 +14,9 @@ import { mergeRelayPriorityLayers, relayUrlsLocalsFirst } from '@/lib/relay-url-priority' -import { normalizeAnyRelayUrl } from '@/lib/url' const blockedSet = (blockedRelays: string[]) => - new Set(blockedRelays.map((b) => normalizeUrl(b) || b)) + new Set(blockedRelays.map((b) => normalizeAnyRelayUrl(b) || b)) /** * Logged-in user’s favorite relays (kind 10012 `relay` tags via {@link useFavoriteRelays}, plus bootstrap defaults @@ -42,14 +41,14 @@ export function getFavoritesFeedRelayUrls( ): string[] { const blocked = blockedSet(blockedRelays) const visible = favoriteRelays.filter((r) => { - const k = normalizeUrl(r) || r + const k = normalizeAnyRelayUrl(r) || r return k && !blocked.has(k) }) const base = visible.length > 0 ? visible : DEFAULT_FAVORITE_RELAYS const seen = new Set() const out: string[] = [] for (const u of base) { - const k = normalizeUrl(u) || u + const k = normalizeAnyRelayUrl(u) || u if (!k || seen.has(k)) continue seen.add(k) out.push(k) @@ -66,7 +65,7 @@ export function mergeRelayUrlLayers(layers: string[][], blockedRelays: string[]) const out: string[] = [] for (const layer of layers) { for (const u of layer) { - const k = normalizeUrl(u) || u + const k = normalizeAnyRelayUrl(u) || u if (!k || blocked.has(k) || seen.has(k)) continue seen.add(k) out.push(k) @@ -80,11 +79,17 @@ export function mergeRelayUrlLayers(layers: string[][], blockedRelays: string[]) * stripped. Used for profile pins + Medien before {@link buildProfileAugmentedReadRelayUrls}. */ export function buildAuthorInboxOutboxRelayUrls( - authorRelayList: { read: string[]; write: string[] }, + authorRelayList: { read: string[]; write: string[]; httpRead?: string[]; httpWrite?: string[] }, blockedRelays: string[] ): string[] { - const inboxLayer = relayUrlsLocalsFirst(authorRelayList.read ?? []) - const outboxLayer = relayUrlsLocalsFirst(authorRelayList.write ?? []) + const inboxLayer = relayUrlsLocalsFirst([ + ...(authorRelayList.httpRead ?? []), + ...(authorRelayList.read ?? []) + ]) + const outboxLayer = relayUrlsLocalsFirst([ + ...(authorRelayList.httpWrite ?? []), + ...(authorRelayList.write ?? []) + ]) return mergeRelayUrlLayers([inboxLayer, outboxLayer], blockedRelays) } @@ -161,15 +166,15 @@ export const PROFILE_PAGE_PINS_RESOLVE_LIMIT = 10 export function buildProfilePageReadRelayUrls( favoriteRelays: string[], blockedRelays: string[], - authorRelayList: { read: string[]; write: string[] }, + authorRelayList: { read: string[]; write: string[]; httpRead?: string[]; httpWrite?: string[] }, kindsIncludeSocialBlockedKind: boolean ): string[] { return getRelayUrlsWithFavoritesFastReadAndInbox( favoriteRelays, blockedRelays, - authorRelayList.read ?? [], + [...(authorRelayList.httpRead ?? []), ...(authorRelayList.read ?? [])], { - userWriteRelays: authorRelayList.write ?? [], + userWriteRelays: [...(authorRelayList.httpWrite ?? []), ...(authorRelayList.write ?? [])], authorWriteRelays: [], maxRelays: PROFILE_PAGE_FEED_MAX_RELAYS, applySocialKindBlockedFilter: kindsIncludeSocialBlockedKind diff --git a/src/lib/index-relay-http.ts b/src/lib/index-relay-http.ts index 12fd7780..7325b96d 100644 --- a/src/lib/index-relay-http.ts +++ b/src/lib/index-relay-http.ts @@ -197,7 +197,12 @@ export async function queryIndexRelay( } } if (sawHardFailure && out.length === 0 && filters.length > 0) { - options?.onHardFailure?.() + // In dev, transport failures on the Vite loopback proxy (relay unreachable / proxy not yet ready) + // should not record session strikes — the relay may be temporarily down or the dev server + // needs a restart. Only real application errors (4xx/5xx from a live relay) trigger strikes in dev. + if (!isDevViteIndexRelayProxyPath(endpoint)) { + options?.onHardFailure?.() + } } return out } diff --git a/src/lib/url.ts b/src/lib/url.ts index 51b5f91f..0eed0f6b 100644 --- a/src/lib/url.ts +++ b/src/lib/url.ts @@ -33,6 +33,7 @@ export function normalizeHttpRelayUrl(url: string): string { /** * In dev, loopback HTTP relay bases (`http://localhost:*` / `http://127.0.0.1:*`) use the Vite * same-origin `/dev-index-relay` proxy (see `vite.config.ts`) so JSON APIs and NIP-11 avoid CORS. + * Only used for the configured HTTP index relay — WS relay NIP-11 fetches bypass this proxy. */ export function devProxyLoopbackHttpRelayBase(normalizedBase: string): string { if (import.meta.env.PROD || typeof window === 'undefined') return normalizedBase diff --git a/src/services/client.service.ts b/src/services/client.service.ts index e6dce3d0..d93ca47b 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -2578,20 +2578,48 @@ 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)) + + // 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: + // (a) handleTimelineEose to set eosedAt=now with 0 events, so HTTP poll events arrive + // post-eose and land in onNew rather than the initial-load onEvents path. + // (b) onBatchEnd([]) (empty row array) → feedSubscribeRelayOutcomes stays length 0 → + // the "Looking for more events…" banner never clears. + // Fix: for HTTP-only shards, skip oneose + relayReqLog on the (no-op) WS subscribe and + // defer both to after the HTTP initial poll completes. + const httpOnlyShard = httpTimelinePollBases.length > 0 && wsRelays.length === 0 + const subCloser = this.subscribe(wsRelays, filter, { startLogin, onevent: (evt: NEvent) => { applySubscribedTimelineEvent(evt) }, - oneose: handleTimelineEose, + oneose: httpOnlyShard ? undefined : handleTimelineEose, onclose: onClose }, - relayReqLog) + httpOnlyShard ? undefined : relayReqLog) if (httpTimelinePollBases.length > 0) { const backfillFilter = { ...(filter as Filter) } as Filter & { until?: number } delete backfillFilter.until - void runHttpTimelinePollQuery(backfillFilter) + const httpInitialPoll = runHttpTimelinePollQuery(backfillFilter) + if (httpOnlyShard) { + void httpInitialPoll.then(() => { + // Report HTTP relay outcomes first so feedSubscribeRelayOutcomes is non-empty + // before feedTimelineEmptyUiReady flips to true (both land in the same React batch). + if (relayReqLog?.onBatchEnd) { + const t0 = performance.now() + const httpRows: RelayOpTerminalRow[] = httpTimelinePollBases.map((url, i) => ({ + cmdIndex: i, + relayUrl: url, + outcome: 'eose' as const, + msFromBatchStart: Math.round(performance.now() - t0) + })) + relayReqLog.onBatchEnd(httpRows) + } + handleTimelineEose(true) + }) + } } return { @@ -2662,7 +2690,7 @@ class ClientService extends EventTarget { */ getSeenEventRelayUrls(eventId: string): string[] { const key = canonicalSeenOnEventId(eventId) - const poolUrls = this.getSeenEventRelays(key).map((r) => normalizeUrl(r.url) || r.url) + const poolUrls = this.getSeenEventRelays(key).map((r) => normalizeAnyRelayUrl(r.url) || r.url) const queryUrls = this.queryService.getSeenEventRelayUrls(key).map((u) => normalizeAnyRelayUrl(u) || u) return Array.from(new Set([...poolUrls, ...queryUrls].filter(Boolean))) } diff --git a/src/services/relay-info.service.ts b/src/services/relay-info.service.ts index 2e3cc78b..f2052db3 100644 --- a/src/services/relay-info.service.ts +++ b/src/services/relay-info.service.ts @@ -124,6 +124,11 @@ class RelayInfoService { const at = relayInfo.cachedAt if (at == null) return true const age = Date.now() - at + // In dev, use a shorter TTL for localhost relay URLs so stale data from proxy misconfigurations + // (e.g. wrong NIP-11 cached for ws://localhost:7777) self-heals within the same session. + if (import.meta.env.DEV && /^(ws|wss|http|https):\/\/localhost/.test(relayInfo.url)) { + return age > 30 * 60 * 1000 + } const hasNip11Data = !!(relayInfo.name || relayInfo.description || relayInfo.pubkey) if (!hasNip11Data) return age > RelayInfoService.RELAY_INFO_EMPTY_RETRY_TTL_MS const hasImages = !!(relayInfo.icon || relayInfo.banner) @@ -158,7 +163,11 @@ class RelayInfoService { try { const httpCandidate = url.trim().replace(/^ws:\/\//i, 'http://').replace(/^wss:\/\//i, 'https://') const httpBase = normalizeHttpRelayUrl(httpCandidate) || httpCandidate - const fetchUrl = devProxyLoopbackHttpRelayBase(httpBase) + // WS relay NIP-11 must NOT go through the dev proxy — the proxy is fixed to the HTTP index relay + // port and would return that relay's NIP-11 for any localhost WS relay (wrong data). + // HTTP index relay URLs do use the proxy to avoid CORS. + const isWsRelay = /^wss?:\/\//i.test(url.trim()) + const fetchUrl = isWsRelay ? httpBase : devProxyLoopbackHttpRelayBase(httpBase) logger.debug('[RelayInfo] Fetching NIP-11', { url, fetchUrl }) const res = await fetchWithTimeout(fetchUrl, { headers: { Accept: 'application/nostr+json' },