mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-11 16:07:15 +02:00
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:
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user