From 14c73c07a326a1ef5af47c394fa129bdaeb93adf Mon Sep 17 00:00:00 2001 From: Alejandro Date: Sat, 17 Jan 2026 20:30:32 +0100 Subject: [PATCH] feat: Add NIP-58 Badge Definition renderers (#123) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. * refactor: Simplify badge renderers Simplify NIP-58 badge renderers based on feedback: - Rename "badge definition" to "badge" in comments and docs - Remove image and ID from feed view (show only name + description) - Remove award statistics fetching/display from detail view - Remove badge address section from detail view Feed view now shows minimal info (name, description) while detail view focuses on badge metadata and image variants without external queries. * feat: Add NIP-58 Badge Award renderers (kind 8) Implement feed and detail renderers for Badge Award events that: - Fetch and display badge metadata (thumbnail, name) - Show recipient count in feed view ("Awarded to n people") - Display full recipient list with usernames in detail view - Link badge thumbnail and name to the badge event (kind 30009) - Support award comments in detail view Technical details: - Parse badge address from "a" tag (30009:pubkey:identifier format) - Fetch badge event reactively using eventStore.replaceable - Use AddressPointer from nostr-tools/nip19 for type safety - Register kind 8 in both feed and detail renderer registries Also updates all "badge definition" terminology to just "badge" in code comments for consistency with user-facing strings. * refactor: Make badge award feed view more compact Change BadgeAwardRenderer to display inline compact format: - Small thumbnail (size-6) inline with text - Badge name linked to badge event - "awarded to n people" linked to award event (clickable) - All elements in single line with flex-wrap for overflow Layout: [thumbnail] badge-name awarded to n people * feat: Show username when badge awarded to single person Update BadgeAwardRenderer to display the recipient's username when only 1 person is awarded the badge, instead of "1 person". - Single recipient: "awarded to @username" - Multiple recipients: "awarded to n people" Uses UserName component for proper profile name resolution. * feat: Add NIP-58 Profile Badges renderer (kind 30008) - Add ProfileBadgesRenderer for feed view showing first 4 badges with count - Add ProfileBadgesDetailRenderer for detail view showing all badges in grid - Add getProfileBadgePairs helper to extract badge pairs from events - Adjust BadgeAwardRenderer icon size from 6 to 5 and spacing to gap-1.5 - Register kind 30008 in both feed and detail renderer registries Completes NIP-58 implementation with all three event types: - Kind 8: Badge Awards - Kind 30009: Badge Definitions - Kind 30008: Profile Badges (this commit) * refactor: Improve Profile Badges UX Feed view: - Show all badge thumbnails (removed 4-badge limit) - Entire feed item is clickable to open detail view - Badge count displayed inline Detail view: - Change from grid to vertical list layout - Show one badge per row with horizontal layout - Display: awarded by author, badge image, name, and description - Better readability for badge information * refactor: Improve Profile Badges layout and hierarchy Feed view: - Badge count now appears as clickable title - Thumbnails displayed below title in separate row - Better visual hierarchy and clearer affordance Detail view: - Increase badge images from size-16 to size-24 - Remove "Awarded by" label, show issuer directly - Cleaner, more prominent badge presentation * feat: Add Badge Awards (kind 8) to chat as system messages Implemented NIP-58 badge award rendering in chat adapters: Chat types (src/types/chat.ts): - Add kind 8 to CHAT_KINDS array - Add badgeAddress and awardedPubkeys to MessageMetadata NIP-29 adapter (src/lib/chat/adapters/nip-29-adapter.ts): - Include kind 8 in message filters - Convert badge awards to system messages - Extract badge metadata (address, recipients) ChatViewer (src/components/ChatViewer.tsx): - Add BadgeAwardSystemMessage component - Parse badge address and fetch badge definition - Render: "* username awarded 🏅 badge-name to username(s)" - Show badge icon/image inline with badge name Badge awards now appear as system messages showing issuer, badge icon, badge name, and recipients in a clean horizontal layout. * feat: Add Badge Awards (kind 8) to NIP-53 live chat Extended badge award system messages to NIP-53 live streaming chats: NIP-53 adapter (src/lib/chat/adapters/nip-53-adapter.ts): - Import getAwardedPubkeys and getTagValues helpers - Add kind 8 to message filters (loadMessages and loadMoreMessages) - Add badge award handler in eventToMessage - Convert to system messages with badge metadata Badge awards from stream hosts now appear in live chat as system messages, showing issuer, badge icon, and recipients in real-time. * Revert "feat: Add Badge Awards to chat" This reverts commits: - 1686a94 feat: Add Badge Awards (kind 8) to NIP-53 live chat - 909359f feat: Add Badge Awards (kind 8) to chat as system messages Badge award rendering in chat will be implemented later. This keeps the PR focused on Profile Badges (kind 30008) implementation. --------- Co-authored-by: Claude --- package-lock.json | 1 + package.json | 1 + .../nostr/kinds/BadgeAwardDetailRenderer.tsx | 139 +++++++++++++++ .../nostr/kinds/BadgeAwardRenderer.tsx | 111 ++++++++++++ .../kinds/BadgeDefinitionDetailRenderer.tsx | 135 +++++++++++++++ .../nostr/kinds/BadgeDefinitionRenderer.tsx | 42 +++++ .../kinds/ProfileBadgesDetailRenderer.tsx | 162 ++++++++++++++++++ .../nostr/kinds/ProfileBadgesRenderer.tsx | 113 ++++++++++++ src/components/nostr/kinds/index.tsx | 12 ++ src/lib/nip58-helpers.ts | 125 ++++++++++++++ 10 files changed, 841 insertions(+) create mode 100644 src/components/nostr/kinds/BadgeAwardDetailRenderer.tsx create mode 100644 src/components/nostr/kinds/BadgeAwardRenderer.tsx create mode 100644 src/components/nostr/kinds/BadgeDefinitionDetailRenderer.tsx create mode 100644 src/components/nostr/kinds/BadgeDefinitionRenderer.tsx create mode 100644 src/components/nostr/kinds/ProfileBadgesDetailRenderer.tsx create mode 100644 src/components/nostr/kinds/ProfileBadgesRenderer.tsx create mode 100644 src/lib/nip58-helpers.ts diff --git a/package-lock.json b/package-lock.json index abd2db3..6ef4afb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,6 +50,7 @@ "dexie-react-hooks": "^4.2.0", "flexsearch": "^0.8.212", "framer-motion": "^12.23.26", + "hash-sum": "^2.0.0", "hls-video-element": "^1.5.10", "hls.js": "^1.6.15", "jotai": "^2.15.2", diff --git a/package.json b/package.json index 494749e..4a13e62 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "dexie-react-hooks": "^4.2.0", "flexsearch": "^0.8.212", "framer-motion": "^12.23.26", + "hash-sum": "^2.0.0", "hls-video-element": "^1.5.10", "hls.js": "^1.6.15", "jotai": "^2.15.2", diff --git a/src/components/nostr/kinds/BadgeAwardDetailRenderer.tsx b/src/components/nostr/kinds/BadgeAwardDetailRenderer.tsx new file mode 100644 index 0000000..b6efdd0 --- /dev/null +++ b/src/components/nostr/kinds/BadgeAwardDetailRenderer.tsx @@ -0,0 +1,139 @@ +import { NostrEvent } from "@/types/nostr"; +import { getAwardBadgeAddress, getAwardedPubkeys } from "@/lib/nip58-helpers"; +import { use$ } from "applesauce-react/hooks"; +import eventStore from "@/services/event-store"; +import { AddressPointer } from "nostr-tools/nip19"; +import { + getBadgeName, + getBadgeIdentifier, + getBadgeImageUrl, +} from "@/lib/nip58-helpers"; +import { UserName } from "../UserName"; +import { Award } from "lucide-react"; +import { ClickableEventTitle } from "./BaseEventRenderer"; + +interface BadgeAwardDetailRendererProps { + event: NostrEvent; +} + +/** + * Parse an address pointer from an a tag value + * Format: "kind:pubkey:identifier" + */ +function parseAddress(aTagValue: string): AddressPointer | null { + const parts = aTagValue.split(":"); + if (parts.length !== 3) return null; + + const kind = parseInt(parts[0], 10); + const pubkey = parts[1]; + const identifier = parts[2]; + + if (isNaN(kind) || !pubkey || identifier === undefined) return null; + + return { kind, pubkey, identifier }; +} + +/** + * Detail renderer for Kind 8 - Badge Award (NIP-58) + * Shows badge information and list of recipients + */ +export function BadgeAwardDetailRenderer({ + event, +}: BadgeAwardDetailRendererProps) { + const badgeAddress = getAwardBadgeAddress(event); + const awardedPubkeys = getAwardedPubkeys(event); + + // Parse the badge address (30009:pubkey:identifier) + const coordinate = badgeAddress ? parseAddress(badgeAddress) : null; + + // Fetch the badge event + const badgeEvent = use$( + () => + coordinate + ? eventStore.replaceable( + coordinate.kind, + coordinate.pubkey, + coordinate.identifier, + ) + : undefined, + [coordinate?.kind, coordinate?.pubkey, coordinate?.identifier], + ); + + // Get badge metadata + const badgeName = badgeEvent ? getBadgeName(badgeEvent) : null; + const badgeIdentifier = badgeEvent ? getBadgeIdentifier(badgeEvent) : null; + const badgeImageUrl = badgeEvent ? getBadgeImageUrl(badgeEvent) : null; + + const displayTitle = badgeName || badgeIdentifier || "Badge"; + + return ( +
+ {/* Badge Header */} +
+ {/* Badge Image */} + {badgeImageUrl ? ( + {displayTitle} + ) : ( +
+ +
+ )} + + {/* Badge Title */} +
+ {badgeEvent ? ( + + {displayTitle} + + ) : ( +

{displayTitle}

+ )} +

Badge Award

+
+
+ + {/* Metadata */} +
+

Issued By

+ +
+ + {/* Recipients List */} + {awardedPubkeys.length > 0 && ( +
+

+ Awarded to {awardedPubkeys.length}{" "} + {awardedPubkeys.length === 1 ? "Person" : "People"} +

+
+ {awardedPubkeys.map((pubkey) => ( +
+ + +
+ ))} +
+
+ )} + + {/* Award Comment */} + {event.content && ( +
+

Comment

+

{event.content}

+
+ )} +
+ ); +} diff --git a/src/components/nostr/kinds/BadgeAwardRenderer.tsx b/src/components/nostr/kinds/BadgeAwardRenderer.tsx new file mode 100644 index 0000000..abef1fc --- /dev/null +++ b/src/components/nostr/kinds/BadgeAwardRenderer.tsx @@ -0,0 +1,111 @@ +import { + BaseEventContainer, + BaseEventProps, + ClickableEventTitle, +} from "./BaseEventRenderer"; +import { getAwardBadgeAddress, getAwardedPubkeys } from "@/lib/nip58-helpers"; +import { use$ } from "applesauce-react/hooks"; +import eventStore from "@/services/event-store"; +import { AddressPointer } from "nostr-tools/nip19"; +import { + getBadgeName, + getBadgeIdentifier, + getBadgeImageUrl, +} from "@/lib/nip58-helpers"; +import { Award } from "lucide-react"; +import { UserName } from "../UserName"; + +/** + * Parse an address pointer from an a tag value + * Format: "kind:pubkey:identifier" + */ +function parseAddress(aTagValue: string): AddressPointer | null { + const parts = aTagValue.split(":"); + if (parts.length !== 3) return null; + + const kind = parseInt(parts[0], 10); + const pubkey = parts[1]; + const identifier = parts[2]; + + if (isNaN(kind) || !pubkey || identifier === undefined) return null; + + return { kind, pubkey, identifier }; +} + +/** + * Renderer for Kind 8 - Badge Award (NIP-58) + * Shows inline badge thumbnail, name, and linked recipient count + */ +export function BadgeAwardRenderer({ event }: BaseEventProps) { + const badgeAddress = getAwardBadgeAddress(event); + const awardedPubkeys = getAwardedPubkeys(event); + + // Parse the badge address (30009:pubkey:identifier) + const coordinate = badgeAddress ? parseAddress(badgeAddress) : null; + + // Fetch the badge event + const badgeEvent = use$( + () => + coordinate + ? eventStore.replaceable( + coordinate.kind, + coordinate.pubkey, + coordinate.identifier, + ) + : undefined, + [coordinate?.kind, coordinate?.pubkey, coordinate?.identifier], + ); + + // Get badge metadata + const badgeName = badgeEvent ? getBadgeName(badgeEvent) : null; + const badgeIdentifier = badgeEvent ? getBadgeIdentifier(badgeEvent) : null; + const badgeImageUrl = badgeEvent ? getBadgeImageUrl(badgeEvent) : null; + + const displayTitle = badgeName || badgeIdentifier || "Badge"; + const recipientCount = awardedPubkeys.length; + + return ( + +
+ {/* Badge Thumbnail - small inline */} + {badgeImageUrl ? ( + {displayTitle} + ) : ( + + )} + + {/* Badge Name - linked to badge event */} + {badgeEvent ? ( + + {displayTitle} + + ) : ( + + {displayTitle} + + )} + + {/* Awarded count/name - linked to this award event */} + + awarded to + {recipientCount === 1 ? ( + + ) : ( + {recipientCount} people + )} + +
+
+ ); +} diff --git a/src/components/nostr/kinds/BadgeDefinitionDetailRenderer.tsx b/src/components/nostr/kinds/BadgeDefinitionDetailRenderer.tsx new file mode 100644 index 0000000..e0b1ed6 --- /dev/null +++ b/src/components/nostr/kinds/BadgeDefinitionDetailRenderer.tsx @@ -0,0 +1,135 @@ +import { NostrEvent } from "@/types/nostr"; +import { + getBadgeIdentifier, + getBadgeName, + getBadgeDescription, + getBadgeImage, + getBadgeThumbnails, +} from "@/lib/nip58-helpers"; +import { UserName } from "../UserName"; +import { Award } from "lucide-react"; + +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 (NIP-58) + * Shows 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"; + + return ( +
+ {/* Header Section */} +
+ {/* Badge Image */} + {image ? ( + {displayTitle} + ) : ( +
+ +
+ )} + + {/* Badge Title & Description */} +
+

