From bd3078bcd069623ff8650a5c65385cd9cb7ccb62 Mon Sep 17 00:00:00 2001 From: Cody Tseng Date: Wed, 6 Nov 2024 22:51:52 +0800 Subject: [PATCH] feat: like & repost (#3) --- package-lock.json | 28 +++ package.json | 1 + src/renderer/src/App.tsx | 11 +- .../src/components/Embedded/EmbeddedNote.tsx | 2 +- .../src/components/Note/NoteStats.tsx | 48 ----- src/renderer/src/components/Note/index.tsx | 12 +- .../components/NoteCard/ShortTextNoteCard.tsx | 11 +- .../src/components/NoteStats/LikeButton.tsx | 79 ++++++++ .../NoteOptions}/RawEventDialog.tsx | 0 .../NoteOptions/index.tsx} | 2 +- .../src/components/NoteStats/ReplyButton.tsx | 17 ++ .../src/components/NoteStats/RepostButton.tsx | 104 ++++++++++ .../src/components/NoteStats/index.tsx | 27 +++ .../src/components/NoteStats/utils.ts | 4 + .../src/components/ReplyNote/index.tsx | 2 + .../src/components/ReplyNoteList/index.tsx | 5 +- .../src/components/ui/alert-dialog.tsx | 139 +++++++++++++ src/renderer/src/components/ui/button.tsx | 4 +- src/renderer/src/components/ui/input.tsx | 2 +- src/renderer/src/hooks/useFetchEventStats.tsx | 29 --- src/renderer/src/lib/draft-event.ts | 38 ++++ src/renderer/src/lib/event.ts | 19 +- src/renderer/src/lib/tag.ts | 11 + .../src/pages/secondary/NotePage/index.tsx | 2 +- .../src/providers/NoteStatsProvider.tsx | 190 ++++++++++++++++++ src/renderer/src/services/client.service.ts | 22 +- .../src/services/event-bus.service.ts | 7 +- src/renderer/src/types.ts | 2 - 28 files changed, 688 insertions(+), 130 deletions(-) delete mode 100644 src/renderer/src/components/Note/NoteStats.tsx create mode 100644 src/renderer/src/components/NoteStats/LikeButton.tsx rename src/renderer/src/components/{Note => NoteStats/NoteOptions}/RawEventDialog.tsx (100%) rename src/renderer/src/components/{Note/NoteOptionsTrigger.tsx => NoteStats/NoteOptions/index.tsx} (93%) create mode 100644 src/renderer/src/components/NoteStats/ReplyButton.tsx create mode 100644 src/renderer/src/components/NoteStats/RepostButton.tsx create mode 100644 src/renderer/src/components/NoteStats/index.tsx create mode 100644 src/renderer/src/components/NoteStats/utils.ts create mode 100644 src/renderer/src/components/ui/alert-dialog.tsx delete mode 100644 src/renderer/src/hooks/useFetchEventStats.tsx create mode 100644 src/renderer/src/lib/draft-event.ts create mode 100644 src/renderer/src/lib/tag.ts create mode 100644 src/renderer/src/providers/NoteStatsProvider.tsx diff --git a/package-lock.json b/package-lock.json index 933df3e3..bf3c79ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "dependencies": { "@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/utils": "^3.0.0", + "@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-dialog": "^1.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", "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz", diff --git a/package.json b/package.json index 3ae02d77..85743758 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "dependencies": { "@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/utils": "^3.0.0", + "@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2", diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index c93e3df4..21033d40 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 { - - - - + + + + + + diff --git a/src/renderer/src/components/Embedded/EmbeddedNote.tsx b/src/renderer/src/components/Embedded/EmbeddedNote.tsx index e8567316..24f43631 100644 --- a/src/renderer/src/components/Embedded/EmbeddedNote.tsx +++ b/src/renderer/src/components/Embedded/EmbeddedNote.tsx @@ -7,7 +7,7 @@ export function EmbeddedNote({ noteId }: { noteId: string }) { const event = useFetchEventById(noteId) return event && event.kind === kinds.ShortTextNote ? ( - + ) : ( { - 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 ( -
-
- -
{formatCount(replyCount)}
-
-
- -
{formatCount(stats.repostCount)}
-
-
- -
{formatCount(stats.reactionCount)}
-
- -
- ) -} - -function formatCount(count: number) { - return count >= 100 ? '99+' : count -} diff --git a/src/renderer/src/components/Note/index.tsx b/src/renderer/src/components/Note/index.tsx index dfeaed1b..ff455aee 100644 --- a/src/renderer/src/components/Note/index.tsx +++ b/src/renderer/src/components/Note/index.tsx @@ -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 (
@@ -36,7 +38,9 @@ export default function Note({
)} - {displayStats && } + {!hideStats && ( + + )} ) } diff --git a/src/renderer/src/components/NoteCard/ShortTextNoteCard.tsx b/src/renderer/src/components/NoteCard/ShortTextNoteCard.tsx index 99e26a7b..53bde29d 100644 --- a/src/renderer/src/components/NoteCard/ShortTextNoteCard.tsx +++ b/src/renderer/src/components/NoteCard/ShortTextNoteCard.tsx @@ -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({ }} > - + ) diff --git a/src/renderer/src/components/NoteStats/LikeButton.tsx b/src/renderer/src/components/NoteStats/LikeButton.tsx new file mode 100644 index 00000000..67fbb1bb --- /dev/null +++ b/src/renderer/src/components/NoteStats/LikeButton.tsx @@ -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 ( + + ) +} diff --git a/src/renderer/src/components/Note/RawEventDialog.tsx b/src/renderer/src/components/NoteStats/NoteOptions/RawEventDialog.tsx similarity index 100% rename from src/renderer/src/components/Note/RawEventDialog.tsx rename to src/renderer/src/components/NoteStats/NoteOptions/RawEventDialog.tsx diff --git a/src/renderer/src/components/Note/NoteOptionsTrigger.tsx b/src/renderer/src/components/NoteStats/NoteOptions/index.tsx similarity index 93% rename from src/renderer/src/components/Note/NoteOptionsTrigger.tsx rename to src/renderer/src/components/NoteStats/NoteOptions/index.tsx index 10df10e8..f502091a 100644 --- a/src/renderer/src/components/Note/NoteOptionsTrigger.tsx +++ b/src/renderer/src/components/NoteStats/NoteOptions/index.tsx @@ -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 ( diff --git a/src/renderer/src/components/NoteStats/ReplyButton.tsx b/src/renderer/src/components/NoteStats/ReplyButton.tsx new file mode 100644 index 00000000..d426fd02 --- /dev/null +++ b/src/renderer/src/components/NoteStats/ReplyButton.tsx @@ -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 ( +
+ +
{formatCount(replyCount)}
+
+ ) +} diff --git a/src/renderer/src/components/NoteStats/RepostButton.tsx b/src/renderer/src/components/NoteStats/RepostButton.tsx new file mode 100644 index 00000000..97b93952 --- /dev/null +++ b/src/renderer/src/components/NoteStats/RepostButton.tsx @@ -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 ( + + + + + + + Repost Note + + Are you sure you want to repost this note? + + + + Cancel + Repost + + + + ) +} diff --git a/src/renderer/src/components/NoteStats/index.tsx b/src/renderer/src/components/NoteStats/index.tsx new file mode 100644 index 00000000..1258091a --- /dev/null +++ b/src/renderer/src/components/NoteStats/index.tsx @@ -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 ( +
+
+ + + +
+ +
+ ) +} diff --git a/src/renderer/src/components/NoteStats/utils.ts b/src/renderer/src/components/NoteStats/utils.ts new file mode 100644 index 00000000..7771cb45 --- /dev/null +++ b/src/renderer/src/components/NoteStats/utils.ts @@ -0,0 +1,4 @@ +export function formatCount(count?: number) { + if (count === undefined || count <= 0) return '' + return count >= 100 ? '99+' : count +} diff --git a/src/renderer/src/components/ReplyNote/index.tsx b/src/renderer/src/components/ReplyNote/index.tsx index cbae2f03..215a54c6 100644 --- a/src/renderer/src/components/ReplyNote/index.tsx +++ b/src/renderer/src/components/ReplyNote/index.tsx @@ -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({ )} + ) } diff --git a/src/renderer/src/components/ReplyNoteList/index.tsx b/src/renderer/src/components/ReplyNoteList/index.tsx index 0ef45a11..fa5772df 100644 --- a/src/renderer/src/components/ReplyNoteList/index.tsx +++ b/src/renderer/src/components/ReplyNoteList/index.tsx @@ -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(false) const [hasMore, setHasMore] = useState(false) const [highlightReplyId, setHighlightReplyId] = useState(undefined) + const { updateNoteReplyCount } = useNoteStats() const replyRefs = useRef>({}) 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) => { diff --git a/src/renderer/src/components/ui/alert-dialog.tsx b/src/renderer/src/components/ui/alert-dialog.tsx new file mode 100644 index 00000000..4e1b6485 --- /dev/null +++ b/src/renderer/src/components/ui/alert-dialog.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/src/renderer/src/components/ui/button.tsx b/src/renderer/src/components/ui/button.tsx index 84552285..78332972 100644 --- a/src/renderer/src/components/ui/button.tsx +++ b/src/renderer/src/components/ui/button.tsx @@ -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' diff --git a/src/renderer/src/components/ui/input.tsx b/src/renderer/src/components/ui/input.tsx index 17bc6200..c54aa3a1 100644 --- a/src/renderer/src/components/ui/input.tsx +++ b/src/renderer/src/components/ui/input.tsx @@ -10,7 +10,7 @@ const Input = React.forwardRef( ({ - 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 } -} diff --git a/src/renderer/src/lib/draft-event.ts b/src/renderer/src/lib/draft-event.ts new file mode 100644 index 00000000..42c5ce6c --- /dev/null +++ b/src/renderer/src/lib/draft-event.ts @@ -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() + } +} diff --git a/src/renderer/src/lib/event.ts b/src/renderer/src/lib/event.ts index c8b5236c..4573e886 100644 --- a/src/renderer/src/lib/event.ts +++ b/src/renderer/src/lib/event.ts @@ -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}` } diff --git a/src/renderer/src/lib/tag.ts b/src/renderer/src/lib/tag.ts new file mode 100644 index 00000000..e80bb20e --- /dev/null +++ b/src/renderer/src/lib/tag.ts @@ -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' +} diff --git a/src/renderer/src/pages/secondary/NotePage/index.tsx b/src/renderer/src/pages/secondary/NotePage/index.tsx index d1a91534..e5acd962 100644 --- a/src/renderer/src/pages/secondary/NotePage/index.tsx +++ b/src/renderer/src/pages/secondary/NotePage/index.tsx @@ -9,7 +9,7 @@ export default function NotePage({ event }: { event?: Event }) { {event && ( <> - + diff --git a/src/renderer/src/providers/NoteStatsProvider.tsx b/src/renderer/src/providers/NoteStatsProvider.tsx new file mode 100644 index 00000000..40327d25 --- /dev/null +++ b/src/renderer/src/providers/NoteStatsProvider.tsx @@ -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> + updateNoteReplyCount: (noteId: string, replyCount: number) => void + markNoteAsLiked: (noteId: string) => void + markNoteAsReposted: (noteId: string) => void + fetchNoteLikeCount: (event: Event) => Promise + fetchNoteRepostCount: (event: Event) => Promise + fetchNoteLikedStatus: (event: Event) => Promise + fetchNoteRepostedStatus: (event: Event) => Promise +} + +const NoteStatsContext = createContext(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>>(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() + 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 ( + + {children} + + ) +} diff --git a/src/renderer/src/services/client.service.ts b/src/renderer/src/services/client.service.ts index a6195c3e..1a47acff 100644 --- a/src/renderer/src/services/client.service.ts +++ b/src/renderer/src/services/client.service.ts @@ -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 - private eventStatsCache = new LRUCache>({ - max: 10000, - ttl: 1000 * 60 * 10, // 10 minutes - fetchMethod: async (id) => this._fetchEventStatsById(id) - }) - private eventCache = new LRUCache>({ max: 10000, fetchMethod: async (filterStr) => { @@ -114,11 +108,6 @@ class ClientService { return await this.pool.querySync(relayUrls, filter) } - async fetchEventStatsById(id: string): Promise { - 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[], diff --git a/src/renderer/src/services/event-bus.service.ts b/src/renderer/src/services/event-bus.service.ts index 4702e4d0..c315f3bf 100644 --- a/src/renderer/src/services/event-bus.service.ts +++ b/src/renderer/src/services/event-bus.service.ts @@ -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(event: TCustomEventMap[K]): boolean { diff --git a/src/renderer/src/types.ts b/src/renderer/src/types.ts index 61cd0d8b..352357a3 100644 --- a/src/renderer/src/types.ts +++ b/src/renderer/src/types.ts @@ -1,5 +1,3 @@ -export type TEventStats = { reactionCount: number; repostCount: number } - export type TProfile = { username: string pubkey?: string