diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx
index b164539..75252f6 100644
--- a/src/components/ChatViewer.tsx
+++ b/src/components/ChatViewer.tsx
@@ -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 (
+
+
*
+
+ {/* Issuer */}
+
+
+
awarded
+
+ {/* Badge Icon */}
+ {badgeImageUrl ? (
+

+ ) : (
+
+ )}
+
+ {/* Badge Name */}
+
+ {displayBadgeName}
+
+
+
to
+
+ {/* Recipients */}
+ {awardedPubkeys.length === 1 ? (
+
+ ) : (
+
+ {awardedPubkeys.length} people
+
+ )}
+
+ );
+});
+
/**
* 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 (
+
+ );
+ }
+
+ // Default system message rendering (join/leave)
return (
diff --git a/src/lib/chat/adapters/nip-29-adapter.ts b/src/lib/chat/adapters/nip-29-adapter.ts
index 3f51c30..db7492c 100644
--- a/src/lib/chat/adapters/nip-29-adapter.ts
+++ b/src/lib/chat/adapters/nip-29-adapter.ts
@@ -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
diff --git a/src/types/chat.ts b/src/types/chat.ts
index f81b518..29d62ea 100644
--- a/src/types/chat.ts
+++ b/src/types/chat.ts
@@ -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
}
/**