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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user