{displayTitle}

+ {description && ( +

{description}

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

Issued By

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

Badge ID

+ + {identifier} + +
+ )} +
+ + {/* Image Variants Section */} + {(image || thumbnails.length > 0) && ( +
+

Image Variants

+
+ {image && ( + + )} + {thumbnails.map((thumb, idx) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/src/components/nostr/kinds/BadgeDefinitionRenderer.tsx b/src/components/nostr/kinds/BadgeDefinitionRenderer.tsx new file mode 100644 index 0000000..7b29420 --- /dev/null +++ b/src/components/nostr/kinds/BadgeDefinitionRenderer.tsx @@ -0,0 +1,42 @@ +import { + BaseEventContainer, + BaseEventProps, + ClickableEventTitle, +} from "./BaseEventRenderer"; +import { + getBadgeIdentifier, + getBadgeName, + getBadgeDescription, +} from "@/lib/nip58-helpers"; + +/** + * Renderer for Kind 30009 - Badge (NIP-58) + * Simple feed view with name and description + */ +export function BadgeDefinitionRenderer({ event }: BaseEventProps) { + const identifier = getBadgeIdentifier(event); + const name = getBadgeName(event); + const description = getBadgeDescription(event); + + // Use name if available, fallback to identifier + const displayTitle = name || identifier || "Badge"; + + return ( + +
+ + {displayTitle} + + + {description && ( +

+ {description} +

+ )} +
+
+ ); +} diff --git a/src/components/nostr/kinds/ProfileBadgesDetailRenderer.tsx b/src/components/nostr/kinds/ProfileBadgesDetailRenderer.tsx new file mode 100644 index 0000000..1acf0ca --- /dev/null +++ b/src/components/nostr/kinds/ProfileBadgesDetailRenderer.tsx @@ -0,0 +1,162 @@ +import { NostrEvent } from "@/types/nostr"; +import { getProfileBadgePairs } from "@/lib/nip58-helpers"; +import { use$ } from "applesauce-react/hooks"; +import eventStore from "@/services/event-store"; +import { AddressPointer } from "nostr-tools/nip19"; +import { + getBadgeName, + getBadgeIdentifier, + getBadgeDescription, + getBadgeImageUrl, +} from "@/lib/nip58-helpers"; +import { Award } from "lucide-react"; +import { UserName } from "../UserName"; +import { ClickableEventTitle } from "./BaseEventRenderer"; + +interface ProfileBadgesDetailRendererProps { + event: NostrEvent; +} + +/** + * Parse an address pointer from an a tag value + * Format: "kind:pubkey:identifier" + */ +function parseAddress(aTagValue: string): AddressPointer | null { + const parts = aTagValue.split(":"); + if (parts.length !== 3) return null; + + const kind = parseInt(parts[0], 10); + const pubkey = parts[1]; + const identifier = parts[2]; + + if (isNaN(kind) || !pubkey || identifier === undefined) return null; + + return { kind, pubkey, identifier }; +} + +/** + * Single badge row component with author, image, name, and description + */ +function BadgeRow({ + badgeAddress, + awardEventId, +}: { + badgeAddress: string; + awardEventId: string; +}) { + const coordinate = parseAddress(badgeAddress); + + // Fetch the badge definition event + const badgeEvent = use$( + () => + coordinate + ? eventStore.replaceable( + coordinate.kind, + coordinate.pubkey, + coordinate.identifier, + ) + : undefined, + [coordinate?.kind, coordinate?.pubkey, coordinate?.identifier], + ); + + // Fetch the award event + const awardEvent = use$(() => eventStore.event(awardEventId), [awardEventId]); + + const badgeName = badgeEvent ? getBadgeName(badgeEvent) : null; + const badgeIdentifier = badgeEvent ? getBadgeIdentifier(badgeEvent) : null; + const badgeDescription = badgeEvent ? getBadgeDescription(badgeEvent) : null; + const badgeImageUrl = badgeEvent ? getBadgeImageUrl(badgeEvent) : null; + + const displayTitle = badgeName || badgeIdentifier || "Badge"; + + return ( +
+ {/* Badge Image */} + {badgeImageUrl ? ( + {displayTitle} + ) : ( +
+ +
+ )} + + {/* Badge Info */} +
+ {/* Issuer */} + {awardEvent && ( +
+ +
+ )} + + {/* Badge Name */} + {badgeEvent ? ( + + {displayTitle} + + ) : ( +

+ {displayTitle} +

+ )} + + {/* Badge Description */} + {badgeDescription && ( +

{badgeDescription}

+ )} +
+
+ ); +} + +/** + * Detail renderer for Kind 30008 - Profile Badges (NIP-58) + * Shows all badges in a vertical list + */ +export function ProfileBadgesDetailRenderer({ + event, +}: ProfileBadgesDetailRendererProps) { + const badgePairs = getProfileBadgePairs(event); + + return ( +
+ {/* Header */} +
+

Profile Badges

+
+ + + + {badgePairs.length} {badgePairs.length === 1 ? "badge" : "badges"} + +
+
+ + {/* Badges List */} + {badgePairs.length > 0 ? ( +
+ {badgePairs.map((pair, idx) => ( + + ))} +
+ ) : ( +
+ +

No badges to display

+
+ )} +
+ ); +} diff --git a/src/components/nostr/kinds/ProfileBadgesRenderer.tsx b/src/components/nostr/kinds/ProfileBadgesRenderer.tsx new file mode 100644 index 0000000..f1ff6cf --- /dev/null +++ b/src/components/nostr/kinds/ProfileBadgesRenderer.tsx @@ -0,0 +1,113 @@ +import { + BaseEventContainer, + BaseEventProps, + ClickableEventTitle, +} from "./BaseEventRenderer"; +import { getProfileBadgePairs } from "@/lib/nip58-helpers"; +import { use$ } from "applesauce-react/hooks"; +import eventStore from "@/services/event-store"; +import { AddressPointer } from "nostr-tools/nip19"; +import { + getBadgeName, + getBadgeIdentifier, + getBadgeImageUrl, +} from "@/lib/nip58-helpers"; +import { Award } from "lucide-react"; + +/** + * Parse an address pointer from an a tag value + * Format: "kind:pubkey:identifier" + */ +function parseAddress(aTagValue: string): AddressPointer | null { + const parts = aTagValue.split(":"); + if (parts.length !== 3) return null; + + const kind = parseInt(parts[0], 10); + const pubkey = parts[1]; + const identifier = parts[2]; + + if (isNaN(kind) || !pubkey || identifier === undefined) return null; + + return { kind, pubkey, identifier }; +} + +/** + * Single badge display component for feed view + */ +function BadgeItem({ badgeAddress }: { badgeAddress: string }) { + const coordinate = parseAddress(badgeAddress); + + // Fetch the badge event + const badgeEvent = use$( + () => + coordinate + ? eventStore.replaceable( + coordinate.kind, + coordinate.pubkey, + coordinate.identifier, + ) + : undefined, + [coordinate?.kind, coordinate?.pubkey, coordinate?.identifier], + ); + + const badgeName = badgeEvent ? getBadgeName(badgeEvent) : null; + const badgeIdentifier = badgeEvent ? getBadgeIdentifier(badgeEvent) : null; + const badgeImageUrl = badgeEvent ? getBadgeImageUrl(badgeEvent) : null; + + const displayTitle = badgeName || badgeIdentifier || "Badge"; + + return ( +
+ {badgeImageUrl ? ( + {displayTitle} + ) : ( + + )} +
+ ); +} + +/** + * Renderer for Kind 30008 - Profile Badges (NIP-58) + * Shows all badge thumbnails, clickable to open detail view + */ +export function ProfileBadgesRenderer({ event }: BaseEventProps) { + const badgePairs = getProfileBadgePairs(event); + + if (badgePairs.length === 0) { + return ( + +
+ + No badges +
+
+ ); + } + + return ( + +
+ {/* Badge Count - Clickable Title */} + + {badgePairs.length} {badgePairs.length === 1 ? "badge" : "badges"} + + + {/* All Badge Thumbnails */} +
+ {badgePairs.map((pair, idx) => ( + + ))} +
+
+
+ ); +} diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx index 59b4b05..1a8396e 100644 --- a/src/components/nostr/kinds/index.tsx +++ b/src/components/nostr/kinds/index.tsx @@ -142,6 +142,12 @@ 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"; +import { BadgeAwardRenderer } from "./BadgeAwardRenderer"; +import { BadgeAwardDetailRenderer } from "./BadgeAwardDetailRenderer"; +import { ProfileBadgesRenderer } from "./ProfileBadgesRenderer"; +import { ProfileBadgesDetailRenderer } from "./ProfileBadgesDetailRenderer"; /** * Registry of kind-specific renderers @@ -153,6 +159,7 @@ const kindRenderers: Record> = { 3: Kind3Renderer, // Contact List 6: RepostRenderer, // Repost 7: Kind7Renderer, // Reaction + 8: BadgeAwardRenderer, // Badge Award (NIP-58) 9: Kind9Renderer, // Chat Message (NIP-C7) 11: Kind1Renderer, // Public Thread Reply (NIP-10) 16: RepostRenderer, // Generic Repost @@ -200,6 +207,8 @@ const kindRenderers: Record> = { 30005: VideoCurationSetRenderer, // Video Curation Sets (NIP-51) 30006: PictureCurationSetRenderer, // Picture Curation Sets (NIP-51) 30007: KindMuteSetRenderer, // Kind Mute Sets (NIP-51) + 30008: ProfileBadgesRenderer, // Profile Badges (NIP-58) + 30009: BadgeDefinitionRenderer, // Badge (NIP-58) 30015: InterestSetRenderer, // Interest Sets (NIP-51) 30023: Kind30023Renderer, // Long-form Article 30030: EmojiSetRenderer, // Emoji Sets (NIP-30) @@ -265,6 +274,7 @@ const detailRenderers: Record< > = { 0: Kind0DetailRenderer, // Profile Metadata Detail 3: Kind3DetailView, // Contact List Detail + 8: BadgeAwardDetailRenderer, // Badge Award Detail (NIP-58) 777: SpellDetailRenderer, // Spell Detail 1337: Kind1337DetailRenderer, // Code Snippet Detail (NIP-C0) 1617: PatchDetailRenderer, // Patch Detail (NIP-34) @@ -293,6 +303,8 @@ 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) + 30008: ProfileBadgesDetailRenderer, // Profile Badges Detail (NIP-58) + 30009: BadgeDefinitionDetailRenderer, // Badge 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..e2bc4d5 --- /dev/null +++ b/src/lib/nip58-helpers.ts @@ -0,0 +1,125 @@ +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 (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 badges (kind 30009) + */ +export function getAwardedPubkeys(awardEvent: NostrEvent): string[] { + return getTagValues(awardEvent, "p"); +} + +/** + * Get the badge 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"); +} + +/** + * Badge pair from Profile Badges event (kind 30008) + * Contains references to both the badge definition and the award event + */ +export interface BadgePair { + badgeAddress: string; // a tag - references badge definition (30009:pubkey:identifier) + awardEventId: string; // e tag - references award event (kind 8) +} + +/** + * Extract ordered badge pairs from Profile Badges event (kind 30008) + * Returns pairs of (badge definition address, award event id) + */ +export function getProfileBadgePairs(event: NostrEvent): BadgePair[] { + const pairs: BadgePair[] = []; + const aTags = event.tags.filter((tag) => tag[0] === "a" && tag[1]); + const eTags = event.tags.filter((tag) => tag[0] === "e" && tag[1]); + + // Pair them up in order - each a tag should have a corresponding e tag + const minLength = Math.min(aTags.length, eTags.length); + for (let i = 0; i < minLength; i++) { + pairs.push({ + badgeAddress: aTags[i][1], + awardEventId: eTags[i][1], + }); + } + + return pairs; +}