feat: relay reviews

This commit is contained in:
codytseng 2025-09-20 22:00:28 +08:00
parent fcb31d8052
commit 2439150c6e
40 changed files with 1206 additions and 207 deletions

View file

@ -0,0 +1,57 @@
import { useSecondaryPage } from '@/PageManager'
import { getStarsFromRelayReviewEvent } from '@/lib/event-metadata'
import { toNote } from '@/lib/link'
import { cn } from '@/lib/utils'
import { NostrEvent } from 'nostr-tools'
import { useMemo } from 'react'
import ClientTag from '../ClientTag'
import ContentPreview from '../ContentPreview'
import { FormattedTimestamp } from '../FormattedTimestamp'
import Nip05 from '../Nip05'
import Stars from '../Stars'
import TranslateButton from '../TranslateButton'
import { SimpleUserAvatar } from '../UserAvatar'
import { SimpleUsername } from '../Username'
export default function RelayReviewCard({
event,
className
}: {
event: NostrEvent
className?: string
}) {
const { push } = useSecondaryPage()
const stars = useMemo(() => getStarsFromRelayReviewEvent(event), [event])
return (
<div
className={cn('clickable border rounded-lg bg-muted/20 p-3 h-full', className)}
onClick={() => push(toNote(event))}
>
<div className="flex justify-between items-start gap-2">
<div className="flex items-center space-x-2 flex-1">
<SimpleUserAvatar userId={event.pubkey} size="medium" />
<div className="flex-1 w-0">
<div className="flex gap-2 items-center">
<SimpleUsername
userId={event.pubkey}
className="font-semibold flex truncate text-sm"
skeletonClassName="h-3"
/>
<ClientTag event={event} />
</div>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Nip05 pubkey={event.pubkey} append="·" />
<FormattedTimestamp timestamp={event.created_at} className="shrink-0" short />
</div>
</div>
</div>
<div className="flex items-center">
<TranslateButton event={event} className="pr-0" />
</div>
</div>
<Stars stars={stars} className="mt-2 gap-0.5 [&_svg]:size-3" />
<ContentPreview className="mt-2 line-clamp-4" event={event} />
</div>
)
}

View file

@ -0,0 +1,200 @@
import { useSecondaryPage } from '@/PageManager'
import { Button } from '@/components/ui/button'
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious
} from '@/components/ui/carousel'
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
import { compareEvents } from '@/lib/event'
import { getStarsFromRelayReviewEvent } from '@/lib/event-metadata'
import { toRelayReviews } from '@/lib/link'
import { cn, isTouchDevice } from '@/lib/utils'
import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import client from '@/services/client.service'
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures'
import { Filter, NostrEvent } from 'nostr-tools'
import { 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 } = useNostr()
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])
useEffect(() => {
const init = async () => {
const filters: Filter[] = [
{ kinds: [ExtendedKind.RELAY_REVIEW], '#d': [relayUrl], limit: 100 }
]
if (pubkey) {
filters.push({ kinds: [ExtendedKind.RELAY_REVIEW], authors: [pubkey], '#d': [relayUrl] })
}
const events = await client.fetchEvents([relayUrl, ...BIG_RELAY_URLS], filters, {
cache: true
})
const pubkeySet = new Set<string>()
const reviews: NostrEvent[] = []
let myReview: NostrEvent | null = null
events.sort((a, b) => compareEvents(b, a))
for (const evt of events) {
if (
mutePubkeySet.has(evt.pubkey) ||
pubkeySet.has(evt.pubkey) ||
(hideUntrustedNotes && !isUserTrusted(evt.pubkey))
) {
continue
}
const stars = getStarsFromRelayReviewEvent(evt)
if (!stars) {
continue
}
pubkeySet.add(evt.pubkey)
if (evt.pubkey === pubkey) {
myReview = evt
} else {
reviews.push(evt)
}
}
setMyReview(myReview)
setReviews(reviews)
setInitialized(true)
}
init()
}, [relayUrl, pubkey, mutePubkeySet, hideUntrustedNotes, isUserTrusted])
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>
)
}

View file

