import { useSecondaryPage } from '@/PageManager' import { Button } from '@/components/ui/button' import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from '@/components/ui/carousel' 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' import { relayReviewDTagsForRelayUrl, relayReviewEventTargetsRelay, relayReviewsFeedSnapshotKey } from '@/lib/relay-review-feed' import { normalizeUrl } from '@/lib/url' import { cn, isTouchDevice } from '@/lib/utils' import { useMuteList } from '@/contexts/mute-list-context' import { muteSetHas } from '@/lib/mute-set' import { useNostr } from '@/providers/NostrProvider' import { useUserTrust } from '@/contexts/user-trust-context' import { queryService } from '@/services/client.service' import { getSessionFeedSnapshot } from '@/services/session-feed-snapshot.service' import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures' import type { NostrEvent } from 'nostr-tools' import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import Stars from '../Stars' import RelayReviewCard from './RelayReviewCard' import ReviewEditor from './ReviewEditor' export default function RelayReviewsPreview({ relayUrl }: { relayUrl: string }) { const { t } = useTranslation() const { push } = useSecondaryPage() const { pubkey, checkLogin, relayList } = useNostr() const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { hideUntrustedNotes, isUserTrusted } = useUserTrust() const { mutePubkeySet } = useMuteList() const [showEditor, setShowEditor] = useState(false) const [myReview, setMyReview] = useState(null) const [reviews, setReviews] = useState([]) const [initialized, setInitialized] = useState(false) const { stars, count } = useMemo(() => { let totalStars = 0 let totalCount = 0 ;[myReview, ...reviews].forEach((evt) => { if (!evt) return const stars = getStarsFromRelayReviewEvent(evt) if (stars) { totalStars += stars totalCount += 1 } }) return { stars: totalCount > 0 ? +(totalStars / totalCount).toFixed(1) : 0, count: totalCount } }, [myReview, reviews]) const ingestReviewEvent = useCallback( (evt: NostrEvent) => { if (muteSetHas(mutePubkeySet, evt.pubkey)) return if (hideUntrustedNotes && !isUserTrusted(evt.pubkey)) return const stars = getStarsFromRelayReviewEvent(evt) if (!stars) return if (pubkey && evt.pubkey === pubkey) { setMyReview((prev) => (!prev || evt.created_at > prev.created_at ? evt : prev)) return } setReviews((prev) => { const existing = prev.find((e) => e.pubkey === evt.pubkey) if (existing && existing.created_at >= evt.created_at) return prev const filtered = prev.filter((e) => e.pubkey !== evt.pubkey) return [...filtered, evt].sort((a, b) => compareEvents(b, a)) }) }, [pubkey, mutePubkeySet, hideUntrustedNotes, isUserTrusted] ) useEffect(() => { let cancelled = false setMyReview(null) setReviews([]) setInitialized(false) const normalizedTarget = normalizeUrl(relayUrl) || relayUrl const dTags = relayReviewDTagsForRelayUrl(relayUrl) const snapKey = relayReviewsFeedSnapshotKey(normalizedTarget) const fromSession = getSessionFeedSnapshot(snapKey) if (fromSession?.length) { let seedMy: NostrEvent | null = null const seedByPubkey = new Map() for (const evt of fromSession) { if (evt.kind !== ExtendedKind.RELAY_REVIEW || !relayReviewEventTargetsRelay(evt, relayUrl)) continue if (muteSetHas(mutePubkeySet, evt.pubkey)) continue if (hideUntrustedNotes && !isUserTrusted(evt.pubkey)) continue const st = getStarsFromRelayReviewEvent(evt) if (!st) continue if (pubkey && evt.pubkey === pubkey) { if (!seedMy || evt.created_at > seedMy.created_at) seedMy = evt } else { const ex = seedByPubkey.get(evt.pubkey) if (!ex || evt.created_at > ex.created_at) seedByPubkey.set(evt.pubkey, evt) } } setMyReview(seedMy) setReviews([...seedByPubkey.values()].sort((a, b) => compareEvents(b, a))) } const base = getRelayUrlsWithFavoritesFastReadAndInbox( favoriteRelays, blockedRelays, userReadRelaysWithHttp(relayList) ) const uniqueUrls = [...new Set([normalizedTarget, ...base])] const filter = { kinds: [ExtendedKind.RELAY_REVIEW], '#d': dTags.length > 0 ? dTags : [relayUrl], limit: 100 } let dispose: (() => void) | undefined let closed = false const finish = () => { if (closed) return closed = true if (!cancelled) setInitialized(true) dispose?.() } const sub = queryService.subscribe(uniqueUrls, filter, { onevent: (evt) => { if (cancelled || evt.kind !== ExtendedKind.RELAY_REVIEW) return ingestReviewEvent(evt) }, oneose: () => { if (cancelled) return finish() } }) dispose = sub.close const safety = window.setTimeout(() => { if (cancelled) return finish() }, 12_000) return () => { cancelled = true window.clearTimeout(safety) finish() } }, [relayUrl, ingestReviewEvent, favoriteRelays, blockedRelays, relayList]) const handleReviewed = (evt: NostrEvent) => { setMyReview(evt) setShowEditor(false) } return (
{stars}
0 && 'underline cursor-pointer hover:text-foreground' )} onClick={() => { if (count > 0) { push(toRelayReviews(relayUrl)) } }} > {t('{{count}} reviews', { count })}
{!showEditor && !myReview && ( )}
{showEditor && } {myReview || reviews.length > 0 ? ( ) : !showEditor ? (
{initialized ? t('No reviews yet. Be the first to write one!') : t('Loading...')}
) : null}
) } function ReviewCarousel({ relayUrl, myReview, reviews }: { relayUrl: string myReview: NostrEvent | null reviews: NostrEvent[] }) { const { t } = useTranslation() const { push } = useSecondaryPage() const showPreviousAndNext = useMemo(() => !isTouchDevice(), []) return ( {myReview && ( )} {reviews.slice(0, 10).map((evt) => ( ))} {reviews.length > 10 && (
push(toRelayReviews(relayUrl))} >
{t('View more reviews')}
)}
{showPreviousAndNext && } {showPreviousAndNext && }
) } function Item({ children }: { children: React.ReactNode }) { return ( {children} ) }