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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user