Browse Source

update relay review card

imwald
Silberengel 3 weeks ago
parent
commit
f414261b1d
  1. 57
      src/components/Explore/ExploreRelayReviews.tsx
  2. 2
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  3. 45
      src/components/RelayInfo/RelayReviewCard.tsx
  4. 19
      src/hooks/useFetchCalendarRsvps.tsx
  5. 10
      src/hooks/useProfileTimeline.tsx
  6. 4
      src/lib/event-metadata.ts
  7. 29
      src/lib/favorites-feed-relays.ts
  8. 5
      src/lib/index-relay-http.ts
  9. 1
      src/lib/url.ts
  10. 36
      src/services/client.service.ts
  11. 11
      src/services/relay-info.service.ts

57
src/components/Explore/ExploreRelayReviews.tsx

@ -1,13 +1,17 @@ @@ -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' @@ -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 (
<button
type="button"
className="flex w-full min-w-0 items-center gap-2 px-4 md:px-4 pt-4 pb-2 border-b text-left hover:opacity-75 transition-opacity"
onClick={() => navigateToRelay(toRelay(url))}
>
<RelayIcon url={url} className="h-8 w-8 shrink-0 rounded-sm" iconSize={16} />
<div className="min-w-0 flex-1">
{relayInfo?.name && (
<div className="truncate font-semibold text-sm leading-tight">{relayInfo.name}</div>
)}
<div className="flex items-center gap-1.5 min-w-0">
<div className="truncate font-mono text-xs text-muted-foreground leading-tight">{url}</div>
<span className="shrink-0 text-xs text-muted-foreground">
· {reviewCount} {reviewCount === 1 ? 'review' : 'reviews'}
</span>
</div>
</div>
</button>
)
}
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() { @@ -149,6 +178,18 @@ export default function ExploreRelayReviews() {
}, [showCount, events.length])
const visible = events.slice(0, showCount)
const groupedVisible = useMemo(() => {
const groups = new Map<string, Event[]>()
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() { @@ -164,11 +205,21 @@ export default function ExploreRelayReviews() {
<p className="px-4 py-6 text-center text-sm text-muted-foreground">{t('no relays found')}</p>
) : (
<>
<div className="grid min-w-0 md:px-4 md:grid-cols-2 md:gap-3">
{visible.map((event) => (
<RelayReviewCard key={event.id} event={event} className="border-b md:border md:border-border" />
{groupedVisible.map(([relayUrl, relayEvents]) => (
<div key={relayUrl} className="mb-4">
<RelayGroupHeader url={relayUrl} reviewCount={relayEvents.length} />
<div className="grid min-w-0 md:px-4 md:grid-cols-2 md:gap-3 mt-2">
{relayEvents.map((event) => (
<RelayReviewCard
key={event.id}
event={event}
showRelayInfo={false}
className="border-b md:border md:border-border"
/>
))}
</div>
</div>
))}
{loading ? (
<div
className="mt-4 grid min-w-0 gap-3 md:grid-cols-2 md:px-4"

2
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -583,7 +583,7 @@ function normalizeSetextHeaders(content: string): string { @@ -583,7 +583,7 @@ function normalizeSetextHeaders(content: string): string {
* Returns both rendered nodes and a set of hashtags found in content (for deduplication)
*/
// Deprecated legacy parser kept only as a fallback reference during migration.
export function parseMarkdownContentLegacy(
function parseMarkdownContentLegacy(
content: string,
options: {
eventPubkey: string

45
src/components/RelayInfo/RelayReviewCard.tsx

@ -1,37 +1,39 @@ @@ -1,37 +1,39 @@
import { useSmartNoteNavigation, useSmartRelayNavigation } from '@/PageManager'
import { getRelayUrlFromRelayReviewEvent, getStarsFromRelayReviewEvent } from '@/lib/event-metadata'
import { toNote, toRelay } from '@/lib/link'
import { simplifyUrl } from '@/lib/url'
import { cn } from '@/lib/utils'
import { useFetchRelayInfo } from '@/hooks'
import client from '@/services/client.service'
import { Link2 } from 'lucide-react'
import { NostrEvent } from 'nostr-tools'
import { useMemo } from 'react'
import ClientTag from '../ClientTag'
import ContentPreview from '../ContentPreview'
import { FormattedTimestamp } from '../FormattedTimestamp'
import Nip05 from '../Nip05'
import RelayIcon from '../RelayIcon'
import Stars from '../Stars'
import { SimpleUserAvatar } from '../UserAvatar'
import { SimpleUsername } from '../Username'
export default function RelayReviewCard({
event,
className
className,
showRelayInfo = true
}: {
event: NostrEvent
className?: string
showRelayInfo?: boolean
}) {
const { navigateToNote } = useSmartNoteNavigation()
const { navigateToRelay } = useSmartRelayNavigation()
const stars = useMemo(() => getStarsFromRelayReviewEvent(event), [event])
const relayUrl = useMemo(() => getRelayUrlFromRelayReviewEvent(event), [event])
const { relayInfo } = useFetchRelayInfo(relayUrl)
return (
<div
className={cn('clickable border rounded-lg bg-muted/20 p-3 h-full', className)}
onClick={(e) => {
// 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({ @@ -40,6 +42,24 @@ export default function RelayReviewCard({
navigateToNote(toNote(event), event)
}}
>
{showRelayInfo && relayUrl && (
<button
type="button"
className="flex w-full min-w-0 items-center gap-2 rounded-md border bg-muted/30 px-2 py-1.5 text-left hover:bg-muted/60 mb-2"
onClick={(e) => {
e.stopPropagation()
navigateToRelay(toRelay(relayUrl))
}}
>
<RelayIcon url={relayUrl} className="h-6 w-6 shrink-0" iconSize={12} />
<div className="min-w-0 flex-1">
{relayInfo?.name && (
<div className="truncate text-xs font-semibold leading-tight">{relayInfo.name}</div>
)}
<div className="truncate font-mono text-xs text-muted-foreground leading-tight">{relayUrl}</div>
</div>
</button>
)}
<div className="flex justify-between items-start gap-2">
<div className="flex items-center space-x-2 flex-1">
<SimpleUserAvatar userId={event.pubkey} size="medium" />
@ -58,22 +78,7 @@ export default function RelayReviewCard({ @@ -58,22 +78,7 @@ export default function RelayReviewCard({
</div>
</div>
</div>
</div>
<div className="mt-2 flex flex-wrap items-center justify-between gap-x-2 gap-y-1">
<Stars stars={stars} className="gap-0.5 [&_svg]:size-3 shrink-0" />
{relayUrl ? (
<button
type="button"
className="flex min-w-0 max-w-full items-center gap-1 text-left text-xs text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation()
navigateToRelay(toRelay(relayUrl))
}}
>
<Link2 className="size-3 shrink-0" aria-hidden />
<span className="truncate font-mono">{simplifyUrl(relayUrl)}</span>
</button>
) : null}
<Stars stars={stars} className="gap-0.5 [&_svg]:size-3 shrink-0 mt-0.5" />
</div>
<ContentPreview className="mt-2 line-clamp-4" event={event} />
</div>

19
src/hooks/useFetchCalendarRsvps.tsx

@ -6,7 +6,7 @@ import { queryService } from '@/services/client.service' @@ -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) { @@ -42,8 +42,8 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) {
const coordinate = getReplaceableCoordinateFromEvent(calendarEvent)
const userRead = userReadRelaysWithHttp(relayList)
const baseUrls = new Set<string>([
...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) { @@ -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)

10
src/hooks/useProfileTimeline.tsx

@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -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( @@ -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({ @@ -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(

4
src/lib/event-metadata.ts

@ -6,7 +6,7 @@ import { getReplaceableEventIdentifier } from './event' @@ -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 { @@ -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
}

29
src/lib/favorites-feed-relays.ts

@ -5,7 +5,7 @@ import { @@ -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 { @@ -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 users favorite relays (kind 10012 `relay` tags via {@link useFavoriteRelays}, plus bootstrap defaults
@ -42,14 +41,14 @@ export function getFavoritesFeedRelayUrls( @@ -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<string>()
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[]) @@ -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[]) @@ -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 @@ -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

5
src/lib/index-relay-http.ts

@ -197,8 +197,13 @@ export async function queryIndexRelay( @@ -197,8 +197,13 @@ export async function queryIndexRelay(
}
}
if (sawHardFailure && out.length === 0 && filters.length > 0) {
// 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
}

1
src/lib/url.ts

@ -33,6 +33,7 @@ export function normalizeHttpRelayUrl(url: string): string { @@ -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

36
src/services/client.service.ts

@ -2578,20 +2578,48 @@ class ClientService extends EventTarget { @@ -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 { @@ -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)))
}

11
src/services/relay-info.service.ts

@ -124,6 +124,11 @@ class RelayInfoService { @@ -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 { @@ -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' },

Loading…
Cancel
Save