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 <username> for <reason>" 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 <noreply@anthropic.com>
This commit is contained in:
Alejandro
2026-01-23 13:53:57 +01:00
committed by GitHub
parent 7838b0ab98
commit 9b36120dfe
4 changed files with 352 additions and 4 deletions

View File

@@ -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({
>
<div className="flex items-center gap-2 min-w-0">
<UserName pubkey={event.pubkey} className="text-xs font-medium" />
<span className="text-xs text-muted-foreground truncate">
{previewText}
{hasMore && "..."}
</span>
{!hidePreview && (
<span className="text-xs text-muted-foreground truncate">
{previewText}
{hasMore && "..."}
</span>
)}
{hidePreview && !isExpanded && (
<span className="text-xs text-muted-foreground italic">
Click to reveal content
</span>
)}
</div>
{isExpanded ? (
<ChevronUp className="size-3 flex-shrink-0" />

View File

@@ -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 <AlertTriangle className={className} />;
case "malware":
return <Bug className={className} />;
case "profanity":
return <MessageSquareWarning className={className} />;
case "illegal":
return <Gavel className={className} />;
case "spam":
return <Mail className={className} />;
case "impersonation":
return <UserX className={className} />;
case "other":
default:
return <HelpCircle className={className} />;
}
}
/**
* 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 (
<BaseEventContainer event={event}>
<div className="text-sm text-muted-foreground">
Invalid report event (missing required tags)
</div>
</BaseEventContainer>
);
}
const reasonLabel = REPORT_TYPE_LABELS[report.reportType].toLowerCase();
return (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
{/* Report header: "Reported <username> for <reason>" */}
<ClickableEventTitle
event={event}
as="div"
className="flex items-center gap-1.5 flex-wrap text-sm"
>
<Flag className="size-4 text-muted-foreground flex-shrink-0" />
<span className="text-muted-foreground">Reported</span>
<UserName pubkey={report.reportedPubkey} />
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-muted text-muted-foreground">
for {getReportTypeIcon(report.reportType)} {reasonLabel}
</span>
</ClickableEventTitle>
{/* Reported event - collapsed with hidden preview (depth=2, hidePreview) */}
{report.targetType === "event" && report.reportedEventId && (
<QuotedEvent
eventPointer={{ id: report.reportedEventId }}
depth={2}
hidePreview
className="mt-0"
/>
)}
{/* Blob details */}
{report.targetType === "blob" && (
<div className="flex flex-col gap-1 text-sm">
<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>
)}
{/* Report comment - rendered like a kind 1 note */}
{event.content && <RichText event={event} className="text-sm" />}
</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)

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

@@ -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<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";
// 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],
}));
});
}