feat(chat): add NIP-22 comment adapter as wildcard chat for all events

Implements a NIP-22 adapter that enables comments (kind 1111) on any
Nostr event or external resource. This acts as the catch-all wildcard
for events not handled by NIP-10 (kind 1 threads) or NIP-53 (live
activities). All events now show a "Comments" option in context menus.

- Create Nip22Adapter using applesauce CommentBlueprint for proper
  NIP-22 tag structure (uppercase root/lowercase parent convention)
- Support event pointers (nevent), address pointers (naddr), and
  external identifiers (NIP-73) as comment roots
- Add CommentIdentifier, CommentAddressIdentifier, and
  CommentExternalIdentifier types to chat.ts
- Update chat-parser to route NIP-22 as wildcard after NIP-10/29/53
- Wire NIP-22 into ChatViewer (getAdapter, getChatIdentifier,
  getConversationRelays)
- Update BaseEventRenderer to show Chat/Comments for all event kinds
  with kind-aware protocol routing (getChatPropsForEvent helper)
- Update man.ts command docs and CLAUDE.md chat system docs

https://claude.ai/code/session_01PsevnSkZf2Pn1yhc1Rinc3
This commit is contained in:
Claude
2026-02-12 22:56:44 +00:00
parent 62ce435043
commit c469c36564
7 changed files with 1274 additions and 75 deletions

View File

