feat(chat): add NIP-22 event comments adapter

Add NIP-22 adapter for event comments/threading that works as a
catch-all for any event type. This enables chat interface for any
Nostr event via nevent/naddr/note identifiers.

Features:
- Parse any nevent/naddr (catch-all after specialized adapters)
- Handle kind 1111 comments on any event kind
- Support both regular and addressable events as root
- Root event displayed using appropriate kind renderer
- Only kind 16 reposts (generic), not kind 6 (kind 1 only)
- Smart relay selection from participants and authors

Architecture:
- NIP-22 positioned last in adapter priority (after NIP-10/29/53)
- NIP-10 still handles kind 1 threads specifically
- Thread root resolution logic:
  * kind 1111: find root via A-tag
  * other kinds: event IS the root
- A-tag based threading (not e-tags like NIP-10)

Changes:
- Add Nip22Adapter with full implementation
- Add "nip-22" to ChatProtocol type union
- Update chat-parser to include NIP-22 adapter (last/catch-all)
- Update ChatViewer and DynamicWindowTitle adapters
- Add rootCoordinate field to ConversationMetadata (for addressables)
- Comprehensive test coverage (14 tests)

All tests pass (1113 tests) and build succeeds.
This commit is contained in:
Claude
2026-01-22 20:33:58 +00:00
parent 27b077be8a
commit 1bf89a829c
7 changed files with 1384 additions and 18 deletions

View File

@@ -29,6 +29,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 { Nip22Adapter } from "@/lib/chat/adapters/nip-22-adapter";
import type { ChatProtocolAdapter } from "@/lib/chat/adapters/base-adapter";
import type { Message } from "@/types/chat";
import type { ChatAction } from "@/types/chat-actions";
@@ -1235,13 +1236,19 @@ 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 supported:
* - NIP-10 (kind 1 note threads)
* - NIP-22 (event comments/threads for any kind)
* - NIP-29 (relay-based groups)
* - NIP-53 (live activity chat)
* Other protocols will be enabled in future phases
*/
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

