Fix NoteMoreMenu: copy naddr/nevent IDs, add report modal (NIP-56), implement mention action

- Copy Event ID now copies naddr1 for addressable events or nevent1 for
  others, instead of raw hex
- Add ReportDialog component with NIP-56 kind 1984 reporting (7 report
  types, optional details textarea, scrollable on small screens)
- Wire report dialog into both NoteMoreMenu (post reports) and
  ProfileMoreMenu (profile reports)
- Mention action opens compose modal with nostr:npub pre-filled
- Fix dialog lifecycle: report/mention state lives in outer component to
  survive menu unmount
This commit is contained in:
Derek Ross
2026-03-03 16:31:41 -05:00
parent 32e73eb568
commit 003cc21ade
3 changed files with 210 additions and 23 deletions
+61 -22
View File
@@ -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 <NoteMoreMenuContent event={event} open={open} onOpenChange={onOpenChange} />;
/** 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 && (
<NoteMoreMenuContent
event={event}
open={open}
onOpenChange={onOpenChange}
onReport={() => {
onOpenChange(false);
setTimeout(() => setReportOpen(true), 150);
}}
onMention={() => {
onOpenChange(false);
setTimeout(() => setMentionComposeOpen(true), 150);
}}
/>
)}
<ReportDialog event={event} open={reportOpen} onOpenChange={setReportOpen} />
<ReplyComposeModal
open={mentionComposeOpen}
onOpenChange={setMentionComposeOpen}
initialContent={mentionContent}
title="New post"
/>
</>
);
}
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) {
<MenuItem
icon={<AtSign className="size-5" />}
label={`Mention @${displayName}`}
onClick={handleMention}
onClick={onMention}
/>
)}
</div>
@@ -317,7 +356,7 @@ function NoteMoreMenuContent({ event, open, onOpenChange }: NoteMoreMenuProps) {
<MenuItem
icon={<Flag className="size-5" />}
label={`Report @${displayName}`}
onClick={handleReport}
onClick={onReport}
destructive
/>
</div>
+142
View File
@@ -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<ReportType | ''>('');
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md max-h-[85dvh] rounded-2xl flex flex-col overflow-hidden">
<DialogTitle>{isProfileReport ? 'Report user' : 'Report post'}</DialogTitle>
<DialogDescription className="text-sm text-muted-foreground">
Select a reason for reporting this {isProfileReport ? 'user' : 'post'}.
</DialogDescription>
<div className="flex-1 overflow-y-auto min-h-0 -mx-6 px-6">
<RadioGroup
value={reportType}
onValueChange={(v) => setReportType(v as ReportType)}
className="mt-2 space-y-1"
>
{REPORT_TYPES.map((type) => (
<label
key={type.value}
className="flex items-start gap-3 rounded-lg px-3 py-2.5 cursor-pointer transition-colors hover:bg-secondary/60"
>
<RadioGroupItem value={type.value} className="mt-0.5 shrink-0" />
<div className="min-w-0">
<span className="text-sm font-medium">{type.label}</span>
<p className="text-xs text-muted-foreground mt-0.5">{type.description}</p>
</div>
</label>
))}
</RadioGroup>
<div className="mt-3 space-y-2 pb-1">
<Label htmlFor="report-details" className="text-sm font-medium">
Additional details {reportType !== 'other' && <span className="text-muted-foreground font-normal">(optional)</span>}
</Label>
<Textarea
id="report-details"
value={details}
onChange={(e) => setDetails(e.target.value)}
placeholder="Provide additional context..."
className="resize-none"
rows={2}
/>
</div>
</div>
<div className="flex gap-2 justify-end pt-2 shrink-0">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={!canSubmit}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isPending ? 'Submitting...' : 'Submit Report'}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
+7 -1
View File
@@ -45,6 +45,7 @@ import { genUserName } from '@/lib/genUserName';
import { canZap } from '@/lib/canZap';
import { EmojifiedText } from '@/components/CustomEmoji';
import { PullToRefresh } from '@/components/PullToRefresh';
import { ReportDialog } from '@/components/ReportDialog';
import { useActiveProfileTheme } from '@/hooks/useActiveProfileTheme';
import { usePublishTheme } from '@/hooks/usePublishTheme';
@@ -120,6 +121,7 @@ function ProfileMoreMenu({ pubkey, displayName, open, onOpenChange, isOwnProfile
const npubEncoded = useMemo(() => nip19.npubEncode(pubkey), [pubkey]);
const { addMute, removeMute, isMuted } = useMuteList();
const userMuted = isMuted('pubkey', pubkey);
const [reportOpen, setReportOpen] = useState(false);
const close = () => onOpenChange(false);
@@ -156,11 +158,12 @@ function ProfileMoreMenu({ pubkey, displayName, open, onOpenChange, isOwnProfile
};
const handleReport = () => {
toast({ title: 'Report is not yet implemented' });
close();
setTimeout(() => setReportOpen(true), 150);
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md p-0 gap-0 rounded-2xl overflow-hidden [&>button]:hidden">
<DialogTitle className="sr-only">Profile options</DialogTitle>
@@ -233,6 +236,9 @@ function ProfileMoreMenu({ pubkey, displayName, open, onOpenChange, isOwnProfile
</div>
</DialogContent>
</Dialog>
<ReportDialog pubkey={pubkey} open={reportOpen} onOpenChange={setReportOpen} />
</>
);
}