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.
This commit is contained in:
Mary Kate Fain
2026-02-23 15:55:44 -06:00
parent d4bac9f255
commit 7a8ee981bd
4 changed files with 140 additions and 30 deletions
+9 -7
View File
@@ -410,13 +410,15 @@ export function NoteCard({ event, className, repostedBy, compact, threaded }: No
</button>
<RepostMenu event={event}>
<button
className="flex items-center gap-1.5 p-2 rounded-full text-muted-foreground hover:text-green-500 hover:bg-green-500/10 transition-colors"
title="Repost"
>
<RepostIcon className="size-5" />
{(stats?.reposts || stats?.quotes) ? <span className="text-sm tabular-nums">{(stats?.reposts ?? 0) + (stats?.quotes ?? 0)}</span> : null}
</button>
{(isReposted: boolean) => (
<button
className={`flex items-center gap-1.5 p-2 rounded-full transition-colors ${isReposted ? 'text-green-500 hover:text-green-600 hover:bg-green-500/10' : 'text-muted-foreground hover:text-green-500 hover:bg-green-500/10'}`}
title={isReposted ? 'Undo repost' : 'Repost'}
>
<RepostIcon className="size-5" />
{(stats?.reposts || stats?.quotes) ? <span className="text-sm tabular-nums">{(stats?.reposts ?? 0) + (stats?.quotes ?? 0)}</span> : null}
</button>
)}
</RepostMenu>
<ReactionButton
+80 -16
View File
@@ -1,4 +1,4 @@
import { Quote } from 'lucide-react';
import { Quote, Undo2 } from 'lucide-react';
import { RepostIcon } from '@/components/icons/RepostIcon';
import { useState } from 'react';
import type { NostrEvent } from '@nostrify/nostrify';
@@ -7,13 +7,15 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useDeleteEvent } from '@/hooks/useDeleteEvent';
import { useRepostStatus } from '@/hooks/useRepostStatus';
import { useQueryClient } from '@tanstack/react-query';
import { useToast } from '@/hooks/useToast';
import type { EventStats } from '@/hooks/useTrending';
interface RepostMenuProps {
event: NostrEvent;
children: React.ReactNode;
children: React.ReactNode | ((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<EventStats>(['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<EventStats>(['event-stats', event.id]);
if (prevStats) {
queryClient.setQueryData<EventStats>(['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<EventStats>(['event-stats', event.id], prevStats);
}
queryClient.setQueryData(['user-repost', event.id], prevRepostStatus);
},
}
);
@@ -80,16 +129,29 @@ export function RepostMenu({ event, children }: RepostMenuProps) {
const menuContent = (
<div className="w-full">
<button
onClick={(e) => {
e.stopPropagation();
handleRepost();
}}
className="flex items-center gap-3 w-full px-4 py-3 text-[15px] text-foreground hover:bg-secondary/60 transition-colors"
>
<RepostIcon className="size-5" />
<span>Repost</span>
</button>
{isReposted ? (
<button
onClick={(e) => {
e.stopPropagation();
handleUnrepost();
}}
className="flex items-center gap-3 w-full px-4 py-3 text-[15px] text-green-500 hover:bg-secondary/60 transition-colors"
>
<Undo2 className="size-5" />
<span>Undo repost</span>
</button>
) : (
<button
onClick={(e) => {
e.stopPropagation();
handleRepost();
}}
className="flex items-center gap-3 w-full px-4 py-3 text-[15px] text-foreground hover:bg-secondary/60 transition-colors"
>
<RepostIcon className="size-5" />
<span>Repost</span>
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
@@ -103,11 +165,13 @@ export function RepostMenu({ event, children }: RepostMenuProps) {
</div>
);
const trigger = typeof children === 'function' ? children(isReposted) : children;
return (
<>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild onClick={(e) => e.stopPropagation()}>
{children}
{trigger}
</PopoverTrigger>
<PopoverContent
className="w-48 p-0 rounded-xl overflow-hidden"
+42
View File
@@ -0,0 +1,42 @@
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
/**
* Returns the current user's repost event ID for a given event, if any.
*
* Queries the relay for kind 6 events authored by the current user
* that reference the target event via an `e` tag.
*
* Returns:
* - `undefined` while loading
* - `null` if the user has not reposted this event
* - the repost event ID (string) if the user has reposted
*/
export function useRepostStatus(eventId: string | undefined) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
return useQuery({
queryKey: ['user-repost', eventId ?? ''],
queryFn: async ({ signal }): Promise<string | null> => {
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,
});
}
+9 -7
View File
@@ -924,13 +924,15 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
{/* Repost */}
<RepostMenu event={event}>
<button
className="flex items-center gap-1.5 p-2 rounded-full text-muted-foreground hover:text-green-500 hover:bg-green-500/10 transition-colors"
title="Reposts"
>
<RepostIcon className="size-5" />
{repostTotal ? <span className="text-sm tabular-nums">{repostTotal}</span> : null}
</button>
{(isReposted: boolean) => (
<button
className={`flex items-center gap-1.5 p-2 rounded-full transition-colors ${isReposted ? 'text-green-500 hover:text-green-600 hover:bg-green-500/10' : 'text-muted-foreground hover:text-green-500 hover:bg-green-500/10'}`}
title={isReposted ? 'Undo repost' : 'Reposts'}
>
<RepostIcon className="size-5" />
{repostTotal ? <span className="text-sm tabular-nums">{repostTotal}</span> : null}
</button>
)}
</RepostMenu>
{/* React */}