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.
 
 
 
 

161 lines
6.0 KiB

import RelayReviewCard from '@/components/RelayInfo/RelayReviewCard'
import { Skeleton } from '@/components/ui/skeleton'
import { ExtendedKind, FIRST_RELAY_RESULT_GRACE_MS } from '@/constants'
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
import { getRelayUrlFromRelayReviewEvent } from '@/lib/event-metadata'
import { getRelayUrlsWithFavoritesFastReadAndInbox } from '@/lib/favorites-feed-relays'
import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpellFeeds'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service'
import { Loader2 } from 'lucide-react'
import type { Event } from 'nostr-tools'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
const REVIEW_QUERY_LIMIT = 100
const SHOW_COUNT = 20
/** Fewer sockets + faster aggregate EOSE than full inbox stack; read-only mirrors still appended 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 dedupeRelayReviewsNewestFirst(events: Event[]): Event[] {
const sorted = [...events].sort((a, b) => b.created_at - a.created_at)
const seen = new Set<string>()
const out: Event[] = []
for (const evt of sorted) {
const key = isReplaceableEvent(evt.kind) ? getReplaceableCoordinateFromEvent(evt) : evt.id
if (seen.has(key)) continue
seen.add(key)
out.push(evt)
}
return out
}
export default function ExploreRelayReviews() {
const { t } = useTranslation()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const { relayList } = useNostr()
const relayUrls = useMemo(() => {
const stacked = appendCuratedReadOnlyRelays(
getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays,
blockedRelays,
relayList?.read ?? [],
{
userWriteRelays: relayList?.write ?? [],
maxRelays: EXPLORE_REVIEWS_MAX_RELAYS,
applyKind1BlockedFilter: false
}
),
blockedRelays
)
return stacked.slice(0, EXPLORE_REVIEWS_MAX_RELAYS)
}, [favoriteRelays, blockedRelays, relayList])
const relayUrlsKey = useMemo(() => relayUrls.join('|'), [relayUrls])
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 () => {
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: FIRST_RELAY_RESULT_GRACE_MS,
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 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>
) : (
<>
<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" />
))}
</div>
{loading ? (
<div
className="mt-4 flex items-center justify-center gap-2 text-sm text-muted-foreground"
aria-busy="true"
aria-live="polite"
>
<Loader2 className="size-4 shrink-0 animate-spin" aria-hidden />
{t('Loading...')}
</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>
)
}