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

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