diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx
index 53696b5..2510e29 100644
--- a/src/components/ChatViewer.tsx
+++ b/src/components/ChatViewer.tsx
@@ -28,6 +28,7 @@ import { CHAT_KINDS } from "@/types/chat";
import { Nip10Adapter } from "@/lib/chat/adapters/nip-10-adapter";
import { Nip29Adapter } from "@/lib/chat/adapters/nip-29-adapter";
import { Nip53Adapter } from "@/lib/chat/adapters/nip-53-adapter";
+import { CommunikeyAdapter } from "@/lib/chat/adapters/communikey-adapter";
import type { ChatProtocolAdapter } from "@/lib/chat/adapters/base-adapter";
import type { Message } from "@/types/chat";
import type { ChatAction } from "@/types/chat-actions";
@@ -1138,7 +1139,7 @@ export function ChatViewer({
/**
* Get the appropriate adapter for a protocol
- * Currently NIP-10 (thread chat), NIP-29 (relay-based groups) and NIP-53 (live activity chat) are supported
+ * Currently NIP-10 (thread chat), NIP-29 (relay-based groups), Communikey, and NIP-53 (live activity chat) are supported
* Other protocols will be enabled in future phases
*/
function getAdapter(protocol: ChatProtocol): ChatProtocolAdapter {
@@ -1149,6 +1150,8 @@ function getAdapter(protocol: ChatProtocol): ChatProtocolAdapter {
// return new NipC7Adapter();
case "nip-29":
return new Nip29Adapter();
+ case "communikey":
+ return new CommunikeyAdapter();
// case "nip-17": // Phase 2 - Encrypted DMs (coming soon)
// return new Nip17Adapter();
// case "nip-28": // Phase 3 - Public channels (coming soon)
diff --git a/src/components/nostr/kinds/CommunikeyDetailRenderer.tsx b/src/components/nostr/kinds/CommunikeyDetailRenderer.tsx
new file mode 100644
index 0000000..388ee18
--- /dev/null
+++ b/src/components/nostr/kinds/CommunikeyDetailRenderer.tsx
@@ -0,0 +1,274 @@
+import type { NostrEvent } from "@/types/nostr";
+import { getTagValue, getTagValues } from "@/lib/nostr-utils";
+import { useProfile } from "@/hooks/useProfile";
+import { UserName } from "../UserName";
+import { Label } from "@/components/ui/label";
+import { Button } from "@/components/ui/button";
+import { useGrimoire } from "@/core/state";
+import {
+ MessageSquare,
+ Server,
+ Shield,
+ FileText,
+ MapPin,
+ Coins,
+ Image as ImageIcon,
+} from "lucide-react";
+
+interface CommunikeyDetailRendererProps {
+ event: NostrEvent;
+}
+
+/**
+ * Detail renderer for Communikey Community Definition events (kind 10222)
+ * Shows full community metadata, content sections, relays, and features
+ */
+export function CommunikeyDetailRenderer({
+ event,
+}: CommunikeyDetailRendererProps) {
+ const { addWindow } = useGrimoire();
+
+ // Get community pubkey (the event author = community admin)
+ const communityPubkey = event.pubkey;
+
+ // Fetch community profile for name/picture
+ const profile = useProfile(communityPubkey);
+
+ // Extract community metadata from kind 10222
+ const descriptionOverride = getTagValue(event, "description");
+ const relays = getTagValues(event, "r").filter((url) => url);
+ const blossomServers = getTagValues(event, "blossom").filter((url) => url);
+ const mints = getTagValues(event, "mint").filter((url) => url);
+ const tosPointer = getTagValue(event, "tos");
+ const location = getTagValue(event, "location");
+ const geoHash = getTagValue(event, "g");
+
+ // Use profile metadata or fallback
+ const name = profile?.name || communityPubkey.slice(0, 8);
+ const about = descriptionOverride || profile?.about;
+ const picture = profile?.picture;
+
+ // Parse content sections
+ // Content sections are groups of tags between "content" tags
+ const contentSections: Array<{
+ name: string;
+ kinds: number[];
+ badges: string[];
+ }> = [];
+
+ let currentSection: {
+ name: string;
+ kinds: number[];
+ badges: string[];
+ } | null = null;
+
+ for (const tag of event.tags) {
+ if (tag[0] === "content" && tag[1]) {
+ // Save previous section if exists
+ if (currentSection) {
+ contentSections.push(currentSection);
+ }
+ // Start new section
+ currentSection = {
+ name: tag[1],
+ kinds: [],
+ badges: [],
+ };
+ } else if (currentSection) {
+ // Add tags to current section
+ if (tag[0] === "k" && tag[1]) {
+ const kind = parseInt(tag[1], 10);
+ if (!isNaN(kind)) {
+ currentSection.kinds.push(kind);
+ }
+ } else if (tag[0] === "a" && tag[1]) {
+ currentSection.badges.push(tag[1]);
+ }
+ }
+ }
+
+ // Don't forget the last section
+ if (currentSection) {
+ contentSections.push(currentSection);
+ }
+
+ const handleOpenChat = () => {
+ if (!relays.length) return;
+
+ addWindow("chat", {
+ protocol: "communikey",
+ identifier: {
+ type: "communikey",
+ value: communityPubkey,
+ relays,
+ },
+ });
+ };
+
+ const canOpenChat = relays.length > 0;
+
+ return (
+
+ {/* Header with picture */}
+ {picture && (
+
+

+
+
+ )}
+
+ {/* Content Section */}
+
+ {/* Title and Admin */}
+
+
{name}
+
+
+ Admin:
+
+
+
+
+ {/* Description */}
+ {about && (
+
+ {about}
+
+ )}
+
+ {/* Location */}
+ {location && (
+
+
+ {location}
+ {geoHash && ({geoHash})}
+
+ )}
+
+ {/* Content Sections */}
+ {contentSections.length > 0 && (
+
+
Content Sections
+
+ {contentSections.map((section, i) => (
+
+
+
{section.name}
+ {section.kinds.length > 0 && (
+
+ {section.kinds.map((kind) => (
+
+ ))}
+
+ )}
+
+ {section.badges.length > 0 && (
+
+
Badge requirements:
+ {section.badges.map((badge, j) => (
+
+ {badge}
+
+ ))}
+
+ )}
+
+ ))}
+
+
+ )}
+
+ {/* Relays */}
+ {relays.length > 0 && (
+
+
+
+ Relays
+
+
+ {relays.map((relay, i) => (
+
+ {i === 0 && (
+
+ [main]
+
+ )}
+ {relay}
+
+ ))}
+
+
+ )}
+
+ {/* Optional Features */}
+ {(blossomServers.length > 0 || mints.length > 0) && (
+
+
Features
+
+ {blossomServers.length > 0 && (
+
+
+
+
Blossom servers:
+ {blossomServers.map((server, i) => (
+
+ {server}
+
+ ))}
+
+
+ )}
+ {mints.length > 0 && (
+
+
+
+
Cashu mints:
+ {mints.map((mint, i) => (
+
+ {mint}
+
+ ))}
+
+
+ )}
+
+
+ )}
+
+ {/* Terms of Service */}
+ {tosPointer && (
+
+
+ Terms of service: {tosPointer.slice(0, 16)}...
+
+ )}
+
+ {/* Open Chat Button */}
+ {canOpenChat && (
+
+ )}
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/CommunikeyRenderer.tsx b/src/components/nostr/kinds/CommunikeyRenderer.tsx
new file mode 100644
index 0000000..25c4a27
--- /dev/null
+++ b/src/components/nostr/kinds/CommunikeyRenderer.tsx
@@ -0,0 +1,102 @@
+import type { NostrEvent } from "@/types/nostr";
+import { getTagValue, getTagValues } from "@/lib/nostr-utils";
+import { BaseEventContainer, ClickableEventTitle } from "./BaseEventRenderer";
+import { useGrimoire } from "@/core/state";
+import { MessageSquare, Server } from "lucide-react";
+import { useProfile } from "@/hooks/useProfile";
+
+interface CommunikeyRendererProps {
+ event: NostrEvent;
+}
+
+/**
+ * Renderer for Communikey Community Definition events (kind 10222)
+ * Displays community info, content sections, and links to chat
+ */
+export function CommunikeyRenderer({ event }: CommunikeyRendererProps) {
+ const { addWindow } = useGrimoire();
+
+ // Get community pubkey (the event author)
+ const communityPubkey = event.pubkey;
+
+ // Fetch community profile for name/picture
+ const profile = useProfile(communityPubkey);
+
+ // Extract community metadata from kind 10222
+ const descriptionOverride = getTagValue(event, "description");
+ const relays = getTagValues(event, "r").filter((url) => url);
+
+ // Use profile metadata or fallback
+ const name = profile?.name || communityPubkey.slice(0, 8);
+ const about = descriptionOverride || profile?.about;
+
+ // Parse content sections (groups of tags between "content" tags)
+ const contentTags = event.tags.filter((t) => t[0] === "content");
+ const contentSections = contentTags.map((t) => t[1]).filter((s) => s);
+
+ const handleOpenChat = () => {
+ if (!relays.length) return;
+
+ // Use relay'pubkey format for compatibility with NIP-29 parser
+ const primaryRelay = relays[0].replace(/^wss?:\/\//, "");
+ const identifier = `${primaryRelay}'${communityPubkey}`;
+
+ // Open chat command - parser will detect it's a Communikey
+ addWindow("chat", {
+ protocol: "communikey",
+ identifier: {
+ type: "communikey",
+ value: communityPubkey,
+ relays,
+ },
+ });
+ };
+
+ const canOpenChat = relays.length > 0;
+
+ return (
+
+
+
+ {name}
+
+
+ {about && (
+
{about}
+ )}
+
+ {contentSections.length > 0 && (
+
+ {contentSections.map((section, i) => (
+
+ {section}
+
+ ))}
+
+ )}
+
+
+ {canOpenChat && (
+
+ )}
+
+ {relays.length > 0 && (
+
+
+ {relays.length} relay{relays.length === 1 ? "" : "s"}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx
index d14e79b..d2723ad 100644
--- a/src/components/nostr/kinds/index.tsx
+++ b/src/components/nostr/kinds/index.tsx
@@ -67,6 +67,8 @@ import { ZapstoreAppSetDetailRenderer } from "./ZapstoreAppSetDetailRenderer";
import { ZapstoreReleaseRenderer } from "./ZapstoreReleaseRenderer";
import { ZapstoreReleaseDetailRenderer } from "./ZapstoreReleaseDetailRenderer";
import { GroupMetadataRenderer } from "./GroupMetadataRenderer";
+import { CommunikeyRenderer } from "./CommunikeyRenderer";
+import { CommunikeyDetailRenderer } from "./CommunikeyDetailRenderer";
import {
RelayMembersRenderer,
RelayMembersDetailRenderer,
@@ -198,6 +200,7 @@ const kindRenderers: Record> = {
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 Community Definition (kind 10222)
10317: Kind10317Renderer, // User Grasp List (NIP-34)
13534: RelayMembersRenderer, // Relay Members (NIP-43)
30000: FollowSetRenderer, // Follow Sets (NIP-51)
@@ -296,6 +299,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 Community Definition Detail (kind 10222)
10317: Kind10317DetailRenderer, // User Grasp List Detail (NIP-34)
13534: RelayMembersDetailRenderer, // Relay Members Detail (NIP-43)
30000: FollowSetDetailRenderer, // Follow Sets Detail (NIP-51)
diff --git a/src/lib/chat-parser.ts b/src/lib/chat-parser.ts
index a393740..e9e8aa3 100644
--- a/src/lib/chat-parser.ts
+++ b/src/lib/chat-parser.ts
@@ -3,11 +3,80 @@ import type { ChatCommandResult, GroupListIdentifier } from "@/types/chat";
import { Nip10Adapter } from "./chat/adapters/nip-10-adapter";
import { Nip29Adapter } from "./chat/adapters/nip-29-adapter";
import { Nip53Adapter } from "./chat/adapters/nip-53-adapter";
+import { CommunikeyAdapter } from "./chat/adapters/communikey-adapter";
import { nip19 } from "nostr-tools";
+import type { Filter } from "nostr-tools";
+import pool from "@/services/relay-pool";
+import eventStore from "@/services/event-store";
+import { firstValueFrom } from "rxjs";
+import { toArray } from "rxjs/operators";
// Import other adapters as they're implemented
// import { Nip17Adapter } from "./chat/adapters/nip-17-adapter";
// import { Nip28Adapter } from "./chat/adapters/nip-28-adapter";
+/**
+ * Check if a string is a valid hex pubkey (64 hex characters)
+ */
+function isValidPubkey(str: string): boolean {
+ return /^[0-9a-f]{64}$/i.test(str);
+}
+
+/**
+ * Try to detect if a group ID is actually a Communikey (kind 10222)
+ * Returns true if kind 10222 event found, false otherwise
+ */
+async function isCommunikey(
+ pubkey: string,
+ relayHints: string[],
+): Promise {
+ if (!isValidPubkey(pubkey)) {
+ return false;
+ }
+
+ console.log(
+ `[Chat Parser] Checking if ${pubkey.slice(0, 8)}... is a Communikey...`,
+ );
+
+ const filter: Filter = {
+ kinds: [10222],
+ authors: [pubkey.toLowerCase()],
+ limit: 1,
+ };
+
+ try {
+ // Use available relays for detection (relay hints + some connected relays)
+ const relays = [
+ ...relayHints,
+ ...Array.from(pool.connectedRelays.keys()).slice(0, 3),
+ ].filter((r) => r);
+
+ if (relays.length === 0) {
+ console.log("[Chat Parser] No relays available for Communikey detection");
+ return false;
+ }
+
+ // Quick check with 2 second timeout
+ const timeoutPromise = new Promise((_, reject) => {
+ setTimeout(() => reject(new Error("Timeout")), 2000);
+ });
+
+ const fetchPromise = firstValueFrom(
+ pool.request(relays, [filter], { eventStore }).pipe(toArray()),
+ );
+
+ const events = await Promise.race([fetchPromise, timeoutPromise]);
+
+ const hasCommunikey = events.length > 0;
+ console.log(
+ `[Chat Parser] Communikey detection: ${hasCommunikey ? "found" : "not found"}`,
+ );
+ return hasCommunikey;
+ } catch (err) {
+ console.log("[Chat Parser] Communikey detection failed:", err);
+ return false;
+ }
+}
+
/**
* Parse a chat command identifier and auto-detect the protocol
*
@@ -16,6 +85,7 @@ import { nip19 } from "nostr-tools";
* 2. NIP-17 (encrypted DMs) - prioritized for privacy
* 3. NIP-28 (channels) - specific event format (kind 40)
* 4. NIP-29 (groups) - specific group ID format
+ * - Communikey fallback: if group ID is valid pubkey with kind 10222
* 5. NIP-53 (live chat) - specific addressable format (kind 30311)
* 6. NIP-C7 (simple chat) - fallback for generic pubkeys
*
@@ -23,7 +93,9 @@ import { nip19 } from "nostr-tools";
* @returns Parsed result with protocol and identifier
* @throws Error if no adapter can parse the identifier
*/
-export function parseChatCommand(args: string[]): ChatCommandResult {
+export async function parseChatCommand(
+ args: string[],
+): Promise {
if (args.length === 0) {
throw new Error("Chat identifier required. Usage: chat ");
}
@@ -75,6 +147,28 @@ export function parseChatCommand(args: string[]): ChatCommandResult {
for (const adapter of adapters) {
const parsed = adapter.parseIdentifier(identifier);
if (parsed) {
+ // Special case: NIP-29 group fallback to Communikey
+ if (parsed.type === "group" && adapter.protocol === "nip-29") {
+ const groupId = parsed.value;
+ const relays = parsed.relays || [];
+
+ // Check if group ID is a valid pubkey with kind 10222
+ if (await isCommunikey(groupId, relays)) {
+ console.log("[Chat Parser] Using Communikey adapter for", groupId);
+ const communikeyAdapter = new CommunikeyAdapter();
+ return {
+ protocol: "communikey",
+ identifier: {
+ type: "communikey",
+ value: groupId.toLowerCase(),
+ relays, // Use relays from NIP-29 format as hints
+ },
+ adapter: communikeyAdapter,
+ };
+ }
+ }
+
+ // Return the original adapter result
return {
protocol: adapter.protocol,
identifier: parsed,
@@ -95,6 +189,9 @@ Currently supported formats:
Examples:
chat relay.example.com'bitcoin-dev
chat wss://relay.example.com'nostr-dev
+ - relay.com'pubkey (Communikey fallback, if pubkey has kind 10222)
+ Examples:
+ chat relay.example.com'<64-char-hex-pubkey>
- naddr1... (NIP-29 group metadata, kind 39000)
Example:
chat naddr1qqxnzdesxqmnxvpexqmny...
diff --git a/src/lib/chat/adapters/communikey-adapter.ts b/src/lib/chat/adapters/communikey-adapter.ts
new file mode 100644
index 0000000..debb8d5
--- /dev/null
+++ b/src/lib/chat/adapters/communikey-adapter.ts
@@ -0,0 +1,784 @@
+import { Observable, firstValueFrom } from "rxjs";
+import { map, first, toArray } from "rxjs/operators";
+import type { Filter } from "nostr-tools";
+import { ChatProtocolAdapter, type SendMessageOptions } from "./base-adapter";
+import type {
+ Conversation,
+ Message,
+ ProtocolIdentifier,
+ ChatCapabilities,
+ LoadMessagesOptions,
+ Participant,
+} from "@/types/chat";
+import type { NostrEvent } from "@/types/nostr";
+import type { ChatAction, GetActionsOptions } from "@/types/chat-actions";
+import eventStore from "@/services/event-store";
+import pool from "@/services/relay-pool";
+import { publishEventToRelays, publishEvent } from "@/services/hub";
+import accountManager from "@/services/accounts";
+import { getTagValues } from "@/lib/nostr-utils";
+import { normalizeRelayURL } from "@/lib/relay-url";
+import { EventFactory } from "applesauce-core/event-factory";
+
+/**
+ * Communikey Adapter - NIP-29 fallback using kind 10222 communities
+ *
+ * Features:
+ * - Fallback when NIP-29 group ID is a valid pubkey with kind 10222 definition
+ * - Community pubkey acts as admin
+ * - Members derived from chat participants (unique message authors)
+ * - Multi-relay support (main + backups from r-tags)
+ * - Client-side moderation only
+ *
+ * Identifier format: pubkey (hex) with relays from kind 10222
+ * Events use "h" tag with community pubkey (same as NIP-29)
+ */
+export class CommunikeyAdapter extends ChatProtocolAdapter {
+ readonly protocol = "communikey" as const;
+ readonly type = "group" as const;
+
+ /**
+ * Parse identifier - only accepts valid hex pubkeys
+ * Relay list comes from kind 10222, not the identifier
+ */
+ parseIdentifier(input: string): ProtocolIdentifier | null {
+ // Check if input is a valid 64-character hex pubkey
+ if (!/^[0-9a-f]{64}$/i.test(input)) {
+ return null;
+ }
+
+ // Return minimal identifier - relays will be fetched from kind 10222
+ return {
+ type: "communikey",
+ value: input.toLowerCase(),
+ relays: [],
+ };
+ }
+
+ /**
+ * Resolve conversation from communikey identifier
+ * Fetches kind 10222 (community definition) and kind 0 (profile)
+ */
+ async resolveConversation(
+ identifier: ProtocolIdentifier,
+ ): Promise {
+ // This adapter only handles communikey identifiers
+ if (identifier.type !== "communikey") {
+ throw new Error(
+ `Communikey adapter cannot handle identifier type: ${identifier.type}`,
+ );
+ }
+ const communikeyPubkey = identifier.value;
+
+ const activePubkey = accountManager.active$.value?.pubkey;
+ if (!activePubkey) {
+ throw new Error("No active account");
+ }
+
+ console.log(
+ `[Communikey] Fetching community definition for ${communikeyPubkey.slice(0, 8)}...`,
+ );
+
+ // Fetch kind 10222 (community definition)
+ const definitionFilter: Filter = {
+ kinds: [10222],
+ authors: [communikeyPubkey],
+ limit: 1,
+ };
+
+ // Use user's outbox/general relays for fetching
+ // TODO: Could use more sophisticated relay selection
+ const definitionEvents = await firstValueFrom(
+ pool
+ .request(
+ identifier.relays.length > 0
+ ? identifier.relays
+ : Array.from(pool.connectedRelays.keys()).slice(0, 5),
+ [definitionFilter],
+ { eventStore },
+ )
+ .pipe(toArray()),
+ );
+
+ const definitionEvent = definitionEvents[0];
+ if (!definitionEvent) {
+ throw new Error(
+ `No community definition found for ${communikeyPubkey.slice(0, 8)}...`,
+ );
+ }
+
+ console.log(
+ `[Communikey] Found community definition, tags:`,
+ definitionEvent.tags,
+ );
+
+ // Extract relays from r-tags
+ const relays = getTagValues(definitionEvent, "r")
+ .map((url) => {
+ // Add wss:// prefix if not present
+ if (!url.startsWith("ws://") && !url.startsWith("wss://")) {
+ return `wss://${url}`;
+ }
+ return url;
+ })
+ .filter((url) => url); // Remove empty strings
+
+ if (relays.length === 0) {
+ throw new Error("Community definition has no relay URLs (r-tags)");
+ }
+
+ console.log(`[Communikey] Community relays:`, relays);
+
+ // Fetch kind 0 (profile) for community name/picture
+ const profileFilter: Filter = {
+ kinds: [0],
+ authors: [communikeyPubkey],
+ limit: 1,
+ };
+
+ const profileEvents = await firstValueFrom(
+ pool.request(relays, [profileFilter], { eventStore }).pipe(toArray()),
+ );
+
+ const profileEvent = profileEvents[0];
+
+ // Parse profile metadata
+ let profileName = communikeyPubkey.slice(0, 8);
+ let profilePicture: string | undefined;
+ let profileAbout: string | undefined;
+
+ if (profileEvent) {
+ try {
+ const metadata = JSON.parse(profileEvent.content);
+ profileName = metadata.name || profileName;
+ profilePicture = metadata.picture;
+ profileAbout = metadata.about;
+ } catch (err) {
+ console.warn("[Communikey] Failed to parse profile metadata:", err);
+ }
+ }
+
+ // Check for description override in kind 10222
+ const descriptionOverride = getTagValues(definitionEvent, "description")[0];
+ const description = descriptionOverride || profileAbout;
+
+ console.log(`[Communikey] Community name: ${profileName}`);
+
+ // Community pubkey is the admin
+ const participants: Participant[] = [
+ {
+ pubkey: communikeyPubkey,
+ role: "admin",
+ },
+ ];
+
+ // Note: Additional members will be derived dynamically from message authors
+ // We'll add them as we see messages in the loadMessages observable
+
+ return {
+ id: `communikey:${communikeyPubkey}`,
+ type: "group",
+ protocol: "communikey",
+ title: profileName,
+ participants,
+ metadata: {
+ communikeyPubkey,
+ communikeyDefinition: definitionEvent,
+ communikeyRelays: relays,
+ ...(description && { description }),
+ ...(profilePicture && { icon: profilePicture }),
+ },
+ unreadCount: 0,
+ };
+ }
+
+ /**
+ * Load messages for a communikey group
+ * Uses same kind 9 format as NIP-29 with #h tag
+ */
+ loadMessages(
+ conversation: Conversation,
+ options?: LoadMessagesOptions,
+ ): Observable {
+ const communikeyPubkey = conversation.metadata?.communikeyPubkey;
+ const relays = conversation.metadata?.communikeyRelays;
+
+ if (!communikeyPubkey || !relays || relays.length === 0) {
+ throw new Error("Community pubkey and relays required");
+ }
+
+ console.log(
+ `[Communikey] Loading messages for ${communikeyPubkey.slice(0, 8)}... from ${relays.length} relays`,
+ );
+
+ // Filter for chat messages (kind 9) and nutzaps (kind 9321)
+ // Same as NIP-29 but without system events (no relay-enforced moderation)
+ const filter: Filter = {
+ kinds: [9, 9321],
+ "#h": [communikeyPubkey],
+ limit: options?.limit || 50,
+ };
+
+ if (options?.before) {
+ filter.until = options.before;
+ }
+ if (options?.after) {
+ filter.since = options.after;
+ }
+
+ // Clean up any existing subscription for this conversation
+ const conversationId = `communikey:${communikeyPubkey}`;
+ this.cleanup(conversationId);
+
+ // Start a persistent subscription to all community relays
+ const subscription = pool
+ .subscription(relays, [filter], {
+ eventStore,
+ })
+ .subscribe({
+ next: (response) => {
+ if (typeof response === "string") {
+ console.log("[Communikey] EOSE received");
+ } else {
+ console.log(
+ `[Communikey] Received event k${response.kind}: ${response.id.slice(0, 8)}...`,
+ );
+ }
+ },
+ });
+
+ // Store subscription for cleanup
+ this.subscriptions.set(conversationId, subscription);
+
+ // Return observable from EventStore which will update automatically
+ return eventStore.timeline(filter).pipe(
+ map((events) => {
+ const messages = events.map((event) => {
+ // Convert nutzaps (kind 9321) using nutzapToMessage
+ if (event.kind === 9321) {
+ return this.nutzapToMessage(event, conversation.id);
+ }
+ // All other events use eventToMessage
+ return this.eventToMessage(event, conversation.id);
+ });
+
+ console.log(`[Communikey] Timeline has ${messages.length} events`);
+ // EventStore timeline returns events sorted by created_at desc,
+ // we need ascending order for chat. Since it's already sorted,
+ // just reverse instead of full sort (O(n) vs O(n log n))
+ return messages.reverse();
+ }),
+ );
+ }
+
+ /**
+ * Load more historical messages (pagination)
+ */
+ async loadMoreMessages(
+ conversation: Conversation,
+ before: number,
+ ): Promise {
+ const communikeyPubkey = conversation.metadata?.communikeyPubkey;
+ const relays = conversation.metadata?.communikeyRelays;
+
+ if (!communikeyPubkey || !relays || relays.length === 0) {
+ throw new Error("Community pubkey and relays required");
+ }
+
+ console.log(
+ `[Communikey] Loading older messages for ${communikeyPubkey.slice(0, 8)}... before ${before}`,
+ );
+
+ // Same filter as loadMessages but with until for pagination
+ const filter: Filter = {
+ kinds: [9, 9321],
+ "#h": [communikeyPubkey],
+ until: before,
+ limit: 50,
+ };
+
+ // One-shot request to fetch older messages
+ const events = await firstValueFrom(
+ pool.request(relays, [filter], { eventStore }).pipe(toArray()),
+ );
+
+ console.log(`[Communikey] Loaded ${events.length} older events`);
+
+ // Convert events to messages
+ const messages = events.map((event) => {
+ if (event.kind === 9321) {
+ return this.nutzapToMessage(event, conversation.id);
+ }
+ return this.eventToMessage(event, conversation.id);
+ });
+
+ // loadMoreMessages returns events in desc order from relay,
+ // reverse for ascending chronological order
+ return messages.reverse();
+ }
+
+ /**
+ * Send a message to the communikey group
+ */
+ async sendMessage(
+ conversation: Conversation,
+ content: string,
+ options?: SendMessageOptions,
+ ): Promise {
+ const activePubkey = accountManager.active$.value?.pubkey;
+ const activeSigner = accountManager.active$.value?.signer;
+
+ if (!activePubkey || !activeSigner) {
+ throw new Error("No active account or signer");
+ }
+
+ const communikeyPubkey = conversation.metadata?.communikeyPubkey;
+ const relays = conversation.metadata?.communikeyRelays;
+
+ if (!communikeyPubkey || !relays || relays.length === 0) {
+ throw new Error("Community pubkey and relays required");
+ }
+
+ // Create event factory and sign event
+ const factory = new EventFactory();
+ factory.setSigner(activeSigner);
+
+ const tags: string[][] = [["h", communikeyPubkey]];
+
+ if (options?.replyTo) {
+ // Use q-tag for replies (same as NIP-29/NIP-C7)
+ tags.push(["q", options.replyTo]);
+ }
+
+ // Add NIP-30 emoji tags
+ if (options?.emojiTags) {
+ for (const emoji of options.emojiTags) {
+ tags.push(["emoji", emoji.shortcode, emoji.url]);
+ }
+ }
+
+ // Add NIP-92 imeta tags for blob attachments
+ if (options?.blobAttachments) {
+ for (const blob of options.blobAttachments) {
+ const imetaParts = [`url ${blob.url}`];
+ if (blob.sha256) imetaParts.push(`x ${blob.sha256}`);
+ if (blob.mimeType) imetaParts.push(`m ${blob.mimeType}`);
+ if (blob.size) imetaParts.push(`size ${blob.size}`);
+ tags.push(["imeta", ...imetaParts]);
+ }
+ }
+
+ // Use kind 9 for group chat messages
+ const draft = await factory.build({ kind: 9, content, tags });
+ const event = await factory.sign(draft);
+
+ // Publish to all community relays
+ await publishEventToRelays(event, relays);
+ }
+
+ /**
+ * Send a reaction (kind 7) to a message in the communikey group
+ */
+ async sendReaction(
+ conversation: Conversation,
+ messageId: string,
+ emoji: string,
+ customEmoji?: { shortcode: string; url: string },
+ ): Promise {
+ const activePubkey = accountManager.active$.value?.pubkey;
+ const activeSigner = accountManager.active$.value?.signer;
+
+ if (!activePubkey || !activeSigner) {
+ throw new Error("No active account or signer");
+ }
+
+ const communikeyPubkey = conversation.metadata?.communikeyPubkey;
+ const relays = conversation.metadata?.communikeyRelays;
+
+ if (!communikeyPubkey || !relays || relays.length === 0) {
+ throw new Error("Community pubkey and relays required");
+ }
+
+ // Create event factory and sign event
+ const factory = new EventFactory();
+ factory.setSigner(activeSigner);
+
+ const tags: string[][] = [
+ ["e", messageId], // Event being reacted to
+ ["h", communikeyPubkey], // Communikey context
+ ["k", "9"], // Kind of event being reacted to (chat message)
+ ];
+
+ // Add NIP-30 custom emoji tag if provided
+ if (customEmoji) {
+ tags.push(["emoji", customEmoji.shortcode, customEmoji.url]);
+ }
+
+ // Use kind 7 for reactions
+ const draft = await factory.build({ kind: 7, content: emoji, tags });
+ const event = await factory.sign(draft);
+
+ // Publish to all community relays
+ await publishEventToRelays(event, relays);
+ }
+
+ /**
+ * Get protocol capabilities
+ */
+ getCapabilities(): ChatCapabilities {
+ return {
+ supportsEncryption: false, // kind 9 messages are public
+ supportsThreading: true, // q-tag replies
+ supportsModeration: false, // Client-side only, no relay enforcement
+ supportsRoles: true, // Admin role for community pubkey
+ supportsGroupManagement: false, // No join/leave - open participation
+ canCreateConversations: false, // Communities created via kind 10222
+ requiresRelay: true, // Multi-relay (main + backups)
+ };
+ }
+
+ /**
+ * Get available actions for Communikey groups
+ * Currently only bookmark/unbookmark (no join/leave - open participation)
+ */
+ getActions(options?: GetActionsOptions): ChatAction[] {
+ const actions: ChatAction[] = [];
+
+ // Bookmark/unbookmark actions (same as NIP-29)
+ actions.push({
+ name: "bookmark",
+ description: "Add community to your group list",
+ handler: async (context) => {
+ try {
+ await this.bookmarkCommunity(
+ context.conversation,
+ context.activePubkey,
+ );
+ return {
+ success: true,
+ message: "Community added to your list",
+ };
+ } catch (error) {
+ return {
+ success: false,
+ message:
+ error instanceof Error
+ ? error.message
+ : "Failed to bookmark community",
+ };
+ }
+ },
+ });
+
+ actions.push({
+ name: "unbookmark",
+ description: "Remove community from your group list",
+ handler: async (context) => {
+ try {
+ await this.unbookmarkCommunity(
+ context.conversation,
+ context.activePubkey,
+ );
+ return {
+ success: true,
+ message: "Community removed from your list",
+ };
+ } catch (error) {
+ return {
+ success: false,
+ message:
+ error instanceof Error
+ ? error.message
+ : "Failed to unbookmark community",
+ };
+ }
+ },
+ });
+
+ return actions;
+ }
+
+ /**
+ * Load a replied-to message
+ * First checks EventStore, then fetches from community relays if needed
+ */
+ async loadReplyMessage(
+ conversation: Conversation,
+ eventId: string,
+ ): Promise {
+ // First check EventStore - might already be loaded
+ const cachedEvent = await eventStore
+ .event(eventId)
+ .pipe(first())
+ .toPromise();
+ if (cachedEvent) {
+ return cachedEvent;
+ }
+
+ // Not in store, fetch from community relays
+ const relays = conversation.metadata?.communikeyRelays;
+ if (!relays || relays.length === 0) {
+ console.warn("[Communikey] No relays for loading reply message");
+ return null;
+ }
+
+ console.log(
+ `[Communikey] Fetching reply message ${eventId.slice(0, 8)}... from ${relays.length} relays`,
+ );
+
+ const filter: Filter = {
+ ids: [eventId],
+ limit: 1,
+ };
+
+ const events: NostrEvent[] = [];
+ const obs = pool.subscription(relays, [filter], { eventStore });
+
+ await new Promise((resolve) => {
+ const timeout = setTimeout(() => {
+ console.log(
+ `[Communikey] Reply message fetch timeout for ${eventId.slice(0, 8)}...`,
+ );
+ resolve();
+ }, 3000);
+
+ const sub = obs.subscribe({
+ next: (response) => {
+ if (typeof response === "string") {
+ // EOSE received
+ clearTimeout(timeout);
+ sub.unsubscribe();
+ resolve();
+ } else {
+ // Event received
+ events.push(response);
+ }
+ },
+ error: (err) => {
+ clearTimeout(timeout);
+ console.error(`[Communikey] Reply message fetch error:`, err);
+ sub.unsubscribe();
+ resolve();
+ },
+ });
+ });
+
+ return events[0] || null;
+ }
+
+ /**
+ * Add a communikey to the user's group list (kind 10009)
+ * Uses same format as NIP-29 bookmark but with communikey pubkey
+ */
+ async bookmarkCommunity(
+ conversation: Conversation,
+ activePubkey: string,
+ ): Promise {
+ const activeSigner = accountManager.active$.value?.signer;
+
+ if (!activeSigner) {
+ throw new Error("No active signer");
+ }
+
+ const communikeyPubkey = conversation.metadata?.communikeyPubkey;
+ const relays = conversation.metadata?.communikeyRelays;
+
+ if (!communikeyPubkey || !relays || relays.length === 0) {
+ throw new Error("Community pubkey and relays required");
+ }
+
+ // Use first relay as primary
+ const primaryRelay = relays[0];
+ const normalizedRelayUrl = normalizeRelayURL(primaryRelay);
+
+ // Fetch current kind 10009 event (group list)
+ const currentEvent = await firstValueFrom(
+ eventStore.replaceable(10009, activePubkey, ""),
+ { defaultValue: undefined },
+ );
+
+ // Build new tags array
+ let tags: string[][] = [];
+
+ if (currentEvent) {
+ // Copy existing tags
+ tags = [...currentEvent.tags];
+
+ // Check if communikey is already in the list
+ const existingGroup = tags.find(
+ (t) =>
+ t[0] === "group" &&
+ t[1] === communikeyPubkey &&
+ normalizeRelayURL(t[2] || "") === normalizedRelayUrl,
+ );
+
+ if (existingGroup) {
+ throw new Error("Community is already in your list");
+ }
+ }
+
+ // Add the new group tag (use communikey pubkey as group ID)
+ tags.push(["group", communikeyPubkey, normalizedRelayUrl]);
+
+ // Create and publish the updated event
+ const factory = new EventFactory();
+ factory.setSigner(activeSigner);
+
+ const draft = await factory.build({
+ kind: 10009,
+ content: "",
+ tags,
+ });
+ const event = await factory.sign(draft);
+ await publishEvent(event);
+ }
+
+ /**
+ * Remove a communikey from the user's group list (kind 10009)
+ */
+ async unbookmarkCommunity(
+ conversation: Conversation,
+ activePubkey: string,
+ ): Promise {
+ const activeSigner = accountManager.active$.value?.signer;
+
+ if (!activeSigner) {
+ throw new Error("No active signer");
+ }
+
+ const communikeyPubkey = conversation.metadata?.communikeyPubkey;
+ const relays = conversation.metadata?.communikeyRelays;
+
+ if (!communikeyPubkey || !relays || relays.length === 0) {
+ throw new Error("Community pubkey and relays required");
+ }
+
+ // Use first relay as primary
+ const primaryRelay = relays[0];
+ const normalizedRelayUrl = normalizeRelayURL(primaryRelay);
+
+ // Fetch current kind 10009 event (group list)
+ const currentEvent = await firstValueFrom(
+ eventStore.replaceable(10009, activePubkey, ""),
+ { defaultValue: undefined },
+ );
+
+ if (!currentEvent) {
+ throw new Error("No group list found");
+ }
+
+ // Find and remove the communikey tag
+ const originalLength = currentEvent.tags.length;
+ const tags = currentEvent.tags.filter(
+ (t) =>
+ !(
+ t[0] === "group" &&
+ t[1] === communikeyPubkey &&
+ normalizeRelayURL(t[2] || "") === normalizedRelayUrl
+ ),
+ );
+
+ if (tags.length === originalLength) {
+ throw new Error("Community is not in your list");
+ }
+
+ // Create and publish the updated event
+ const factory = new EventFactory();
+ factory.setSigner(activeSigner);
+
+ const draft = await factory.build({
+ kind: 10009,
+ content: "",
+ tags,
+ });
+ const event = await factory.sign(draft);
+ await publishEvent(event);
+ }
+
+ /**
+ * Helper: Convert Nostr event to Message
+ */
+ private eventToMessage(event: NostrEvent, conversationId: string): Message {
+ // Look for reply q-tags
+ const qTags = getTagValues(event, "q");
+ const replyTo = qTags[0]; // First q-tag is the reply target
+
+ return {
+ id: event.id,
+ conversationId,
+ author: event.pubkey,
+ content: event.content,
+ timestamp: event.created_at,
+ type: "user",
+ replyTo,
+ protocol: "communikey",
+ metadata: {
+ encrypted: false, // kind 9 messages are always public
+ },
+ event,
+ };
+ }
+
+ /**
+ * Helper: Convert nutzap event (kind 9321) to Message
+ * NIP-61 nutzaps are P2PK-locked Cashu token transfers
+ */
+ private nutzapToMessage(event: NostrEvent, conversationId: string): Message {
+ // Sender is the event author
+ const sender = event.pubkey;
+
+ // Recipient is the p-tag value
+ const pTag = event.tags.find((t) => t[0] === "p");
+ const recipient = pTag?.[1] || "";
+
+ // Reply target is the e-tag (the event being nutzapped)
+ const eTag = event.tags.find((t) => t[0] === "e");
+ const replyTo = eTag?.[1];
+
+ // Amount is sum of proof amounts from all proof tags
+ let amount = 0;
+ for (const tag of event.tags) {
+ if (tag[0] === "proof" && tag[1]) {
+ try {
+ const proof = JSON.parse(tag[1]);
+ // Proof can be a single object or an array of proofs
+ if (Array.isArray(proof)) {
+ amount += proof.reduce(
+ (sum: number, p: { amount?: number }) => sum + (p.amount || 0),
+ 0,
+ );
+ } else if (typeof proof === "object" && proof.amount) {
+ amount += proof.amount;
+ }
+ } catch {
+ // Invalid proof JSON, skip this tag
+ }
+ }
+ }
+
+ // Unit defaults to "sat" per NIP-61
+ const unitTag = event.tags.find((t) => t[0] === "unit");
+ const unit = unitTag?.[1] || "sat";
+
+ // Comment is in the content field
+ const comment = event.content || "";
+
+ return {
+ id: event.id,
+ conversationId,
+ author: sender,
+ content: comment,
+ timestamp: event.created_at,
+ type: "zap", // Render the same as zaps
+ replyTo,
+ protocol: "communikey",
+ metadata: {
+ encrypted: false,
+ zapAmount: amount, // In the unit specified (usually sats)
+ zapRecipient: recipient,
+ nutzapUnit: unit, // Store unit for potential future use
+ },
+ event,
+ };
+ }
+}
diff --git a/src/types/chat.ts b/src/types/chat.ts
index 98c3ef0..9f6aff8 100644
--- a/src/types/chat.ts
+++ b/src/types/chat.ts
@@ -19,6 +19,7 @@ export type ChatProtocol =
| "nip-17"
| "nip-28"
| "nip-29"
+ | "communikey"
| "nip-53"
| "nip-10";
@@ -71,6 +72,11 @@ export interface ConversationMetadata {
description?: string; // Group/thread description
icon?: string; // Group icon/picture URL
+ // Communikey
+ communikeyPubkey?: string; // Community pubkey (admin)
+ communikeyDefinition?: NostrEvent; // kind 10222 event
+ communikeyRelays?: string[]; // Main + backup relays from r-tags
+
// NIP-53 live chat
activityAddress?: {
kind: number;
@@ -234,6 +240,18 @@ export interface ThreadIdentifier {
relays?: string[];
}
+/**
+ * Communikey identifier (NIP-29 group fallback)
+ * Used when group ID is a valid pubkey with kind 10222 community definition
+ */
+export interface CommunikeyIdentifier {
+ type: "communikey";
+ /** Community pubkey (hex) */
+ value: string;
+ /** Relay URLs from kind 10222 r-tags (main + backups) */
+ relays: string[];
+}
+
/**
* Protocol-specific identifier - discriminated union
* Returned by adapter parseIdentifier()
@@ -245,7 +263,8 @@ export type ProtocolIdentifier =
| NIP05Identifier
| ChannelIdentifier
| GroupListIdentifier
- | ThreadIdentifier;
+ | ThreadIdentifier
+ | CommunikeyIdentifier;
/**
* Chat command parsing result
diff --git a/src/types/man.ts b/src/types/man.ts
index 9031732..db7ac71 100644
--- a/src/types/man.ts
+++ b/src/types/man.ts
@@ -580,7 +580,7 @@ export const manPages: Record = {
appId: "chat",
category: "Nostr",
argParser: async (args: string[]) => {
- const result = parseChatCommand(args);
+ const result = await parseChatCommand(args);
return {
protocol: result.protocol,
identifier: result.identifier,