From be7322b88d8911cd582759553b780762eba55e59 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Jan 2026 17:07:50 +0000 Subject: [PATCH] feat: Add NIP-58 Badge Definition renderers Implement feed and detail renderers for kind 30009 Badge Definition events. - Add nip58-helpers.ts with badge metadata extraction functions - Create BadgeDefinitionRenderer for compact feed view - Create BadgeDefinitionDetailRenderer with award statistics - Register both renderers in kinds registry Badge definitions display: - Badge image or Award icon fallback - Badge name, description, and identifier - In detail view: issuer, award count, recipients, image variants - Automatically queries for badge awards (kind 8) to show stats Follows existing renderer patterns (ZapstoreApp, EmojiSet) with reactive queries using useLiveTimeline and cached helpers. --- .../kinds/BadgeDefinitionDetailRenderer.tsx | 226 ++++++++++++++++++ .../nostr/kinds/BadgeDefinitionRenderer.tsx | 68 ++++++ src/components/nostr/kinds/index.tsx | 4 + src/lib/nip58-helpers.ts | 95 ++++++++ 4 files changed, 393 insertions(+) create mode 100644 src/components/nostr/kinds/BadgeDefinitionDetailRenderer.tsx create mode 100644 src/components/nostr/kinds/BadgeDefinitionRenderer.tsx create mode 100644 src/lib/nip58-helpers.ts diff --git a/src/components/nostr/kinds/BadgeDefinitionDetailRenderer.tsx b/src/components/nostr/kinds/BadgeDefinitionDetailRenderer.tsx new file mode 100644 index 0000000..2be0d45 --- /dev/null +++ b/src/components/nostr/kinds/BadgeDefinitionDetailRenderer.tsx @@ -0,0 +1,226 @@ +import { NostrEvent } from "@/types/nostr"; +import { + getBadgeIdentifier, + getBadgeName, + getBadgeDescription, + getBadgeImage, + getBadgeThumbnails, +} from "@/lib/nip58-helpers"; +import { UserName } from "../UserName"; +import { Award } from "lucide-react"; +import { useMemo } from "react"; +import { useLiveTimeline } from "@/hooks/useLiveTimeline"; +import { getSeenRelays } from "applesauce-core/helpers/relays"; +import { relayListCache } from "@/services/relay-list-cache"; + +interface BadgeDefinitionDetailRendererProps { + event: NostrEvent; +} + +/** + * Image variant display component + */ +function ImageVariant({ + url, + dimensions, + label, +}: { + url: string; + dimensions?: string; + label: string; +}) { + return ( +
+
+ + {label} + + {dimensions && ( + {dimensions} + )} +
+ {label} +
+ ); +} + +/** + * Detail renderer for Kind 30009 - Badge Definition (NIP-58) + * Shows comprehensive badge information including all image variants + */ +export function BadgeDefinitionDetailRenderer({ + event, +}: BadgeDefinitionDetailRendererProps) { + const identifier = getBadgeIdentifier(event); + const name = getBadgeName(event); + const description = getBadgeDescription(event); + const image = getBadgeImage(event); + const thumbnails = getBadgeThumbnails(event); + + // Use name if available, fallback to identifier + const displayTitle = name || identifier || "Badge"; + + // Build relay list for fetching badge awards (kind 8) + const relays = useMemo(() => { + const relaySet = new Set(); + + // Add seen relays from the badge definition event + const seenRelays = getSeenRelays(event); + if (seenRelays) { + for (const relay of seenRelays) { + relaySet.add(relay); + } + } + + // Add issuer's outbox relays + const outboxRelays = relayListCache.getOutboxRelaysSync(event.pubkey); + if (outboxRelays) { + for (const relay of outboxRelays.slice(0, 3)) { + relaySet.add(relay); + } + } + + return Array.from(relaySet); + }, [event]); + + // Query for awards (kind 8) that reference this badge definition + const awardsFilter = useMemo(() => { + if (!identifier) { + return { kinds: [8], ids: [] }; // No match if no identifier + } + return { + kinds: [8], + "#a": [`30009:${event.pubkey}:${identifier}`], + }; + }, [event.pubkey, identifier]); + + // Fetch awards from relays + const { events: awards } = useLiveTimeline( + `badge-awards-${event.id}`, + awardsFilter, + relays, + { limit: 100 }, + ); + + // Count unique recipients + const uniqueRecipients = useMemo(() => { + if (!awards || awards.length === 0) return 0; + const recipients = new Set(); + for (const award of awards) { + const pTags = award.tags.filter((tag) => tag[0] === "p" && tag[1]); + for (const pTag of pTags) { + recipients.add(pTag[1]); + } + } + return recipients.size; + }, [awards]); + + return ( +
+ {/* Header Section */} +
+ {/* Badge Image */} + {image ? ( + {displayTitle} + ) : ( +
+ +
+ )} + + {/* Badge Title & Description */} +
+

{displayTitle}

+ {description && ( +

{description}

+ )} +
+
+ + {/* Metadata Grid */} +
+ {/* Issuer */} +
+

Issued By

+ +
+ + {/* Identifier */} + {identifier && ( +
+

Badge ID

+ + {identifier} + +
+ )} + + {/* Awards Count */} + {awards && awards.length > 0 && ( +
+

Times Awarded

+ + {awards.length} award{awards.length !== 1 ? "s" : ""} + +
+ )} + + {/* Recipients Count */} + {uniqueRecipients > 0 && ( +
+

Recipients

+ + {uniqueRecipients} user{uniqueRecipients !== 1 ? "s" : ""} + +
+ )} +
+ + {/* Image Variants Section */} + {(image || thumbnails.length > 0) && ( +
+

Image Variants

+
+ {image && ( + + )} + {thumbnails.map((thumb, idx) => ( + + ))} +
+
+ )} + + {/* Award Address for Reference */} + {identifier && ( +
+

+ Badge Address (for awarding) +

+ + 30009:{event.pubkey}:{identifier} + +
+ )} +
+ ); +} diff --git a/src/components/nostr/kinds/BadgeDefinitionRenderer.tsx b/src/components/nostr/kinds/BadgeDefinitionRenderer.tsx new file mode 100644 index 0000000..8dcec5e --- /dev/null +++ b/src/components/nostr/kinds/BadgeDefinitionRenderer.tsx @@ -0,0 +1,68 @@ +import { + BaseEventContainer, + BaseEventProps, + ClickableEventTitle, +} from "./BaseEventRenderer"; +import { + getBadgeIdentifier, + getBadgeName, + getBadgeDescription, + getBadgeImageUrl, +} from "@/lib/nip58-helpers"; +import { Award } from "lucide-react"; + +/** + * Renderer for Kind 30009 - Badge Definition (NIP-58) + * Clean feed view with badge image, name, and description + */ +export function BadgeDefinitionRenderer({ event }: BaseEventProps) { + const identifier = getBadgeIdentifier(event); + const name = getBadgeName(event); + const description = getBadgeDescription(event); + const imageUrl = getBadgeImageUrl(event); + + // Use name if available, fallback to identifier + const displayTitle = name || identifier || "Badge"; + + return ( + +
+ {/* Badge Image */} + {imageUrl ? ( + {displayTitle} + ) : ( +
+ +
+ )} + + {/* Badge Info */} +
+ + {displayTitle} + + + {description && ( +

+ {description} +

+ )} + + {identifier && ( + + {identifier} + + )} +
+
+
+ ); +} diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx index 59b4b05..7cd07ca 100644 --- a/src/components/nostr/kinds/index.tsx +++ b/src/components/nostr/kinds/index.tsx @@ -142,6 +142,8 @@ import { NostrEvent } from "@/types/nostr"; import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer"; import { P2pOrderRenderer } from "./P2pOrderRenderer"; import { P2pOrderDetailRenderer } from "./P2pOrderDetailRenderer"; +import { BadgeDefinitionRenderer } from "./BadgeDefinitionRenderer"; +import { BadgeDefinitionDetailRenderer } from "./BadgeDefinitionDetailRenderer"; /** * Registry of kind-specific renderers @@ -200,6 +202,7 @@ const kindRenderers: Record> = { 30005: VideoCurationSetRenderer, // Video Curation Sets (NIP-51) 30006: PictureCurationSetRenderer, // Picture Curation Sets (NIP-51) 30007: KindMuteSetRenderer, // Kind Mute Sets (NIP-51) + 30009: BadgeDefinitionRenderer, // Badge Definition (NIP-58) 30015: InterestSetRenderer, // Interest Sets (NIP-51) 30023: Kind30023Renderer, // Long-form Article 30030: EmojiSetRenderer, // Emoji Sets (NIP-30) @@ -293,6 +296,7 @@ const detailRenderers: Record< 30005: VideoCurationSetDetailRenderer, // Video Curation Sets Detail (NIP-51) 30006: PictureCurationSetDetailRenderer, // Picture Curation Sets Detail (NIP-51) 30007: KindMuteSetDetailRenderer, // Kind Mute Sets Detail (NIP-51) + 30009: BadgeDefinitionDetailRenderer, // Badge Definition Detail (NIP-58) 30015: InterestSetDetailRenderer, // Interest Sets Detail (NIP-51) 30023: Kind30023DetailRenderer, // Long-form Article Detail 30030: EmojiSetDetailRenderer, // Emoji Sets Detail (NIP-30) diff --git a/src/lib/nip58-helpers.ts b/src/lib/nip58-helpers.ts new file mode 100644 index 0000000..f89b5d7 --- /dev/null +++ b/src/lib/nip58-helpers.ts @@ -0,0 +1,95 @@ +import { getTagValue } from "applesauce-core/helpers"; +import { getTagValues } from "./nostr-utils"; +import type { NostrEvent } from "nostr-tools"; + +/** + * NIP-58 Badge Helpers + * These helpers extract badge-related metadata from badge events. + * They wrap getTagValue which caches results internally, so no need for useMemo. + */ + +/** + * Get the unique identifier for a badge definition (d tag) + */ +export function getBadgeIdentifier(event: NostrEvent): string | undefined { + return getTagValue(event, "d"); +} + +/** + * Get the display name for a badge + */ +export function getBadgeName(event: NostrEvent): string | undefined { + return getTagValue(event, "name"); +} + +/** + * Get the description explaining the badge meaning or issuance criteria + */ +export function getBadgeDescription(event: NostrEvent): string | undefined { + return getTagValue(event, "description"); +} + +/** + * Get the badge image URL and optional dimensions + * @returns Object with url and optional dimensions (e.g., "1024x1024") + */ +export function getBadgeImage(event: NostrEvent): { + url: string; + dimensions?: string; +} | null { + const imageTag = event.tags.find((tag) => tag[0] === "image" && tag[1]); + if (!imageTag) return null; + + return { + url: imageTag[1], + dimensions: imageTag[2], + }; +} + +/** + * Get all thumbnail variants with dimensions + * @returns Array of thumbnails with url and optional dimensions + */ +export function getBadgeThumbnails(event: NostrEvent): Array<{ + url: string; + dimensions?: string; +}> { + return event.tags + .filter((tag) => tag[0] === "thumb" && tag[1]) + .map((tag) => ({ + url: tag[1], + dimensions: tag[2], + })); +} + +/** + * Get the best badge image URL to display based on available variants + * Prefers image over thumbnails + */ +export function getBadgeImageUrl(event: NostrEvent): string | null { + const image = getBadgeImage(event); + if (image) return image.url; + + const thumbnails = getBadgeThumbnails(event); + if (thumbnails.length > 0) return thumbnails[0].url; + + return null; +} + +/** + * Get all pubkeys awarded this badge (from kind 8 award events) + * Note: This should be called on award events (kind 8), not badge definitions + */ +export function getAwardedPubkeys(awardEvent: NostrEvent): string[] { + return getTagValues(awardEvent, "p"); +} + +/** + * Get the badge definition address referenced by an award event (kind 8) + * @returns The "a" tag value (e.g., "30009:pubkey:identifier") + */ +export function getAwardBadgeAddress( + awardEvent: NostrEvent, +): string | undefined { + return getTagValue(awardEvent, "a"); +}