From 423b50123298a741b5254891f7879994b38408ef Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 8 Apr 2026 09:08:04 +0200 Subject: [PATCH] http relay bug-fixes --- src/components/Relay/index.tsx | 2 +- src/components/RelayIcon/index.tsx | 43 ++++++++++++++++--- .../RelayInfo/RelayReviewsPreview.tsx | 21 ++++++--- src/components/RelayInfo/index.tsx | 10 +++++ src/components/ReplyNoteList/index.tsx | 4 +- src/lib/index-relay-http.ts | 2 + .../secondary/RelayReviewsPage/index.tsx | 25 +++++++++-- src/services/client-query.service.ts | 6 ++- src/services/client.service.ts | 1 + src/services/note-stats.service.ts | 6 ++- src/services/relay-info.service.ts | 31 ++++++++++--- src/types/index.d.ts | 1 + 12 files changed, 124 insertions(+), 28 deletions(-) diff --git a/src/components/Relay/index.tsx b/src/components/Relay/index.tsx index da21b2b6..3a1887d3 100644 --- a/src/components/Relay/index.tsx +++ b/src/components/Relay/index.tsx @@ -98,7 +98,7 @@ const Relay = forwardRef< }, [normalizedUrl, noteListRef]) const relayFeedSubRequests = useMemo(() => { - if (!normalizedUrl || isHttpRelay) return [] + if (!normalizedUrl) return [] const q = debouncedInput.trim() return [ { diff --git a/src/components/RelayIcon/index.tsx b/src/components/RelayIcon/index.tsx index c2bf9c62..ea54700a 100644 --- a/src/components/RelayIcon/index.tsx +++ b/src/components/RelayIcon/index.tsx @@ -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({ }) { 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 } diff --git a/src/components/RelayInfo/RelayReviewsPreview.tsx b/src/components/RelayInfo/RelayReviewsPreview.tsx index f80d80fd..9f43d83a 100644 --- a/src/components/RelayInfo/RelayReviewsPreview.tsx +++ b/src/components/RelayInfo/RelayReviewsPreview.tsx @@ -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' 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 }) 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 }) window.clearTimeout(safety) finish() } - }, [relayUrl, ingestReviewEvent]) + }, [relayUrl, ingestReviewEvent, favoriteRelays, blockedRelays, relayList]) const handleReviewed = (evt: NostrEvent) => { setMyReview(evt) diff --git a/src/components/RelayInfo/index.tsx b/src/components/RelayInfo/index.tsx index 6311b77a..eb354cb3 100644 --- a/src/components/RelayInfo/index.tsx +++ b/src/components/RelayInfo/index.tsx @@ -49,6 +49,16 @@ export default function RelayInfo({ url, className }: { url: string; className?: return (
+ {relayInfo.banner && ( +
+ { (e.currentTarget as HTMLImageElement).style.display = 'none' }} + /> +
+ )}
diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index 464b4dea..3cea481b 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -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({ 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]) diff --git a/src/lib/index-relay-http.ts b/src/lib/index-relay-http.ts index b9472be8..12fd7780 100644 --- a/src/lib/index-relay-http.ts +++ b/src/lib/index-relay-http.ts @@ -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() } diff --git a/src/pages/secondary/RelayReviewsPage/index.tsx b/src/pages/secondary/RelayReviewsPage/index.tsx index 0a7cbcce..a19e60a3 100644 --- a/src/pages/secondary/RelayReviewsPage/index.tsx +++ b/src/pages/secondary/RelayReviewsPage/index.tsx @@ -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 const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const feedRef = useRef(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 () => (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(() => { 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 } } ] - }, [normalizedUrl, relayReviewDTags]) + }, [normalizedUrl, relayReviewDTags, reviewRelayUrls]) const title = useMemo( () => (url ? t('Reviews for {{relay}}', { relay: simplifyUrl(url) }) : undefined), [url, t] diff --git a/src/services/client-query.service.ts b/src/services/client-query.service.ts index eaabb59b..842d4592 100644 --- a/src/services/client-query.service.ts +++ b/src/services/client-query.service.ts @@ -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 { eventCount++ onevent?.(evt) events.push(evt) + this.trackEventSeenOnByUrl(evt.id, base) if (!shouldDropEventOnIngest(evt)) { this.onQueryResultIngest?.([evt]) } diff --git a/src/services/client.service.ts b/src/services/client.service.ts index a87a1150..1f96f54e 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -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 diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts index 74bdad38..cfd6acf6 100644 --- a/src/services/note-stats.service.ts +++ b/src/services/note-stats.service.ts @@ -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 { 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) } diff --git a/src/services/relay-info.service.ts b/src/services/relay-info.service.ts index 45e5ddf2..2e3cc78b 100644 --- a/src/services/relay-info.service.ts +++ b/src/services/relay-info.service.ts @@ -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 { 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 { 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 - } catch { + if (!res.ok) { + logger.warn('[RelayInfo] NIP-11 fetch failed', { url, status: res.status }) + return undefined + } + const data = await res.json() as Omit + 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 } } diff --git a/src/types/index.d.ts b/src/types/index.d.ts index cbd3a080..86e08df8 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -76,6 +76,7 @@ export type TRelayInfo = { name?: string description?: string icon?: string + banner?: string pubkey?: string contact?: string supported_nips?: number[]