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],
+ }));
+ });
+}