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 @@
import RelayIcon from '@/components/RelayIcon'
import RelayReviewCard from '@/components/RelayInfo/RelayReviewCard' import RelayReviewCard from '@/components/RelayInfo/RelayReviewCard'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { useFetchRelayInfo } from '@/hooks'
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
import { getRelayUrlFromRelayReviewEvent } from '@/lib/event-metadata' import { getRelayUrlFromRelayReviewEvent } from '@/lib/event-metadata'
import { import {
getRelayUrlsWithFavoritesFastReadAndInbox, getRelayUrlsWithFavoritesFastReadAndInbox,
userReadRelaysWithHttp userReadRelaysWithHttp
} from '@/lib/favorites-feed-relays' } from '@/lib/favorites-feed-relays'
import { toRelay } from '@/lib/link'
import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpellFeeds' import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpellFeeds'
import { useSmartRelayNavigation } from '@/PageManager'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
@ -16,6 +20,31 @@ import type { Event } from 'nostr-tools'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' 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 REVIEW_QUERY_LIMIT = 100
const SHOW_COUNT = 20 const SHOW_COUNT = 20
/** Fewer sockets + faster aggregate EOSE than full inbox stack; read-only mirrors prepended then capped. */ /** 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]) }, [showCount, events.length])
const visible = events.slice(0, showCount) 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 showInitialSkeleton = loading && events.length === 0
const showEmptyAfterLoad = !loading && events.length === 0 const showEmptyAfterLoad = !loading && events.length === 0
@ -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> <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"> {groupedVisible.map(([relayUrl, relayEvents]) => (
{visible.map((event) => ( <div key={relayUrl} className="mb-4">
<RelayReviewCard key={event.id} event={event} className="border-b md:border md:border-border" /> <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>
</div>
))}
{loading ? ( {loading ? (
<div <div
className="mt-4 grid min-w-0 gap-3 md:grid-cols-2 md:px-4" 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 {
* Returns both rendered nodes and a set of hashtags found in content (for deduplication) * 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. // Deprecated legacy parser kept only as a fallback reference during migration.
export function parseMarkdownContentLegacy( function parseMarkdownContentLegacy(
content: string, content: string,
options: { options: {
eventPubkey: string eventPubkey: string

45
src/components/RelayInfo/RelayReviewCard.tsx

@ -1,37 +1,39 @@
import { useSmartNoteNavigation, useSmartRelayNavigation } from '@/PageManager' import { useSmartNoteNavigation, useSmartRelayNavigation } from '@/PageManager'
import { getRelayUrlFromRelayReviewEvent, getStarsFromRelayReviewEvent } from '@/lib/event-metadata' import { getRelayUrlFromRelayReviewEvent, getStarsFromRelayReviewEvent } from '@/lib/event-metadata'
import { toNote, toRelay } from '@/lib/link' import { toNote, toRelay } from '@/lib/link'
import { simplifyUrl } from '@/lib/url'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useFetchRelayInfo } from '@/hooks'
import client from '@/services/client.service' import client from '@/services/client.service'
import { Link2 } from 'lucide-react'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
import ClientTag from '../ClientTag' import ClientTag from '../ClientTag'
import ContentPreview from '../ContentPreview' import ContentPreview from '../ContentPreview'
import { FormattedTimestamp } from '../FormattedTimestamp' import { FormattedTimestamp } from '../FormattedTimestamp'
import Nip05 from '../Nip05' import Nip05 from '../Nip05'
import RelayIcon from '../RelayIcon'
import Stars from '../Stars' import Stars from '../Stars'
import { SimpleUserAvatar } from '../UserAvatar' import { SimpleUserAvatar } from '../UserAvatar'
import { SimpleUsername } from '../Username' import { SimpleUsername } from '../Username'
export default function RelayReviewCard({ export default function RelayReviewCard({
event, event,
className className,
showRelayInfo = true
}: { }: {
event: NostrEvent event: NostrEvent
className?: string className?: string
showRelayInfo?: boolean
}) { }) {
const { navigateToNote } = useSmartNoteNavigation() const { navigateToNote } = useSmartNoteNavigation()
const { navigateToRelay } = useSmartRelayNavigation() const { navigateToRelay } = useSmartRelayNavigation()
const stars = useMemo(() => getStarsFromRelayReviewEvent(event), [event]) const stars = useMemo(() => getStarsFromRelayReviewEvent(event), [event])
const relayUrl = useMemo(() => getRelayUrlFromRelayReviewEvent(event), [event]) const relayUrl = useMemo(() => getRelayUrlFromRelayReviewEvent(event), [event])
const { relayInfo } = useFetchRelayInfo(relayUrl)
return ( return (
<div <div
className={cn('clickable border rounded-lg bg-muted/20 p-3 h-full', className)} className={cn('clickable border rounded-lg bg-muted/20 p-3 h-full', className)}
onClick={(e) => { onClick={(e) => {
// Don't navigate if clicking on interactive elements
const target = e.target as HTMLElement 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]')) { if (target.closest('button') || target.closest('[role="button"]') || target.closest('a') || target.closest('[data-embedded-note]') || target.closest('[data-parent-note-preview]')) {
return return
@ -40,6 +42,24 @@ export default function RelayReviewCard({
navigateToNote(toNote(event), event) 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 justify-between items-start gap-2">
<div className="flex items-center space-x-2 flex-1"> <div className="flex items-center space-x-2 flex-1">
<SimpleUserAvatar userId={event.pubkey} size="medium" /> <SimpleUserAvatar userId={event.pubkey} size="medium" />
@ -58,22 +78,7 @@ export default function RelayReviewCard({
</div> </div>
</div> </div>
</div> </div>
</div> <Stars stars={stars} className="gap-0.5 [&_svg]:size-3 shrink-0 mt-0.5" />
<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}
</div> </div>
<ContentPreview className="mt-2 line-clamp-4" event={event} /> <ContentPreview className="mt-2 line-clamp-4" event={event} />
</div> </div>

19
src/hooks/useFetchCalendarRsvps.tsx

@ -6,7 +6,7 @@ import { queryService } from '@/services/client.service'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { normalizeUrl } from '@/lib/url' import { normalizeAnyRelayUrl } from '@/lib/url'
import { FAST_READ_RELAY_URLS } from '@/constants' import { FAST_READ_RELAY_URLS } from '@/constants'
import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays'
import { tagNameEquals } from '@/lib/tag' import { tagNameEquals } from '@/lib/tag'
@ -42,8 +42,8 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) {
const coordinate = getReplaceableCoordinateFromEvent(calendarEvent) const coordinate = getReplaceableCoordinateFromEvent(calendarEvent)
const userRead = userReadRelaysWithHttp(relayList) const userRead = userReadRelaysWithHttp(relayList)
const baseUrls = new Set<string>([ const baseUrls = new Set<string>([
...FAST_READ_RELAY_URLS.map((url) => normalizeUrl(url) || url), ...FAST_READ_RELAY_URLS.map((url) => normalizeAnyRelayUrl(url) || url),
...userRead.map((url) => normalizeUrl(url) || url) ...userRead.map((url) => normalizeAnyRelayUrl(url) || url)
].filter(Boolean) as string[]) ].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) // 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) .fetchRelayList(organizerPubkey)
.then((organizerRelays) => { .then((organizerRelays) => {
if (cancelled) return if (cancelled) return
organizerRelays?.read?.forEach((url) => { ;[
const u = normalizeUrl(url) ...(organizerRelays?.httpRead ?? []),
if (u) baseUrls.add(u) ...(organizerRelays?.read ?? []),
}) ...(organizerRelays?.httpWrite ?? []),
organizerRelays?.write?.forEach((url) => { ...(organizerRelays?.write ?? [])
const u = normalizeUrl(url) ].forEach((url) => {
const u = normalizeAnyRelayUrl(url)
if (u) baseUrls.add(u) if (u) baseUrls.add(u)
}) })
return Array.from(baseUrls) return Array.from(baseUrls)

10
src/hooks/useProfileTimeline.tsx

@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { CALENDAR_EVENT_KINDS, ExtendedKind, isSocialKindBlockedKind } from '@/constants' import { CALENDAR_EVENT_KINDS, ExtendedKind, isSocialKindBlockedKind } from '@/constants'
import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays' import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays'
import { normalizeUrl, subtractNormalizedRelayUrls } from '@/lib/url' import { normalizeAnyRelayUrl, subtractNormalizedRelayUrls } from '@/lib/url'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
type ProfileTimelineMemoryEntry = { type ProfileTimelineMemoryEntry = {
@ -112,8 +112,8 @@ function postProcessEvents(
} }
function relayListsContentKey(favoriteRelays: string[], blockedRelays: string[]): string { function relayListsContentKey(favoriteRelays: string[], blockedRelays: string[]): string {
const fav = [...favoriteRelays].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) => normalizeUrl(u) || u).filter(Boolean).sort().join('\u0001') const blk = [...blockedRelays].map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean).sort().join('\u0001')
return `${fav}\u0000${blk}` return `${fav}\u0000${blk}`
} }
@ -250,7 +250,9 @@ export function useProfileTimeline({
void (async () => { void (async () => {
const authorRl = await client.fetchRelayList(pubkey).catch(() => ({ const authorRl = await client.fetchRelayList(pubkey).catch(() => ({
read: [] as string[], read: [] as string[],
write: [] as string[] write: [] as string[],
httpRead: [] as string[],
httpWrite: [] as string[]
})) }))
if (cancelled) return if (cancelled) return
const fullFeedUrls = buildProfilePageReadRelayUrls( const fullFeedUrls = buildProfilePageReadRelayUrls(

4
src/lib/event-metadata.ts

@ -6,7 +6,7 @@ import { getReplaceableEventIdentifier } from './event'
import { getAmountFromInvoice, getLightningAddressFromProfile } from './lightning' import { getAmountFromInvoice, getLightningAddressFromProfile } from './lightning'
import { formatPubkey, pubkeyToNpub } from './pubkey' import { formatPubkey, pubkeyToNpub } from './pubkey'
import { generateBech32IdFromATag, generateBech32IdFromETag, getImetaInfoFromImetaTag, tagNameEquals } from './tag' 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 { isTorBrowser } from './utils'
import logger from '@/lib/logger' import logger from '@/lib/logger'
@ -712,5 +712,5 @@ export function getStarsFromRelayReviewEvent(event: Event): number {
export function getRelayUrlFromRelayReviewEvent(event: Event): string | undefined { export function getRelayUrlFromRelayReviewEvent(event: Event): string | undefined {
const d = event.tags.find((t) => t[0] === 'd')?.[1]?.trim() const d = event.tags.find((t) => t[0] === 'd')?.[1]?.trim()
if (!d) return undefined if (!d) return undefined
return normalizeUrl(d) || d return normalizeAnyRelayUrl(d) || d
} }

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

@ -5,7 +5,7 @@ import {
relayFilterIncludesSocialKindBlockedKind relayFilterIncludesSocialKindBlockedKind
} from '@/constants' } from '@/constants'
import type { TFeedSubRequest } from '@/types' import type { TFeedSubRequest } from '@/types'
import { normalizeUrl } from '@/lib/url' import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import { import {
buildPrioritizedReadRelayUrls, buildPrioritizedReadRelayUrls,
buildReadRelayPriorityLayers, buildReadRelayPriorityLayers,
@ -14,10 +14,9 @@ import {
mergeRelayPriorityLayers, mergeRelayPriorityLayers,
relayUrlsLocalsFirst relayUrlsLocalsFirst
} from '@/lib/relay-url-priority' } from '@/lib/relay-url-priority'
import { normalizeAnyRelayUrl } from '@/lib/url'
const blockedSet = (blockedRelays: string[]) => 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 * Logged-in users favorite relays (kind 10012 `relay` tags via {@link useFavoriteRelays}, plus bootstrap defaults
@ -42,14 +41,14 @@ export function getFavoritesFeedRelayUrls(
): string[] { ): string[] {
const blocked = blockedSet(blockedRelays) const blocked = blockedSet(blockedRelays)
const visible = favoriteRelays.filter((r) => { const visible = favoriteRelays.filter((r) => {
const k = normalizeUrl(r) || r const k = normalizeAnyRelayUrl(r) || r
return k && !blocked.has(k) return k && !blocked.has(k)
}) })
const base = visible.length > 0 ? visible : DEFAULT_FAVORITE_RELAYS const base = visible.length > 0 ? visible : DEFAULT_FAVORITE_RELAYS
const seen = new Set<string>() const seen = new Set<string>()
const out: string[] = [] const out: string[] = []
for (const u of base) { for (const u of base) {
const k = normalizeUrl(u) || u const k = normalizeAnyRelayUrl(u) || u
if (!k || seen.has(k)) continue if (!k || seen.has(k)) continue
seen.add(k) seen.add(k)
out.push(k) out.push(k)
@ -66,7 +65,7 @@ export function mergeRelayUrlLayers(layers: string[][], blockedRelays: string[])
const out: string[] = [] const out: string[] = []
for (const layer of layers) { for (const layer of layers) {
for (const u of layer) { for (const u of layer) {
const k = normalizeUrl(u) || u const k = normalizeAnyRelayUrl(u) || u
if (!k || blocked.has(k) || seen.has(k)) continue if (!k || blocked.has(k) || seen.has(k)) continue
seen.add(k) seen.add(k)
out.push(k) out.push(k)
@ -80,11 +79,17 @@ export function mergeRelayUrlLayers(layers: string[][], blockedRelays: string[])
* stripped. Used for profile pins + Medien before {@link buildProfileAugmentedReadRelayUrls}. * stripped. Used for profile pins + Medien before {@link buildProfileAugmentedReadRelayUrls}.
*/ */
export function buildAuthorInboxOutboxRelayUrls( export function buildAuthorInboxOutboxRelayUrls(
authorRelayList: { read: string[]; write: string[] }, authorRelayList: { read: string[]; write: string[]; httpRead?: string[]; httpWrite?: string[] },
blockedRelays: string[] blockedRelays: string[]
): string[] { ): string[] {
const inboxLayer = relayUrlsLocalsFirst(authorRelayList.read ?? []) const inboxLayer = relayUrlsLocalsFirst([
const outboxLayer = relayUrlsLocalsFirst(authorRelayList.write ?? []) ...(authorRelayList.httpRead ?? []),
...(authorRelayList.read ?? [])
])
const outboxLayer = relayUrlsLocalsFirst([
...(authorRelayList.httpWrite ?? []),
...(authorRelayList.write ?? [])
])
return mergeRelayUrlLayers([inboxLayer, outboxLayer], blockedRelays) return mergeRelayUrlLayers([inboxLayer, outboxLayer], blockedRelays)
} }
@ -161,15 +166,15 @@ export const PROFILE_PAGE_PINS_RESOLVE_LIMIT = 10
export function buildProfilePageReadRelayUrls( export function buildProfilePageReadRelayUrls(
favoriteRelays: string[], favoriteRelays: string[],
blockedRelays: string[], blockedRelays: string[],
authorRelayList: { read: string[]; write: string[] }, authorRelayList: { read: string[]; write: string[]; httpRead?: string[]; httpWrite?: string[] },
kindsIncludeSocialBlockedKind: boolean kindsIncludeSocialBlockedKind: boolean
): string[] { ): string[] {
return getRelayUrlsWithFavoritesFastReadAndInbox( return getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays, favoriteRelays,
blockedRelays, blockedRelays,
authorRelayList.read ?? [], [...(authorRelayList.httpRead ?? []), ...(authorRelayList.read ?? [])],
{ {
userWriteRelays: authorRelayList.write ?? [], userWriteRelays: [...(authorRelayList.httpWrite ?? []), ...(authorRelayList.write ?? [])],
authorWriteRelays: [], authorWriteRelays: [],
maxRelays: PROFILE_PAGE_FEED_MAX_RELAYS, maxRelays: PROFILE_PAGE_FEED_MAX_RELAYS,
applySocialKindBlockedFilter: kindsIncludeSocialBlockedKind applySocialKindBlockedFilter: kindsIncludeSocialBlockedKind

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

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

1
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 * 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. * 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 { export function devProxyLoopbackHttpRelayBase(normalizedBase: string): string {
if (import.meta.env.PROD || typeof window === 'undefined') return normalizedBase if (import.meta.env.PROD || typeof window === 'undefined') return normalizedBase

36
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. // HTTP index relays are handled via httpTimelinePollBases above — never pass them to the WS subscribe path.
const wsRelays = relays.filter((u) => !isHttpRelayUrl(u)) 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, { const subCloser = this.subscribe(wsRelays, filter, {
startLogin, startLogin,
onevent: (evt: NEvent) => { onevent: (evt: NEvent) => {
applySubscribedTimelineEvent(evt) applySubscribedTimelineEvent(evt)
}, },
oneose: handleTimelineEose, oneose: httpOnlyShard ? undefined : handleTimelineEose,
onclose: onClose onclose: onClose
}, },
relayReqLog) httpOnlyShard ? undefined : relayReqLog)
if (httpTimelinePollBases.length > 0) { if (httpTimelinePollBases.length > 0) {
const backfillFilter = { ...(filter as Filter) } as Filter & { until?: number } const backfillFilter = { ...(filter as Filter) } as Filter & { until?: number }
delete backfillFilter.until 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 { return {
@ -2662,7 +2690,7 @@ class ClientService extends EventTarget {
*/ */
getSeenEventRelayUrls(eventId: string): string[] { getSeenEventRelayUrls(eventId: string): string[] {
const key = canonicalSeenOnEventId(eventId) 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) const queryUrls = this.queryService.getSeenEventRelayUrls(key).map((u) => normalizeAnyRelayUrl(u) || u)
return Array.from(new Set([...poolUrls, ...queryUrls].filter(Boolean))) return Array.from(new Set([...poolUrls, ...queryUrls].filter(Boolean)))
} }

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

@ -124,6 +124,11 @@ class RelayInfoService {
const at = relayInfo.cachedAt const at = relayInfo.cachedAt
if (at == null) return true if (at == null) return true
const age = Date.now() - at 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) const hasNip11Data = !!(relayInfo.name || relayInfo.description || relayInfo.pubkey)
if (!hasNip11Data) return age > RelayInfoService.RELAY_INFO_EMPTY_RETRY_TTL_MS if (!hasNip11Data) return age > RelayInfoService.RELAY_INFO_EMPTY_RETRY_TTL_MS
const hasImages = !!(relayInfo.icon || relayInfo.banner) const hasImages = !!(relayInfo.icon || relayInfo.banner)
@ -158,7 +163,11 @@ class RelayInfoService {
try { try {
const httpCandidate = url.trim().replace(/^ws:\/\//i, 'http://').replace(/^wss:\/\//i, 'https://') const httpCandidate = url.trim().replace(/^ws:\/\//i, 'http://').replace(/^wss:\/\//i, 'https://')
const httpBase = normalizeHttpRelayUrl(httpCandidate) || httpCandidate 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 }) logger.debug('[RelayInfo] Fetching NIP-11', { url, fetchUrl })
const res = await fetchWithTimeout(fetchUrl, { const res = await fetchWithTimeout(fetchUrl, {
headers: { Accept: 'application/nostr+json' }, headers: { Accept: 'application/nostr+json' },

Loading…
Cancel
Save