import { useState } from 'react'; import { nip19 } from 'nostr-tools'; import { useNavigate } from 'react-router-dom'; import { Bookmark, ClipboardCopy, AtSign, BellOff, VolumeX, Flag, Pin, FileJson, Trash2, StickyNote, ListPlus, PanelLeft, Copy, Check, Radio, } from 'lucide-react'; import { Dialog, DialogContent, DialogTitle, DialogHeader, } from '@/components/ui/dialog'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { getAvatarShape } from '@/lib/avatarShape'; import { Separator } from '@/components/ui/separator'; import { Button } from '@/components/ui/button'; import { NoteContent } from '@/components/NoteContent'; import { EmojifiedText } from '@/components/CustomEmoji'; import { ReplyComposeModal } from '@/components/ReplyComposeModal'; import { ReportDialog } from '@/components/ReportDialog'; import { AddToListDialog } from '@/components/AddToListDialog'; import { useNostr } from '@nostrify/react'; import { useBookmarks } from '@/hooks/useBookmarks'; import { usePinnedNotes } from '@/hooks/usePinnedNotes'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAuthor } from '@/hooks/useAuthor'; import { useMuteList } from '@/hooks/useMuteList'; import { useDeleteEvent } from '@/hooks/useDeleteEvent'; import { useFeedSettings } from '@/hooks/useFeedSettings'; import { genUserName } from '@/lib/genUserName'; import { timeAgo } from '@/lib/timeAgo'; import { toast } from '@/hooks/useToast'; import { impactLight } from '@/lib/haptics'; import { cn } from '@/lib/utils'; import type { NostrEvent } from '@nostrify/nostrify'; interface NoteMoreMenuProps { event: NostrEvent; open: boolean; onOpenChange: (open: boolean) => void; } interface MenuItemProps { icon: React.ReactNode; label: string; onClick: () => void; destructive?: boolean; } function MenuItem({ icon, label, onClick, destructive }: MenuItemProps) { return ( ); } /** Encode the NIP-19 identifier for an event — naddr for addressable events, nevent otherwise. */ function encodeEventNip19(event: NostrEvent): string { if (event.kind >= 30000 && event.kind < 40000) { const dTag = event.tags.find(([n]) => n === 'd')?.[1]; if (dTag) { return nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: dTag }); } } return nip19.neventEncode({ id: event.id, author: event.pubkey }); } interface EventJsonDialogProps { event: NostrEvent; nip19Id: string; open: boolean; onOpenChange: (open: boolean) => void; } function CopyButton({ text, label }: { text: string; label: string }) { const [copied, setCopied] = useState(false); const handleCopy = () => { navigator.clipboard.writeText(text); setCopied(true); toast({ title: `${label} copied to clipboard` }); setTimeout(() => setCopied(false), 2000); }; return ( ); } function EventJsonDialog({ event, nip19Id, open, onOpenChange }: EventJsonDialogProps) { const { nostr } = useNostr(); const [broadcasting, setBroadcasting] = useState(false); const jsonText = JSON.stringify(event, null, 2); const handleBroadcast = async () => { setBroadcasting(true); try { await nostr.event(event, { signal: AbortSignal.timeout(5000) }); toast({ title: 'Event broadcast to relays' }); } catch { toast({ title: 'Failed to broadcast event', variant: 'destructive' }); } finally { setBroadcasting(false); } }; return ( Event Details

Event ID

{nip19Id}

Raw JSON

              {jsonText}
            
); } export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) { // These states live here (not in NoteMoreMenuContent) so they persist after the menu closes const [reportOpen, setReportOpen] = useState(false); const [mentionComposeOpen, setMentionComposeOpen] = useState(false); const [addToListOpen, setAddToListOpen] = useState(false); const [eventJsonOpen, setEventJsonOpen] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const { mutate: deleteEvent, isPending: isDeleting } = useDeleteEvent(); const nip19Id = encodeEventNip19(event); const mentionContent = `nostr:${nip19.npubEncode(event.pubkey)} `; const handleDelete = () => { const dTag = event.tags.find(([name]) => name === 'd')?.[1]; deleteEvent( { eventId: event.id, eventKind: event.kind, eventPubkey: event.pubkey, eventDTag: dTag }, { onSuccess: () => { setDeleteConfirmOpen(false); toast({ title: 'Post deleted' }); }, onError: () => { toast({ title: 'Failed to delete post', variant: 'destructive' }); }, }, ); }; return ( <> {open && ( { onOpenChange(false); setTimeout(() => setReportOpen(true), 150); }} onMention={() => { onOpenChange(false); setTimeout(() => setMentionComposeOpen(true), 150); }} onAddToList={() => { onOpenChange(false); setTimeout(() => setAddToListOpen(true), 150); }} onViewEventJson={() => { onOpenChange(false); setTimeout(() => setEventJsonOpen(true), 150); }} onDelete={() => { onOpenChange(false); setTimeout(() => setDeleteConfirmOpen(true), 150); }} /> )} e.stopPropagation()}> Delete post? This will request deletion from relays. Some relays may still keep a copy of the original event. This action cannot be undone. Cancel { e.preventDefault(); handleDelete(); }} disabled={isDeleting} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > {isDeleting ? 'Deleting...' : 'Delete'} ); } interface NoteMoreMenuContentProps extends NoteMoreMenuProps { onReport: () => void; onMention: () => void; onAddToList: () => void; onViewEventJson: () => void; onDelete: () => void; } function NoteMoreMenuContent({ event, open, onOpenChange, onReport, onMention, onAddToList, onViewEventJson, onDelete }: NoteMoreMenuContentProps) { const navigate = useNavigate(); const { user } = useCurrentUser(); const { isBookmarked, toggleBookmark } = useBookmarks(); const bookmarked = isBookmarked(event.id); const { isPinned, togglePin } = usePinnedNotes(user?.pubkey); const pinned = isPinned(event.id); const isOwnPost = user?.pubkey === event.pubkey; const author = useAuthor(event.pubkey); const metadata = author.data?.metadata; const avatarShape = getAvatarShape(metadata); const displayName = metadata?.name || genUserName(event.pubkey); const { addMute, removeMute, isMuted } = useMuteList(); const userMuted = isMuted('pubkey', event.pubkey); const { addToSidebar, removeFromSidebar, orderedItems } = useFeedSettings(); const nip19Id = encodeEventNip19(event); const nostrUri = `nostr:${nip19Id}`; const isInSidebar = orderedItems.includes(nostrUri); const close = () => onOpenChange(false); const handleViewPostDetails = () => { navigate(`/${nip19Id}`); close(); }; const handleCopyLink = () => { const url = `${window.location.origin}/${nip19Id}`; navigator.clipboard.writeText(url); toast({ title: 'Link copied to clipboard' }); close(); }; const handleBookmark = () => { impactLight(); toggleBookmark.mutate(event.id); close(); }; const handleToggleSidebar = () => { if (isInSidebar) { removeFromSidebar(nostrUri); toast({ title: 'Removed from sidebar' }); } else { addToSidebar(nostrUri); toast({ title: 'Added to sidebar' }); } close(); }; const handleTogglePin = () => { impactLight(); togglePin.mutate(event.id, { onSuccess: () => { toast({ title: pinned ? 'Unpinned from profile' : 'Pinned to profile' }); }, onError: () => { toast({ title: 'Failed to update pinned posts', variant: 'destructive' }); }, }); close(); }; const handleMuteConversation = () => { impactLight(); const rootTag = event.tags.find(([name, , , marker]) => name === 'e' && marker === 'root'); const threadId = rootTag?.[1] ?? event.id; addMute.mutate( { type: 'thread', value: threadId }, { onSuccess: () => { toast({ title: 'Conversation muted' }); }, onError: () => { toast({ title: 'Failed to mute conversation', variant: 'destructive' }); }, }, ); close(); }; const handleMuteUser = () => { const muteItem = { type: 'pubkey' as const, value: event.pubkey }; const mutation = userMuted ? removeMute : addMute; mutation.mutate(muteItem, { onSuccess: () => { toast({ title: userMuted ? `Unmuted @${displayName}` : `Muted @${displayName}` }); }, onError: () => { toast({ title: userMuted ? 'Failed to unmute user' : 'Failed to mute user', variant: 'destructive' }); }, }); close(); }; return ( Post options {/* Post preview */}
{displayName[0].toUpperCase()}
{author.data?.event ? ( {displayName} ) : displayName} · {timeAgo(event.created_at)}
{/^[A-Za-z0-9+/=_-]{20,}$/.test(event.content.trim()) ? ( Encrypted content ) : ( )}
} label="View post details" onClick={handleViewPostDetails} /> } label="Copy Link to Post" onClick={handleCopyLink} /> } label="View Event JSON" onClick={onViewEventJson} /> } label={bookmarked ? 'Remove Bookmark' : 'Bookmark'} onClick={handleBookmark} /> {user && ( } label="Add to list" onClick={() => { onAddToList(); }} /> )} : } label={isInSidebar ? 'Remove from sidebar' : 'Add to sidebar'} onClick={handleToggleSidebar} />
{!isOwnPost && ( } label="Mute Conversation" onClick={handleMuteConversation} /> )} {isOwnPost && ( } label={pinned ? 'Unpin from profile' : 'Pin on profile'} onClick={handleTogglePin} /> )} {isOwnPost && ( } label="Delete post" onClick={onDelete} destructive /> )} {!isOwnPost && ( } label={`Mention @${displayName}`} onClick={onMention} /> )}
{!isOwnPost && ( <>
} label={userMuted ? `Unmute @${displayName}` : `Mute @${displayName}`} onClick={handleMuteUser} /> } label={`Report @${displayName}`} onClick={onReport} destructive />
)}
); }