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
This commit is contained in:
Claude
2026-01-23 12:07:46 +00:00
parent 7838b0ab98
commit f3b00f15d7
3 changed files with 399 additions and 0 deletions

View File

@@ -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 <AlertTriangle className="size-4 text-orange-500" />;
case "malware":
return <Bug className="size-4 text-red-500" />;
case "profanity":
return <MessageSquareWarning className="size-4 text-yellow-500" />;
case "illegal":
return <Gavel className="size-4 text-red-600" />;
case "spam":
return <Mail className="size-4 text-blue-500" />;
case "impersonation":
return <UserX className="size-4 text-purple-500" />;
case "other":
default:
return <HelpCircle className="size-4 text-muted-foreground" />;
}
}
/**
* 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 (
<BaseEventContainer event={event}>
<div className="text-sm text-muted-foreground">
Invalid report event (missing required tags)
</div>
</BaseEventContainer>
);
}
return (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-3">
{/* Report header with type badge */}
<div className="flex items-center gap-2 flex-wrap">
<Flag className="size-4 text-red-500 flex-shrink-0" />
<span className="text-sm text-muted-foreground">Reported</span>
{/* Report type badge */}
<span
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${getReportTypeBgClass(report.reportType)}`}
>
{getReportTypeIcon(report.reportType)}
{REPORT_TYPE_LABELS[report.reportType]}
</span>
</div>
{/* Reported target */}
<div className="flex flex-col gap-2">
{/* Profile being reported */}
{report.targetType === "profile" && (
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Profile:</span>
<UserName pubkey={report.reportedPubkey} />
</div>
)}
{/* Event being reported */}
{report.targetType === "event" && (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Event by:</span>
<UserName pubkey={report.reportedPubkey} />
</div>
{/* Embedded reported event */}
{reportedEvent && (
<div className="border border-muted rounded-md overflow-hidden">
<KindRenderer event={reportedEvent} depth={1} />
</div>
)}
{/* Loading state */}
{report.reportedEventId && !reportedEvent && (
<div className="border border-muted rounded-md p-2">
<EventCardSkeleton variant="compact" showActions={false} />
</div>
)}
</div>
)}
{/* Blob being reported */}
{report.targetType === "blob" && (
<div className="flex flex-col gap-2 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Blob by:</span>
<UserName pubkey={report.reportedPubkey} />
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Hash:</span>
<code className="text-xs bg-muted px-1.5 py-0.5 rounded font-mono">
{report.reportedBlobHash?.slice(0, 16)}...
</code>
</div>
{report.serverUrls && report.serverUrls.length > 0 && (
<div className="text-xs text-muted-foreground">
Server: {report.serverUrls[0]}
</div>
)}
</div>
)}
</div>
{/* Report comment */}
{report.comment && (
<div className="text-sm border-l-2 border-muted pl-3 text-muted-foreground italic">
"{report.comment}"
</div>
)}
</div>
</BaseEventContainer>
);
}
/**
* 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 <ReportRenderer event={event} />;
}

View File

@@ -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<number, React.ComponentType<BaseEventProps>> = {
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)

202
src/lib/nip56-helpers.ts Normal file
View File

@@ -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<ReportType, string> = {
nudity: "Nudity",
malware: "Malware",
profanity: "Profanity",
illegal: "Illegal",
spam: "Spam",
impersonation: "Impersonation",
other: "Other",
};
/**
* Descriptions for report types
*/
export const REPORT_TYPE_DESCRIPTIONS: Record<ReportType, string> = {
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;
}