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 ? ( + {displayBadgeName} + ) : ( + + )} + + {/* 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 } /**