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
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> |
|
) |
|
}
|
|
|