add VineRepostButton: tap to repost/unrepost directly on vine

Replace RepostMenu popover with a dedicated VineRepostButton that works
like VineHeartButton — single tap to repost, tap again to undo — with
optimistic cache updates so the button color changes immediately.

The popover menu was invisible on the dark fullscreen video background
and provided no visible confirmation of the action.
This commit is contained in:
Chad Curtis
2026-03-03 00:39:54 -06:00
parent 530f5b4d0d
commit 09271be935
2 changed files with 108 additions and 20 deletions
+5 -10
View File
@@ -1,12 +1,13 @@
import { useNostr } from '@nostrify/react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
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.
*
* Checks the optimistic cache first (set by RepostMenu on repost/unrepost),
* then falls back to querying the relay for the user's kind 6 or kind 16 events.
* Optimistic updates (set by RepostMenu via setQueryData) flow through
* useQuery's reactive `data` automatically — no separate getQueryData check
* needed.
*
* Returns:
* - `undefined` while loading
@@ -16,10 +17,6 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
export function useRepostStatus(eventId: string | undefined): string | null | undefined {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
// Check optimistic cache first
const optimistic = queryClient.getQueryData<string | null>(['user-repost', eventId ?? '']);
const { data } = useQuery({
queryKey: ['user-repost', eventId ?? ''],
@@ -40,12 +37,10 @@ export function useRepostStatus(eventId: string | undefined): string | null | un
if (events.length === 0) return null;
return events[0].id;
},
enabled: !!eventId && !!user && !optimistic,
enabled: !!eventId && !!user,
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
});
// Prefer optimistic value, then query result
if (optimistic) return optimistic;
return data;
}
+103 -10
View File
@@ -32,8 +32,10 @@ import { useLayoutOptions } from '@/contexts/LayoutContext';
import { useFollowList } from '@/hooks/useFollowActions';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useUserReaction } from '@/hooks/useUserReaction';
import { RepostMenu } from '@/components/RepostMenu';
import { RepostIcon } from '@/components/icons/RepostIcon';
import { useRepostStatus } from '@/hooks/useRepostStatus';
import { useDeleteEvent } from '@/hooks/useDeleteEvent';
import { getRepostKind } from '@/lib/feedUtils';
import { ZapDialog } from '@/components/ZapDialog';
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
@@ -183,6 +185,102 @@ export function VineHeartButton({ event, label, noBackground }: { event: NostrEv
);
}
// ─── VineRepostButton ────────────────────────────────────────────────────────
export function VineRepostButton({ event, label }: { event: NostrEvent; label?: string }) {
const { user } = useCurrentUser();
const { mutate: publishEvent } = useNostrPublish();
const { mutate: deleteEvent } = useDeleteEvent();
const queryClient = useQueryClient();
const repostEventId = useRepostStatus(event.id);
const isReposted = !!repostEventId;
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (!user) return;
const repostKind = getRepostKind(event.kind);
const prevStats = queryClient.getQueryData<EventStats>(['event-stats', event.id]);
if (isReposted && repostEventId) {
// Undo repost
if (prevStats) {
queryClient.setQueryData<EventStats>(['event-stats', event.id], {
...prevStats,
reposts: Math.max(0, prevStats.reposts - 1),
});
}
const prevRepostStatus = queryClient.getQueryData(['user-repost', event.id]);
queryClient.setQueryData(['user-repost', event.id], null);
deleteEvent(
{ eventId: repostEventId, eventKind: repostKind },
{
onSuccess: () => {
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ['event-stats', event.id] });
queryClient.invalidateQueries({ queryKey: ['user-repost', event.id] });
}, 3000);
},
onError: () => {
if (prevStats) queryClient.setQueryData<EventStats>(['event-stats', event.id], prevStats);
queryClient.setQueryData(['user-repost', event.id], prevRepostStatus);
},
},
);
} else {
// Repost
if (prevStats) {
queryClient.setQueryData<EventStats>(['event-stats', event.id], {
...prevStats,
reposts: prevStats.reposts + 1,
});
}
queryClient.setQueryData(['user-repost', event.id], 'optimistic');
const tags: string[][] = [['e', event.id], ['p', event.pubkey]];
if (repostKind === 16) {
tags.push(['k', String(event.kind)]);
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: '', tags },
{
onSuccess: () => {
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ['event-stats', event.id] });
queryClient.invalidateQueries({ queryKey: ['event-interactions', event.id] });
queryClient.invalidateQueries({ queryKey: ['user-repost', event.id] });
}, 3000);
},
onError: () => {
if (prevStats) queryClient.setQueryData<EventStats>(['event-stats', event.id], prevStats);
queryClient.setQueryData(['user-repost', event.id], null);
},
},
);
}
};
return (
<VineActionButton label={label}>
<button
className={cn(
'size-11 rounded-full flex items-center justify-center transition-colors backdrop-blur-sm bg-black/20 hover:bg-white/10',
isReposted ? 'text-accent' : 'text-white hover:text-accent',
)}
onClick={handleClick}
>
<RepostIcon className="size-6" />
</button>
</VineActionButton>
);
}
// ─── VineCard ────────────────────────────────────────────────────────────────
export interface VineCardProps {
@@ -354,15 +452,10 @@ export function VineCard({ event, isActive, isNearActive, onCommentClick }: Vine
/>
{/* Repost */}
<RepostMenu event={event}>
{(isReposted: boolean) => (
<VineActionButton
icon={<RepostIcon className="size-6" />}
label={(stats?.reposts || stats?.quotes) ? String((stats?.reposts ?? 0) + (stats?.quotes ?? 0)) : undefined}
className={isReposted ? 'text-accent' : 'text-white hover:text-accent'}
/>
)}
</RepostMenu>
<VineRepostButton
event={event}
label={(stats?.reposts || stats?.quotes) ? String((stats?.reposts ?? 0) + (stats?.quotes ?? 0)) : undefined}
/>
{/* Zap */}
{canZapAuthor && (