Files
eranos/src/components/RepostMenu.tsx
T
Chad Curtis 530f5b4d0d fix vine reactions and kind 16 repost for addressable events
- VineHeartButton: add optimistic cache updates and query invalidation on
  reaction so the heart fills and count increments immediately without
  a page refresh
- RepostMenu: include NIP-18 'a' tag (kind:pubkey:d-tag) when reposting
  addressable events (30000–39999) so kind 16 events are properly linked
  back to the original vine
2026-03-03 00:36:55 -06:00

206 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Quote, Undo2 } from 'lucide-react';
import { RepostIcon } from '@/components/icons/RepostIcon';
import { useState } from 'react';
import type { NostrEvent } from '@nostrify/nostrify';
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 { getRepostKind } from '@/lib/feedUtils';
import type { EventStats } from '@/hooks/useTrending';
interface RepostMenuProps {
event: NostrEvent;
children: React.ReactNode | ((isReposted: boolean) => React.ReactNode);
}
export function RepostMenu({ event, children }: RepostMenuProps) {
const [open, setOpen] = useState(false);
const [quoteOpen, setQuoteOpen] = useState(false);
const { user } = useCurrentUser();
const { mutate: publishEvent } = useNostrPublish();
const { mutate: deleteEvent } = useDeleteEvent();
const 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' });
return;
}
// Optimistically update stats cache immediately
const prevStats = queryClient.getQueryData<EventStats>(['event-stats', event.id]);
if (prevStats) {
queryClient.setQueryData<EventStats>(['event-stats', event.id], {
...prevStats,
reposts: prevStats.reposts + 1,
});
}
// Optimistically mark as reposted
queryClient.setQueryData(['user-repost', event.id], 'optimistic');
// Kind 6 for kind 1 notes, kind 16 (generic repost) for everything else
const repostKind = getRepostKind(event.kind);
const tags: string[][] = [
['e', event.id],
['p', event.pubkey],
];
// Kind 16 generic reposts require a 'k' tag with the original event's kind
if (repostKind === 16) {
tags.push(['k', String(event.kind)]);
// Addressable events (3000039999) should include an 'a' tag per NIP-18
if (event.kind >= 30000 && event.kind < 40000) {
const dTag = event.tags.find(([name]) => name === 'd')?.[1] ?? '';
tags.push(['a', `${event.kind}:${event.pubkey}:${dTag}`]);
}
}
publishEvent(
{
kind: repostKind,
content: '',
created_at: Math.floor(Date.now() / 1000),
tags,
},
{
onSuccess: () => {
toast({ title: 'Reposted!' });
setOpen(false);
// Delay invalidation so the relay has time to index the new event.
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 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: getRepostKind(event.kind) },
{
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);
},
}
);
};
const handleQuote = () => {
setOpen(false);
setQuoteOpen(true);
};
const menuContent = (
<div className="w-full">
{isReposted ? (
<button
onClick={(e) => {
e.stopPropagation();
handleUnrepost();
}}
className="flex items-center gap-3 w-full px-4 py-3 text-[15px] text-accent 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();
handleQuote();
}}
className="flex items-center gap-3 w-full px-4 py-3 text-[15px] text-foreground hover:bg-secondary/60 transition-colors"
>
<Quote className="size-5" />
<span>Quote post</span>
</button>
</div>
);
const trigger = typeof children === 'function' ? children(isReposted) : children;
return (
<>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild onClick={(e) => e.stopPropagation()}>
{trigger}
</PopoverTrigger>
<PopoverContent
className="w-48 p-0 rounded-xl overflow-hidden"
align="start"
side="top"
onClick={(e) => e.stopPropagation()}
>
{menuContent}
</PopoverContent>
</Popover>
<ReplyComposeModal
quotedEvent={event}
open={quoteOpen}
onOpenChange={setQuoteOpen}
/>
</>
);
}