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 && ( +
+ {name} +
+
+ )} + + {/* 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,