feat: like & repost (#3)
This commit is contained in:
@@ -9,6 +9,7 @@ import HashtagPage from './pages/secondary/HashtagPage'
|
||||
import NotePage from './pages/secondary/NotePage'
|
||||
import ProfilePage from './pages/secondary/ProfilePage'
|
||||
import { NostrProvider } from './providers/NostrProvider'
|
||||
import { NoteStatsProvider } from './providers/NoteStatsProvider'
|
||||
import { RelaySettingsProvider } from './providers/RelaySettingsProvider'
|
||||
|
||||
const routes = [
|
||||
@@ -23,10 +24,12 @@ export default function App(): JSX.Element {
|
||||
<ThemeProvider>
|
||||
<NostrProvider>
|
||||
<RelaySettingsProvider>
|
||||
<PageManager routes={routes}>
|
||||
<NoteListPage />
|
||||
</PageManager>
|
||||
<Toaster />
|
||||
<NoteStatsProvider>
|
||||
<PageManager routes={routes}>
|
||||
<NoteListPage />
|
||||
</PageManager>
|
||||
<Toaster />
|
||||
</NoteStatsProvider>
|
||||
</RelaySettingsProvider>
|
||||
</NostrProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -7,7 +7,7 @@ export function EmbeddedNote({ noteId }: { noteId: string }) {
|
||||
const event = useFetchEventById(noteId)
|
||||
|
||||
return event && event.kind === kinds.ShortTextNote ? (
|
||||
<ShortTextNoteCard size="small" className="mt-2 w-full" event={event} />
|
||||
<ShortTextNoteCard size="small" className="mt-2 w-full" event={event} hideStats />
|
||||
) : (
|
||||
<a
|
||||
href={toNoStrudelNote(noteId)}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import useFetchEventStats from '@renderer/hooks/useFetchEventStats'
|
||||
import { cn } from '@renderer/lib/utils'
|
||||
import { EVENT_TYPES, eventBus } from '@renderer/services/event-bus.service'
|
||||
import { Heart, MessageCircle, Repeat } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useEffect, useState } from 'react'
|
||||
import NoteOptionsTrigger from './NoteOptionsTrigger'
|
||||
|
||||
export default function NoteStats({ event, className }: { event: Event; className?: string }) {
|
||||
const [replyCount, setReplyCount] = useState(0)
|
||||
const { stats } = useFetchEventStats(event.id)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: CustomEvent<{ eventId: string; replyCount: number }>) => {
|
||||
const { eventId, replyCount } = e.detail
|
||||
if (eventId === event.id) {
|
||||
setReplyCount(replyCount)
|
||||
}
|
||||
}
|
||||
eventBus.on(EVENT_TYPES.REPLY_COUNT_CHANGED, handler)
|
||||
|
||||
return () => {
|
||||
eventBus.remove(EVENT_TYPES.REPLY_COUNT_CHANGED, handler)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={cn('flex justify-between', className)}>
|
||||
<div className="flex gap-1 items-center text-muted-foreground">
|
||||
<MessageCircle size={14} />
|
||||
<div className="text-xs">{formatCount(replyCount)}</div>
|
||||
</div>
|
||||
<div className="flex gap-1 items-center text-muted-foreground">
|
||||
<Repeat size={14} />
|
||||
<div className="text-xs">{formatCount(stats.repostCount)}</div>
|
||||
</div>
|
||||
<div className="flex gap-1 items-center text-muted-foreground">
|
||||
<Heart size={14} />
|
||||
<div className="text-xs">{formatCount(stats.reactionCount)}</div>
|
||||
</div>
|
||||
<NoteOptionsTrigger event={event} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatCount(count: number) {
|
||||
return count >= 100 ? '99+' : count
|
||||
}
|
||||
@@ -3,20 +3,22 @@ import { Event } from 'nostr-tools'
|
||||
import Content from '../Content'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
import Username from '../Username'
|
||||
import NoteStats from './NoteStats'
|
||||
import NoteStats from '../NoteStats'
|
||||
|
||||
export default function Note({
|
||||
event,
|
||||
parentEvent,
|
||||
size = 'normal',
|
||||
className,
|
||||
displayStats = false
|
||||
hideStats = false,
|
||||
fetchNoteStats = false
|
||||
}: {
|
||||
event: Event
|
||||
parentEvent?: Event
|
||||
size?: 'normal' | 'small'
|
||||
className?: string
|
||||
displayStats?: boolean
|
||||
hideStats?: boolean
|
||||
fetchNoteStats?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className={className}>
|
||||
@@ -36,7 +38,9 @@ export default function Note({
|
||||
</div>
|
||||
)}
|
||||
<Content className="mt-2" event={event} />
|
||||
{displayStats && <NoteStats className="mt-2" event={event} />}
|
||||
{!hideStats && (
|
||||
<NoteStats className="mt-2" event={event} fetchIfNotExisting={fetchNoteStats} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,11 +9,13 @@ import { getParentEventId, getRootEventId } from '@renderer/lib/event'
|
||||
export default function ShortTextNoteCard({
|
||||
event,
|
||||
className,
|
||||
size
|
||||
size,
|
||||
hideStats = false
|
||||
}: {
|
||||
event: Event
|
||||
className?: string
|
||||
size?: 'normal' | 'small'
|
||||
hideStats?: boolean
|
||||
}) {
|
||||
const { push } = useSecondaryPage()
|
||||
const rootEvent = useFetchEventById(getRootEventId(event))
|
||||
@@ -28,7 +30,12 @@ export default function ShortTextNoteCard({
|
||||
}}
|
||||
>
|
||||
<Card className="p-4 hover:bg-muted/50 text-left cursor-pointer">
|
||||
<Note size={size} event={event} parentEvent={parentEvent ?? rootEvent} />
|
||||
<Note
|
||||
size={size}
|
||||
event={event}
|
||||
parentEvent={parentEvent ?? rootEvent}
|
||||
hideStats={hideStats}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
|
||||
79
src/renderer/src/components/NoteStats/LikeButton.tsx
Normal file
79
src/renderer/src/components/NoteStats/LikeButton.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { createReactionDraftEvent } from '@renderer/lib/draft-event'
|
||||
import { cn } from '@renderer/lib/utils'
|
||||
import { useNostr } from '@renderer/providers/NostrProvider'
|
||||
import { useNoteStats } from '@renderer/providers/NoteStatsProvider'
|
||||
import { Heart } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { formatCount } from './utils'
|
||||
|
||||
export default function LikeButton({
|
||||
event,
|
||||
variant = 'normal',
|
||||
canFetch = false
|
||||
}: {
|
||||
event: Event
|
||||
variant?: 'normal' | 'reply'
|
||||
canFetch?: boolean
|
||||
}) {
|
||||
const { pubkey, publish } = useNostr()
|
||||
const { noteStatsMap, fetchNoteLikedStatus, fetchNoteLikeCount, markNoteAsLiked } = useNoteStats()
|
||||
const [liking, setLiking] = useState(false)
|
||||
const { likeCount, hasLiked } = useMemo(
|
||||
() => noteStatsMap.get(event.id) ?? {},
|
||||
[noteStatsMap, event.id]
|
||||
)
|
||||
const canLike = pubkey && !hasLiked && !liking
|
||||
|
||||
useEffect(() => {
|
||||
if (!canFetch) return
|
||||
|
||||
if (likeCount === undefined) {
|
||||
fetchNoteLikeCount(event)
|
||||
}
|
||||
if (hasLiked === undefined) {
|
||||
fetchNoteLikedStatus(event)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const like = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!canLike) return
|
||||
|
||||
setLiking(true)
|
||||
const timer = setTimeout(() => setLiking(false), 5000)
|
||||
|
||||
try {
|
||||
const [liked] = await Promise.all([
|
||||
hasLiked === undefined ? fetchNoteLikedStatus(event) : hasLiked,
|
||||
likeCount === undefined ? fetchNoteLikeCount(event) : likeCount
|
||||
])
|
||||
if (liked) return
|
||||
|
||||
const reaction = createReactionDraftEvent(event)
|
||||
await publish(reaction)
|
||||
markNoteAsLiked(event.id)
|
||||
} catch (error) {
|
||||
console.error('like failed', error)
|
||||
} finally {
|
||||
setLiking(false)
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center enabled:hover:text-red-400',
|
||||
variant === 'normal' ? 'gap-1' : 'flex-col',
|
||||
hasLiked ? 'text-red-400' : 'text-muted-foreground'
|
||||
)}
|
||||
onClick={like}
|
||||
disabled={!canLike}
|
||||
title="like"
|
||||
>
|
||||
<Heart size={16} className={hasLiked ? 'fill-red-400' : ''} />
|
||||
<div className="text-xs">{formatCount(likeCount)}</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { Event } from 'nostr-tools'
|
||||
import { useState } from 'react'
|
||||
import RawEventDialog from './RawEventDialog'
|
||||
|
||||
export default function NoteOptionsTrigger({ event }: { event: Event }) {
|
||||
export default function NoteOptions({ event }: { event: Event }) {
|
||||
const [isRawEventDialogOpen, setIsRawEventDialogOpen] = useState(false)
|
||||
|
||||
return (
|
||||
17
src/renderer/src/components/NoteStats/ReplyButton.tsx
Normal file
17
src/renderer/src/components/NoteStats/ReplyButton.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useNoteStats } from '@renderer/providers/NoteStatsProvider'
|
||||
import { MessageCircle } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo } from 'react'
|
||||
import { formatCount } from './utils'
|
||||
|
||||
export default function ReplyButton({ event }: { event: Event }) {
|
||||
const { noteStatsMap } = useNoteStats()
|
||||
const { replyCount } = useMemo(() => noteStatsMap.get(event.id) ?? {}, [noteStatsMap, event.id])
|
||||
|
||||
return (
|
||||
<div className="flex gap-1 items-center text-muted-foreground">
|
||||
<MessageCircle size={16} />
|
||||
<div className="text-xs">{formatCount(replyCount)}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
104
src/renderer/src/components/NoteStats/RepostButton.tsx
Normal file
104
src/renderer/src/components/NoteStats/RepostButton.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger
|
||||
} from '@renderer/components/ui/alert-dialog'
|
||||
import { createRepostDraftEvent } from '@renderer/lib/draft-event'
|
||||
import { cn } from '@renderer/lib/utils'
|
||||
import { useNostr } from '@renderer/providers/NostrProvider'
|
||||
import { useNoteStats } from '@renderer/providers/NoteStatsProvider'
|
||||
import { Repeat } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { formatCount } from './utils'
|
||||
|
||||
export default function RepostButton({
|
||||
event,
|
||||
canFetch = false
|
||||
}: {
|
||||
event: Event
|
||||
canFetch?: boolean
|
||||
}) {
|
||||
const { pubkey, publish } = useNostr()
|
||||
const { noteStatsMap, fetchNoteRepostCount, fetchNoteRepostedStatus, markNoteAsReposted } =
|
||||
useNoteStats()
|
||||
const [reposting, setReposting] = useState(false)
|
||||
const { repostCount, hasReposted } = useMemo(
|
||||
() => noteStatsMap.get(event.id) ?? {},
|
||||
[noteStatsMap, event.id]
|
||||
)
|
||||
const canRepost = pubkey && !hasReposted && !reposting
|
||||
|
||||
useEffect(() => {
|
||||
if (!canFetch) return
|
||||
|
||||
if (repostCount === undefined) {
|
||||
fetchNoteRepostCount(event)
|
||||
}
|
||||
if (hasReposted === undefined) {
|
||||
fetchNoteRepostedStatus(event)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const repost = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!canRepost) return
|
||||
|
||||
setReposting(true)
|
||||
const timer = setTimeout(() => setReposting(false), 5000)
|
||||
|
||||
try {
|
||||
const [reposted] = await Promise.all([
|
||||
hasReposted === undefined ? fetchNoteRepostedStatus(event) : hasReposted,
|
||||
repostCount === undefined ? fetchNoteRepostCount(event) : repostCount
|
||||
])
|
||||
if (reposted) return
|
||||
|
||||
const repost = createRepostDraftEvent(event)
|
||||
await publish(repost)
|
||||
markNoteAsReposted(event.id)
|
||||
} catch (error) {
|
||||
console.error('repost failed', error)
|
||||
} finally {
|
||||
setReposting(false)
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'flex gap-1 items-center enabled:hover:text-lime-500',
|
||||
hasReposted ? 'text-lime-500' : 'text-muted-foreground'
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={!canRepost}
|
||||
title="repost"
|
||||
>
|
||||
<Repeat size={16} />
|
||||
<div className="text-xs">{formatCount(repostCount)}</div>
|
||||
</button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Repost Note</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to repost this note?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={repost}>Repost</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
27
src/renderer/src/components/NoteStats/index.tsx
Normal file
27
src/renderer/src/components/NoteStats/index.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { cn } from '@renderer/lib/utils'
|
||||
import { Event } from 'nostr-tools'
|
||||
import LikeButton from './LikeButton'
|
||||
import NoteOptions from './NoteOptions'
|
||||
import ReplyButton from './ReplyButton'
|
||||
import RepostButton from './RepostButton'
|
||||
|
||||
export default function NoteStats({
|
||||
event,
|
||||
className,
|
||||
fetchIfNotExisting = false
|
||||
}: {
|
||||
event: Event
|
||||
className?: string
|
||||
fetchIfNotExisting?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('flex justify-between', className)}>
|
||||
<div className="flex gap-4 h-4 items-center">
|
||||
<ReplyButton event={event} />
|
||||
<RepostButton event={event} canFetch={fetchIfNotExisting} />
|
||||
<LikeButton event={event} canFetch={fetchIfNotExisting} />
|
||||
</div>
|
||||
<NoteOptions event={event} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
4
src/renderer/src/components/NoteStats/utils.ts
Normal file
4
src/renderer/src/components/NoteStats/utils.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export function formatCount(count?: number) {
|
||||
if (count === undefined || count <= 0) return ''
|
||||
return count >= 100 ? '99+' : count
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { formatTimestamp } from '@renderer/lib/timestamp'
|
||||
import Content from '../Content'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
import Username from '../Username'
|
||||
import LikeButton from '../NoteStats/LikeButton'
|
||||
|
||||
export default function ReplyNote({
|
||||
event,
|
||||
@@ -40,6 +41,7 @@ export default function ReplyNote({
|
||||
)}
|
||||
<Content event={event} size="small" />
|
||||
</div>
|
||||
<LikeButton event={event} variant="reply" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Separator } from '@renderer/components/ui/separator'
|
||||
import { getParentEventId } from '@renderer/lib/event'
|
||||
import { cn } from '@renderer/lib/utils'
|
||||
import { useNoteStats } from '@renderer/providers/NoteStatsProvider'
|
||||
import client from '@renderer/services/client.service'
|
||||
import { createReplyCountChangedEvent, eventBus } from '@renderer/services/event-bus.service'
|
||||
import dayjs from 'dayjs'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
@@ -15,6 +15,7 @@ export default function ReplyNoteList({ event, className }: { event: Event; clas
|
||||
const [loading, setLoading] = useState<boolean>(false)
|
||||
const [hasMore, setHasMore] = useState<boolean>(false)
|
||||
const [highlightReplyId, setHighlightReplyId] = useState<string | undefined>(undefined)
|
||||
const { updateNoteReplyCount } = useNoteStats()
|
||||
const replyRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
||||
|
||||
const loadMore = async () => {
|
||||
@@ -45,7 +46,7 @@ export default function ReplyNoteList({ event, className }: { event: Event; clas
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
eventBus.emit(createReplyCountChangedEvent(event.id, eventsWithParentIds.length))
|
||||
updateNoteReplyCount(event.id, eventsWithParentIds.length)
|
||||
}, [eventsWithParentIds])
|
||||
|
||||
const onClickParent = (eventId: string) => {
|
||||
|
||||
139
src/renderer/src/components/ui/alert-dialog.tsx
Normal file
139
src/renderer/src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@renderer/lib/utils"
|
||||
import { buttonVariants } from "@renderer/components/ui/button"
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"mt-2 sm:mt-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
@@ -19,8 +19,8 @@ const buttonVariants = cva(
|
||||
titlebar: 'non-draggable hover:bg-accent hover:text-accent-foreground'
|
||||
},
|
||||
size: {
|
||||
default: 'h-8 rounded-md px-2',
|
||||
sm: 'h-8 rounded-md px-2',
|
||||
default: 'h-8 rounded-lg px-2',
|
||||
sm: 'h-8 rounded-lg px-2',
|
||||
lg: 'h-10 px-4 py-2',
|
||||
icon: 'h-8 w-8 rounded-full',
|
||||
titlebar: 'h-7 w-7 rounded-full'
|
||||
|
||||
@@ -10,7 +10,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'flex h-8 w-full rounded-lg p-2 text-sm bg-muted border border-muted ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import client from '@renderer/services/client.service'
|
||||
import { TEventStats } from '@renderer/types'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export default function useFetchEventStats(eventId: string) {
|
||||
const [stats, setStats] = useState<TEventStats>({
|
||||
reactionCount: 0,
|
||||
repostCount: 0
|
||||
})
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const stats = await client.fetchEventStatsById(eventId)
|
||||
setStats(stats)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch event stats', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchStats()
|
||||
}, [eventId])
|
||||
|
||||
return { stats, loading }
|
||||
}
|
||||
38
src/renderer/src/lib/draft-event.ts
Normal file
38
src/renderer/src/lib/draft-event.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { TDraftEvent } from '@common/types'
|
||||
import dayjs from 'dayjs'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
import { getEventCoordinate, isReplaceable } from './event'
|
||||
|
||||
// https://github.com/nostr-protocol/nips/blob/master/25.md
|
||||
export function createReactionDraftEvent(event: Event): TDraftEvent {
|
||||
const tags = event.tags.filter((tag) => tag.length >= 2 && ['e', 'p'].includes(tag[0]))
|
||||
tags.push(['e', event.id])
|
||||
tags.push(['p', event.pubkey])
|
||||
tags.push(['k', event.kind.toString()])
|
||||
|
||||
if (isReplaceable(event.kind)) {
|
||||
tags.push(['a', getEventCoordinate(event)])
|
||||
}
|
||||
|
||||
return {
|
||||
kind: kinds.Reaction,
|
||||
content: '+',
|
||||
tags,
|
||||
created_at: dayjs().unix()
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/nostr-protocol/nips/blob/master/18.md
|
||||
export function createRepostDraftEvent(event: Event): TDraftEvent {
|
||||
const tags = [
|
||||
['e', event.id], // TODO: url
|
||||
['p', event.pubkey]
|
||||
]
|
||||
|
||||
return {
|
||||
kind: kinds.Repost,
|
||||
content: JSON.stringify(event),
|
||||
tags,
|
||||
created_at: dayjs().unix()
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
import { replyETag, rootETag, tagNameEquals } from './tag'
|
||||
|
||||
export function isNsfwEvent(event: Event) {
|
||||
return event.tags.some(
|
||||
@@ -8,16 +9,22 @@ export function isNsfwEvent(event: Event) {
|
||||
}
|
||||
|
||||
export function isReplyNoteEvent(event: Event) {
|
||||
return (
|
||||
event.kind === kinds.ShortTextNote &&
|
||||
event.tags.some(([tagName, , , type]) => tagName === 'e' && ['root', 'reply'].includes(type))
|
||||
)
|
||||
return event.kind === kinds.ShortTextNote && event.tags.some(tagNameEquals('e'))
|
||||
}
|
||||
|
||||
export function getParentEventId(event: Event) {
|
||||
return event.tags.find(([tagName, , , type]) => tagName === 'e' && type === 'reply')?.[1]
|
||||
return event.tags.find(replyETag)?.[1]
|
||||
}
|
||||
|
||||
export function getRootEventId(event: Event) {
|
||||
return event.tags.find(([tagName, , , type]) => tagName === 'e' && type === 'root')?.[1]
|
||||
return event.tags.find(rootETag)?.[1]
|
||||
}
|
||||
|
||||
export function isReplaceable(kind: number) {
|
||||
return kinds.isReplaceableKind(kind) || kinds.isParameterizedReplaceableKind(kind)
|
||||
}
|
||||
|
||||
export function getEventCoordinate(event: Event) {
|
||||
const d = event.tags.find(tagNameEquals('d'))?.[1]
|
||||
return d ? `${event.kind}:${event.pubkey}:${d}` : `${event.kind}:${event.pubkey}`
|
||||
}
|
||||
|
||||
11
src/renderer/src/lib/tag.ts
Normal file
11
src/renderer/src/lib/tag.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export function tagNameEquals(tagName: string) {
|
||||
return (tag: string[]) => tag[0] === tagName
|
||||
}
|
||||
|
||||
export function replyETag([tagName, , , alt]: string[]) {
|
||||
return tagName === 'e' && alt === 'reply'
|
||||
}
|
||||
|
||||
export function rootETag([tagName, , , alt]: string[]) {
|
||||
return tagName === 'e' && alt === 'root'
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export default function NotePage({ event }: { event?: Event }) {
|
||||
<SecondaryPageLayout titlebarContent="note">
|
||||
{event && (
|
||||
<>
|
||||
<Note key={`note-${event.id}`} event={event} displayStats />
|
||||
<Note key={`note-${event.id}`} event={event} fetchNoteStats />
|
||||
<Separator className="mt-2" />
|
||||
<ReplyNoteList key={`reply-note-list-${event.id}`} event={event} />
|
||||
</>
|
||||
|
||||
190
src/renderer/src/providers/NoteStatsProvider.tsx
Normal file
190
src/renderer/src/providers/NoteStatsProvider.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { tagNameEquals } from '@renderer/lib/tag'
|
||||
import client from '@renderer/services/client.service'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
import { useNostr } from './NostrProvider'
|
||||
|
||||
export type TNoteStats = {
|
||||
likeCount: number
|
||||
repostCount: number
|
||||
replyCount: number
|
||||
hasLiked: boolean
|
||||
hasReposted: boolean
|
||||
}
|
||||
|
||||
type TNoteStatsContext = {
|
||||
noteStatsMap: Map<string, Partial<TNoteStats>>
|
||||
updateNoteReplyCount: (noteId: string, replyCount: number) => void
|
||||
markNoteAsLiked: (noteId: string) => void
|
||||
markNoteAsReposted: (noteId: string) => void
|
||||
fetchNoteLikeCount: (event: Event) => Promise<number>
|
||||
fetchNoteRepostCount: (event: Event) => Promise<number>
|
||||
fetchNoteLikedStatus: (event: Event) => Promise<boolean>
|
||||
fetchNoteRepostedStatus: (event: Event) => Promise<boolean>
|
||||
}
|
||||
|
||||
const NoteStatsContext = createContext<TNoteStatsContext | undefined>(undefined)
|
||||
|
||||
export const useNoteStats = () => {
|
||||
const context = useContext(NoteStatsContext)
|
||||
if (!context) {
|
||||
throw new Error('useNoteStats must be used within a NoteStatsProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export function NoteStatsProvider({ children }: { children: React.ReactNode }) {
|
||||
const [noteStatsMap, setNoteStatsMap] = useState<Map<string, Partial<TNoteStats>>>(new Map())
|
||||
const { pubkey } = useNostr()
|
||||
|
||||
useEffect(() => {
|
||||
setNoteStatsMap((prev) => {
|
||||
const newMap = new Map()
|
||||
for (const [noteId, stats] of prev) {
|
||||
newMap.set(noteId, { ...stats, hasLiked: undefined, hasReposted: undefined })
|
||||
}
|
||||
return newMap
|
||||
})
|
||||
}, [pubkey])
|
||||
|
||||
const fetchNoteLikeCount = async (event: Event) => {
|
||||
const events = await client.fetchEvents({
|
||||
'#e': [event.id],
|
||||
kinds: [kinds.Reaction],
|
||||
limit: 500
|
||||
})
|
||||
const countMap = new Map<string, number>()
|
||||
for (const e of events) {
|
||||
const targetEventId = e.tags.findLast(tagNameEquals('e'))?.[1]
|
||||
if (targetEventId) {
|
||||
countMap.set(targetEventId, (countMap.get(targetEventId) || 0) + 1)
|
||||
}
|
||||
}
|
||||
setNoteStatsMap((prev) => {
|
||||
const newMap = new Map(prev)
|
||||
for (const [eventId, count] of countMap) {
|
||||
const old = prev.get(eventId)
|
||||
newMap.set(eventId, old ? { ...old, likeCount: count } : { likeCount: count })
|
||||
}
|
||||
return newMap
|
||||
})
|
||||
return countMap.get(event.id) || 0
|
||||
}
|
||||
|
||||
const fetchNoteRepostCount = async (event: Event) => {
|
||||
const events = await client.fetchEvents({
|
||||
'#e': [event.id],
|
||||
kinds: [kinds.Repost],
|
||||
limit: 100
|
||||
})
|
||||
setNoteStatsMap((prev) => {
|
||||
const newMap = new Map(prev)
|
||||
const old = prev.get(event.id)
|
||||
newMap.set(
|
||||
event.id,
|
||||
old ? { ...old, repostCount: events.length } : { repostCount: events.length }
|
||||
)
|
||||
return newMap
|
||||
})
|
||||
return events.length
|
||||
}
|
||||
|
||||
const fetchNoteLikedStatus = async (event: Event) => {
|
||||
if (!pubkey) return false
|
||||
|
||||
const events = await client.fetchEvents({
|
||||
'#e': [event.id],
|
||||
authors: [pubkey],
|
||||
kinds: [kinds.Reaction]
|
||||
})
|
||||
const likedEventIds = events
|
||||
.map((e) => e.tags.findLast(tagNameEquals('e'))?.[1])
|
||||
.filter(Boolean) as string[]
|
||||
|
||||
setNoteStatsMap((prev) => {
|
||||
const newMap = new Map(prev)
|
||||
likedEventIds.forEach((eventId) => {
|
||||
const old = newMap.get(eventId)
|
||||
newMap.set(eventId, old ? { ...old, hasLiked: true } : { hasLiked: true })
|
||||
})
|
||||
if (!likedEventIds.includes(event.id)) {
|
||||
const old = newMap.get(event.id)
|
||||
newMap.set(event.id, old ? { ...old, hasLiked: false } : { hasLiked: false })
|
||||
}
|
||||
return newMap
|
||||
})
|
||||
return likedEventIds.includes(event.id)
|
||||
}
|
||||
|
||||
const fetchNoteRepostedStatus = async (event: Event) => {
|
||||
if (!pubkey) return false
|
||||
|
||||
const events = await client.fetchEvents({
|
||||
'#e': [event.id],
|
||||
authors: [pubkey],
|
||||
kinds: [kinds.Repost]
|
||||
})
|
||||
|
||||
setNoteStatsMap((prev) => {
|
||||
const hasReposted = events.length > 0
|
||||
const newMap = new Map(prev)
|
||||
const old = prev.get(event.id)
|
||||
newMap.set(event.id, old ? { ...old, hasReposted } : { hasReposted })
|
||||
return newMap
|
||||
})
|
||||
return events.length > 0
|
||||
}
|
||||
|
||||
const updateNoteReplyCount = (noteId: string, replyCount: number) => {
|
||||
setNoteStatsMap((prev) => {
|
||||
const old = prev.get(noteId)
|
||||
if (!old) {
|
||||
return new Map(prev).set(noteId, { replyCount })
|
||||
} else if (old.replyCount === undefined || old.replyCount < replyCount) {
|
||||
return new Map(prev).set(noteId, { ...old, replyCount })
|
||||
}
|
||||
return prev
|
||||
})
|
||||
}
|
||||
|
||||
const markNoteAsLiked = (noteId: string) => {
|
||||
setNoteStatsMap((prev) => {
|
||||
const old = prev.get(noteId)
|
||||
return new Map(prev).set(
|
||||
noteId,
|
||||
old
|
||||
? { ...old, hasLiked: true, likeCount: (old.likeCount ?? 0) + 1 }
|
||||
: { hasLiked: true, likeCount: 1 }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const markNoteAsReposted = (noteId: string) => {
|
||||
setNoteStatsMap((prev) => {
|
||||
const old = prev.get(noteId)
|
||||
return new Map(prev).set(
|
||||
noteId,
|
||||
old
|
||||
? { ...old, hasReposted: true, repostCount: (old.repostCount ?? 0) + 1 }
|
||||
: { hasReposted: true, repostCount: 1 }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<NoteStatsContext.Provider
|
||||
value={{
|
||||
noteStatsMap,
|
||||
fetchNoteLikeCount,
|
||||
fetchNoteLikedStatus,
|
||||
fetchNoteRepostCount,
|
||||
fetchNoteRepostedStatus,
|
||||
updateNoteReplyCount,
|
||||
markNoteAsLiked,
|
||||
markNoteAsReposted
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</NoteStatsContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TRelayGroup } from '@common/types'
|
||||
import { formatPubkey } from '@renderer/lib/pubkey'
|
||||
import { TEventStats, TProfile } from '@renderer/types'
|
||||
import { TProfile } from '@renderer/types'
|
||||
import DataLoader from 'dataloader'
|
||||
import { LRUCache } from 'lru-cache'
|
||||
import { Filter, kinds, Event as NEvent, SimplePool } from 'nostr-tools'
|
||||
@@ -21,12 +21,6 @@ class ClientService {
|
||||
private relayUrls: string[] = BIG_RELAY_URLS
|
||||
private initPromise!: Promise<void>
|
||||
|
||||
private eventStatsCache = new LRUCache<string, Promise<TEventStats>>({
|
||||
max: 10000,
|
||||
ttl: 1000 * 60 * 10, // 10 minutes
|
||||
fetchMethod: async (id) => this._fetchEventStatsById(id)
|
||||
})
|
||||
|
||||
private eventCache = new LRUCache<string, Promise<NEvent | undefined>>({
|
||||
max: 10000,
|
||||
fetchMethod: async (filterStr) => {
|
||||
@@ -114,11 +108,6 @@ class ClientService {
|
||||
return await this.pool.querySync(relayUrls, filter)
|
||||
}
|
||||
|
||||
async fetchEventStatsById(id: string): Promise<TEventStats> {
|
||||
const stats = await this.eventStatsCache.fetch(id)
|
||||
return stats ?? { reactionCount: 0, repostCount: 0 }
|
||||
}
|
||||
|
||||
async fetchEventByFilter(filter: Filter) {
|
||||
return this.eventCache.fetch(JSON.stringify({ ...filter, limit: 1 }))
|
||||
}
|
||||
@@ -131,15 +120,6 @@ class ClientService {
|
||||
return this.profileDataloader.load(pubkey)
|
||||
}
|
||||
|
||||
private async _fetchEventStatsById(id: string) {
|
||||
const [reactionEvents, repostEvents] = await Promise.all([
|
||||
this.fetchEvents({ '#e': [id], kinds: [kinds.Reaction] }),
|
||||
this.fetchEvents({ '#e': [id], kinds: [kinds.Repost] })
|
||||
])
|
||||
|
||||
return { reactionCount: reactionEvents.length, repostCount: repostEvents.length }
|
||||
}
|
||||
|
||||
private async eventBatchLoadFn(ids: readonly string[]) {
|
||||
const events = await this.fetchEvents({
|
||||
ids: ids as string[],
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { TRelayGroup } from '@common/types'
|
||||
|
||||
export const EVENT_TYPES = {
|
||||
RELAY_GROUPS_CHANGED: 'relay-groups-changed',
|
||||
REPLY_COUNT_CHANGED: 'reply-count-changed'
|
||||
RELAY_GROUPS_CHANGED: 'relay-groups-changed'
|
||||
} as const
|
||||
|
||||
type TEventMap = {
|
||||
[EVENT_TYPES.RELAY_GROUPS_CHANGED]: TRelayGroup[]
|
||||
[EVENT_TYPES.REPLY_COUNT_CHANGED]: { eventId: string; replyCount: number }
|
||||
}
|
||||
|
||||
type TCustomEventMap = {
|
||||
@@ -17,9 +15,6 @@ type TCustomEventMap = {
|
||||
export const createRelayGroupsChangedEvent = (relayGroups: TRelayGroup[]) => {
|
||||
return new CustomEvent(EVENT_TYPES.RELAY_GROUPS_CHANGED, { detail: relayGroups })
|
||||
}
|
||||
export const createReplyCountChangedEvent = (eventId: string, replyCount: number) => {
|
||||
return new CustomEvent(EVENT_TYPES.REPLY_COUNT_CHANGED, { detail: { eventId, replyCount } })
|
||||
}
|
||||
|
||||
class EventBus extends EventTarget {
|
||||
emit<K extends keyof TEventMap>(event: TCustomEventMap[K]): boolean {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
export type TEventStats = { reactionCount: number; repostCount: number }
|
||||
|
||||
export type TProfile = {
|
||||
username: string
|
||||
pubkey?: string
|
||||
|
||||
Reference in New Issue
Block a user