Files
eranos/src/components/RepostMenu.tsx
T
Alex Gleason cc702027b0 Add native haptic feedback to all key interactions
Install @capacitor/haptics and add a centralized haptics utility
(src/lib/haptics.ts) that uses the native taptic engine on iOS/Android
and falls back to navigator.vibrate() on web.

Haptics added to:
- Switch component (covers 36+ toggle switches app-wide)
- PullToRefresh threshold (covers 15+ pages)
- MobileBottomNav tab taps
- ReactionButton (like/unlike, double-click heart)
- RepostMenu (repost/undo repost)
- ZapDialog button press + payment success (NWC and WebLN)
- FollowButton and ProfilePage follow toggle
- ComposeBox (post, voice message, and poll publish success)
- NoteMoreMenu (bookmark, pin, mute)
- VinesFeedPage reaction and repost buttons
- ProfileReactionButton and ExternalReactionButton
- NoteCard share button
- BlobbiRoomShell swipe navigation

Replaces raw navigator.vibrate() calls in GameControls and
SendAnimation with the new cross-platform haptics utility, fixing
haptic feedback on iOS where the Vibration API is not available.
2026-04-14 11:06:18 -05:00

210 lines
7.0 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 { impactLight } from '@/lib/haptics';
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 { DITTO_RELAY } from '@/lib/appRelays';
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;
}
impactLight();
// 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, DITTO_RELAY],
['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}`, DITTO_RELAY]);
}
}
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;
impactLight();
// 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}
/>
</>
);
}