@@ -25,6 +25,7 @@ import { UserName } from "./nostr/UserName";
import { getTagValues } from "@/lib/nostr-utils";
import { getSemanticAuthor } from "@/lib/semantic-author";
import { Nip29Adapter } from "@/lib/chat/adapters/nip-29-adapter";
import { Nip22Adapter } from "@/lib/chat/adapters/nip-22-adapter";
import type { ChatProtocol, ProtocolIdentifier } from "@/types/chat";
import { useState, useEffect } from "react";
@@ -738,9 +739,10 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
const identifier = props.identifier as ProtocolIdentifier;
// Get adapter and resolve conversation
// Currently only NIP-29 is supported
const getAdapter = () => {
switch (protocol) {
case "nip-22":
return new Nip22Adapter();
case "nip-29":
return new Nip29Adapter();
default:

View File

@@ -89,13 +89,13 @@ describe("parseChatCommand", () => {
);
});
it("should throw error for npub (NIP-C7 disabled)", () => {
it("should throw error for npub (not an event)", () => {
expect(() => parseChatCommand(["npub1xyz"])).toThrow(
/Unable to determine chat protocol/,
);
});
it("should throw error for note/nevent (NIP-28 not implemented)", () => {
it("should throw error for malformed note/nevent", () => {
expect(() => parseChatCommand(["note1xyz"])).toThrow(
/Unable to determine chat protocol/,
);
@@ -173,4 +173,61 @@ describe("parseChatCommand", () => {
expect(result.protocol).toBe("nip-29");
});
});
describe("NIP-22 event comments (catch-all)", () => {
it("should parse note1 format", () => {
const eventId =
"0000000000000000000000000000000000000000000000000000000000000001";
const note = nip19.noteEncode(eventId);
const result = parseChatCommand([note]);
// NIP-10 handles kind 1 notes specifically, so note1 goes to NIP-10
// For NIP-22, we need nevent with non-kind-1 kind
expect(result.protocol).toBe("nip-10"); // note1 defaults to NIP-10
});
it("should parse nevent for non-kind-1 events", () => {
const nevent = nip19.neventEncode({
id: "0000000000000000000000000000000000000000000000000000000000000001",
kind: 30023, // Long-form article
relays: ["wss://relay.example.com"],
});
const result = parseChatCommand([nevent]);
expect(result.protocol).toBe("nip-22");
expect(result.identifier.type).toBe("thread");
expect(result.adapter.protocol).toBe("nip-22");
});
it("should parse naddr for non-NIP-29/NIP-53 addressable events", () => {
const naddr = nip19.naddrEncode({
kind: 30023, // Long-form article (not 39000 or 30311)
pubkey:
"0000000000000000000000000000000000000000000000000000000000000001",
identifier: "my-article",
relays: ["wss://relay.example.com"],
});
const result = parseChatCommand([naddr]);
expect(result.protocol).toBe("nip-22");
expect(result.identifier.type).toBe("thread");
expect(result.adapter.protocol).toBe("nip-22");
});
it("should parse nevent without kind (defaults to NIP-10)", () => {
const nevent = nip19.neventEncode({
id: "0000000000000000000000000000000000000000000000000000000000000001",
relays: ["wss://relay.example.com"],
});
const result = parseChatCommand([nevent]);
// Without kind hint, NIP-10 accepts it (assumes kind 1 thread)
// Only nevents with explicit non-kind-1 kind hint go to NIP-22
expect(result.protocol).toBe("nip-10");
});
});
});

View File

@@ -2,6 +2,7 @@ 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 { Nip22Adapter } from "./chat/adapters/nip-22-adapter";
import { nip19 } from "nostr-tools";
// Import other adapters as they're implemented
// import { Nip17Adapter } from "./chat/adapters/nip-17-adapter";
@@ -11,11 +12,12 @@ import { nip19 } from "nostr-tools";
* Parse a chat command identifier and auto-detect the protocol
*
* Tries each adapter's parseIdentifier() in priority order:
* 1. NIP-10 (thread chat) - nevent/note format for kind 1 threads
* 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
* 1. NIP-10 (thread chat) - nevent/note format for kind 1 threads specifically
* 2. NIP-17 (encrypted DMs) - prioritized for privacy (coming soon)
* 3. NIP-28 (channels) - specific event format (kind 40) (coming soon)
* 4. NIP-29 (groups) - specific group ID format (relay'group-id or kind 39000)
* 5. NIP-53 (live chat) - specific addressable format (kind 30311)
* 6. NIP-22 (event comments) - catch-all for any other nevent/naddr/note
*
* @param args - Command arguments (first arg is the identifier)
* @returns Parsed result with protocol and identifier
@@ -62,11 +64,12 @@ export function parseChatCommand(args: string[]): ChatCommandResult {
// Try each adapter in priority order
const adapters = [
new Nip10Adapter(), // NIP-10 - Thread chat (nevent/note)
// new Nip17Adapter(), // Phase 2
// new Nip28Adapter(), // Phase 3
new Nip29Adapter(), // Phase 4 - Relay groups
new Nip53Adapter(), // Phase 5 - Live activity chat
new Nip10Adapter(), // NIP-10 - Thread chat (kind 1 notes specifically)
// new Nip17Adapter(), // Phase 2 - Encrypted DMs
// new Nip28Adapter(), // Phase 3 - Public channels
new Nip29Adapter(), // NIP-29 - Relay groups
new Nip53Adapter(), // NIP-53 - Live activity chat
new Nip22Adapter(), // NIP-22 - Event comments (catch-all for any event)
];
for (const adapter of adapters) {
@@ -84,10 +87,11 @@ 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.../note1... (NIP-10/NIP-22 threaded chat)
Examples:
chat nevent1qqsxyz... (thread with relay hints)
chat note1abc... (thread with event ID only)
chat nevent1qqsxyz... (event with relay hints)
chat note1abc... (event with ID only)
chat naddr1... (addressable event)
- relay.com'group-id (NIP-29 relay group, wss:// prefix optional)
Examples:
chat relay.example.com'bitcoin-dev

View File

@@ -0,0 +1,183 @@
import { describe, it, expect } from "vitest";
import { nip19 } from "nostr-tools";
import { Nip22Adapter } from "./nip-22-adapter";
describe("Nip22Adapter", () => {
const adapter = new Nip22Adapter();
describe("parseIdentifier", () => {
it("should parse note1 format (simple event ID)", () => {
const eventId =
"0000000000000000000000000000000000000000000000000000000000000001";
const note = nip19.noteEncode(eventId);
const result = adapter.parseIdentifier(note);
expect(result).toEqual({
type: "thread",
value: { id: eventId },
relays: [],
});
});
it("should parse nevent format with relay hints", () => {
const nevent = nip19.neventEncode({
id: "0000000000000000000000000000000000000000000000000000000000000001",
relays: ["wss://relay.example.com"],
});
const result = adapter.parseIdentifier(nevent);
expect(result).toEqual({
type: "thread",
value: {
id: "0000000000000000000000000000000000000000000000000000000000000001",
relays: ["wss://relay.example.com"],
author: undefined,
kind: undefined,
},
relays: ["wss://relay.example.com"],
});
});
it("should parse nevent with author and kind hints", () => {
const nevent = nip19.neventEncode({
id: "0000000000000000000000000000000000000000000000000000000000000001",
relays: ["wss://relay1.example.com", "wss://relay2.example.com"],
author:
"0000000000000000000000000000000000000000000000000000000000000002",
kind: 30023,
});
const result = adapter.parseIdentifier(nevent);
expect(result).toEqual({
type: "thread",
value: {
id: "0000000000000000000000000000000000000000000000000000000000000001",
relays: ["wss://relay1.example.com", "wss://relay2.example.com"],
author:
"0000000000000000000000000000000000000000000000000000000000000002",
kind: 30023,
},
relays: ["wss://relay1.example.com", "wss://relay2.example.com"],
});
});
it("should parse naddr format (addressable events)", () => {
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey:
"0000000000000000000000000000000000000000000000000000000000000001",
identifier: "my-article",
relays: ["wss://relay.example.com"],
});
const result = adapter.parseIdentifier(naddr);
expect(result).toEqual({
type: "thread",
value: {
id: "30023:0000000000000000000000000000000000000000000000000000000000000001:my-article",
relays: ["wss://relay.example.com"],
author:
"0000000000000000000000000000000000000000000000000000000000000001",
kind: 30023,
},
relays: ["wss://relay.example.com"],
});
});
it("should parse naddr with empty identifier", () => {
const naddr = nip19.naddrEncode({
kind: 30311,
pubkey:
"0000000000000000000000000000000000000000000000000000000000000001",
identifier: "",
relays: ["wss://relay.example.com"],
});
const result = adapter.parseIdentifier(naddr);
expect(result).toEqual({
type: "thread",
value: {
id: "30311:0000000000000000000000000000000000000000000000000000000000000001:",
relays: ["wss://relay.example.com"],
author:
"0000000000000000000000000000000000000000000000000000000000000001",
kind: 30311,
},
relays: ["wss://relay.example.com"],
});
});
it("should accept any event kind (catch-all)", () => {
// Kind 6 (repost)
const nevent1 = nip19.neventEncode({
id: "0000000000000000000000000000000000000000000000000000000000000001",
kind: 6,
});
expect(adapter.parseIdentifier(nevent1)).not.toBeNull();
// Kind 30023 (long-form article)
const nevent2 = nip19.neventEncode({
id: "0000000000000000000000000000000000000000000000000000000000000002",
kind: 30023,
});
expect(adapter.parseIdentifier(nevent2)).not.toBeNull();
// Kind 1063 (file metadata)
const nevent3 = nip19.neventEncode({
id: "0000000000000000000000000000000000000000000000000000000000000003",
kind: 1063,
});
expect(adapter.parseIdentifier(nevent3)).not.toBeNull();
});
it("should return null for invalid formats", () => {
expect(adapter.parseIdentifier("")).toBeNull();
expect(adapter.parseIdentifier("just-a-string")).toBeNull();
expect(adapter.parseIdentifier("invalid1xyz")).toBeNull();
});
it("should return null for npub (not an event)", () => {
const npub = nip19.npubEncode(
"0000000000000000000000000000000000000000000000000000000000000001",
);
expect(adapter.parseIdentifier(npub)).toBeNull();
});
it("should handle malformed nevent gracefully", () => {
expect(adapter.parseIdentifier("nevent1xyz")).toBeNull();
});
it("should handle malformed naddr gracefully", () => {
expect(adapter.parseIdentifier("naddr1xyz")).toBeNull();
});
it("should handle malformed note gracefully", () => {
expect(adapter.parseIdentifier("note1xyz")).toBeNull();
});
});
describe("protocol and type", () => {
it("should have correct protocol identifier", () => {
expect(adapter.protocol).toBe("nip-22");
});
it("should have correct conversation type", () => {
expect(adapter.type).toBe("group");
});
});
describe("capabilities", () => {
it("should return correct capabilities", () => {
const caps = adapter.getCapabilities();
expect(caps).toEqual({
supportsEncryption: false,
supportsThreading: true,
supportsModeration: false,
supportsRoles: false,
supportsGroupManagement: false,
canCreateConversations: false,
requiresRelay: false,
});
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,13 @@ 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
@@ -78,11 +84,12 @@ export interface ConversationMetadata {
encrypted?: boolean;
giftWrapped?: boolean;
// NIP-10 thread
// NIP-10/NIP-22 thread
rootEventId?: string; // Thread root event ID
providedEventId?: string; // Original event from nevent (may be reply)
threadDepth?: number; // Approximate depth of thread
relays?: string[]; // Relays for this conversation
rootCoordinate?: string; // For addressable events: "kind:pubkey:d-tag"
}
/**