mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-16 17:48:34 +02:00
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:
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
183
src/lib/chat/adapters/nip-22-adapter.test.ts
Normal file
183
src/lib/chat/adapters/nip-22-adapter.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
1106
src/lib/chat/adapters/nip-22-adapter.ts
Normal file
1106
src/lib/chat/adapters/nip-22-adapter.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user