You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

241 lines
8.9 KiB

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 { getRelayUrlFromRelayReviewEvent } from '@/lib/event-metadata'
import {
dedupeRelayReviewsNewestFirst,
loadCachedRelayReviews
} from '@/lib/explore-relay-reviews'
import {
getRelayUrlsWithFavoritesFastReadAndInbox,
userReadRelaysWithHttp
} from '@/lib/favorites-feed-relays'
import { toRelay } from '@/lib/link'
import { isExploreBrowsableRelayUrl } from '@/lib/explore-popular-relays'
import { normalizeAnyRelayUrl } from '@/lib/url'
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'
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} skipRelayInfoFetch 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. */
const EXPLORE_REVIEWS_MAX_RELAYS = 12
/** After all relays EOSE, wait longer than default so slow mirrors can flush events (default query eose is 500ms). */
const EXPLORE_REVIEWS_EOSE_TAIL_MS = 4500
function stableRelayInputsKey(
favoriteRelays: string[],
blockedRelays: string[],
relayList: { read?: string[]; write?: string[]; httpRead?: string[] } | null | undefined
): string {
const normSortJoin = (urls: string[]) =>
[...urls]
.map((u) => normalizeAnyRelayUrl(u) || u.trim())
.filter(Boolean)
.sort((a, b) => a.localeCompare(b))
.join('|')
return [
normSortJoin(favoriteRelays),
normSortJoin(blockedRelays),
normSortJoin([...(relayList?.httpRead ?? []), ...(relayList?.read ?? [])]),
normSortJoin(relayList?.write ?? [])
].join('::')
}
export default function ExploreRelayReviews() {
const { t } = useTranslation()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const { relayList } = useNostr()
const relayInputsKey = useMemo(
() => stableRelayInputsKey(favoriteRelays, blockedRelays, relayList),
[favoriteRelays, blockedRelays, relayList]
)
const relayUrls = useMemo(() => {
const stacked = appendCuratedReadOnlyRelays(
getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays,
blockedRelays,
userReadRelaysWithHttp(relayList),
{
userWriteRelays: relayList?.write ?? [],
maxRelays: EXPLORE_REVIEWS_MAX_RELAYS,
applySocialKindBlockedFilter: false
}
),
blockedRelays
)
const sliced = stacked.slice(0, EXPLORE_REVIEWS_MAX_RELAYS)
const normalized = sliced
.map((u) => normalizeAnyRelayUrl(u) || u.trim())
.filter((u): u is string => Boolean(u) && isExploreBrowsableRelayUrl(u))
normalized.sort((a, b) => a.localeCompare(b))
return normalized
// eslint-disable-next-line react-hooks/exhaustive-deps -- relayInputsKey is a content hash of favorites/blocked/NIP-65; relayList identity churn must not re-open REQ sockets.
}, [relayInputsKey])
const relayUrlsKey = relayInputsKey
const [loading, setLoading] = useState(true)
const [events, setEvents] = useState<Event[]>([])
const [showCount, setShowCount] = useState(SHOW_COUNT)
const bottomRef = useRef<HTMLDivElement>(null)
const fetchGenRef = useRef(0)
useEffect(() => {
const gen = ++fetchGenRef.current
let cancelled = false
setLoading(true)
setEvents([])
setShowCount(SHOW_COUNT)
void (async () => {
const cached = await loadCachedRelayReviews(REVIEW_QUERY_LIMIT)
if (!cancelled && fetchGenRef.current === gen && cached.length > 0) {
setEvents(cached)
}
try {
const raw = await client.fetchEvents(
relayUrls,
{ kinds: [ExtendedKind.RELAY_REVIEW], limit: REVIEW_QUERY_LIMIT },
{
onevent: (e) => {
if (cancelled || fetchGenRef.current !== gen) return
if (e.kind === ExtendedKind.RELAY_REVIEW && getRelayUrlFromRelayReviewEvent(e)) {
setEvents((prev) => dedupeRelayReviewsNewestFirst([...prev, e]))
}
},
firstRelayResultGraceMs: false,
globalTimeout: 12_000,
eoseTimeout: EXPLORE_REVIEWS_EOSE_TAIL_MS,
cache: true
}
)
if (cancelled || fetchGenRef.current !== gen) return
const withRelay = raw.filter(
(e) => e.kind === ExtendedKind.RELAY_REVIEW && getRelayUrlFromRelayReviewEvent(e)
)
setEvents((prev) => dedupeRelayReviewsNewestFirst([...prev, ...withRelay]))
} catch {
if (!cancelled && fetchGenRef.current === gen) setEvents([])
} finally {
if (!cancelled && fetchGenRef.current === gen) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [relayUrlsKey])
useEffect(() => {
const options = { root: null, rootMargin: '120px', threshold: 0 }
const observer = new IntersectionObserver((entries) => {
if (entries[0]?.isIntersecting && showCount < events.length) {
setShowCount((prev) => prev + SHOW_COUNT)
}
}, options)
const el = bottomRef.current
if (el) observer.observe(el)
return () => {
if (el) observer.unobserve(el)
}
}, [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 || !isExploreBrowsableRelayUrl(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
return (
<div className="min-w-0 pt-1 pb-8">
{showInitialSkeleton ? (
<div className="grid min-w-0 md:px-4 md:grid-cols-2 md:gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-40 rounded-lg border md:border" />
))}
</div>
) : showEmptyAfterLoad ? (
<p className="px-4 py-6 text-center text-sm text-muted-foreground">{t('no relays found')}</p>
) : (
<>
{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"
aria-busy="true"
aria-live="polite"
>
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-28 rounded-lg border md:border" />
))}
</div>
) : null}
{showCount < events.length ? <div ref={bottomRef} className="h-4" aria-hidden /> : null}
{!loading && showCount >= events.length ? (
<p className="mt-3 text-center text-sm text-muted-foreground">{t('no more relays')}</p>
) : null}
</>
)}
</div>
)
}