feat: Add communikey definition event renderer (kind 10222)

- Created CommunikeyRenderer for feed view with compact display
- Created CommunikeyDetailRenderer for detail view with full config
- Shows relays, blossom servers, mints, and content sections
- Includes "Open Chat" button to open communikey chat
- Registered both renderers in kind renderer system
- Fixed code ordering in ChatViewer for communikeyServers useMemo
This commit is contained in:
Claude
2026-01-16 13:04:56 +00:00
parent ca6226e53e
commit 457ba1fcd4
3 changed files with 390 additions and 34 deletions

View File

@@ -446,40 +446,6 @@ export function ChatViewer({
// Ref to MentionEditor for programmatic submission
const editorRef = useRef<MentionEditorHandle>(null);
// Extract communikey blossom servers if available
const communikeyServers = useMemo(() => {
if (
conversationResult.status === "success" &&
conversationResult.conversation.protocol === "communikeys"
) {
return (
conversationResult.conversation.metadata?.communikeyConfig
?.blossomServers || []
);
}
return [];
}, [conversationResult]);
// Blossom upload hook for file attachments
const { open: openUpload, dialog: uploadDialog } = useBlossomUpload({
accept: "image/*,video/*,audio/*",
communikeyServers,
onSuccess: (results) => {
if (results.length > 0 && editorRef.current) {
// Insert the first successful upload as a blob attachment with metadata
const { blob, server } = results[0];
editorRef.current.insertBlob({
url: blob.url,
sha256: blob.sha256,
mimeType: blob.type,
size: blob.size,
server,
});
editorRef.current.focus();
}
},
});
// Get the appropriate adapter for this protocol
const adapter = useMemo(() => getAdapter(protocol), [protocol]);
@@ -515,6 +481,34 @@ export function ChatViewer({
? conversationResult.conversation
: null;
// Extract communikey blossom servers if available
const communikeyServers = useMemo(() => {
if (conversation && conversation.protocol === "communikeys") {
return conversation.metadata?.communikeyConfig?.blossomServers || [];
}
return [];
}, [conversation]);
// Blossom upload hook for file attachments
const { open: openUpload, dialog: uploadDialog } = useBlossomUpload({
accept: "image/*,video/*,audio/*",
communikeyServers,
onSuccess: (results) => {
if (results.length > 0 && editorRef.current) {
// Insert the first successful upload as a blob attachment with metadata
const { blob, server } = results[0];
editorRef.current.insertBlob({
url: blob.url,
sha256: blob.sha256,
mimeType: blob.type,
size: blob.size,
server,
});
editorRef.current.focus();
}
},
});
// Slash command search for action autocomplete
// Context-aware: only shows relevant actions based on membership status
const searchCommands = useCallback(

View File

@@ -0,0 +1,356 @@
import { BaseEventProps, BaseEventContainer } from "./BaseEventRenderer";
import type { NostrEvent } from "@/types/nostr";
import { getTagValue } from "applesauce-core/helpers";
import { getTagValues } from "@/lib/nostr-utils";
import { useGrimoire } from "@/core/state";
import { MessageSquare, HardDrive, Coins, Shield } from "lucide-react";
import { useProfile } from "@/hooks/useProfile";
import { UserName } from "@/components/nostr/UserName";
/**
* Kind 10222 Renderer - Communikey Definition (Feed View)
* Shows communikey info with chat link
*/
export function CommunikeyRenderer({ event }: BaseEventProps) {
const { addWindow } = useGrimoire();
const profile = useProfile(event.pubkey);
// Extract communikey configuration
const description = getTagValue(event, "description");
const relays = getTagValues(event, "r");
const blossomServers = getTagValues(event, "blossom");
const mints = getTagValues(event, "mint");
// Get content sections
const contentTags = event.tags.filter((t) => t[0] === "content");
const contentSections = contentTags.map((t) => t[1]).filter(Boolean);
// Community name from profile or pubkey
const name =
profile?.display_name ||
profile?.name ||
`Communikey ${event.pubkey.slice(0, 8)}`;
const handleOpenChat = () => {
try {
addWindow("chat", {
protocol: "communikeys",
identifier: {
type: "group",
value: event.pubkey,
relays,
},
});
} catch (error) {
console.error("Failed to open communikey chat:", error);
}
};
const canOpenChat = relays.length > 0;
return (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-1.5">
{/* Title */}
<div className="flex items-center gap-2">
<Shield className="size-4 text-primary flex-shrink-0" />
<div className="font-semibold text-sm truncate">{name}</div>
<span className="text-xs text-muted-foreground">
(<UserName pubkey={event.pubkey} />)
</span>
</div>
{/* Description */}
{(description || profile?.about) && (
<p className="text-xs text-muted-foreground line-clamp-2">
{description || profile?.about}
</p>
)}
{/* Stats */}
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{relays.length > 0 && (
<span className="flex items-center gap-1">
<Shield className="size-3" />
{relays.length} relay{relays.length !== 1 ? "s" : ""}
</span>
)}
{blossomServers.length > 0 && (
<span className="flex items-center gap-1">
<HardDrive className="size-3" />
{blossomServers.length} blossom
</span>
)}
{mints.length > 0 && (
<span className="flex items-center gap-1">
<Coins className="size-3" />
{mints.length} mint{mints.length !== 1 ? "s" : ""}
</span>
)}
</div>
{/* Content Sections */}
{contentSections.length > 0 && (
<div className="flex items-center gap-1 flex-wrap text-xs">
{contentSections.slice(0, 3).map((section, i) => (
<span
key={i}
className="bg-muted px-1.5 py-0.5 rounded text-muted-foreground"
>
{section}
</span>
))}
{contentSections.length > 3 && (
<span className="text-muted-foreground">
+{contentSections.length - 3} more
</span>
)}
</div>
)}
{/* Open Chat Button */}
{canOpenChat && (
<button
onClick={handleOpenChat}
className="text-xs text-primary hover:underline flex items-center gap-1 w-fit mt-0.5"
>
<MessageSquare className="size-3" />
Open Chat
</button>
)}
</div>
</BaseEventContainer>
);
}
/**
* Kind 10222 Detail Renderer - Communikey Definition (Detail View)
* Shows full communikey configuration
*/
export function CommunikeyDetailRenderer({ event }: { event: NostrEvent }) {
const { addWindow } = useGrimoire();
const profile = useProfile(event.pubkey);
// Extract all configuration
const description = getTagValue(event, "description");
const location = getTagValue(event, "location");
const geoHash = getTagValue(event, "g");
const relays = getTagValues(event, "r");
const blossomServers = getTagValues(event, "blossom");
const mints = getTagValues(event, "mint");
// Parse content sections with their settings
interface ContentSection {
name: string;
kinds: number[];
roles: string[];
fee?: { amount: number; unit: string };
exclusive: boolean;
}
const contentSections: ContentSection[] = [];
let currentSection: ContentSection | null = null;
for (const tag of event.tags) {
if (tag[0] === "content" && tag[1]) {
// Save previous section
if (currentSection) {
contentSections.push(currentSection);
}
// Start new section
currentSection = {
name: tag[1],
kinds: [],
roles: [],
exclusive: false,
};
} else if (currentSection) {
if (tag[0] === "k" && tag[1]) {
const kind = parseInt(tag[1], 10);
if (!isNaN(kind)) {
currentSection.kinds.push(kind);
}
} else if (tag[0] === "role") {
currentSection.roles.push(...tag.slice(1));
} else if (tag[0] === "fee" && tag[1] && tag[2]) {
currentSection.fee = {
amount: parseInt(tag[1], 10),
unit: tag[2],
};
} else if (tag[0] === "exclusive") {
currentSection.exclusive = tag[1] === "true";
}
}
}
// Don't forget the last section
if (currentSection) {
contentSections.push(currentSection);
}
// Community name from profile or pubkey
const name =
profile?.display_name ||
profile?.name ||
`Communikey ${event.pubkey.slice(0, 8)}`;
const handleOpenChat = () => {
try {
addWindow("chat", {
protocol: "communikeys",
identifier: {
type: "group",
value: event.pubkey,
relays,
},
});
} catch (error) {
console.error("Failed to open communikey chat:", error);
}
};
return (
<div className="flex flex-col gap-4 p-4">
{/* Header */}
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Shield className="size-5 text-primary" />
<h2 className="text-xl font-bold">{name}</h2>
</div>
<div className="text-sm text-muted-foreground">
<UserName pubkey={event.pubkey} />
</div>
</div>
{/* Description */}
{(description || profile?.about) && (
<div className="text-sm">{description || profile?.about}</div>
)}
{/* Location */}
{location && (
<div className="text-sm">
<span className="font-medium">Location:</span> {location}
{geoHash && (
<span className="text-muted-foreground ml-2">({geoHash})</span>
)}
</div>
)}
{/* Relays */}
{relays.length > 0 && (
<div>
<h3 className="font-medium text-sm mb-2 flex items-center gap-2">
<Shield className="size-4" />
Relays ({relays.length})
</h3>
<div className="space-y-1">
{relays.map((relay, i) => (
<div
key={i}
className="font-mono text-xs bg-muted rounded px-2 py-1 break-all"
>
{i === 0 && (
<span className="text-primary font-semibold mr-2">
[Main]
</span>
)}
{relay}
</div>
))}
</div>
</div>
)}
{/* Blossom Servers */}
{blossomServers.length > 0 && (
<div>
<h3 className="font-medium text-sm mb-2 flex items-center gap-2">
<HardDrive className="size-4" />
Blossom Servers ({blossomServers.length})
</h3>
<div className="space-y-1">
{blossomServers.map((server, i) => (
<div
key={i}
className="font-mono text-xs bg-muted rounded px-2 py-1 break-all"
>
{server}
</div>
))}
</div>
</div>
)}
{/* Mints */}
{mints.length > 0 && (
<div>
<h3 className="font-medium text-sm mb-2 flex items-center gap-2">
<Coins className="size-4" />
Ecash Mints ({mints.length})
</h3>
<div className="space-y-1">
{mints.map((mint, i) => (
<div
key={i}
className="font-mono text-xs bg-muted rounded px-2 py-1 break-all"
>
{mint}
</div>
))}
</div>
</div>
)}
{/* Content Sections */}
{contentSections.length > 0 && (
<div>
<h3 className="font-medium text-sm mb-2">
Content Sections ({contentSections.length})
</h3>
<div className="space-y-3">
{contentSections.map((section, i) => (
<div key={i} className="border rounded p-3 space-y-2">
<div className="font-medium text-sm">{section.name}</div>
{section.kinds.length > 0 && (
<div className="text-xs">
<span className="text-muted-foreground">Kinds:</span>{" "}
{section.kinds.join(", ")}
</div>
)}
{section.roles.length > 0 && (
<div className="text-xs">
<span className="text-muted-foreground">Roles:</span>{" "}
{section.roles.join(", ")}
</div>
)}
{section.fee && (
<div className="text-xs">
<span className="text-muted-foreground">Fee:</span>{" "}
{section.fee.amount} {section.fee.unit}
</div>
)}
{section.exclusive && (
<div className="text-xs text-amber-600">
Exclusive (cannot target other communities)
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Open Chat Button */}
{relays.length > 0 && (
<button
onClick={handleOpenChat}
className="bg-primary text-primary-foreground hover:bg-primary/90 px-4 py-2 rounded flex items-center justify-center gap-2 text-sm font-medium"
>
<MessageSquare className="size-4" />
Open Chat
</button>
)}
</div>
);
}

View File

@@ -30,6 +30,10 @@ import {
BlossomServerListRenderer,
BlossomServerListDetailRenderer,
} from "./BlossomServerListRenderer";
import {
CommunikeyRenderer,
CommunikeyDetailRenderer,
} from "./CommunikeyRenderer";
import { Kind10317Renderer } from "./GraspListRenderer";
import { Kind10317DetailRenderer } from "./GraspListDetailRenderer";
import { Kind30023Renderer } from "./ArticleRenderer";
@@ -189,6 +193,7 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
10063: BlossomServerListRenderer, // Blossom User Server List (BUD-03)
10101: WikiAuthorsRenderer, // Good Wiki Authors (NIP-51)
10102: WikiRelaysRenderer, // Good Wiki Relays (NIP-51)
10222: CommunikeyRenderer, // Communikey Definition (NIP-CC)
10317: Kind10317Renderer, // User Grasp List (NIP-34)
13534: RelayMembersRenderer, // Relay Members (NIP-43)
30000: FollowSetRenderer, // Follow Sets (NIP-51)
@@ -282,6 +287,7 @@ const detailRenderers: Record<
10063: BlossomServerListDetailRenderer, // Blossom User Server List Detail (BUD-03)
10101: WikiAuthorsDetailRenderer, // Good Wiki Authors Detail (NIP-51)
10102: WikiRelaysDetailRenderer, // Good Wiki Relays Detail (NIP-51)
10222: CommunikeyDetailRenderer, // Communikey Definition Detail (NIP-CC)
10317: Kind10317DetailRenderer, // User Grasp List Detail (NIP-34)
13534: RelayMembersDetailRenderer, // Relay Members Detail (NIP-43)
30000: FollowSetDetailRenderer, // Follow Sets Detail (NIP-51)