Files
eranos/src/components/ReportDialog.tsx
T
Derek Ross 003cc21ade 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
2026-03-03 16:31:41 -05:00

143 lines
5.1 KiB
TypeScript

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>
);
}