28 changed files with 688 additions and 130 deletions
@ -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 |
|
||||||
} |
|
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,4 @@ |
|||||||
|
export function formatCount(count?: number) { |
||||||
|
if (count === undefined || count <= 0) return '' |
||||||
|
return count >= 100 ? '99+' : count |
||||||
|
} |
||||||
@ -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, |
||||||
|
} |
||||||
@ -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 } |
|
||||||
} |
|
||||||
@ -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() |
||||||
|
} |
||||||
|
} |
||||||
@ -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' |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
Loading…
Reference in new issue