Browse Source

http relay bug-fixes

imwald
Silberengel 3 weeks ago
parent
commit
423b501232
  1. 2
      src/components/Relay/index.tsx
  2. 43
      src/components/RelayIcon/index.tsx
  3. 21
      src/components/RelayInfo/RelayReviewsPreview.tsx
  4. 10
      src/components/RelayInfo/index.tsx
  5. 4
      src/components/ReplyNoteList/index.tsx
  6. 2
      src/lib/index-relay-http.ts
  7. 25
      src/pages/secondary/RelayReviewsPage/index.tsx
  8. 6
      src/services/client-query.service.ts
  9. 1
      src/services/client.service.ts
  10. 6
      src/services/note-stats.service.ts
  11. 31
      src/services/relay-info.service.ts
  12. 1
      src/types/index.d.ts

2
src/components/Relay/index.tsx

@ -98,7 +98,7 @@ const Relay = forwardRef< @@ -98,7 +98,7 @@ const Relay = forwardRef<
}, [normalizedUrl, noteListRef])
const relayFeedSubRequests = useMemo<TFeedSubRequest[]>(() => {
if (!normalizedUrl || isHttpRelay) return []
if (!normalizedUrl) return []
const q = debouncedInput.trim()
return [
{

43
src/components/RelayIcon/index.tsx

@ -1,9 +1,31 @@ @@ -1,9 +1,31 @@
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { useFetchRelayInfo } from '@/hooks'
import { cn } from '@/lib/utils'
import logger from '@/lib/logger'
import { Server } from 'lucide-react'
import { useMemo } from 'react'
/**
* Resolve an image URL from NIP-11. Handles:
* - Absolute HTTP(S) URLs used as-is
* - Relative paths (e.g. "/favicon.ico") resolved against the relay's base HTTP URL
* - ws(s):// URLs some relays mistakenly return → ignored, fall through to favicon
*/
function resolveRelayImageUrl(raw: string, relayUrl: string): string | undefined {
if (!raw) return undefined
if (raw.startsWith('https://') || raw.startsWith('http://')) return raw
if (raw.startsWith('/')) {
try {
const base = relayUrl.replace(/^wss?:\/\//i, 'https://').replace(/^ws:\/\//i, 'http://')
const u = new URL(base)
return `${u.protocol}//${u.host}${raw}`
} catch {
return undefined
}
}
return undefined
}
export default function RelayIcon({
url,
className,
@ -15,16 +37,23 @@ export default function RelayIcon({ @@ -15,16 +37,23 @@ export default function RelayIcon({
}) {
const { relayInfo } = useFetchRelayInfo(url)
const iconUrl = useMemo(() => {
const raw = relayInfo?.icon && typeof relayInfo.icon === 'string' ? relayInfo.icon : undefined
// Only use HTTP(S) URLs for images; reject ws(s):// (e.g. some relays return relay URL as icon)
if (raw && (raw.startsWith('https://') || raw.startsWith('http://'))) {
return raw
}
if (!url) return undefined
// Prefer the NIP-11 icon field
const rawIcon = relayInfo?.icon && typeof relayInfo.icon === 'string' ? relayInfo.icon : undefined
const nip11Icon = rawIcon ? resolveRelayImageUrl(rawIcon, url) : undefined
if (nip11Icon) {
logger.debug('[RelayIcon] using NIP-11 icon', { url, rawIcon, nip11Icon })
return nip11Icon
}
// Fall back to /favicon.ico at the relay's host
try {
const u = new URL(url)
const href = `${u.protocol === 'wss:' ? 'https:' : 'http:'}//${u.host}/favicon.ico`
return href
const scheme = u.protocol === 'wss:' ? 'https:' : 'http:'
const favicon = `${scheme}//${u.host}/favicon.ico`
logger.debug('[RelayIcon] using favicon fallback', { url, rawIcon, favicon })
return favicon
} catch {
return undefined
}

21
src/components/RelayInfo/RelayReviewsPreview.tsx

@ -7,7 +7,12 @@ import { @@ -7,7 +7,12 @@ import {
CarouselNext,
CarouselPrevious
} from '@/components/ui/carousel'
import { FAST_READ_RELAY_URLS, ExtendedKind } from '@/constants'
import { ExtendedKind } from '@/constants'
import {
getRelayUrlsWithFavoritesFastReadAndInbox,
userReadRelaysWithHttp
} from '@/lib/favorites-feed-relays'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { compareEvents } from '@/lib/event'
import { getStarsFromRelayReviewEvent } from '@/lib/event-metadata'
import { toRelayReviews } from '@/lib/link'
@ -35,7 +40,8 @@ import ReviewEditor from './ReviewEditor' @@ -35,7 +40,8 @@ import ReviewEditor from './ReviewEditor'
export default function RelayReviewsPreview({ relayUrl }: { relayUrl: string }) {
const { t } = useTranslation()
const { push } = useSecondaryPage()
const { pubkey, checkLogin } = useNostr()
const { pubkey, checkLogin, relayList } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const { hideUntrustedNotes, isUserTrusted } = useUserTrust()
const { mutePubkeySet } = useMuteList()
const [showEditor, setShowEditor] = useState(false)
@ -112,9 +118,12 @@ export default function RelayReviewsPreview({ relayUrl }: { relayUrl: string }) @@ -112,9 +118,12 @@ export default function RelayReviewsPreview({ relayUrl }: { relayUrl: string })
setReviews([...seedByPubkey.values()].sort((a, b) => compareEvents(b, a)))
}
const uniqueUrls = [
...new Set([normalizedTarget, ...FAST_READ_RELAY_URLS.map((u) => normalizeUrl(u) || u)])
]
const base = getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays,
blockedRelays,
userReadRelaysWithHttp(relayList)
)
const uniqueUrls = [...new Set([normalizedTarget, ...base])]
const filter = {
kinds: [ExtendedKind.RELAY_REVIEW],
@ -153,7 +162,7 @@ export default function RelayReviewsPreview({ relayUrl }: { relayUrl: string }) @@ -153,7 +162,7 @@ export default function RelayReviewsPreview({ relayUrl }: { relayUrl: string })
window.clearTimeout(safety)
finish()
}
}, [relayUrl, ingestReviewEvent])
}, [relayUrl, ingestReviewEvent, favoriteRelays, blockedRelays, relayList])
const handleReviewed = (evt: NostrEvent) => {
setMyReview(evt)

10
src/components/RelayInfo/index.tsx

@ -49,6 +49,16 @@ export default function RelayInfo({ url, className }: { url: string; className?: @@ -49,6 +49,16 @@ export default function RelayInfo({ url, className }: { url: string; className?:
return (
<div className={cn('space-y-4 mb-2', className)}>
{relayInfo.banner && (
<div className="w-full h-48 overflow-hidden rounded-b-none">
<img
src={relayInfo.banner}
alt=""
className="w-full h-full object-cover object-center"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none' }}
/>
</div>
)}
<div className="px-4 space-y-4">
<div className="space-y-2">
<div className="flex items-center gap-2 justify-between">

4
src/components/ReplyNoteList/index.tsx

@ -19,7 +19,7 @@ import { @@ -19,7 +19,7 @@ import {
} from '@/lib/event'
import logger from '@/lib/logger'
import { getZapInfoFromEvent, shouldIncludeZapReceiptAtReplyThreshold } from '@/lib/event-metadata'
import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import { normalizeAnyRelayUrl } from '@/lib/url'
import { shouldHideThreadResponseEvent } from '@/lib/thread-response-filter'
import { getCachedThreadContextEvents } from '@/lib/navigation-related-events'
import { toNote } from '@/lib/link'
@ -767,7 +767,7 @@ function ReplyNoteList({ @@ -767,7 +767,7 @@ function ReplyNoteList({
try {
// READ from: FAST_READ_RELAY_URLS + user's inboxes + local relays + OP author's outboxes
const opAuthorPubkey = rootInfo.type === 'E' || rootInfo.type === 'A' ? rootInfo.pubkey : undefined
const seenOn = client.getSeenEventRelayUrls(event.id).map((u) => normalizeUrl(u) || u).filter(Boolean)
const seenOn = client.getSeenEventRelayUrls(event.id).map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean)
const fromBrowsingFeed = browsingRelayUrls.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean)
const threadRelayHints = [
...new Set([...relayHintsFromEventTags(event), ...seenOn, ...fromBrowsingFeed])

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

@ -236,6 +236,8 @@ export async function publishEventToHttpRelay( @@ -236,6 +236,8 @@ export async function publishEventToHttpRelay(
timeoutMs: 25_000
})
if (!res.ok) {
// 409 Conflict means the relay already has this event — treat as success.
if (res.status === 409) return
if (isDevViteIndexRelayProxyPath(endpoint) && res.status === 500) {
throw new IndexRelayTransportError()
}

25
src/pages/secondary/RelayReviewsPage/index.tsx

@ -1,11 +1,17 @@ @@ -1,11 +1,17 @@
import type { TNoteListRef } from '@/components/NoteList'
import NoteList from '@/components/NoteList'
import { RefreshButton } from '@/components/RefreshButton'
import { FAST_READ_RELAY_URLS, ExtendedKind } from '@/constants'
import { ExtendedKind } from '@/constants'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import {
getRelayUrlsWithFavoritesFastReadAndInbox,
userReadRelaysWithHttp
} from '@/lib/favorites-feed-relays'
import { relayReviewDTagsForRelayUrl, relayReviewsFeedSnapshotKey } from '@/lib/relay-review-feed'
import { normalizeAnyRelayUrl, simplifyUrl } from '@/lib/url'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider'
import type { TFeedSubRequest } from '@/types'
import { forwardRef, useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
@ -16,6 +22,8 @@ const RelayReviewsPage = forwardRef(({ url, index, hideTitlebar = false }: { url @@ -16,6 +22,8 @@ const RelayReviewsPage = forwardRef(({ url, index, hideTitlebar = false }: { url
const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const feedRef = useRef<TNoteListRef>(null)
const bumpFeed = useCallback(() => feedRef.current?.refresh(), [])
const { relayList } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
useEffect(() => {
if (!hideTitlebar) {
@ -32,16 +40,25 @@ const RelayReviewsPage = forwardRef(({ url, index, hideTitlebar = false }: { url @@ -32,16 +40,25 @@ const RelayReviewsPage = forwardRef(({ url, index, hideTitlebar = false }: { url
() => (url ? relayReviewDTagsForRelayUrl(url) : []),
[url]
)
/** Stable identity for session feed snapshot (decoupled from FAST_READ_RELAY_URLS JSON churn). */
/** Stable identity for session feed snapshot (decoupled from relay URL list churn). */
const relayReviewsFeedSubscriptionKey = useMemo(
() => (normalizedUrl ? relayReviewsFeedSnapshotKey(normalizedUrl) : ''),
[normalizedUrl]
)
const reviewRelayUrls = useMemo(() => {
if (!normalizedUrl) return []
const base = getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays,
blockedRelays,
userReadRelaysWithHttp(relayList)
)
return [...new Set([normalizedUrl, ...base])]
}, [normalizedUrl, favoriteRelays, blockedRelays, relayList])
const reviewsSubRequests = useMemo<TFeedSubRequest[]>(() => {
if (!normalizedUrl || relayReviewDTags.length === 0) return []
return [
{
urls: [normalizedUrl, ...FAST_READ_RELAY_URLS],
urls: reviewRelayUrls,
filter: {
kinds: [ExtendedKind.RELAY_REVIEW],
'#d': relayReviewDTags,
@ -49,7 +66,7 @@ const RelayReviewsPage = forwardRef(({ url, index, hideTitlebar = false }: { url @@ -49,7 +66,7 @@ const RelayReviewsPage = forwardRef(({ url, index, hideTitlebar = false }: { url
}
}
]
}, [normalizedUrl, relayReviewDTags])
}, [normalizedUrl, relayReviewDTags, reviewRelayUrls])
const title = useMemo(
() => (url ? t('Reviews for {{relay}}', { relay: simplifyUrl(url) }) : undefined),
[url, t]

6
src/services/client-query.service.ts

@ -227,8 +227,11 @@ export class QueryService { @@ -227,8 +227,11 @@ export class QueryService {
}
trackEventSeenOn(eventId: string, relay: AbstractRelay): void {
this.trackEventSeenOnByUrl(eventId, relay.url)
}
trackEventSeenOnByUrl(eventId: string, url: string): void {
const id = this.canonicalSeenOnEventId(eventId)
const url = relay.url
let set = this.eventSeenOnRelays.get(id)
if (!set) {
set = new Set()
@ -326,6 +329,7 @@ export class QueryService { @@ -326,6 +329,7 @@ export class QueryService {
eventCount++
onevent?.(evt)
events.push(evt)
this.trackEventSeenOnByUrl(evt.id, base)
if (!shouldDropEventOnIngest(evt)) {
this.onQueryResultIngest?.([evt])
}

1
src/services/client.service.ts

@ -1415,6 +1415,7 @@ class ClientService extends EventTarget { @@ -1415,6 +1415,7 @@ class ClientService extends EventTarget {
)
])
that.recordPublishSuccess(url, Date.now() - startMs)
that.queryService.trackEventSeenOnByUrl(event.id, base)
successCount++
relayStatuses.push({ url, success: true })
return

6
src/services/note-stats.service.ts

@ -25,7 +25,7 @@ import { @@ -25,7 +25,7 @@ import {
} from '@/lib/rss-article'
import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays'
import { getEmojiInfosFromEmojiTags, getFirstHexEventIdFromETags, tagNameEquals } from '@/lib/tag'
import { normalizeUrl } from '@/lib/url'
import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import client, { eventService } from '@/services/client.service'
import { TEmoji } from '@/types'
import dayjs from 'dayjs'
@ -253,7 +253,9 @@ class NoteStatsService { @@ -253,7 +253,9 @@ class NoteStatsService {
const add = (url: string | undefined) => {
if (!url) return
const n = normalizeUrl(url)
// Must use normalizeAnyRelayUrl, not normalizeUrl: the latter converts http(s)://
// index relay URLs into ws(s):// which then hit the WebSocket pool and get session strikes.
const n = normalizeAnyRelayUrl(url)
if (!n || blocked.has(n.toLowerCase()) || seen.has(n)) return
seen.add(n)
}

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

@ -40,6 +40,10 @@ class RelayInfoService { @@ -40,6 +40,10 @@ class RelayInfoService {
/** Relay info cache TTL: refetch NIP-11 after this long (24h). */
private static RELAY_INFO_CACHE_TTL_MS = 24 * 60 * 60 * 1000
/** TTL for entries with NIP-11 text data but no images — retry sooner in case icon/banner were added. */
private static RELAY_INFO_PARTIAL_CACHE_TTL_MS = 30 * 60 * 1000
/** Short retry TTL for entries where NIP-11 fetch failed entirely (no name/description/pubkey). */
private static RELAY_INFO_EMPTY_RETRY_TTL_MS = 5 * 60 * 1000
async search(query: string) {
if (this.initPromise) {
@ -119,7 +123,12 @@ class RelayInfoService { @@ -119,7 +123,12 @@ class RelayInfoService {
private isStale(relayInfo: TRelayInfo): boolean {
const at = relayInfo.cachedAt
if (at == null) return true
return Date.now() - at > RelayInfoService.RELAY_INFO_CACHE_TTL_MS
const age = Date.now() - at
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)
if (!hasImages) return age > RelayInfoService.RELAY_INFO_PARTIAL_CACHE_TTL_MS
return age > RelayInfoService.RELAY_INFO_CACHE_TTL_MS
}
private async _getRelayInfo(url: string) {
@ -147,17 +156,29 @@ class RelayInfoService { @@ -147,17 +156,29 @@ class RelayInfoService {
private async fetchRelayNip11(url: string) {
try {
logger.debug('Fetching NIP-11 metadata', { url })
const httpCandidate = url.trim().replace(/^ws:\/\//i, 'http://').replace(/^wss:\/\//i, 'https://')
const httpBase = normalizeHttpRelayUrl(httpCandidate) || httpCandidate
const fetchUrl = devProxyLoopbackHttpRelayBase(httpBase)
logger.debug('[RelayInfo] Fetching NIP-11', { url, fetchUrl })
const res = await fetchWithTimeout(fetchUrl, {
headers: { Accept: 'application/nostr+json' },
timeoutMs: 12_000
})
if (!res.ok) return undefined
return res.json() as Omit<TRelayInfo, 'url' | 'shortUrl'>
} catch {
if (!res.ok) {
logger.warn('[RelayInfo] NIP-11 fetch failed', { url, status: res.status })
return undefined
}
const data = await res.json() as Omit<TRelayInfo, 'url' | 'shortUrl'>
logger.info('[RelayInfo] NIP-11 received', {
url,
name: data.name,
icon: data.icon,
banner: data.banner,
supported_nips: data.supported_nips
})
return data
} catch (err) {
logger.warn('[RelayInfo] NIP-11 fetch threw', { url, err })
return undefined
}
}

1
src/types/index.d.ts vendored

@ -76,6 +76,7 @@ export type TRelayInfo = { @@ -76,6 +76,7 @@ export type TRelayInfo = {
name?: string
description?: string
icon?: string
banner?: string
pubkey?: string
contact?: string
supported_nips?: number[]

Loading…
Cancel
Save