From 7a8ee981bdcfb5764e6fae0b1d7328e459755d85 Mon Sep 17 00:00:00 2001 From: Mary Kate Fain Date: Mon, 23 Feb 2026 15:55:44 -0600 Subject: [PATCH] Add ability to unrepost a post Query the user's own kind 6 events to detect existing reposts, then show an 'Undo repost' option in the popover menu and highlight the repost button green when active. Unreposting publishes a kind 5 deletion event (NIP-09) with optimistic UI updates. --- src/components/NoteCard.tsx | 16 +++--- src/components/RepostMenu.tsx | 96 +++++++++++++++++++++++++++++------ src/hooks/useRepostStatus.ts | 42 +++++++++++++++ src/pages/PostDetailPage.tsx | 16 +++--- 4 files changed, 140 insertions(+), 30 deletions(-) create mode 100644 src/hooks/useRepostStatus.ts diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx index c297caac..1b0476f4 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -410,13 +410,15 @@ export function NoteCard({ event, className, repostedBy, compact, threaded }: No - + {(isReposted: boolean) => ( + + )} React.ReactNode); } export function RepostMenu({ event, children }: RepostMenuProps) { @@ -21,9 +23,13 @@ export function RepostMenu({ event, children }: RepostMenuProps) { const [quoteOpen, setQuoteOpen] = useState(false); const { user } = useCurrentUser(); const { mutate: publishEvent } = useNostrPublish(); + const { mutate: deleteEvent } = useDeleteEvent(); + const { data: repostEventId } = useRepostStatus(event.id); const queryClient = useQueryClient(); const { toast } = useToast(); + const isReposted = !!repostEventId; + const handleRepost = () => { if (!user) { toast({ title: 'Please log in to repost', variant: 'destructive' }); @@ -39,6 +45,9 @@ export function RepostMenu({ event, children }: RepostMenuProps) { }); } + // Optimistically mark as reposted + queryClient.setQueryData(['user-repost', event.id], 'optimistic'); + // Kind 6 repost publishEvent( { @@ -55,19 +64,59 @@ export function RepostMenu({ event, children }: RepostMenuProps) { toast({ title: 'Reposted!' }); setOpen(false); // Delay invalidation so the relay has time to index the new event. - // Without this, the refetch returns stale counts and overwrites - // the optimistic update. setTimeout(() => { queryClient.invalidateQueries({ queryKey: ['event-stats', event.id] }); queryClient.invalidateQueries({ queryKey: ['event-interactions', event.id] }); + queryClient.invalidateQueries({ queryKey: ['user-repost', event.id] }); }, 3000); }, onError: () => { toast({ title: 'Failed to repost', variant: 'destructive' }); - // Revert optimistic update + // Revert optimistic updates if (prevStats) { queryClient.setQueryData(['event-stats', event.id], prevStats); } + queryClient.setQueryData(['user-repost', event.id], null); + }, + } + ); + }; + + const handleUnrepost = () => { + if (!user || !repostEventId) return; + + // Optimistically update stats cache + const prevStats = queryClient.getQueryData(['event-stats', event.id]); + if (prevStats) { + queryClient.setQueryData(['event-stats', event.id], { + ...prevStats, + reposts: Math.max(0, prevStats.reposts - 1), + }); + } + + // Optimistically mark as not reposted + const prevRepostStatus = queryClient.getQueryData(['user-repost', event.id]); + queryClient.setQueryData(['user-repost', event.id], null); + + deleteEvent( + { eventId: repostEventId, eventKind: 6 }, + { + onSuccess: () => { + toast({ title: 'Repost removed' }); + setOpen(false); + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: ['event-stats', event.id] }); + queryClient.invalidateQueries({ queryKey: ['event-interactions', event.id] }); + queryClient.invalidateQueries({ queryKey: ['user-repost', event.id] }); + }, 3000); + }, + onError: () => { + toast({ title: 'Failed to remove repost', variant: 'destructive' }); + // Revert optimistic updates + if (prevStats) { + queryClient.setQueryData(['event-stats', event.id], prevStats); + } + queryClient.setQueryData(['user-repost', event.id], prevRepostStatus); }, } ); @@ -80,16 +129,29 @@ export function RepostMenu({ event, children }: RepostMenuProps) { const menuContent = (
- + {isReposted ? ( + + ) : ( + + )}
); + const trigger = typeof children === 'function' ? children(isReposted) : children; + return ( <> e.stopPropagation()}> - {children} + {trigger} => { + if (!eventId || !user) return null; + + const events = await nostr.query( + [{ + kinds: [6], + authors: [user.pubkey], + '#e': [eventId], + limit: 1, + }], + { signal }, + ); + + if (events.length === 0) return null; + return events[0].id; + }, + enabled: !!eventId && !!user, + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + }); +} diff --git a/src/pages/PostDetailPage.tsx b/src/pages/PostDetailPage.tsx index a45cb4c9..eb0b99b9 100644 --- a/src/pages/PostDetailPage.tsx +++ b/src/pages/PostDetailPage.tsx @@ -924,13 +924,15 @@ function PostDetailContent({ event }: { event: NostrEvent }) { {/* Repost */} - + {(isReposted: boolean) => ( + + )} {/* React */}