RelayReviewsPreview.tsx raw

   1  import { useSecondaryPage } from '@/PageManager'
   2  import { Button } from '@/components/ui/button'
   3  import {
   4    Carousel,
   5    CarouselContent,
   6    CarouselItem,
   7    CarouselNext,
   8    CarouselPrevious
   9  } from '@/components/ui/carousel'
  10  import { ExtendedKind } from '@/constants'
  11  import { compareEvents } from '@/lib/event'
  12  import { getStarsFromRelayReviewEvent } from '@/lib/event-metadata'
  13  import { toRelayReviews } from '@/lib/link'
  14  import { cn, isTouchDevice } from '@/lib/utils'
  15  import { useMuteList } from '@/providers/MuteListProvider'
  16  import { useNostr } from '@/providers/NostrProvider'
  17  import { useUserPreferences } from '@/providers/UserPreferencesProvider'
  18  import { useUserTrust } from '@/providers/UserTrustProvider'
  19  import client from '@/services/client.service'
  20  import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures'
  21  import { Filter, NostrEvent } from 'nostr-tools'
  22  import { useEffect, useMemo, useState } from 'react'
  23  import { useTranslation } from 'react-i18next'
  24  import Stars from '../Stars'
  25  import RelayReviewCard from './RelayReviewCard'
  26  import ReviewEditor from './ReviewEditor'
  27  
  28  export default function RelayReviewsPreview({ relayUrl }: { relayUrl: string }) {
  29    const { t } = useTranslation()
  30    const { push } = useSecondaryPage()
  31    const { pubkey, checkLogin } = useNostr()
  32    const { hideUntrustedNotes, isUserTrusted, isSpammer } = useUserTrust()
  33    const { mutePubkeySet } = useMuteList()
  34    const [showEditor, setShowEditor] = useState(false)
  35    const [myReview, setMyReview] = useState<NostrEvent | null>(null)
  36    const [reviews, setReviews] = useState<NostrEvent[]>([])
  37    const [initialized, setInitialized] = useState(false)
  38    const { stars, count } = useMemo(() => {
  39      let totalStars = 0
  40      let totalCount = 0
  41      ;[myReview, ...reviews].forEach((evt) => {
  42        if (!evt) return
  43        const stars = getStarsFromRelayReviewEvent(evt)
  44        if (stars) {
  45          totalStars += stars
  46          totalCount += 1
  47        }
  48      })
  49      return {
  50        stars: totalCount > 0 ? +(totalStars / totalCount).toFixed(1) : 0,
  51        count: totalCount
  52      }
  53    }, [myReview, reviews])
  54  
  55    useEffect(() => {
  56      const init = async () => {
  57        const filters: Filter[] = [
  58          { kinds: [ExtendedKind.RELAY_REVIEW], '#d': [relayUrl], limit: 100 }
  59        ]
  60        if (pubkey) {
  61          filters.push({ kinds: [ExtendedKind.RELAY_REVIEW], authors: [pubkey], '#d': [relayUrl] })
  62        }
  63        const events = await client.fetchEvents([relayUrl, ...client.currentRelays], filters, {
  64          cache: true
  65        })
  66  
  67        const pubkeySet = new Set<string>()
  68        const reviews: NostrEvent[] = []
  69        let myReview: NostrEvent | null = null
  70  
  71        events.sort((a, b) => compareEvents(b, a))
  72  
  73        for (const evt of events) {
  74          if (mutePubkeySet.has(evt.pubkey) || pubkeySet.has(evt.pubkey)) {
  75            continue
  76          }
  77          const stars = getStarsFromRelayReviewEvent(evt)
  78          if (!stars) {
  79            continue
  80          }
  81  
  82          pubkeySet.add(evt.pubkey)
  83          if (evt.pubkey === pubkey) {
  84            myReview = evt
  85          } else {
  86            reviews.push(evt)
  87          }
  88        }
  89  
  90        const filteredReviews = (
  91          await Promise.all(
  92            reviews.map(async (evt) => {
  93              if (await isSpammer(evt.pubkey)) {
  94                return null
  95              }
  96              return evt
  97            })
  98          )
  99        ).filter(Boolean) as NostrEvent[]
 100  
 101        setMyReview(myReview)
 102        setReviews(filteredReviews)
 103        setInitialized(true)
 104      }
 105      init()
 106    }, [relayUrl, pubkey, mutePubkeySet, hideUntrustedNotes, isUserTrusted])
 107  
 108    const handleReviewed = (evt: NostrEvent) => {
 109      setMyReview(evt)
 110      setShowEditor(false)
 111    }
 112  
 113    return (
 114      <div className="space-y-4">
 115        <div className="px-4 flex items-center justify-between">
 116          <div>
 117            <div className="flex items-center gap-2">
 118              <div className="text-lg font-semibold">{stars}</div>
 119              <Stars stars={stars} />
 120            </div>
 121            <div
 122              className={cn(
 123                'text-sm text-muted-foreground',
 124                count > 0 && 'underline cursor-pointer hover:text-foreground'
 125              )}
 126              onClick={() => {
 127                if (count > 0) {
 128                  push(toRelayReviews(relayUrl))
 129                }
 130              }}
 131            >
 132              {t('{{count}} reviews', { count })}
 133            </div>
 134          </div>
 135          {!showEditor && !myReview && (
 136            <Button variant="outline" onClick={() => checkLogin(() => setShowEditor(true))}>
 137              {t('Write a review')}
 138            </Button>
 139          )}
 140        </div>
 141  
 142        {showEditor && <ReviewEditor relayUrl={relayUrl} onReviewed={handleReviewed} />}
 143  
 144        {myReview || reviews.length > 0 ? (
 145          <ReviewCarousel relayUrl={relayUrl} myReview={myReview} reviews={reviews} />
 146        ) : !showEditor ? (
 147          <div className="flex items-center justify-center text-sm text-muted-foreground p-4">
 148            {initialized ? t('No reviews yet. Be the first to write one!') : t('Loading...')}
 149          </div>
 150        ) : null}
 151      </div>
 152    )
 153  }
 154  
 155  function ReviewCarousel({
 156    relayUrl,
 157    myReview,
 158    reviews
 159  }: {
 160    relayUrl: string
 161    myReview: NostrEvent | null
 162    reviews: NostrEvent[]
 163  }) {
 164    const { t } = useTranslation()
 165    const { push } = useSecondaryPage()
 166    const showPreviousAndNext = useMemo(() => !isTouchDevice(), [])
 167  
 168    return (
 169      <Carousel
 170        opts={{
 171          skipSnaps: true
 172        }}
 173        plugins={[WheelGesturesPlugin()]}
 174      >
 175        <CarouselContent className="ml-4 mr-2">
 176          {myReview && (
 177            <Item key={myReview.id}>
 178              <RelayReviewCard event={myReview} className="border-primary/60 bg-primary/5" />
 179            </Item>
 180          )}
 181          {reviews.slice(0, 10).map((evt) => (
 182            <Item key={evt.id}>
 183              <RelayReviewCard event={evt} />
 184            </Item>
 185          ))}
 186          {reviews.length > 10 && (
 187            <Item>
 188              <div
 189                className="border rounded-lg bg-muted/20 p-3 flex items-center justify-center h-full hover:bg-muted cursor-pointer"
 190                onClick={() => push(toRelayReviews(relayUrl))}
 191              >
 192                <div className="text-sm text-muted-foreground">{t('View more reviews')}</div>
 193              </div>
 194            </Item>
 195          )}
 196        </CarouselContent>
 197        {showPreviousAndNext && <CarouselPrevious />}
 198        {showPreviousAndNext && <CarouselNext />}
 199      </Carousel>
 200    )
 201  }
 202  
 203  function Item({ children }: { children: React.ReactNode }) {
 204    const { enableSingleColumnLayout } = useUserPreferences()
 205  
 206    return (
 207      <CarouselItem
 208        className={cn(
 209          'basis-11/12 pl-0 pr-2',
 210          enableSingleColumnLayout ? 'md:basis-5/12 lg:basis-7/12' : 'lg:basis-2/3 2xl:basis-5/12'
 211        )}
 212      >
 213        {children}
 214      </CarouselItem>
 215    )
 216  }
 217