diff --git a/src/components/NoteMoreMenu.tsx b/src/components/NoteMoreMenu.tsx index a904d89f..78a95cf0 100644 --- a/src/components/NoteMoreMenu.tsx +++ b/src/components/NoteMoreMenu.tsx @@ -36,6 +36,8 @@ 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 { useBookmarks } from '@/hooks/useBookmarks'; import { usePinnedNotes } from '@/hooks/usePinnedNotes'; import { useCurrentUser } from '@/hooks/useCurrentUser'; @@ -76,12 +78,60 @@ function MenuItem({ icon, label, onClick, destructive }: MenuItemProps) { ); } -export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) { - if (!open) return null; - 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 }); } -function NoteMoreMenuContent({ event, open, onOpenChange }: NoteMoreMenuProps) { +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 mentionContent = `nostr:${nip19.npubEncode(event.pubkey)} `; + + return ( + <> + {open && ( + { + onOpenChange(false); + setTimeout(() => setReportOpen(true), 150); + }} + onMention={() => { + onOpenChange(false); + setTimeout(() => setMentionComposeOpen(true), 150); + }} + /> + )} + + + + + + ); +} + +interface NoteMoreMenuContentProps extends NoteMoreMenuProps { + onReport: () => void; + onMention: () => void; +} + +function NoteMoreMenuContent({ event, open, onOpenChange, onReport, onMention }: NoteMoreMenuContentProps) { const navigate = useNavigate(); const { user } = useCurrentUser(); const { isBookmarked, toggleBookmark } = useBookmarks(); @@ -97,24 +147,24 @@ function NoteMoreMenuContent({ event, open, onOpenChange }: NoteMoreMenuProps) { const { mutate: deleteEvent, isPending: isDeleting } = useDeleteEvent(); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); - const neventId = nip19.neventEncode({ id: event.id, author: event.pubkey }); + const nip19Id = encodeEventNip19(event); const close = () => onOpenChange(false); const handleViewPostDetails = () => { - navigate(`/${neventId}`); + navigate(`/${nip19Id}`); close(); }; const handleCopyLink = () => { - const url = `${window.location.origin}/${neventId}`; + const url = `${window.location.origin}/${nip19Id}`; navigator.clipboard.writeText(url); toast({ title: 'Link copied to clipboard' }); close(); }; const handleViewOnNjump = () => { - window.open(`https://njump.me/${neventId}`, '_blank', 'noopener,noreferrer'); + window.open(`https://njump.me/${nip19Id}`, '_blank', 'noopener,noreferrer'); close(); }; @@ -136,7 +186,7 @@ function NoteMoreMenuContent({ event, open, onOpenChange }: NoteMoreMenuProps) { }; const handleCopyEventId = () => { - navigator.clipboard.writeText(event.id); + navigator.clipboard.writeText(nip19Id); toast({ title: 'Event ID copied to clipboard' }); close(); }; @@ -148,7 +198,6 @@ function NoteMoreMenuContent({ event, open, onOpenChange }: NoteMoreMenuProps) { }; const handleMuteConversation = () => { - // Mute the root event of the thread, or this event if it's the root const rootTag = event.tags.find(([name, , , marker]) => name === 'e' && marker === 'root'); const threadId = rootTag?.[1] ?? event.id; addMute.mutate( @@ -165,11 +214,6 @@ function NoteMoreMenuContent({ event, open, onOpenChange }: NoteMoreMenuProps) { close(); }; - const handleMention = () => { - toast({ title: 'Mention is not yet implemented' }); - close(); - }; - const handleMuteUser = () => { const muteItem = { type: 'pubkey' as const, value: event.pubkey }; const mutation = userMuted ? removeMute : addMute; @@ -184,11 +228,6 @@ function NoteMoreMenuContent({ event, open, onOpenChange }: NoteMoreMenuProps) { close(); }; - const handleReport = () => { - toast({ title: 'Report is not yet implemented' }); - close(); - }; - const handleDelete = () => { deleteEvent( { eventId: event.id, eventKind: event.kind }, @@ -299,7 +338,7 @@ function NoteMoreMenuContent({ event, open, onOpenChange }: NoteMoreMenuProps) { } label={`Mention @${displayName}`} - onClick={handleMention} + onClick={onMention} /> )} @@ -317,7 +356,7 @@ function NoteMoreMenuContent({ event, open, onOpenChange }: NoteMoreMenuProps) { } label={`Report @${displayName}`} - onClick={handleReport} + onClick={onReport} destructive /> diff --git a/src/components/ReportDialog.tsx b/src/components/ReportDialog.tsx new file mode 100644 index 00000000..080c6ad7 --- /dev/null +++ b/src/components/ReportDialog.tsx @@ -0,0 +1,142 @@ +import { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { Label } from '@/components/ui/label'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { useNostrPublish } from '@/hooks/useNostrPublish'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { toast } from '@/hooks/useToast'; +import type { NostrEvent } from '@nostrify/nostrify'; + +interface ReportEventProps { + /** Report a specific post. */ + event: NostrEvent; + pubkey?: never; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +interface ReportProfileProps { + /** Report a user profile. */ + pubkey: string; + event?: never; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +type ReportDialogProps = ReportEventProps | ReportProfileProps; + +const REPORT_TYPES = [ + { value: 'spam', label: 'Spam', description: 'Unsolicited or repetitive content' }, + { value: 'nudity', label: 'Nudity or sexual content', description: 'Depictions of nudity or pornography' }, + { value: 'impersonation', label: 'Impersonation', description: 'Pretending to be someone else' }, + { value: 'profanity', label: 'Hateful speech', description: 'Profanity, hateful or abusive speech' }, + { value: 'illegal', label: 'Illegal content', description: 'Content that may be illegal in some jurisdictions' }, + { value: 'malware', label: 'Malware', description: 'Virus, trojan horse, spyware, or ransomware' }, + { value: 'other', label: 'Other', description: 'Something else not listed above' }, +] as const; + +type ReportType = typeof REPORT_TYPES[number]['value']; + +export function ReportDialog({ event, pubkey, open, onOpenChange }: ReportDialogProps) { + const { user } = useCurrentUser(); + const { mutateAsync: publishEvent, isPending } = useNostrPublish(); + const [reportType, setReportType] = useState(''); + const [details, setDetails] = useState(''); + + const isProfileReport = !!pubkey; + const targetPubkey = event?.pubkey ?? pubkey; + const canSubmit = reportType !== '' && !isPending; + + const handleSubmit = async () => { + if (!reportType || !user || !targetPubkey) return; + + try { + const tags: string[][] = [['p', targetPubkey, reportType]]; + if (event) { + tags.unshift(['e', event.id, reportType]); + } + + await publishEvent({ + kind: 1984, + content: details.trim(), + tags, + }); + toast({ title: 'Report submitted' }); + setReportType(''); + setDetails(''); + onOpenChange(false); + } catch { + toast({ title: 'Failed to submit report', variant: 'destructive' }); + } + }; + + return ( + + + {isProfileReport ? 'Report user' : 'Report post'} + + Select a reason for reporting this {isProfileReport ? 'user' : 'post'}. + + +
+ setReportType(v as ReportType)} + className="mt-2 space-y-1" + > + {REPORT_TYPES.map((type) => ( + + ))} + + +
+ +