mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-11 16:07:15 +02:00
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:
@@ -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(
|
||||
|
||||
356
src/components/nostr/kinds/CommunikeyRenderer.tsx
Normal file
356
src/components/nostr/kinds/CommunikeyRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user