feat: like & repost (#3)
This commit is contained in:
28
package-lock.json
generated
28
package-lock.json
generated
@@ -12,6 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron-toolkit/preload": "^3.0.1",
|
"@electron-toolkit/preload": "^3.0.1",
|
||||||
"@electron-toolkit/utils": "^3.0.0",
|
"@electron-toolkit/utils": "^3.0.0",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.2",
|
||||||
"@radix-ui/react-avatar": "^1.1.1",
|
"@radix-ui/react-avatar": "^1.1.1",
|
||||||
"@radix-ui/react-dialog": "^1.1.2",
|
"@radix-ui/react-dialog": "^1.1.2",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||||
@@ -1587,6 +1588,33 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz",
|
||||||
"integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA=="
|
"integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-alert-dialog": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-eGSlLzPhKO+TErxkiGcCZGuvbVMnLA1MTnyBksGOeGRGkxHiiJUujsjmNTdWTm4iHVSRaUao9/4Ur671auMghQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.0",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.0",
|
||||||
|
"@radix-ui/react-context": "1.1.1",
|
||||||
|
"@radix-ui/react-dialog": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.0.0",
|
||||||
|
"@radix-ui/react-slot": "1.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-arrow": {
|
"node_modules/@radix-ui/react-arrow": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron-toolkit/preload": "^3.0.1",
|
"@electron-toolkit/preload": "^3.0.1",
|
||||||
"@electron-toolkit/utils": "^3.0.0",
|
"@electron-toolkit/utils": "^3.0.0",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.2",
|
||||||
"@radix-ui/react-avatar": "^1.1.1",
|
"@radix-ui/react-avatar": "^1.1.1",
|
||||||
"@radix-ui/react-dialog": "^1.1.2",
|
"@radix-ui/react-dialog": "^1.1.2",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import HashtagPage from './pages/secondary/HashtagPage'
|
|||||||
import NotePage from './pages/secondary/NotePage'
|
import NotePage from './pages/secondary/NotePage'
|
||||||
import ProfilePage from './pages/secondary/ProfilePage'
|
import ProfilePage from './pages/secondary/ProfilePage'
|
||||||
import { NostrProvider } from './providers/NostrProvider'
|
import { NostrProvider } from './providers/NostrProvider'
|
||||||
|
import { NoteStatsProvider } from './providers/NoteStatsProvider'
|
||||||
import { RelaySettingsProvider } from './providers/RelaySettingsProvider'
|
import { RelaySettingsProvider } from './providers/RelaySettingsProvider'
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
@@ -23,10 +24,12 @@ export default function App(): JSX.Element {
|
|||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<NostrProvider>
|
<NostrProvider>
|
||||||
<RelaySettingsProvider>
|
<RelaySettingsProvider>
|
||||||
|
<NoteStatsProvider>
|
||||||
<PageManager routes={routes}>
|
<PageManager routes={routes}>
|
||||||
<NoteListPage />
|
<NoteListPage />
|
||||||
</PageManager>
|
</PageManager>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
</NoteStatsProvider>
|
||||||
</RelaySettingsProvider>
|
</RelaySettingsProvider>
|
||||||
</NostrProvider>
|
</NostrProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export function EmbeddedNote({ noteId }: { noteId: string }) {
|
|||||||
const event = useFetchEventById(noteId)
|
const event = useFetchEventById(noteId)
|
||||||
|
|
||||||
return event && event.kind === kinds.ShortTextNote ? (
|
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
|
<a
|
||||||
href={toNoStrudelNote(noteId)}
|
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 Content from '../Content'
|
||||||
import UserAvatar from '../UserAvatar'
|
import UserAvatar from '../UserAvatar'
|
||||||
import Username from '../Username'
|
import Username from '../Username'
|
||||||
import NoteStats from './NoteStats'
|
import NoteStats from '../NoteStats'
|
||||||
|
|
||||||
export default function Note({
|
export default function Note({
|
||||||
event,
|
event,
|
||||||
parentEvent,
|
parentEvent,
|
||||||
size = 'normal',
|
size = 'normal',
|
||||||
className,
|
className,
|
||||||
displayStats = false
|
hideStats = false,
|
||||||
|
fetchNoteStats = false
|
||||||
}: {
|
}: {
|
||||||
event: Event
|
event: Event
|
||||||
parentEvent?: Event
|
parentEvent?: Event
|
||||||
size?: 'normal' | 'small'
|
size?: 'normal' | 'small'
|
||||||
className?: string
|
className?: string
|
||||||
displayStats?: boolean
|
hideStats?: boolean
|
||||||
|
fetchNoteStats?: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
@@ -36,7 +38,9 @@ export default function Note({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Content className="mt-2" event={event} />
|
<Content className="mt-2" event={event} />
|
||||||
{displayStats && <NoteStats className="mt-2" event={event} />}
|
{!hideStats && (
|
||||||
|
<NoteStats className="mt-2" event={event} fetchIfNotExisting={fetchNoteStats} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ import { getParentEventId, getRootEventId } from '@renderer/lib/event'
|
|||||||
export default function ShortTextNoteCard({
|
export default function ShortTextNoteCard({
|
||||||
event,
|
event,
|
||||||
className,
|
className,
|
||||||
size
|
size,
|
||||||
|
hideStats = false
|
||||||
}: {
|
}: {
|
||||||
event: Event
|
event: Event
|
||||||
className?: string
|
className?: string
|
||||||
size?: 'normal' | 'small'
|
size?: 'normal' | 'small'
|
||||||
|
hideStats?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { push } = useSecondaryPage()
|
const { push } = useSecondaryPage()
|
||||||
const rootEvent = useFetchEventById(getRootEventId(event))
|
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">
|
<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>
|
</Card>
|
||||||
</div>
|
</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 { useState } from 'react'
|
||||||
import RawEventDialog from './RawEventDialog'
|
import RawEventDialog from './RawEventDialog'
|
||||||
|
|
||||||
export default function NoteOptionsTrigger({ event }: { event: Event }) {
|
export default function NoteOptions({ event }: { event: Event }) {
|
||||||
const [isRawEventDialogOpen, setIsRawEventDialogOpen] = useState(false)
|
const [isRawEventDialogOpen, setIsRawEventDialogOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
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 Content from '../Content'
|
||||||
import UserAvatar from '../UserAvatar'
|
import UserAvatar from '../UserAvatar'
|
||||||
import Username from '../Username'
|
import Username from '../Username'
|
||||||
|
import LikeButton from '../NoteStats/LikeButton'
|
||||||
|
|
||||||
export default function ReplyNote({
|
export default function ReplyNote({
|
||||||
event,
|
event,
|
||||||
@@ -40,6 +41,7 @@ export default function ReplyNote({
|
|||||||
)}
|
)}
|
||||||
<Content event={event} size="small" />
|
<Content event={event} size="small" />
|
||||||
</div>
|
</div>
|
||||||
|
<LikeButton event={event} variant="reply" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Separator } from '@renderer/components/ui/separator'
|
import { Separator } from '@renderer/components/ui/separator'
|
||||||
import { getParentEventId } from '@renderer/lib/event'
|
import { getParentEventId } from '@renderer/lib/event'
|
||||||
import { cn } from '@renderer/lib/utils'
|
import { cn } from '@renderer/lib/utils'
|
||||||
|
import { useNoteStats } from '@renderer/providers/NoteStatsProvider'
|
||||||
import client from '@renderer/services/client.service'
|
import client from '@renderer/services/client.service'
|
||||||
import { createReplyCountChangedEvent, eventBus } from '@renderer/services/event-bus.service'
|
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
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 [loading, setLoading] = useState<boolean>(false)
|
||||||
const [hasMore, setHasMore] = useState<boolean>(false)
|
const [hasMore, setHasMore] = useState<boolean>(false)
|
||||||
const [highlightReplyId, setHighlightReplyId] = useState<string | undefined>(undefined)
|
const [highlightReplyId, setHighlightReplyId] = useState<string | undefined>(undefined)
|
||||||
|
const { updateNoteReplyCount } = useNoteStats()
|
||||||
const replyRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
const replyRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
||||||
|
|
||||||
const loadMore = async () => {
|
const loadMore = async () => {
|
||||||
@@ -45,7 +46,7 @@ export default function ReplyNoteList({ event, className }: { event: Event; clas
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
eventBus.emit(createReplyCountChangedEvent(event.id, eventsWithParentIds.length))
|
updateNoteReplyCount(event.id, eventsWithParentIds.length)
|
||||||
}, [eventsWithParentIds])
|
}, [eventsWithParentIds])
|
||||||
|
|
||||||
const onClickParent = (eventId: string) => {
|
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'
|
titlebar: 'non-draggable hover:bg-accent hover:text-accent-foreground'
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: 'h-8 rounded-md px-2',
|
default: 'h-8 rounded-lg px-2',
|
||||||
sm: 'h-8 rounded-md px-2',
|
sm: 'h-8 rounded-lg px-2',
|
||||||
lg: 'h-10 px-4 py-2',
|
lg: 'h-10 px-4 py-2',
|
||||||
icon: 'h-8 w-8 rounded-full',
|
icon: 'h-8 w-8 rounded-full',
|
||||||
titlebar: 'h-7 w-7 rounded-full'
|
titlebar: 'h-7 w-7 rounded-full'
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
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 { Event, kinds } from 'nostr-tools'
|
||||||
|
import { replyETag, rootETag, tagNameEquals } from './tag'
|
||||||
|
|
||||||
export function isNsfwEvent(event: Event) {
|
export function isNsfwEvent(event: Event) {
|
||||||
return event.tags.some(
|
return event.tags.some(
|
||||||
@@ -8,16 +9,22 @@ export function isNsfwEvent(event: Event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isReplyNoteEvent(event: Event) {
|
export function isReplyNoteEvent(event: Event) {
|
||||||
return (
|
return event.kind === kinds.ShortTextNote && event.tags.some(tagNameEquals('e'))
|
||||||
event.kind === kinds.ShortTextNote &&
|
|
||||||
event.tags.some(([tagName, , , type]) => tagName === 'e' && ['root', 'reply'].includes(type))
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getParentEventId(event: Event) {
|
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) {
|
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">
|
<SecondaryPageLayout titlebarContent="note">
|
||||||
{event && (
|
{event && (
|
||||||
<>
|
<>
|
||||||
<Note key={`note-${event.id}`} event={event} displayStats />
|
<Note key={`note-${event.id}`} event={event} fetchNoteStats />
|
||||||
<Separator className="mt-2" />
|
<Separator className="mt-2" />
|
||||||
<ReplyNoteList key={`reply-note-list-${event.id}`} event={event} />
|
<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 { TRelayGroup } from '@common/types'
|
||||||
import { formatPubkey } from '@renderer/lib/pubkey'
|
import { formatPubkey } from '@renderer/lib/pubkey'
|
||||||
import { TEventStats, TProfile } from '@renderer/types'
|
import { TProfile } from '@renderer/types'
|
||||||
import DataLoader from 'dataloader'
|
import DataLoader from 'dataloader'
|
||||||
import { LRUCache } from 'lru-cache'
|
import { LRUCache } from 'lru-cache'
|
||||||
import { Filter, kinds, Event as NEvent, SimplePool } from 'nostr-tools'
|
import { Filter, kinds, Event as NEvent, SimplePool } from 'nostr-tools'
|
||||||
@@ -21,12 +21,6 @@ class ClientService {
|
|||||||
private relayUrls: string[] = BIG_RELAY_URLS
|
private relayUrls: string[] = BIG_RELAY_URLS
|
||||||
private initPromise!: Promise<void>
|
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>>({
|
private eventCache = new LRUCache<string, Promise<NEvent | undefined>>({
|
||||||
max: 10000,
|
max: 10000,
|
||||||
fetchMethod: async (filterStr) => {
|
fetchMethod: async (filterStr) => {
|
||||||
@@ -114,11 +108,6 @@ class ClientService {
|
|||||||
return await this.pool.querySync(relayUrls, filter)
|
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) {
|
async fetchEventByFilter(filter: Filter) {
|
||||||
return this.eventCache.fetch(JSON.stringify({ ...filter, limit: 1 }))
|
return this.eventCache.fetch(JSON.stringify({ ...filter, limit: 1 }))
|
||||||
}
|
}
|
||||||
@@ -131,15 +120,6 @@ class ClientService {
|
|||||||
return this.profileDataloader.load(pubkey)
|
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[]) {
|
private async eventBatchLoadFn(ids: readonly string[]) {
|
||||||
const events = await this.fetchEvents({
|
const events = await this.fetchEvents({
|
||||||
ids: ids as string[],
|
ids: ids as string[],
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { TRelayGroup } from '@common/types'
|
import { TRelayGroup } from '@common/types'
|
||||||
|
|
||||||
export const EVENT_TYPES = {
|
export const EVENT_TYPES = {
|
||||||
RELAY_GROUPS_CHANGED: 'relay-groups-changed',
|
RELAY_GROUPS_CHANGED: 'relay-groups-changed'
|
||||||
REPLY_COUNT_CHANGED: 'reply-count-changed'
|
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
type TEventMap = {
|
type TEventMap = {
|
||||||
[EVENT_TYPES.RELAY_GROUPS_CHANGED]: TRelayGroup[]
|
[EVENT_TYPES.RELAY_GROUPS_CHANGED]: TRelayGroup[]
|
||||||
[EVENT_TYPES.REPLY_COUNT_CHANGED]: { eventId: string; replyCount: number }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type TCustomEventMap = {
|
type TCustomEventMap = {
|
||||||
@@ -17,9 +15,6 @@ type TCustomEventMap = {
|
|||||||
export const createRelayGroupsChangedEvent = (relayGroups: TRelayGroup[]) => {
|
export const createRelayGroupsChangedEvent = (relayGroups: TRelayGroup[]) => {
|
||||||
return new CustomEvent(EVENT_TYPES.RELAY_GROUPS_CHANGED, { detail: relayGroups })
|
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 {
|
class EventBus extends EventTarget {
|
||||||
emit<K extends keyof TEventMap>(event: TCustomEventMap[K]): boolean {
|
emit<K extends keyof TEventMap>(event: TCustomEventMap[K]): boolean {
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
export type TEventStats = { reactionCount: number; repostCount: number }
|
|
||||||
|
|
||||||
export type TProfile = {
|
export type TProfile = {
|
||||||
username: string
|
username: string
|
||||||
pubkey?: string
|
pubkey?: string
|
||||||
|
|||||||
Reference in New Issue
Block a user