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.
This commit is contained in:
Claude
2026-01-17 19:06:11 +00:00
parent a1075840ae
commit 909359f521
3 changed files with 151 additions and 3 deletions

View File

@@ -35,6 +35,12 @@ import { UserName } from "./nostr/UserName";
import { RichText } from "./nostr/RichText";
import Timestamp from "./Timestamp";
import { ReplyPreview } from "./chat/ReplyPreview";
import { Award } from "lucide-react";
import {
getBadgeName,
getBadgeIdentifier,
getBadgeImageUrl,
} from "@/lib/nip58-helpers";
import { MembersDropdown } from "./chat/MembersDropdown";
import { RelaysDropdown } from "./chat/RelaysDropdown";
import { MessageReactions } from "./chat/MessageReactions";
@@ -198,6 +204,106 @@ type ConversationResult =
| { status: "success"; conversation: Conversation }
| { status: "error"; error: string };
/**
* Parse badge address to extract pubkey and identifier
*/
function parseBadgeAddress(address: string): {
kind: number;
pubkey: string;
identifier: string;
} | null {
const parts = address.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 };
}
/**
* BadgeAwardSystemMessage - Renders badge award as system message
* Format: "🏆 username awarded 🏅 badge-name to username(s)"
*/
const BadgeAwardSystemMessage = memo(function BadgeAwardSystemMessage({
message,
badgeAddress,
awardedPubkeys,
}: {
message: Message;
badgeAddress: string;
awardedPubkeys: string[];
}) {
const coordinate = parseBadgeAddress(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],
);
const badgeName = badgeEvent ? getBadgeName(badgeEvent) : null;
const badgeIdentifier = badgeEvent ? getBadgeIdentifier(badgeEvent) : null;
const badgeImageUrl = badgeEvent ? getBadgeImageUrl(badgeEvent) : null;
const displayBadgeName = badgeName || badgeIdentifier || "a badge";
return (
<div className="flex items-center gap-1.5 px-3 py-1 flex-wrap">
<span className="text-xs text-muted-foreground">*</span>
{/* Issuer */}
<UserName
pubkey={message.author}
className="text-xs text-muted-foreground"
/>
<span className="text-xs text-muted-foreground">awarded</span>
{/* Badge Icon */}
{badgeImageUrl ? (
<img
src={badgeImageUrl}
alt={displayBadgeName}
className="size-4 rounded object-cover flex-shrink-0"
loading="lazy"
/>
) : (
<Award className="size-4 text-muted-foreground flex-shrink-0" />
)}
{/* Badge Name */}
<span className="text-xs font-semibold text-muted-foreground">
{displayBadgeName}
</span>
<span className="text-xs text-muted-foreground">to</span>
{/* Recipients */}
{awardedPubkeys.length === 1 ? (
<UserName
pubkey={awardedPubkeys[0]}
className="text-xs text-muted-foreground"
/>
) : (
<span className="text-xs text-muted-foreground">
{awardedPubkeys.length} people
</span>
)}
</div>
);
});
/**
* ComposerReplyPreview - Shows who is being replied to in the composer
*/
@@ -273,8 +379,20 @@ const MessageItem = memo(function MessageItem({
[conversation],
);
// System messages (join/leave) have special styling
// System messages (join/leave/badge-award) have special styling
if (message.type === "system") {
// Badge awards get special rendering
if (message.content === "badge-award" && message.metadata?.badgeAddress) {
return (
<BadgeAwardSystemMessage
message={message}
badgeAddress={message.metadata.badgeAddress}
awardedPubkeys={message.metadata.awardedPubkeys || []}
/>
);
}
// Default system message rendering (join/leave)
return (
<div className="flex items-center px-3 py-1">
<span className="text-xs text-muted-foreground">

View File

@@ -21,6 +21,7 @@ import accountManager from "@/services/accounts";
import { getTagValues } from "@/lib/nostr-utils";
import { normalizeRelayURL } from "@/lib/relay-url";
import { EventFactory } from "applesauce-core/event-factory";
import { getAwardedPubkeys } from "@/lib/nip58-helpers";
/**
* NIP-29 Adapter - Relay-Based Groups
@@ -321,12 +322,13 @@ export class Nip29Adapter extends ChatProtocolAdapter {
console.log(`[NIP-29] Loading messages for ${groupId} from ${relayUrl}`);
// Single filter for all group events:
// kind 8: badge awards (NIP-58)
// kind 9: chat messages
// kind 9000: put-user (admin adds user)
// kind 9001: remove-user (admin removes user)
// kind 9321: nutzaps (NIP-61)
const filter: Filter = {
kinds: [9, 9000, 9001, 9321],
kinds: [8, 9, 9000, 9001, 9321],
"#h": [groupId],
limit: options?.limit || 50,
};
@@ -403,7 +405,7 @@ export class Nip29Adapter extends ChatProtocolAdapter {
// Same filter as loadMessages but with until for pagination
const filter: Filter = {
kinds: [9, 9000, 9001, 9321],
kinds: [8, 9, 9000, 9001, 9321],
"#h": [groupId],
until: before,
limit: 50,
@@ -1056,6 +1058,30 @@ export class Nip29Adapter extends ChatProtocolAdapter {
* Helper: Convert Nostr event to Message
*/
private eventToMessage(event: NostrEvent, conversationId: string): Message {
// Handle badge awards (kind 8) as system messages
if (event.kind === 8) {
const awardedPubkeys = getAwardedPubkeys(event);
const badgeAddress = getTagValues(event, "a")[0]; // Badge definition address
// Content will be set to "badge-award" to trigger special rendering
return {
id: event.id,
conversationId,
author: event.pubkey, // Issuer
content: "badge-award",
timestamp: event.created_at,
type: "system",
protocol: "nip-29",
metadata: {
encrypted: false,
// Store badge info for rendering
badgeAddress,
awardedPubkeys,
},
event,
};
}
// Handle admin events (join/leave) as system messages
if (event.kind === 9000 || event.kind === 9001) {
// Extract the affected user's pubkey from p-tag

View File

@@ -5,6 +5,7 @@ import type { NostrEvent } from "./nostr";
* Used for filtering and validating chat-related events
*/
export const CHAT_KINDS = [
8, // NIP-58: Badge awards (system messages)
9, // NIP-29: Group chat messages
9321, // NIP-61: Nutzaps (ecash zaps in groups/live chats)
1311, // NIP-53: Live chat messages
@@ -106,6 +107,9 @@ export interface MessageMetadata {
zapRecipient?: string; // Pubkey of zap recipient
// NIP-61 nutzap-specific metadata
nutzapUnit?: string; // Unit for nutzap amount (sat, usd, eur, etc.)
// Badge award metadata (for type: "system" with content: "badge-award")
badgeAddress?: string; // Badge definition address (30009:pubkey:identifier)
awardedPubkeys?: string[]; // Pubkeys of recipients
}
/**