From 9b36120dfe3bec4c591afdc3399c477a579bb31c Mon Sep 17 00:00:00 2001 From: Alejandro Date: Fri, 23 Jan 2026 13:53:57 +0100 Subject: [PATCH] Add NIP-56 Report (Kind 1984) renderer and helpers (#210) * Add NIP-56 Report renderer (kind 1984) - Add nip56-helpers.ts with report parsing and type definitions - Add ReportRenderer for displaying report events in feeds - Support profile, event, and blob report targets - Display report type with color-coded badges and icons - Show embedded reported event when available https://claude.ai/code/session_012ux81GyM8iZ1GLnKHC7esJ * refactor(nip56): apply applesauce caching and neutral styling - Use getOrComputeCachedValue for report parsing (applesauce pattern) - Rename parseReport to getReportInfo for consistency - Use muted/neutral colors for all report type icons and badges - Use QuotedEvent component for embedding reported events - Remove unnecessary useMemo (helper caches internally) https://claude.ai/code/session_012ux81GyM8iZ1GLnKHC7esJ * refactor(nip56): use collapsed quote and cleaner copy - Use "Reported for " format - Remove redundant "Event by:" line for event reports - Use depth=2 for QuotedEvent to show collapsed by default - Content may be disturbing, user can expand on demand https://claude.ai/code/session_012ux81GyM8iZ1GLnKHC7esJ * feat(nip56): hide preview and clickable header - Add hidePreview prop to QuotedEvent for sensitive content - Hide text preview when collapsed, show "Click to reveal content" - Make report header clickable to open report detail - UserName stops propagation so clicking username opens profile https://claude.ai/code/session_012ux81GyM8iZ1GLnKHC7esJ * style(nip56): use dotted underline hover, reduce spacing - Remove background highlight on hover - Use underline dotted with cursor crosshair (consistent with app) - Reduce gap between header and quoted event https://claude.ai/code/session_012ux81GyM8iZ1GLnKHC7esJ * refactor(nip56): use RichText for comment and applesauce helpers - Render report comments using RichText like kind 1 notes - Use getTagValue/getTagValues helpers instead of direct tag access - Add explanatory comments where direct tag access is still required https://claude.ai/code/session_012ux81GyM8iZ1GLnKHC7esJ --------- Co-authored-by: Claude --- src/components/nostr/QuotedEvent.tsx | 18 +- src/components/nostr/kinds/ReportRenderer.tsx | 134 ++++++++++++ src/components/nostr/kinds/index.tsx | 3 + src/lib/nip56-helpers.ts | 201 ++++++++++++++++++ 4 files changed, 352 insertions(+), 4 deletions(-) create mode 100644 src/components/nostr/kinds/ReportRenderer.tsx create mode 100644 src/lib/nip56-helpers.ts diff --git a/src/components/nostr/QuotedEvent.tsx b/src/components/nostr/QuotedEvent.tsx index 624605f..3d5d4aa 100644 --- a/src/components/nostr/QuotedEvent.tsx +++ b/src/components/nostr/QuotedEvent.tsx @@ -18,6 +18,8 @@ interface QuotedEventProps { depth?: number; /** Optional className for container */ className?: string; + /** Hide preview text when collapsed (for sensitive content) */ + hidePreview?: boolean; } /** @@ -31,6 +33,7 @@ export function QuotedEvent({ onOpen, depth = 1, className, + hidePreview = false, }: QuotedEventProps) { const [isExpanded, setIsExpanded] = useState(depth < 2); @@ -99,10 +102,17 @@ export function QuotedEvent({ >
- - {previewText} - {hasMore && "..."} - + {!hidePreview && ( + + {previewText} + {hasMore && "..."} + + )} + {hidePreview && !isExpanded && ( + + Click to reveal content + + )}
{isExpanded ? ( diff --git a/src/components/nostr/kinds/ReportRenderer.tsx b/src/components/nostr/kinds/ReportRenderer.tsx new file mode 100644 index 0000000..9757da0 --- /dev/null +++ b/src/components/nostr/kinds/ReportRenderer.tsx @@ -0,0 +1,134 @@ +/** + * NIP-56: Report Renderer (Kind 1984) + * + * Displays report events that signal objectionable content. + * Reports can target profiles, events, or blobs. + */ + +import { + Flag, + AlertTriangle, + Bug, + MessageSquareWarning, + Gavel, + Mail, + UserX, + HelpCircle, +} from "lucide-react"; +import { + BaseEventProps, + BaseEventContainer, + ClickableEventTitle, +} from "./BaseEventRenderer"; +import { QuotedEvent } from "@/components/nostr/QuotedEvent"; +import { UserName } from "@/components/nostr/UserName"; +import { RichText } from "../RichText"; +import { + getReportInfo, + type ReportType, + REPORT_TYPE_LABELS, +} from "@/lib/nip56-helpers"; + +/** + * Get icon for report type (all muted/neutral colors) + */ +function getReportTypeIcon(reportType: ReportType) { + const className = "size-3.5 text-muted-foreground"; + switch (reportType) { + case "nudity": + return ; + case "malware": + return ; + case "profanity": + return ; + case "illegal": + return ; + case "spam": + return ; + case "impersonation": + return ; + case "other": + default: + return ; + } +} + +/** + * Renderer for Kind 1984 - Reports (NIP-56) + */ +export function ReportRenderer({ event }: BaseEventProps) { + // Parse report using cached helper (no useMemo needed - applesauce caches internally) + const report = getReportInfo(event); + + if (!report) { + return ( + +
+ Invalid report event (missing required tags) +
+
+ ); + } + + const reasonLabel = REPORT_TYPE_LABELS[report.reportType].toLowerCase(); + + return ( + +
+ {/* Report header: "Reported for " */} + + + Reported + + + for {getReportTypeIcon(report.reportType)} {reasonLabel} + + + + {/* Reported event - collapsed with hidden preview (depth=2, hidePreview) */} + {report.targetType === "event" && report.reportedEventId && ( + + )} + + {/* Blob details */} + {report.targetType === "blob" && ( +
+
+ Hash: + + {report.reportedBlobHash?.slice(0, 16)}... + +
+ {report.serverUrls && report.serverUrls.length > 0 && ( +
+ Server: {report.serverUrls[0]} +
+ )} +
+ )} + + {/* Report comment - rendered like a kind 1 note */} + {event.content && } +
+
+ ); +} + +/** + * 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..1faeded --- /dev/null +++ b/src/lib/nip56-helpers.ts @@ -0,0 +1,201 @@ +/** + * 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. + * + * Uses applesauce caching pattern - results are cached on the event object. + */ + +import { getTagValue, getOrComputeCachedValue } from "applesauce-core/helpers"; +import type { NostrEvent } from "@/types/nostr"; +import { getTagValues } from "@/lib/nostr-utils"; + +/** + * 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"; + +// Symbols for caching computed values on events +const ParsedReportSymbol = Symbol("parsedReport"); +const ReportLabelsSymbol = Symbol("reportLabels"); + +/** + * 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 { + return getTagValue(event, "p"); +} + +/** + * Get the report type from a report event + * The report type is the 3rd element (index 2) of the p, e, or x tag. + * Direct tag access is required since getTagValue only returns tag[1]. + */ +export function getReportType(event: NostrEvent): ReportType | undefined { + // Check p, e, x tags for report type in the 3rd element + for (const tagName of ["p", "e", "x"]) { + const tag = event.tags.find((t) => t[0] === tagName && t[2]); + if (tag?.[2] && REPORT_TYPES.includes(tag[2] as ReportType)) { + return tag[2] as ReportType; + } + } + return undefined; +} + +/** + * Get the reported event ID from a report event + */ +export function getReportedEventId(event: NostrEvent): string | undefined { + return getTagValue(event, "e"); +} + +/** + * Get the reported blob hash from a report event + */ +export function getReportedBlobHash(event: NostrEvent): string | undefined { + return getTagValue(event, "x"); +} + +/** + * Get server URLs from a report event (for blob reports) + */ +export function getReportServerUrls(event: NostrEvent): string[] { + return getTagValues(event, "server"); +} + +/** + * Parse a report event into a structured format + * Uses applesauce caching - result is cached on the event object + */ +export function getReportInfo(event: NostrEvent): ParsedReport | undefined { + if (event.kind !== 1984) return undefined; + + return getOrComputeCachedValue(event, ParsedReportSymbol, () => { + 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) + * Uses applesauce caching - result is cached on the event object. + * Direct tag access for "l" tags is required to filter by namespace in tag[2]. + */ +export function getReportLabels( + event: NostrEvent, +): { namespace: string; label: string }[] { + return getOrComputeCachedValue(event, ReportLabelsSymbol, () => { + const namespace = getTagValue(event, "L"); + if (!namespace) return []; + + return event.tags + .filter((t) => t[0] === "l" && t[2] === namespace) + .map((t) => ({ + namespace, + label: t[1], + })); + }); +}