@ -0,0 +1,90 @@
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { BIG_RELAY_URLS } from '@/constants'
import { createRelayReviewDraftEvent } from '@/lib/draft-event'
import { useNostr } from '@/providers/NostrProvider'
import { Loader2, Star } from 'lucide-react'
import { NostrEvent } from 'nostr-tools'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
export default function ReviewEditor({
relayUrl,
onReviewed
}: {
relayUrl: string
onReviewed: (evt: NostrEvent) => void
}) {
const { t } = useTranslation()
const { publish } = useNostr()
const [stars, setStars] = useState(0)
const [hoverStars, setHoverStars] = useState(0)
const [review, setReview] = useState('')
const [submitting, setSubmitting] = useState(false)
const canSubmit = useMemo(() => stars > 0 && !!review.trim(), [stars, review])
const submit = async () => {
if (!canSubmit) return
setSubmitting(true)
try {
const draftEvent = createRelayReviewDraftEvent(relayUrl, review, stars)
const evt = await publish(draftEvent, { specifiedRelayUrls: [relayUrl, ...BIG_RELAY_URLS] })
onReviewed(evt)
} catch (error) {
if (error instanceof AggregateError) {
error.errors.forEach((e) => toast.error(`${t('Failed to review')}: ${e.message}`))
} else if (error instanceof Error) {
toast.error(`${t('Failed to review')}: ${error.message}`)
}
console.error(error)
return
} finally {
setSubmitting(false)
}
}
return (
<div className="px-4 space-y-2">
<Textarea
className="min-h-36"
placeholder={t('Write a review and pick a star rating')}
value={review}
onChange={(e) => setReview(e.target.value)}
/>
<div className="flex justify-between items-center">
<div className="flex items-center">
{Array.from({ length: 5 }).map((_, index) => (
<div
key={index}
className="pr-2 cursor-pointer"
onMouseEnter={() => setHoverStars(index + 1)}
onMouseLeave={() => setHoverStars(0)}
>
{index < (hoverStars || stars) ? (
<Star
className="size-6 text-yellow-400 fill-yellow-400"
onClick={() => setStars(index + 1)}
/>
) : (
<Star
className="size-6 text-muted-foreground"
onClick={() => setStars(index + 1)}
/>
)}
</div>
))}
</div>
<Button
disabled={!canSubmit}
variant={canSubmit ? 'default' : 'secondary'}
onClick={submit}
>
{submitting && <Loader2 className="animate-spin" />}
{t('Submit')}
</Button>
</div>
</div>
)
}

View file

