feat(chat): add Communikey fallback for NIP-29 groups

Implements Communikey chat support as a fallback for NIP-29 groups where:
- Group ID is a valid pubkey (64-char hex)
- Pubkey has published kind 10222 (community definition)
- Chat works regardless of content sections (assumes chat is supported)

Key changes:
- Add Communikey protocol to chat types with CommunikeyIdentifier
- Create CommunikeyAdapter extending ChatProtocolAdapter
  - Fetches kind 10222 (community definition) and kind 0 (profile)
  - Uses r-tags for multi-relay support (main + backups)
  - Community pubkey acts as admin, members derived from chat participants
  - Client-side moderation only (no relay enforcement)
- Update chat parser to detect Communikey via kind 10222 lookup
  - Made parseChatCommand async for fallback detection
  - 2-second timeout for quick detection
- Add kind 10222 feed and detail renderers
  - Feed renderer shows community card with name, description, content sections, relay count
  - Detail renderer shows full metadata, content sections, relays, features
- Register Communikey adapter in ChatViewer getAdapter

Technical notes:
- Uses same kind 9 messages with #h tag (compatible with NIP-29 format)
- No join/leave semantics (open participation)
- Bookmark/unbookmark actions use kind 10009 group list
- Multi-relay subscriptions for messages
This commit is contained in:
Claude
2026-01-20 12:11:51 +00:00
parent c52e783fce
commit 9bf66c0fd2
8 changed files with 1287 additions and 4 deletions

View File

@@ -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)

View File

@@ -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 (
<div className="flex flex-col h-full bg-background overflow-y-auto">
{/* Header with picture */}
{picture && (
<div className="relative aspect-[3/1] flex-shrink-0">
<img
src={picture}
alt={name}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-background/90 to-transparent" />
</div>
)}
{/* Content Section */}
<div className="flex-1 p-4 space-y-4">
{/* Title and Admin */}
<div className="space-y-2">
<h1 className="text-2xl font-bold text-balance">{name}</h1>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Shield className="size-4" />
<span>Admin:</span>
<UserName pubkey={communityPubkey} className="text-accent" />
</div>
</div>
{/* Description */}
{about && (
<p className="text-base text-muted-foreground leading-relaxed whitespace-pre-wrap">
{about}
</p>
)}
{/* Location */}
{location && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<MapPin className="size-4" />
<span>{location}</span>
{geoHash && <span className="text-xs">({geoHash})</span>}
</div>
)}
{/* Content Sections */}
{contentSections.length > 0 && (
<div className="space-y-3">
<h2 className="text-lg font-semibold">Content Sections</h2>
<div className="space-y-2">
{contentSections.map((section, i) => (
<div
key={i}
className="p-3 border border-border rounded bg-muted/30 space-y-2"
>
<div className="flex items-center gap-2">
<span className="font-medium">{section.name}</span>
{section.kinds.length > 0 && (
<div className="flex flex-wrap gap-1">
{section.kinds.map((kind) => (
<Label key={kind} size="xs">
kind {kind}
</Label>
))}
</div>
)}
</div>
{section.badges.length > 0 && (
<div className="text-xs text-muted-foreground space-y-1">
<span className="font-medium">Badge requirements:</span>
{section.badges.map((badge, j) => (
<div key={j} className="font-mono text-[10px] truncate">
{badge}
</div>
))}
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Relays */}
{relays.length > 0 && (
<div className="space-y-2">
<h2 className="text-sm font-semibold flex items-center gap-2">
<Server className="size-4" />
Relays
</h2>
<div className="space-y-1">
{relays.map((relay, i) => (
<div
key={i}
className="text-xs font-mono text-muted-foreground px-2 py-1 bg-muted rounded"
>
{i === 0 && (
<span className="text-primary font-semibold mr-2">
[main]
</span>
)}
{relay}
</div>
))}
</div>
</div>
)}
{/* Optional Features */}
{(blossomServers.length > 0 || mints.length > 0) && (
<div className="space-y-2">
<h2 className="text-sm font-semibold">Features</h2>
<div className="space-y-2">
{blossomServers.length > 0 && (
<div className="flex items-start gap-2 text-xs">
<ImageIcon className="size-4 mt-0.5 text-muted-foreground" />
<div className="flex-1 space-y-1">
<span className="font-medium">Blossom servers:</span>
{blossomServers.map((server, i) => (
<div
key={i}
className="font-mono text-muted-foreground px-2 py-1 bg-muted rounded"
>
{server}
</div>
))}
</div>
</div>
)}
{mints.length > 0 && (
<div className="flex items-start gap-2 text-xs">
<Coins className="size-4 mt-0.5 text-muted-foreground" />
<div className="flex-1 space-y-1">
<span className="font-medium">Cashu mints:</span>
{mints.map((mint, i) => (
<div
key={i}
className="font-mono text-muted-foreground px-2 py-1 bg-muted rounded"
>
{mint}
</div>
))}
</div>
</div>
)}
</div>
</div>
)}
{/* Terms of Service */}
{tosPointer && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<FileText className="size-4" />
<span>Terms of service: {tosPointer.slice(0, 16)}...</span>
</div>
)}
{/* Open Chat Button */}
{canOpenChat && (
<Button onClick={handleOpenChat} className="w-full" size="lg">
<MessageSquare className="size-4 mr-2" />
Open Community Chat
</Button>
)}
</div>
</div>
);
}

View File

@@ -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 (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-1">
<ClickableEventTitle event={event} className="font-semibold">
{name}
</ClickableEventTitle>
{about && (
<p className="text-xs text-muted-foreground line-clamp-2">{about}</p>
)}
{contentSections.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{contentSections.map((section, i) => (
<span
key={i}
className="text-[10px] px-1.5 py-0.5 bg-muted rounded border border-border"
>
{section}
</span>
))}
</div>
)}
<div className="flex items-center gap-3 mt-1">
{canOpenChat && (
<button
onClick={handleOpenChat}
className="text-xs text-primary hover:underline flex items-center gap-1"
>
<MessageSquare className="size-3" />
Open Chat
</button>
)}
{relays.length > 0 && (
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Server className="size-3" />
{relays.length} relay{relays.length === 1 ? "" : "s"}
</span>
)}
</div>
</div>
</BaseEventContainer>
);
}

View File

@@ -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<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 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)

View File

@@ -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<boolean> {
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<never>((_, 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<ChatCommandResult> {
if (args.length === 0) {
throw new Error("Chat identifier required. Usage: chat <identifier>");
}
@@ -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...

View File

@@ -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<Conversation> {
// 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<Message[]> {
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<Message[]> {
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<void> {
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<void> {
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<NostrEvent | null> {
// 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<void>((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<void> {
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<void> {
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,
};
}
}

View File

@@ -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

View File

@@ -580,7 +580,7 @@ export const manPages: Record<string, ManPageEntry> = {
appId: "chat",
category: "Nostr",
argParser: async (args: string[]) => {
const result = parseChatCommand(args);
const result = await parseChatCommand(args);
return {
protocol: result.protocol,
identifier: result.identifier,