diff --git a/src/components/nostr/kinds/ReportRenderer.tsx b/src/components/nostr/kinds/ReportRenderer.tsx new file mode 100644 index 0000000..13a6338 --- /dev/null +++ b/src/components/nostr/kinds/ReportRenderer.tsx @@ -0,0 +1,194 @@ +/** + * NIP-56: Report Renderer (Kind 1984) + * + * Displays report events that signal objectionable content. + * Reports can target profiles, events, or blobs. + */ + +import { useMemo } from "react"; +import { + Flag, + AlertTriangle, + Bug, + MessageSquareWarning, + Gavel, + Mail, + UserX, + HelpCircle, +} from "lucide-react"; +import { BaseEventProps, BaseEventContainer } from "./BaseEventRenderer"; +import { KindRenderer } from "./index"; +import { useNostrEvent } from "@/hooks/useNostrEvent"; +import { UserName } from "@/components/nostr/UserName"; +import { EventCardSkeleton } from "@/components/ui/skeleton"; +import { + parseReport, + type ReportType, + REPORT_TYPE_LABELS, +} from "@/lib/nip56-helpers"; + +/** + * Get icon for report type + */ +function getReportTypeIcon(reportType: ReportType) { + switch (reportType) { + case "nudity": + return ; + case "malware": + return ; + case "profanity": + return ; + case "illegal": + return ; + case "spam": + return ; + case "impersonation": + return ; + case "other": + default: + return ; + } +} + +/** + * Get background color class for report type badge + */ +function getReportTypeBgClass(reportType: ReportType): string { + switch (reportType) { + case "nudity": + return "bg-orange-500/10 text-orange-600 dark:text-orange-400"; + case "malware": + return "bg-red-500/10 text-red-600 dark:text-red-400"; + case "profanity": + return "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400"; + case "illegal": + return "bg-red-600/10 text-red-700 dark:text-red-300"; + case "spam": + return "bg-blue-500/10 text-blue-600 dark:text-blue-400"; + case "impersonation": + return "bg-purple-500/10 text-purple-600 dark:text-purple-400"; + case "other": + default: + return "bg-muted text-muted-foreground"; + } +} + +/** + * Renderer for Kind 1984 - Reports (NIP-56) + */ +export function ReportRenderer({ event }: BaseEventProps) { + // Parse the report + const report = useMemo(() => parseReport(event), [event]); + + // Get event pointer if reporting an event + const eventPointer = useMemo(() => { + if (!report?.reportedEventId) return undefined; + return { id: report.reportedEventId }; + }, [report?.reportedEventId]); + + // Fetch reported event if applicable + const reportedEvent = useNostrEvent(eventPointer); + + if (!report) { + return ( + +
+ Invalid report event (missing required tags) +
+
+ ); + } + + return ( + +
+ {/* Report header with type badge */} +
+ + Reported + + {/* Report type badge */} + + {getReportTypeIcon(report.reportType)} + {REPORT_TYPE_LABELS[report.reportType]} + +
+ + {/* Reported target */} +
+ {/* Profile being reported */} + {report.targetType === "profile" && ( +
+ Profile: + +
+ )} + + {/* Event being reported */} + {report.targetType === "event" && ( +
+
+ Event by: + +
+ + {/* Embedded reported event */} + {reportedEvent && ( +
+ +
+ )} + + {/* Loading state */} + {report.reportedEventId && !reportedEvent && ( +
+ +
+ )} +
+ )} + + {/* Blob being reported */} + {report.targetType === "blob" && ( +
+
+ Blob by: + +
+
+ Hash: + + {report.reportedBlobHash?.slice(0, 16)}... + +
+ {report.serverUrls && report.serverUrls.length > 0 && ( +
+ Server: {report.serverUrls[0]} +
+ )} +
+ )} +
+ + {/* Report comment */} + {report.comment && ( +
+ "{report.comment}" +
+ )} +
+
+ ); +} + +/** + * Detail renderer for Kind 1984 - Reports + * Shows full report details with raw data + */ +export function ReportDetailRenderer({ event }: BaseEventProps) { + // For now, use the same renderer for detail view + // Could be enhanced later with more detailed info + return ; +} diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx index 9ce8574..b926e65 100644 --- a/src/components/nostr/kinds/index.tsx +++ b/src/components/nostr/kinds/index.tsx @@ -157,6 +157,7 @@ import { GoalDetailRenderer } from "./GoalDetailRenderer"; import { PollRenderer } from "./PollRenderer"; import { PollDetailRenderer } from "./PollDetailRenderer"; import { PollResponseRenderer } from "./PollResponseRenderer"; +import { ReportRenderer, ReportDetailRenderer } from "./ReportRenderer"; /** * Registry of kind-specific renderers @@ -187,6 +188,7 @@ const kindRenderers: Record> = { 1617: PatchRenderer, // Patch (NIP-34) 1618: PullRequestRenderer, // Pull Request (NIP-34) 1621: IssueRenderer, // Issue (NIP-34) + 1984: ReportRenderer, // Report (NIP-56) 9041: GoalRenderer, // Zap Goal (NIP-75) 9735: Kind9735Renderer, // Zap Receipt 9802: Kind9802Renderer, // Highlight @@ -296,6 +298,7 @@ const detailRenderers: Record< 1617: PatchDetailRenderer, // Patch Detail (NIP-34) 1618: PullRequestDetailRenderer, // Pull Request Detail (NIP-34) 1621: IssueDetailRenderer, // Issue Detail (NIP-34) + 1984: ReportDetailRenderer, // Report Detail (NIP-56) 9041: GoalDetailRenderer, // Zap Goal Detail (NIP-75) 9802: Kind9802DetailRenderer, // Highlight Detail 8000: AddUserDetailRenderer, // Add User Detail (NIP-43) diff --git a/src/lib/nip56-helpers.ts b/src/lib/nip56-helpers.ts new file mode 100644 index 0000000..701ba77 --- /dev/null +++ b/src/lib/nip56-helpers.ts @@ -0,0 +1,202 @@ +/** + * NIP-56: Reporting + * Helpers for creating and parsing report events (kind 1984) + * + * A report signals that some referenced content is objectionable. + * Reports can target profiles, events, or blobs. + */ + +import { getTagValue } from "applesauce-core/helpers"; +import type { NostrEvent } from "@/types/nostr"; + +/** + * Report types as defined in NIP-56 + */ +export const REPORT_TYPES = [ + "nudity", + "malware", + "profanity", + "illegal", + "spam", + "impersonation", + "other", +] as const; + +export type ReportType = (typeof REPORT_TYPES)[number]; + +/** + * Human-readable labels for report types + */ +export const REPORT_TYPE_LABELS: Record = { + nudity: "Nudity", + malware: "Malware", + profanity: "Profanity", + illegal: "Illegal", + spam: "Spam", + impersonation: "Impersonation", + other: "Other", +}; + +/** + * Descriptions for report types + */ +export const REPORT_TYPE_DESCRIPTIONS: Record = { + nudity: "Depictions of nudity, porn, etc.", + malware: + "Virus, trojan horse, worm, robot, spyware, adware, back door, ransomware, rootkit, kidnapper, etc.", + profanity: "Profanity, hateful speech, etc.", + illegal: "Something which may be illegal in some jurisdiction", + spam: "Spam", + impersonation: "Someone pretending to be someone else", + other: "Reports that don't fit in other categories", +}; + +/** + * Report target types + */ +export type ReportTargetType = "profile" | "event" | "blob"; + +/** + * Parsed report information from a kind 1984 event + */ +export interface ParsedReport { + /** The pubkey being reported (always present) */ + reportedPubkey: string; + /** The report type */ + reportType: ReportType; + /** The event ID being reported (if reporting an event) */ + reportedEventId?: string; + /** The blob hash being reported (if reporting a blob) */ + reportedBlobHash?: string; + /** The event ID containing the blob (required with x tag) */ + blobEventId?: string; + /** Media server URLs that may contain the blob */ + serverUrls?: string[]; + /** Optional additional comment from the reporter */ + comment: string; + /** Target type for UI purposes */ + targetType: ReportTargetType; +} + +/** + * Get the reported pubkey from a report event + */ +export function getReportedPubkey(event: NostrEvent): string | undefined { + const pTag = event.tags.find((t) => t[0] === "p"); + return pTag?.[1]; +} + +/** + * Get the report type from a report event + * The report type is the 3rd element of the p, e, or x tag + */ +export function getReportType(event: NostrEvent): ReportType | undefined { + // Check p tag for report type + const pTag = event.tags.find((t) => t[0] === "p" && t[2]); + if (pTag?.[2] && REPORT_TYPES.includes(pTag[2] as ReportType)) { + return pTag[2] as ReportType; + } + + // Check e tag for report type + const eTag = event.tags.find((t) => t[0] === "e" && t[2]); + if (eTag?.[2] && REPORT_TYPES.includes(eTag[2] as ReportType)) { + return eTag[2] as ReportType; + } + + // Check x tag for report type + const xTag = event.tags.find((t) => t[0] === "x" && t[2]); + if (xTag?.[2] && REPORT_TYPES.includes(xTag[2] as ReportType)) { + return xTag[2] as ReportType; + } + + return undefined; +} + +/** + * Get the reported event ID from a report event + */ +export function getReportedEventId(event: NostrEvent): string | undefined { + const eTag = event.tags.find((t) => t[0] === "e"); + return eTag?.[1]; +} + +/** + * Get the reported blob hash from a report event + */ +export function getReportedBlobHash(event: NostrEvent): string | undefined { + const xTag = event.tags.find((t) => t[0] === "x"); + return xTag?.[1]; +} + +/** + * Get server URLs from a report event (for blob reports) + */ +export function getReportServerUrls(event: NostrEvent): string[] { + return event.tags.filter((t) => t[0] === "server").map((t) => t[1]); +} + +/** + * Parse a report event into a structured format + */ +export function parseReport(event: NostrEvent): ParsedReport | undefined { + if (event.kind !== 1984) return undefined; + + const reportedPubkey = getReportedPubkey(event); + if (!reportedPubkey) return undefined; + + const reportType = getReportType(event) || "other"; + const reportedEventId = getReportedEventId(event); + const reportedBlobHash = getReportedBlobHash(event); + const serverUrls = getReportServerUrls(event); + + // Determine blob event ID (e tag when x tag is present) + let blobEventId: string | undefined; + if (reportedBlobHash) { + blobEventId = reportedEventId; + } + + // Determine target type + let targetType: ReportTargetType = "profile"; + if (reportedBlobHash) { + targetType = "blob"; + } else if (reportedEventId) { + targetType = "event"; + } + + return { + reportedPubkey, + reportType, + reportedEventId: targetType === "event" ? reportedEventId : undefined, + reportedBlobHash, + blobEventId, + serverUrls: serverUrls.length > 0 ? serverUrls : undefined, + comment: event.content, + targetType, + }; +} + +/** + * Check if a report type is valid + */ +export function isValidReportType(type: string): type is ReportType { + return REPORT_TYPES.includes(type as ReportType); +} + +/** + * Get NIP-32 label tags from a report event (optional enhancement) + */ +export function getReportLabels( + event: NostrEvent, +): { namespace: string; label: string }[] { + const namespace = getTagValue(event, "L"); + if (!namespace) return []; + + const labels = event.tags + .filter((t) => t[0] === "l" && t[2] === namespace) + .map((t) => ({ + namespace, + label: t[1], + })); + + return labels; +}