From 09271be935acbff02c416ce9bbcbffcb6ae141ed Mon Sep 17 00:00:00 2001 From: Chad Curtis Date: Tue, 3 Mar 2026 00:39:54 -0600 Subject: [PATCH] add VineRepostButton: tap to repost/unrepost directly on vine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/hooks/useRepostStatus.ts | 15 ++--- src/pages/VinesFeedPage.tsx | 113 +++++++++++++++++++++++++++++++---- 2 files changed, 108 insertions(+), 20 deletions(-) diff --git a/src/hooks/useRepostStatus.ts b/src/hooks/useRepostStatus.ts index e371077f..8673caf4 100644 --- a/src/hooks/useRepostStatus.ts +++ b/src/hooks/useRepostStatus.ts @@ -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(['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; } diff --git a/src/pages/VinesFeedPage.tsx b/src/pages/VinesFeedPage.tsx index 9ccae2de..f157cdc2 100644 --- a/src/pages/VinesFeedPage.tsx +++ b/src/pages/VinesFeedPage.tsx @@ -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(['event-stats', event.id]); + + if (isReposted && repostEventId) { + // Undo repost + if (prevStats) { + queryClient.setQueryData(['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(['event-stats', event.id], prevStats); + queryClient.setQueryData(['user-repost', event.id], prevRepostStatus); + }, + }, + ); + } else { + // Repost + if (prevStats) { + queryClient.setQueryData(['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(['event-stats', event.id], prevStats); + queryClient.setQueryData(['user-repost', event.id], null); + }, + }, + ); + } + }; + + return ( + + + + ); +} + // ─── VineCard ──────────────────────────────────────────────────────────────── export interface VineCardProps { @@ -354,15 +452,10 @@ export function VineCard({ event, isActive, isNearActive, onCommentClick }: Vine /> {/* Repost */} - - {(isReposted: boolean) => ( - } - label={(stats?.reposts || stats?.quotes) ? String((stats?.reposts ?? 0) + (stats?.quotes ?? 0)) : undefined} - className={isReposted ? 'text-accent' : 'text-white hover:text-accent'} - /> - )} - + {/* Zap */} {canZapAuthor && (