feat: like & repost (#3)

This commit is contained in:
Cody Tseng
2024-11-06 22:51:52 +08:00
committed by GitHub
parent 751ad16690
commit bd3078bcd0
28 changed files with 688 additions and 130 deletions

View File

@@ -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>

View File

@@ -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)}

View File

@@ -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
}

View File

@@ -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>
)
}

View File

@@ -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>
)

View 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>
)
}

View File

@@ -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 (

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -0,0 +1,4 @@
export function formatCount(count?: number) {
if (count === undefined || count <= 0) return ''
return count >= 100 ? '99+' : count
}

View File

@@ -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>
)
}

View File

@@ -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) => {

View 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,
}

View File

@@ -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'

View File

@@ -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}

View File

@@ -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 }
}

View 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()
}
}

View File

@@ -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}`
}

View 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'
}

View File

@@ -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} />
</>

View 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>
)
}

View File

@@ -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[],

View File

@@ -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 {

View File

@@ -1,5 +1,3 @@
export type TEventStats = { reactionCount: number; repostCount: number }
export type TProfile = {
username: string
pubkey?: string