@@ -377,12 +377,22 @@ This allows `applyTheme()` to switch themes at runtime.
## Chat System
**Current Status**: Only NIP-29 (relay-based groups) is supported. Other protocols are planned for future releases.
**Current Status**: NIP-29 (relay-based groups), NIP-10 (kind 1 threads), NIP-53 (live activity chat), and NIP-22 (comments on any event) are supported. NIP-17 (encrypted DMs) and NIP-28 (channels) are planned.
**Architecture**: Protocol adapter pattern for supporting multiple Nostr messaging protocols:
- `src/lib/chat/adapters/base-adapter.ts` - Base interface all adapters implement
- `src/lib/chat/adapters/nip-29-adapter.ts` - NIP-29 relay groups (currently enabled)
- Other adapters (NIP-C7, NIP-17, NIP-28, NIP-53) are implemented but commented out
- `src/lib/chat/adapters/nip-10-adapter.ts` - NIP-10 kind 1 thread chat
- `src/lib/chat/adapters/nip-22-adapter.ts` - NIP-22 comments on any event (wildcard)
- `src/lib/chat/adapters/nip-29-adapter.ts` - NIP-29 relay groups
- `src/lib/chat/adapters/nip-53-adapter.ts` - NIP-53 live activity chat
- Other adapters (NIP-17, NIP-28) planned for future releases
**NIP-22 Comments (Wildcard)**: Comments on any event not handled by NIP-10 or NIP-53
- Accepts `nevent1...` (any kind except 1) or `naddr1...` (addressable events)
- Uses kind 1111 comment events with uppercase (root) and lowercase (parent) tag convention
- Also supports external identifiers (NIP-73: URLs, ISBNs, DOIs, etc.)
- Uses applesauce `CommentBlueprint` for proper tag construction
- All events now show a "Comments" option in context menus
**NIP-29 Group Format**: `relay'group-id` (wss:// prefix optional)
- Examples: `relay.example.com'bitcoin-dev`, `wss://nos.lol'welcome`
@@ -392,11 +402,14 @@ This allows `applyTheme()` to switch themes at runtime.
**Key Components**:
- `src/components/ChatViewer.tsx` - Main chat interface (protocol-agnostic)
- `src/components/chat/ReplyPreview.tsx` - Shows reply context with scroll-to functionality
- `src/lib/chat-parser.ts` - Auto-detects protocol from identifier format
- `src/lib/chat-parser.ts` - Auto-detects protocol from identifier format (NIP-22 is the wildcard catch-all)
- `src/types/chat.ts` - Protocol-agnostic types (Conversation, Message, etc.)
**Usage**:
```bash
chat nevent1... # Comments on any event (NIP-22)
chat naddr1...30023... # Comments on an article (NIP-22)
chat note1abc... # Thread chat on kind 1 note (NIP-10)
chat relay.example.com'bitcoin-dev # Join NIP-29 group
chat wss://nos.lol'welcome # Join with explicit wss:// prefix
```
@@ -404,7 +417,7 @@ chat wss://nos.lol'welcome # Join with explicit wss:// prefix
**Adding New Protocols** (for future work):
1. Create new adapter extending `ChatProtocolAdapter` in `src/lib/chat/adapters/`
2. Implement all required methods (parseIdentifier, resolveConversation, loadMessages, sendMessage)
3. Uncomment adapter registration in `src/lib/chat-parser.ts` and `src/components/ChatViewer.tsx`
3. Register adapter in `src/lib/chat-parser.ts` and `src/components/ChatViewer.tsx`
4. Update command docs in `src/types/man.ts` if needed
## Testing

View File

@@ -27,6 +27,7 @@ import type {
} from "@/types/chat";
import { CHAT_KINDS } from "@/types/chat";
import { Nip10Adapter } from "@/lib/chat/adapters/nip-10-adapter";
import { Nip22Adapter } from "@/lib/chat/adapters/nip-22-adapter";
import { Nip29Adapter } from "@/lib/chat/adapters/nip-29-adapter";
import { Nip53Adapter } from "@/lib/chat/adapters/nip-53-adapter";
import type { ChatProtocolAdapter } from "@/lib/chat/adapters/base-adapter";
@@ -157,6 +158,11 @@ function getConversationRelays(conversation: Conversation): string[] {
}
}
// NIP-22 comments: Use relays from metadata
if (conversation.protocol === "nip-22") {
return conversation.metadata?.relays || [];
}
// NIP-29 groups and fallback: Use single relay URL
const relayUrl = conversation.metadata?.relayUrl;
return relayUrl ? [relayUrl] : [];
@@ -168,6 +174,7 @@ function getConversationRelays(conversation: Conversation): string[] {
*
* For NIP-29 groups: relay'group-id (without wss:// prefix)
* For NIP-53 live activities: naddr1... encoding
* For NIP-22 comments: nevent1.../naddr1... encoding
*/
function getChatIdentifier(conversation: Conversation): string | null {
if (conversation.protocol === "nip-29") {
@@ -196,6 +203,30 @@ function getChatIdentifier(conversation: Conversation): string | null {
});
}
if (conversation.protocol === "nip-22") {
const relays = (conversation.metadata?.relays || []).slice(0, 3);
// Addressable event — encode as naddr
if (conversation.metadata?.rootAddress) {
const parts = conversation.metadata.rootAddress.split(":");
const kind = parseInt(parts[0]);
const pubkey = parts[1];
const identifier = parts.slice(2).join(":");
return nip19.naddrEncode({ kind, pubkey, identifier, relays });
}
// Regular event — encode as nevent
if (conversation.metadata?.rootEventId) {
return nip19.neventEncode({
id: conversation.metadata.rootEventId,
relays,
kind: conversation.metadata.rootEventKind,
});
}
return null;
}
return null;
}
@@ -1250,13 +1281,14 @@ 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
* Other protocols will be enabled in future phases
* Supported: NIP-10 (threads), NIP-22 (comments), NIP-29 (groups), NIP-53 (live activity)
*/
function getAdapter(protocol: ChatProtocol): ChatProtocolAdapter {
switch (protocol) {
case "nip-10":
return new Nip10Adapter();
case "nip-22":
return new Nip22Adapter();
case "nip-29":
return new Nip29Adapter();
// case "nip-17": // Phase 2 - Encrypted DMs (coming soon)

View File

@@ -40,12 +40,90 @@ import { EventFooter } from "@/components/EventFooter";
import { cn } from "@/lib/utils";
import { isAddressableKind } from "@/lib/nostr-kinds";
import { getSemanticAuthor } from "@/lib/semantic-author";
import type { ChatProtocol, ProtocolIdentifier } from "@/types/chat";
import { EventFactory } from "applesauce-core/event-factory";
import { ReactionBlueprint } from "applesauce-common/blueprints";
import { publishEventToRelays } from "@/services/hub";
import { selectRelaysForInteraction } from "@/services/relay-selection";
import type { EmojiTag } from "@/lib/emoji-helpers";
/**
* Determine the chat protocol and identifier for any event.
* - Kind 1 → NIP-10 thread
* - Kind 30311 → NIP-53 live activity
* - Everything else → NIP-22 comments (wildcard)
*/
function getChatPropsForEvent(
event: NostrEvent,
relays: string[],
): { protocol: ChatProtocol; identifier: ProtocolIdentifier } {
// Kind 1: NIP-10 thread
if (event.kind === 1) {
return {
protocol: "nip-10",
identifier: {
type: "thread",
value: {
id: event.id,
relays,
author: event.pubkey,
kind: event.kind,
},
relays,
},
};
}
// Kind 30311: NIP-53 live activity
if (event.kind === 30311) {
const dTag = getTagValue(event, "d") || "";
return {
protocol: "nip-53",
identifier: {
type: "live-activity",
value: {
kind: 30311 as const,
pubkey: event.pubkey,
identifier: dTag,
},
relays,
},
};
}
// Addressable events (kinds 10000-19999, 30000-39999): NIP-22 comment-address
if (isAddressableKind(event.kind)) {
const dTag = getTagValue(event, "d") || "";
return {
protocol: "nip-22",
identifier: {
type: "comment-address",
value: {
kind: event.kind,
pubkey: event.pubkey,
identifier: dTag,
},
relays,
},
};
}
// All other events: NIP-22 comment by event ID
return {
protocol: "nip-22",
identifier: {
type: "comment",
value: {
id: event.id,
kind: event.kind,
pubkey: event.pubkey,
relay: relays[0],
},
relays,
},
};
}
/**
* Universal event properties and utilities shared across all kind renderers
*/
@@ -214,26 +292,10 @@ export function EventMenu({
};
const openChatWindow = () => {
// Only kind 1 notes support NIP-10 thread chat
if (event.kind === 1) {
const seenRelaysSet = getSeenRelays(event);
const relays = seenRelaysSet ? Array.from(seenRelaysSet) : [];
// Open chat with NIP-10 thread protocol
addWindow("chat", {
protocol: "nip-10",
identifier: {
type: "thread",
value: {
id: event.id,
relays,
author: event.pubkey,
kind: event.kind,
},
relays,
},
});
}
const seenRelaysSet = getSeenRelays(event);
const relays = seenRelaysSet ? Array.from(seenRelaysSet) : [];
const { protocol, identifier } = getChatPropsForEvent(event, relays);
addWindow("chat", { protocol, identifier });
};
return (
@@ -252,12 +314,10 @@ export function EventMenu({
<Zap className="size-4 mr-2 text-yellow-500" />
Zap
</DropdownMenuItem>
{event.kind === 1 && (
<DropdownMenuItem onClick={openChatWindow}>
<MessageSquare className="size-4 mr-2" />
Chat
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={openChatWindow}>
<MessageSquare className="size-4 mr-2" />
{event.kind === 1 ? "Chat" : "Comments"}
</DropdownMenuItem>
{canSign && onReactClick && (
<DropdownMenuItem onClick={onReactClick}>
<SmilePlus className="size-4 mr-2" />
@@ -384,26 +444,10 @@ export function EventContextMenu({
};
const openChatWindow = () => {
// Only kind 1 notes support NIP-10 thread chat
if (event.kind === 1) {
const seenRelaysSet = getSeenRelays(event);
const relays = seenRelaysSet ? Array.from(seenRelaysSet) : [];
// Open chat with NIP-10 thread protocol
addWindow("chat", {
protocol: "nip-10",
identifier: {
type: "thread",
value: {
id: event.id,
relays,
author: event.pubkey,
kind: event.kind,
},
relays,
},
});
}
const seenRelaysSet = getSeenRelays(event);
const relays = seenRelaysSet ? Array.from(seenRelaysSet) : [];
const { protocol, identifier } = getChatPropsForEvent(event, relays);
addWindow("chat", { protocol, identifier });
};
return (
@@ -418,12 +462,10 @@ export function EventContextMenu({
<Zap className="size-4 mr-2 text-yellow-500" />
Zap
</ContextMenuItem>
{event.kind === 1 && (
<ContextMenuItem onClick={openChatWindow}>
<MessageSquare className="size-4 mr-2" />
Chat
</ContextMenuItem>
)}
<ContextMenuItem onClick={openChatWindow}>
<MessageSquare className="size-4 mr-2" />
{event.kind === 1 ? "Chat" : "Comments"}
</ContextMenuItem>
{canSign && onReactClick && (
<ContextMenuItem onClick={onReactClick}>
<SmilePlus className="size-4 mr-2" />

View File

@@ -1,5 +1,6 @@
import type { ChatCommandResult, GroupListIdentifier } from "@/types/chat";
import { Nip10Adapter } from "./chat/adapters/nip-10-adapter";
import { Nip22Adapter } from "./chat/adapters/nip-22-adapter";
import { Nip29Adapter } from "./chat/adapters/nip-29-adapter";
import { Nip53Adapter } from "./chat/adapters/nip-53-adapter";
import { nip19 } from "nostr-tools";
@@ -16,6 +17,7 @@ import { nip19 } from "nostr-tools";
* 3. NIP-28 (channels) - specific event format (kind 40)
* 4. NIP-29 (groups) - specific group ID format
* 5. NIP-53 (live chat) - specific addressable format (kind 30311)
* 6. NIP-22 (comments) - wildcard for all other events/addresses
*
* @param args - Command arguments (first arg is the identifier)
* @returns Parsed result with protocol and identifier
@@ -61,12 +63,14 @@ export function parseChatCommand(args: string[]): ChatCommandResult {
}
// Try each adapter in priority order
// NIP-22 is last — it's the wildcard catch-all for events not claimed by specific adapters
const adapters = [
new Nip10Adapter(), // NIP-10 - Thread chat (nevent/note)
new Nip10Adapter(), // NIP-10 - Thread chat (nevent/note, kind 1 only)
// new Nip17Adapter(), // Phase 2
// new Nip28Adapter(), // Phase 3
new Nip29Adapter(), // NIP-29 - Relay groups
new Nip53Adapter(), // NIP-53 - Live activity chat
new Nip53Adapter(), // NIP-53 - Live activity chat (kind 30311)
new Nip22Adapter(), // NIP-22 - Comments on any event (wildcard)
];
for (const adapter of adapters) {
@@ -84,20 +88,18 @@ export function parseChatCommand(args: string[]): ChatCommandResult {
`Unable to determine chat protocol from identifier: ${identifier}
Currently supported formats:
- nevent1.../note1... (NIP-10 thread chat, kind 1 notes)
- nevent1... (NIP-22 comments on any event, or NIP-10 thread for kind 1)
Examples:
chat nevent1qqsxyz... (thread with relay hints)
chat note1abc... (thread with event ID only)
chat nevent1qqsxyz... (comments/thread with relay hints)
chat note1abc... (kind 1 thread with event ID only)
- naddr1... (NIP-22 comments on addressable events, NIP-53 for kind 30311)
Examples:
chat naddr1... (article, repo, wiki, etc.)
chat naddr1... (live stream address → NIP-53)
- relay.com'group-id (NIP-29 relay group, wss:// prefix optional)
Examples:
chat relay.example.com'bitcoin-dev
chat wss://relay.example.com'nostr-dev
- naddr1... (NIP-29 group metadata, kind 39000)
Example:
chat naddr1qqxnzdesxqmnxvpexqmny...
- naddr1... (NIP-53 live activity chat, kind 30311)
Example:
chat naddr1... (live stream address)
- naddr1... (Multi-room group list, kind 10009)
Example:
chat naddr1... (group list address)

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,7 @@ import type { EventPointer, AddressPointer } from "nostr-tools/nip19";
export const CHAT_KINDS = [
9, // NIP-29: Group chat messages
9321, // NIP-61: Nutzaps (ecash zaps in groups/live chats)
1111, // NIP-22: Comments on any event
1311, // NIP-53: Live chat messages
9735, // NIP-57: Zap receipts (part of chat context)
] as const;
@@ -15,12 +16,23 @@ export const CHAT_KINDS = [
/**
* Chat protocol identifier
*/
export type ChatProtocol = "nip-17" | "nip-28" | "nip-29" | "nip-53" | "nip-10";
export type ChatProtocol =
| "nip-17"
| "nip-28"
| "nip-29"
| "nip-53"
| "nip-10"
| "nip-22";
/**
* Conversation type
*/
export type ConversationType = "dm" | "channel" | "group" | "live-chat";
export type ConversationType =
| "dm"
| "channel"
| "group"
| "live-chat"
| "comments";
/**
* Participant role in a conversation
@@ -83,6 +95,12 @@ export interface ConversationMetadata {
providedEventId?: string; // Original event from nevent (may be reply)
threadDepth?: number; // Approximate depth of thread
relays?: string[]; // Relays for this conversation
// NIP-22 comments
rootEventKind?: number; // Kind of the root event being commented on
rootAddress?: string; // "kind:pubkey:d-tag" for addressable root events
externalId?: string; // External identifier (NIP-73) for external root scope
externalKind?: string; // External identifier type (e.g., "web", "isbn")
}
/**
@@ -229,6 +247,51 @@ export interface ThreadIdentifier {
relays?: string[];
}
/**
* NIP-22 comment identifier - comments on any event or external resource
* Acts as a wildcard for events not handled by NIP-10 or NIP-53
*/
export interface CommentIdentifier {
type: "comment";
/** Event pointer (for regular events) */
value: {
id: string;
kind: number;
pubkey?: string;
relay?: string;
};
/** Relay hints */
relays?: string[];
}
/**
* NIP-22 comment on an addressable event
*/
export interface CommentAddressIdentifier {
type: "comment-address";
/** Address pointer for the root event */
value: {
kind: number;
pubkey: string;
identifier: string;
};
/** Relay hints */
relays?: string[];
}
/**
* NIP-22 comment on an external resource (NIP-73)
*/
export interface CommentExternalIdentifier {
type: "comment-external";
/** External identifier value (URL, ISBN, DOI, etc.) */
value: string;
/** External identifier type (e.g., "web", "isbn", "doi") */
externalKind: string;
/** Relay hints */
relays?: string[];
}
/**
* Protocol-specific identifier - discriminated union
* Returned by adapter parseIdentifier()
@@ -240,7 +303,10 @@ export type ProtocolIdentifier =
| NIP05Identifier
| ChannelIdentifier
| GroupListIdentifier
| ThreadIdentifier;
| ThreadIdentifier
| CommentIdentifier
| CommentAddressIdentifier
| CommentExternalIdentifier;
/**
* Chat command parsing result

View File

@@ -578,15 +578,18 @@ export const manPages: Record<string, ManPageEntry> = {
section: "1",
synopsis: "chat <identifier>",
description:
"Join and participate in Nostr chat conversations. Supports NIP-29 relay-based groups, NIP-53 live activity chat, and multi-room group list interface. For NIP-29 groups, use format 'relay'group-id' where relay is the WebSocket URL (wss:// prefix optional). For NIP-53 live activities, pass the naddr of a kind 30311 live event. For multi-room interface, pass the naddr of a kind 10009 group list event.",
"Join and participate in Nostr chat conversations. Supports NIP-22 comments on any event, NIP-10 kind 1 threads, NIP-29 relay-based groups, NIP-53 live activity chat, and multi-room group list interface. Pass any nevent or naddr to open a comments view (NIP-22). For kind 1 events, NIP-10 threading is used automatically. For NIP-29 groups, use format 'relay'group-id'. For NIP-53 live activities, pass the naddr of a kind 30311 live event.",
options: [
{
flag: "<identifier>",
description:
"NIP-29 group (relay'group-id), NIP-53 live activity (naddr1... kind 30311), or group list (naddr1... kind 10009)",
"nevent1.../naddr1... (comments/thread), relay'group-id (NIP-29 group), or naddr1... kind 10009 (group list)",
},
],
examples: [
"chat nevent1... Comments on any event (NIP-22)",
"chat naddr1...30023... Comments on an article (NIP-22)",
"chat note1... Thread chat on kind 1 note (NIP-10)",
"chat relay.example.com'bitcoin-dev Join NIP-29 relay group",
"chat wss://nos.lol'welcome Join NIP-29 group with explicit protocol",
"chat naddr1...30311... Join NIP-53 live activity chat",