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:
@@ -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
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user