mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-11 16:07:15 +02:00
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.
This commit is contained in:
226
src/components/nostr/kinds/BadgeDefinitionDetailRenderer.tsx
Normal file
226
src/components/nostr/kinds/BadgeDefinitionDetailRenderer.tsx
Normal file
@@ -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 (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
{dimensions && (
|
||||
<code className="text-xs text-muted-foreground">{dimensions}</code>
|
||||
)}
|
||||
</div>
|
||||
<img
|
||||
src={url}
|
||||
alt={label}
|
||||
className="w-full max-w-[200px] rounded-lg object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string>();
|
||||
|
||||
// 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<string>();
|
||||
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 (
|
||||
<div className="flex flex-col gap-6 p-6 max-w-4xl mx-auto">
|
||||
{/* Header Section */}
|
||||
<div className="flex gap-4">
|
||||
{/* Badge Image */}
|
||||
{image ? (
|
||||
<img
|
||||
src={image.url}
|
||||
alt={displayTitle}
|
||||
className="size-32 rounded-lg object-cover flex-shrink-0"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-32 rounded-lg bg-muted flex items-center justify-center flex-shrink-0">
|
||||
<Award className="size-16 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Badge Title & Description */}
|
||||
<div className="flex flex-col gap-2 flex-1 min-w-0">
|
||||
<h1 className="text-3xl font-bold">{displayTitle}</h1>
|
||||
{description && (
|
||||
<p className="text-muted-foreground text-base">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata Grid */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
{/* Issuer */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-muted-foreground">Issued By</h3>
|
||||
<UserName pubkey={event.pubkey} />
|
||||
</div>
|
||||
|
||||
{/* Identifier */}
|
||||
{identifier && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-muted-foreground">Badge ID</h3>
|
||||
<code className="font-mono text-sm truncate" title={identifier}>
|
||||
{identifier}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Awards Count */}
|
||||
{awards && awards.length > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-muted-foreground">Times Awarded</h3>
|
||||
<span className="text-sm">
|
||||
{awards.length} award{awards.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recipients Count */}
|
||||
{uniqueRecipients > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-muted-foreground">Recipients</h3>
|
||||
<span className="text-sm">
|
||||
{uniqueRecipients} user{uniqueRecipients !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Image Variants Section */}
|
||||
{(image || thumbnails.length > 0) && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-xl font-semibold">Image Variants</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{image && (
|
||||
<ImageVariant
|
||||
url={image.url}
|
||||
dimensions={image.dimensions}
|
||||
label="Main Image"
|
||||
/>
|
||||
)}
|
||||
{thumbnails.map((thumb, idx) => (
|
||||
<ImageVariant
|
||||
key={idx}
|
||||
url={thumb.url}
|
||||
dimensions={thumb.dimensions}
|
||||
label={`Thumbnail ${idx + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Award Address for Reference */}
|
||||
{identifier && (
|
||||
<div className="flex flex-col gap-2 p-4 bg-muted/30 rounded-lg">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">
|
||||
Badge Address (for awarding)
|
||||
</h3>
|
||||
<code className="text-xs font-mono break-all">
|
||||
30009:{event.pubkey}:{identifier}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
src/components/nostr/kinds/BadgeDefinitionRenderer.tsx
Normal file
68
src/components/nostr/kinds/BadgeDefinitionRenderer.tsx
Normal file
@@ -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 (
|
||||
<BaseEventContainer event={event}>
|
||||
<div className="flex gap-3">
|
||||
{/* Badge Image */}
|
||||
{imageUrl ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={displayTitle}
|
||||
className="size-16 rounded-lg object-cover flex-shrink-0"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-16 rounded-lg bg-muted flex items-center justify-center flex-shrink-0">
|
||||
<Award className="size-8 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Badge Info */}
|
||||
<div className="flex flex-col gap-1 flex-1 min-w-0">
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
className="text-base font-semibold text-foreground"
|
||||
>
|
||||
{displayTitle}
|
||||
</ClickableEventTitle>
|
||||
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{identifier && (
|
||||
<code className="text-xs text-muted-foreground font-mono truncate">
|
||||
{identifier}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</BaseEventContainer>
|
||||
);
|
||||
}
|
||||
@@ -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<number, React.ComponentType<BaseEventProps>> = {
|
||||
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)
|
||||
|
||||
95
src/lib/nip58-helpers.ts
Normal file
95
src/lib/nip58-helpers.ts
Normal file
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user