@ -1,21 +1,24 @@
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { useFetchRelayInfo } from '@/hooks'
import { normalizeHttpUrl } from '@/lib/url'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { Check, Copy, GitBranch, Link, Mail, SquareCode } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import PostEditor from '../PostEditor'
import RelayBadges from '../RelayBadges'
import RelayIcon from '../RelayIcon'
import SaveRelayDropdownMenu from '../SaveRelayDropdownMenu'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
import RelayReviewsPreview from './RelayReviewsPreview'
export default function RelayInfo({ url, className }: { url: string; className?: string }) {
const { t } = useTranslation()
const { checkLogin } = useNostr()
const { relayInfo, isFetching } = useFetchRelayInfo(url)
const [open, setOpen] = useState(false)
@ -24,97 +27,94 @@ export default function RelayInfo({ url, className }: { url: string; className?:
}
return (
<div className={cn('px-4 space-y-4 mb-2', className)}>
<div className="space-y-2">
<div className="flex items-center gap-2 justify-between">
<div className="flex gap-2 items-center truncate">
<RelayIcon url={url} className="w-8 h-8" />
<div className="text-2xl font-semibold truncate select-text">
{relayInfo.name || relayInfo.shortUrl}
</div>
</div>
<RelayControls url={relayInfo.url} />
</div>
<RelayBadges relayInfo={relayInfo} />
{!!relayInfo.tags?.length && (
<div className="flex gap-2">
{relayInfo.tags.map((tag) => (
<Badge variant="secondary">{tag}</Badge>
))}
</div>
)}
{relayInfo.description && (
<div className="text-wrap break-words whitespace-pre-wrap mt-2 select-text">
{relayInfo.description}
</div>
)}
</div>
<div className="space-y-2">
<div className="text-sm font-semibold text-muted-foreground">{t('Homepage')}:</div>
<a
href={normalizeHttpUrl(relayInfo.url)}
target="_blank"
className="hover:underline text-primary select-text"
>
{normalizeHttpUrl(relayInfo.url)}
</a>
</div>
{relayInfo.payments_url && (
<div className={cn('space-y-4 mb-2', className)}>
<div className="px-4 space-y-4">
<div className="space-y-2">
<div className="text-sm font-semibold text-muted-foreground">{t('Payment page')}:</div>
<div className="flex items-center gap-2 justify-between">
<div className="flex gap-2 items-center truncate">
<RelayIcon url={url} className="w-8 h-8" />
<div className="text-2xl font-semibold truncate select-text">
{relayInfo.name || relayInfo.shortUrl}
</div>
</div>
<RelayControls url={relayInfo.url} />
</div>
{!!relayInfo.tags?.length && (
<div className="flex gap-2">
{relayInfo.tags.map((tag) => (
<Badge variant="secondary">{tag}</Badge>
))}
</div>
)}
{relayInfo.description && (
<div className="text-wrap break-words whitespace-pre-wrap mt-2 select-text">
{relayInfo.description}
</div>
)}
</div>
<div className="space-y-2">
<div className="text-sm font-semibold text-muted-foreground">{t('Homepage')}</div>
<a
href={normalizeHttpUrl(relayInfo.payments_url)}
href={normalizeHttpUrl(relayInfo.url)}
target="_blank"
className="hover:underline text-primary select-text"
>
{relayInfo.payments_url}
{normalizeHttpUrl(relayInfo.url)}
</a>
</div>
)}
<div className="flex flex-wrap gap-4">
{relayInfo.pubkey && (
<div className="space-y-2 flex-1">
<div className="text-sm font-semibold text-muted-foreground">{t('Operator')}</div>
<div className="flex gap-2 items-center">
<UserAvatar userId={relayInfo.pubkey} size="small" />
<Username userId={relayInfo.pubkey} className="font-semibold" />
</div>
<ScrollArea className="overflow-x-auto">
<div className="flex gap-8 pb-2">
{relayInfo.pubkey && (
<div className="space-y-2 w-fit">
<div className="text-sm font-semibold text-muted-foreground">{t('Operator')}</div>
<div className="flex gap-2 items-center">
<UserAvatar userId={relayInfo.pubkey} size="small" />
<Username userId={relayInfo.pubkey} className="font-semibold text-nowrap" />
</div>
</div>
)}
{relayInfo.contact && (
<div className="space-y-2 w-fit">
<div className="text-sm font-semibold text-muted-foreground">{t('Contact')}</div>
<div className="flex gap-2 items-center font-semibold select-text text-nowrap">
<Mail />
{relayInfo.contact}
</div>
</div>
)}
{relayInfo.software && (
<div className="space-y-2 w-fit">
<div className="text-sm font-semibold text-muted-foreground">{t('Software')}</div>
<div className="flex gap-2 items-center font-semibold select-text text-nowrap">
<SquareCode />
{formatSoftware(relayInfo.software)}
</div>
</div>
)}
{relayInfo.version && (
<div className="space-y-2 w-fit">
<div className="text-sm font-semibold text-muted-foreground">{t('Version')}</div>
<div className="flex gap-2 items-center font-semibold select-text text-nowrap">
<GitBranch />
{relayInfo.version}
</div>
</div>
)}
</div>
)}
{relayInfo.contact && (
<div className="space-y-2 flex-1">
<div className="text-sm font-semibold text-muted-foreground">{t('Contact')}</div>
<div className="flex gap-2 items-center font-semibold select-text">
<Mail />
{relayInfo.contact}
</div>
</div>
)}
{relayInfo.software && (
<div className="space-y-2 flex-1">
<div className="text-sm font-semibold text-muted-foreground">{t('Software')}</div>
<div className="flex gap-2 items-center font-semibold select-text">
<SquareCode />
{formatSoftware(relayInfo.software)}
</div>
</div>
)}
{relayInfo.version && (
<div className="space-y-2 flex-1">
<div className="text-sm font-semibold text-muted-foreground">{t('Version')}</div>
<div className="flex gap-2 items-center font-semibold select-text">
<GitBranch />
{relayInfo.version}
</div>
</div>
)}
<ScrollBar orientation="horizontal" />
</ScrollArea>
<Button
variant="secondary"
className="w-full"
onClick={() => checkLogin(() => setOpen(true))}
>
{t('Share something on this Relay')}
</Button>
<PostEditor open={open} setOpen={setOpen} openFrom={[relayInfo.url]} />
</div>
<Button variant="secondary" className="w-full" onClick={() => setOpen(true)}>
{t('Share something on this Relay')}
</Button>
<PostEditor open={open} setOpen={setOpen} openFrom={[relayInfo.url]} />
<RelayReviewsPreview relayUrl={url} />
</div>
)
}