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