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