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.
 
 
 
 

268 lines
8.6 KiB

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<NostrEvent | null>(null)
const [reviews, setReviews] = useState<NostrEvent[]>([])
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<string, NostrEvent>()
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 (
<div className="space-y-4">
<div className="px-4 flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<div className="text-lg font-semibold">{stars}</div>
<Stars stars={stars} />
</div>
<div
className={cn(
'text-sm text-muted-foreground',
count > 0 && 'underline cursor-pointer hover:text-foreground'
)}
onClick={() => {
if (count > 0) {
push(toRelayReviews(relayUrl))
}
}}
>
{t('{{count}} reviews', { count })}
</div>
</div>
{!showEditor && !myReview && (
<Button variant="outline" onClick={() => checkLogin(() => setShowEditor(true))}>
{t('Write a review')}
</Button>
)}
</div>
{showEditor && <ReviewEditor relayUrl={relayUrl} onReviewed={handleReviewed} />}
{myReview || reviews.length > 0 ? (
<ReviewCarousel relayUrl={relayUrl} myReview={myReview} reviews={reviews} />
) : !showEditor ? (
<div className="flex items-center justify-center text-sm text-muted-foreground p-4">
{initialized ? t('No reviews yet. Be the first to write one!') : t('Loading...')}
</div>
) : null}
</div>
)
}
function ReviewCarousel({
relayUrl,
myReview,
reviews
}: {
relayUrl: string
myReview: NostrEvent | null
reviews: NostrEvent[]
}) {
const { t } = useTranslation()
const { push } = useSecondaryPage()
const showPreviousAndNext = useMemo(() => !isTouchDevice(), [])
return (
<Carousel
opts={{
skipSnaps: true
}}
plugins={[WheelGesturesPlugin()]}
>
<CarouselContent className="ml-4 mr-2">
{myReview && (
<Item key={myReview.id}>
<RelayReviewCard event={myReview} className="border-primary/60 bg-primary/5" />
</Item>
)}
{reviews.slice(0, 10).map((evt) => (
<Item key={evt.id}>
<RelayReviewCard event={evt} />
</Item>
))}
{reviews.length > 10 && (
<Item>
<div
className="border rounded-lg bg-muted/20 p-3 flex items-center justify-center h-full hover:bg-muted cursor-pointer"
onClick={() => push(toRelayReviews(relayUrl))}
>
<div className="text-sm text-muted-foreground">{t('View more reviews')}</div>
</div>
</Item>
)}
</CarouselContent>
{showPreviousAndNext && <CarouselPrevious />}
{showPreviousAndNext && <CarouselNext />}
</Carousel>
)
}
function Item({ children }: { children: React.ReactNode }) {
return (
<CarouselItem className="basis-11/12 lg:basis-2/3 2xl:basis-5/12 pl-0 pr-2">
{children}
</CarouselItem>
)
}