mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 00:17:02 +02:00
feat: implement NIP-22 comment thread adapter
Add complete NIP-22 protocol adapter to treat kind 1111 comment threads as chat conversations. Root event is displayed with appropriate kind renderer, followed by all comments, zaps, and reactions. Key features: - Parse note/nevent/naddr identifiers for any event kind (except kind 1) - Smart relay selection using outbox relays and aggregator fallback - Full support for nested comment replies with proper NIP-22 tagging - Root message rendered with KindRenderer for proper event display - Zap and reaction support for individual comments - NIP-30 custom emoji and NIP-92 blob attachment support Files changed: - src/lib/chat/adapters/nip-22-adapter.ts: Core adapter implementation - src/lib/chat/adapters/nip-22-adapter.test.ts: Comprehensive test suite - src/types/chat.ts: Add NIP-22 protocol and CommentThreadIdentifier types - src/lib/chat-parser.ts: Register Nip22Adapter in priority order - src/components/ChatViewer.tsx: Add NIP-22 root message rendering - src/lib/nostr-utils.ts: Add getRootEventTitle() utility - src/types/man.ts: Update chat command documentation
This commit is contained in:
@@ -26,6 +26,7 @@ import type {
|
||||
import { CHAT_KINDS } from "@/types/chat";
|
||||
// import { NipC7Adapter } from "@/lib/chat/adapters/nip-c7-adapter"; // Coming soon
|
||||
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";
|
||||
@@ -41,6 +42,7 @@ import { RelaysDropdown } from "./chat/RelaysDropdown";
|
||||
import { MessageReactions } from "./chat/MessageReactions";
|
||||
import { StatusBadge } from "./live/StatusBadge";
|
||||
import { ChatMessageContextMenu } from "./chat/ChatMessageContextMenu";
|
||||
import { KindRenderer } from "./nostr/kinds";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { Button } from "./ui/button";
|
||||
import LoginDialog from "./nostr/LoginDialog";
|
||||
@@ -278,6 +280,18 @@ const MessageItem = memo(function MessageItem({
|
||||
[conversation],
|
||||
);
|
||||
|
||||
// NIP-22 root messages: render with KindRenderer for proper event display
|
||||
if (conversation.protocol === "nip-22" && message.metadata?.isRootMessage) {
|
||||
return (
|
||||
<div className="px-3 py-2 border-l-4 border-primary/50">
|
||||
<div className="text-xs text-muted-foreground mb-2 font-medium">
|
||||
Commenting on:
|
||||
</div>
|
||||
<KindRenderer event={message.event} depth={0} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// System messages (join/leave) have special styling
|
||||
if (message.type === "system") {
|
||||
return (
|
||||
@@ -999,7 +1013,8 @@ export function ChatViewer({
|
||||
Header: () =>
|
||||
hasMore &&
|
||||
conversationResult.status === "success" &&
|
||||
protocol !== "nip-10" ? (
|
||||
protocol !== "nip-10" &&
|
||||
protocol !== "nip-22" ? (
|
||||
<div className="flex justify-center py-2">
|
||||
<Button
|
||||
onClick={handleLoadOlder}
|
||||
@@ -1033,9 +1048,9 @@ export function ChatViewer({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// For NIP-10 threads, check if this is the root message
|
||||
// For NIP-10 and NIP-22, check if this is the root message
|
||||
const isRootMessage =
|
||||
protocol === "nip-10" &&
|
||||
(protocol === "nip-10" || protocol === "nip-22") &&
|
||||
conversation.metadata?.rootEventId === item.data.id;
|
||||
|
||||
return (
|
||||
@@ -1138,13 +1153,15 @@ 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-22 (comment threads), NIP-29 (relay-based groups) and NIP-53 (live activity chat) are supported
|
||||
* 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-c7": // Phase 1 - Simple chat (coming soon)
|
||||
// return new NipC7Adapter();
|
||||
case "nip-29":
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ChatCommandResult, GroupListIdentifier } from "@/types/chat";
|
||||
// import { NipC7Adapter } from "./chat/adapters/nip-c7-adapter";
|
||||
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";
|
||||
@@ -64,7 +65,8 @@ export function parseChatCommand(args: string[]): ChatCommandResult {
|
||||
|
||||
// Try each adapter in priority order
|
||||
const adapters = [
|
||||
new Nip10Adapter(), // NIP-10 - Thread chat (nevent/note)
|
||||
new Nip10Adapter(), // NIP-10 - Thread chat (kind 1 notes only)
|
||||
new Nip22Adapter(), // NIP-22 - Comment threads (any kind except kind 1)
|
||||
// new Nip17Adapter(), // Phase 2
|
||||
// new Nip28Adapter(), // Phase 3
|
||||
new Nip29Adapter(), // Phase 4 - Relay groups
|
||||
@@ -87,10 +89,15 @@ 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 thread chat, kind 1 notes only)
|
||||
Examples:
|
||||
chat nevent1qqsxyz... (thread with relay hints)
|
||||
chat note1abc... (thread with event ID only)
|
||||
- nevent1.../note1.../naddr1... (NIP-22 comment threads, any kind except kind 1)
|
||||
Examples:
|
||||
chat nevent1... (article, live stream, or other event with comments)
|
||||
chat note1... (any non-kind-1 event)
|
||||
chat naddr1... (addressable event like 30023 article)
|
||||
- relay.com'group-id (NIP-29 relay group, wss:// prefix optional)
|
||||
Examples:
|
||||
chat relay.example.com'bitcoin-dev
|
||||
|
||||
163
src/lib/chat/adapters/nip-22-adapter.test.ts
Normal file
163
src/lib/chat/adapters/nip-22-adapter.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Nip22Adapter } from "./nip-22-adapter";
|
||||
import { nip19 } from "nostr-tools";
|
||||
|
||||
describe("Nip22Adapter", () => {
|
||||
const adapter = new Nip22Adapter();
|
||||
|
||||
describe("parseIdentifier", () => {
|
||||
it("should parse note1 identifier (simple event ID)", () => {
|
||||
const eventId =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
const note = nip19.noteEncode(eventId);
|
||||
|
||||
const result = adapter.parseIdentifier(note);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.type).toBe("comment-thread");
|
||||
if (result && result.type === "comment-thread") {
|
||||
expect(result.value.id).toBe(eventId);
|
||||
}
|
||||
});
|
||||
|
||||
it("should parse nevent1 identifier with relay hints", () => {
|
||||
const eventId =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
const relays = ["wss://relay.example.com", "wss://nos.lol"];
|
||||
const nevent = nip19.neventEncode({
|
||||
id: eventId,
|
||||
relays,
|
||||
kind: 30023, // Article
|
||||
});
|
||||
|
||||
const result = adapter.parseIdentifier(nevent);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.type).toBe("comment-thread");
|
||||
if (result && result.type === "comment-thread") {
|
||||
expect(result.value.id).toBe(eventId);
|
||||
expect(result.value.kind).toBe(30023);
|
||||
expect(result.relays).toEqual(relays);
|
||||
}
|
||||
});
|
||||
|
||||
it("should parse naddr1 identifier for addressable events", () => {
|
||||
const pubkey =
|
||||
"7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194";
|
||||
const identifier = "grimoire";
|
||||
const relays = ["wss://relay.example.com"];
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 30023,
|
||||
pubkey,
|
||||
identifier,
|
||||
relays,
|
||||
});
|
||||
|
||||
const result = adapter.parseIdentifier(naddr);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.type).toBe("comment-thread");
|
||||
if (result && result.type === "comment-thread") {
|
||||
expect(result.value.kind).toBe(30023);
|
||||
expect(result.value.pubkey).toBe(pubkey);
|
||||
expect(result.value.identifier).toBe(identifier);
|
||||
expect(result.relays).toEqual(relays);
|
||||
}
|
||||
});
|
||||
|
||||
it("should reject kind 1 nevent (handled by NIP-10 adapter)", () => {
|
||||
const eventId =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
const nevent = nip19.neventEncode({
|
||||
id: eventId,
|
||||
kind: 1, // Kind 1 should be handled by NIP-10
|
||||
});
|
||||
|
||||
const result = adapter.parseIdentifier(nevent);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for invalid formats", () => {
|
||||
expect(adapter.parseIdentifier("invalid")).toBeNull();
|
||||
expect(adapter.parseIdentifier("npub1...")).toBeNull();
|
||||
expect(adapter.parseIdentifier("")).toBeNull();
|
||||
});
|
||||
|
||||
it("should reject naddr with kind outside addressable range", () => {
|
||||
const pubkey =
|
||||
"7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194";
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 1000, // Not in addressable range (30000-39999)
|
||||
pubkey,
|
||||
identifier: "test",
|
||||
});
|
||||
|
||||
const result = adapter.parseIdentifier(naddr);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("protocol and type", () => {
|
||||
it("should have protocol 'nip-22'", () => {
|
||||
expect(adapter.protocol).toBe("nip-22");
|
||||
});
|
||||
|
||||
it("should have type 'channel' (public comments)", () => {
|
||||
expect(adapter.type).toBe("channel");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCapabilities", () => {
|
||||
it("should return correct capabilities", () => {
|
||||
const capabilities = adapter.getCapabilities();
|
||||
|
||||
expect(capabilities.supportsThreading).toBe(true);
|
||||
expect(capabilities.supportsReactions).toBe(true);
|
||||
expect(capabilities.supportsZaps).toBe(true);
|
||||
expect(capabilities.supportsEncryption).toBe(false);
|
||||
expect(capabilities.supportsModeration).toBe(false);
|
||||
expect(capabilities.supportsRoles).toBe(true);
|
||||
expect(capabilities.supportsGroupManagement).toBe(false);
|
||||
expect(capabilities.requiresRelay).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getZapConfig", () => {
|
||||
it("should return zap config for a message", () => {
|
||||
const message = {
|
||||
id: "comment123",
|
||||
conversationId: "nip-22:root123",
|
||||
author:
|
||||
"7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194",
|
||||
content: "Great article!",
|
||||
timestamp: 1234567890,
|
||||
protocol: "nip-22" as const,
|
||||
event: {} as any,
|
||||
};
|
||||
|
||||
const conversation = {
|
||||
id: "nip-22:root123",
|
||||
type: "channel" as const,
|
||||
protocol: "nip-22" as const,
|
||||
title: "Test Article",
|
||||
participants: [],
|
||||
unreadCount: 0,
|
||||
metadata: {
|
||||
relays: ["wss://relay.example.com"],
|
||||
},
|
||||
};
|
||||
|
||||
const zapConfig = adapter.getZapConfig(message, conversation);
|
||||
|
||||
expect(zapConfig.supported).toBe(true);
|
||||
expect(zapConfig.recipientPubkey).toBe(message.author);
|
||||
expect(zapConfig.eventPointer?.id).toBe(message.id);
|
||||
expect(zapConfig.eventPointer?.author).toBe(message.author);
|
||||
expect(zapConfig.eventPointer?.relays).toEqual(
|
||||
conversation.metadata.relays,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
790
src/lib/chat/adapters/nip-22-adapter.ts
Normal file
790
src/lib/chat/adapters/nip-22-adapter.ts
Normal file
@@ -0,0 +1,790 @@
|
||||
import { Observable, combineLatest } from "rxjs";
|
||||
import { map } from "rxjs/operators";
|
||||
import type { Filter } from "nostr-tools";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import {
|
||||
ChatProtocolAdapter,
|
||||
type SendMessageOptions,
|
||||
type ZapConfig,
|
||||
} from "./base-adapter";
|
||||
import type {
|
||||
Conversation,
|
||||
Message,
|
||||
ProtocolIdentifier,
|
||||
ChatCapabilities,
|
||||
LoadMessagesOptions,
|
||||
Participant,
|
||||
} from "@/types/chat";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import eventStore from "@/services/event-store";
|
||||
import pool from "@/services/relay-pool";
|
||||
import { publishEventToRelays } from "@/services/hub";
|
||||
import accountManager from "@/services/accounts";
|
||||
import { AGGREGATOR_RELAYS } from "@/services/loaders";
|
||||
import { normalizeURL } from "applesauce-core/helpers";
|
||||
import { EventFactory } from "applesauce-core/event-factory";
|
||||
import {
|
||||
getZapAmount,
|
||||
getZapSender,
|
||||
getZapComment,
|
||||
} from "applesauce-common/helpers";
|
||||
import { getTagValue } from "applesauce-core/helpers";
|
||||
import { getRootEventTitle } from "@/lib/nostr-utils";
|
||||
|
||||
/**
|
||||
* NIP-22 Adapter - Comment Threads as Chat
|
||||
*
|
||||
* Features:
|
||||
* - Turn any event's comment thread into a chat interface
|
||||
* - Root event (any kind) displayed as first message
|
||||
* - All kind 1111 comments shown as chat messages
|
||||
* - Proper NIP-22 tag structure (uppercase K/E/P for root, lowercase k/e/p for parent)
|
||||
* - Smart relay selection (root author outbox + commenter outboxes)
|
||||
* - Support for nested comment replies
|
||||
*
|
||||
* Thread ID format: note1.../nevent1.../naddr1...
|
||||
* Events use uppercase tags for root scope, lowercase for parent scope
|
||||
*/
|
||||
export class Nip22Adapter extends ChatProtocolAdapter {
|
||||
readonly protocol = "nip-22" as const;
|
||||
readonly type = "channel" as const; // Comments are public like channels
|
||||
|
||||
/**
|
||||
* Parse identifier - accepts note, nevent, or naddr format
|
||||
* Examples:
|
||||
* - note1abc... (simple event ID)
|
||||
* - nevent1qqsxyz... (with relay hints, author, kind)
|
||||
* - naddr1... (addressable event for kind 30000-39999)
|
||||
*/
|
||||
parseIdentifier(input: string): ProtocolIdentifier | null {
|
||||
// Try note format (simpler event ID)
|
||||
if (input.startsWith("note1")) {
|
||||
try {
|
||||
const decoded = nip19.decode(input);
|
||||
if (decoded.type === "note") {
|
||||
const eventId = decoded.data as string;
|
||||
return {
|
||||
type: "comment-thread",
|
||||
value: { id: eventId },
|
||||
relays: [],
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Try nevent format (includes relay hints)
|
||||
if (input.startsWith("nevent1")) {
|
||||
try {
|
||||
const decoded = nip19.decode(input);
|
||||
if (decoded.type === "nevent") {
|
||||
const { id, relays, author, kind } = decoded.data;
|
||||
|
||||
// If kind is 1, let NIP-10 adapter handle it (kind 1 uses replies, not comments)
|
||||
if (kind === 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "comment-thread",
|
||||
value: { id, relays, author, kind },
|
||||
relays: relays || [],
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Try naddr format (addressable events)
|
||||
if (input.startsWith("naddr1")) {
|
||||
try {
|
||||
const decoded = nip19.decode(input);
|
||||
if (decoded.type === "naddr") {
|
||||
const { kind, pubkey, identifier, relays } = decoded.data;
|
||||
|
||||
// Addressable events are kind 30000-39999
|
||||
if (kind < 30000 || kind >= 40000) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "comment-thread",
|
||||
value: {
|
||||
kind,
|
||||
pubkey,
|
||||
identifier,
|
||||
relays,
|
||||
},
|
||||
relays: relays || [],
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve conversation from comment thread identifier
|
||||
*/
|
||||
async resolveConversation(
|
||||
identifier: ProtocolIdentifier,
|
||||
): Promise<Conversation> {
|
||||
if (identifier.type !== "comment-thread") {
|
||||
throw new Error(
|
||||
`NIP-22 adapter cannot handle identifier type: ${identifier.type}`,
|
||||
);
|
||||
}
|
||||
|
||||
const pointer = identifier.value;
|
||||
const relayHints = identifier.relays || [];
|
||||
|
||||
// 1. Fetch the root event
|
||||
let rootEvent: NostrEvent;
|
||||
let rootId: string;
|
||||
|
||||
if (pointer.id) {
|
||||
// Regular event (note, nevent)
|
||||
const fetched = await this.fetchEvent(pointer.id, relayHints);
|
||||
if (!fetched) {
|
||||
throw new Error("Event not found");
|
||||
}
|
||||
rootEvent = fetched;
|
||||
rootId = fetched.id;
|
||||
} else if (
|
||||
pointer.kind &&
|
||||
pointer.pubkey &&
|
||||
pointer.identifier !== undefined
|
||||
) {
|
||||
// Addressable event (naddr)
|
||||
const fetched = await this.fetchAddressableEvent(
|
||||
pointer.kind,
|
||||
pointer.pubkey,
|
||||
pointer.identifier,
|
||||
relayHints,
|
||||
);
|
||||
if (!fetched) {
|
||||
throw new Error("Addressable event not found");
|
||||
}
|
||||
rootEvent = fetched;
|
||||
rootId = fetched.id;
|
||||
} else {
|
||||
throw new Error("Invalid comment thread identifier");
|
||||
}
|
||||
|
||||
// 2. Determine conversation relays
|
||||
const conversationRelays = await this.getCommentThreadRelays(
|
||||
rootEvent,
|
||||
relayHints,
|
||||
);
|
||||
|
||||
// 3. Extract title from root event
|
||||
const title = getRootEventTitle(rootEvent);
|
||||
|
||||
// 4. Build initial participants (root author as "op")
|
||||
const participants: Participant[] = [
|
||||
{
|
||||
pubkey: rootEvent.pubkey,
|
||||
role: "op", // Original poster
|
||||
},
|
||||
];
|
||||
|
||||
// 5. Build conversation object
|
||||
return {
|
||||
id: `nip-22:${rootId}`,
|
||||
type: "channel",
|
||||
protocol: "nip-22",
|
||||
title,
|
||||
participants,
|
||||
metadata: {
|
||||
rootEventId: rootId,
|
||||
rootEventKind: rootEvent.kind,
|
||||
description: title,
|
||||
relays: conversationRelays,
|
||||
commentCount: 0,
|
||||
},
|
||||
unreadCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load messages for a comment thread
|
||||
*/
|
||||
loadMessages(
|
||||
conversation: Conversation,
|
||||
options?: LoadMessagesOptions,
|
||||
): Observable<Message[]> {
|
||||
const rootEventId = conversation.metadata?.rootEventId;
|
||||
const rootEventKind = conversation.metadata?.rootEventKind;
|
||||
const relays = conversation.metadata?.relays || [];
|
||||
|
||||
if (!rootEventId || !rootEventKind) {
|
||||
throw new Error("Root event ID and kind required");
|
||||
}
|
||||
|
||||
// Build filter for all thread events:
|
||||
// - kind 1111: comments on root
|
||||
// - kind 7: reactions
|
||||
// - kind 9735: zap receipts
|
||||
const filters: Filter[] = [
|
||||
// Comments: kind 1111 events with K and E tags pointing to root
|
||||
{
|
||||
kinds: [1111],
|
||||
"#K": [rootEventKind.toString()],
|
||||
"#E": [rootEventId],
|
||||
limit: options?.limit || 100,
|
||||
},
|
||||
// Reactions: kind 7 events with e-tag pointing to root or comments
|
||||
{
|
||||
kinds: [7],
|
||||
"#e": [rootEventId],
|
||||
limit: 200, // Reactions are small, fetch more
|
||||
},
|
||||
// Zaps: kind 9735 receipts with e-tag pointing to root or comments
|
||||
{
|
||||
kinds: [9735],
|
||||
"#e": [rootEventId],
|
||||
limit: 100,
|
||||
},
|
||||
];
|
||||
|
||||
if (options?.before) {
|
||||
filters[0].until = options.before;
|
||||
}
|
||||
if (options?.after) {
|
||||
filters[0].since = options.after;
|
||||
}
|
||||
|
||||
// Clean up any existing subscription
|
||||
const conversationId = `nip-22:${rootEventId}`;
|
||||
this.cleanup(conversationId);
|
||||
|
||||
// Start persistent subscription
|
||||
const subscription = pool
|
||||
.subscription(relays, filters, { eventStore })
|
||||
.subscribe({
|
||||
next: (_response) => {
|
||||
// EOSE or event - both handled by EventStore
|
||||
},
|
||||
});
|
||||
|
||||
// Store subscription for cleanup
|
||||
this.subscriptions.set(conversationId, subscription);
|
||||
|
||||
// Return observable from EventStore
|
||||
// Combine root event with comments
|
||||
const rootEvent$ = eventStore.event(rootEventId);
|
||||
const comments$ = eventStore.timeline({
|
||||
kinds: [1111, 7, 9735],
|
||||
"#E": [rootEventId],
|
||||
"#K": [rootEventKind.toString()],
|
||||
});
|
||||
|
||||
return combineLatest([rootEvent$, comments$]).pipe(
|
||||
map(([rootEvent, commentEvents]) => {
|
||||
if (!rootEvent) return [];
|
||||
|
||||
// Convert root event to first message
|
||||
const rootMessage = this.rootEventToMessage(rootEvent, conversationId);
|
||||
|
||||
// Convert comment events to messages
|
||||
const messages = commentEvents
|
||||
.map((event) =>
|
||||
this.eventToMessage(event, conversationId, rootEventId),
|
||||
)
|
||||
.filter((m): m is Message => m !== null);
|
||||
|
||||
// Combine and sort by timestamp (ascending)
|
||||
return [rootMessage, ...messages].sort(
|
||||
(a, b) => a.timestamp - b.timestamp,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load more messages (pagination)
|
||||
*/
|
||||
async loadMoreMessages(
|
||||
conversation: Conversation,
|
||||
before: number,
|
||||
): Promise<Message[]> {
|
||||
return (
|
||||
this.loadMessages(conversation, { before, limit: 50 }).toPromise() || []
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message (create kind 1111 comment)
|
||||
*/
|
||||
async sendMessage(
|
||||
conversation: Conversation,
|
||||
content: string,
|
||||
options?: SendMessageOptions,
|
||||
): Promise<void> {
|
||||
const rootEventId = conversation.metadata?.rootEventId;
|
||||
const rootEventKind = conversation.metadata?.rootEventKind;
|
||||
const relays = conversation.metadata?.relays || [];
|
||||
|
||||
if (!rootEventId || !rootEventKind) {
|
||||
throw new Error("Root event ID and kind required");
|
||||
}
|
||||
|
||||
// Get active signer
|
||||
const activeAccount = accountManager.active$.value;
|
||||
if (!activeAccount?.signer) {
|
||||
throw new Error("No active account with signer");
|
||||
}
|
||||
|
||||
// Fetch root event for metadata
|
||||
const rootEvent = await eventStore.event(rootEventId).toPromise();
|
||||
if (!rootEvent) {
|
||||
throw new Error("Root event not found in store");
|
||||
}
|
||||
|
||||
// Create event factory
|
||||
const factory = new EventFactory();
|
||||
factory.setSigner(activeAccount.signer);
|
||||
|
||||
// Build NIP-22 tags
|
||||
const tags: string[][] = [
|
||||
// Root scope (uppercase tags)
|
||||
["K", rootEventKind.toString()],
|
||||
["E", rootEventId, relays[0] || ""],
|
||||
["P", rootEvent.pubkey],
|
||||
];
|
||||
|
||||
// If replying to another comment (nested), add parent scope (lowercase tags)
|
||||
if (options?.replyTo) {
|
||||
const parentComment = await eventStore.event(options.replyTo).toPromise();
|
||||
if (parentComment) {
|
||||
tags.push(
|
||||
["k", "1111"], // Parent is a comment
|
||||
["e", options.replyTo, relays[0] || ""],
|
||||
["p", parentComment.pubkey],
|
||||
);
|
||||
|
||||
// Include all p-tags from parent (mentioned participants)
|
||||
const parentPTags = parentComment.tags.filter((t) => t[0] === "p");
|
||||
for (const pTag of parentPTags) {
|
||||
// Avoid duplicates
|
||||
if (!tags.some((t) => t[0] === "p" && t[1] === pTag[1])) {
|
||||
tags.push(pTag);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Top-level comment - add k tag for self-reference
|
||||
tags.push(["k", "1111"]);
|
||||
}
|
||||
|
||||
// Add optional NIP-30 custom emoji tags
|
||||
if (options?.emojiTags) {
|
||||
for (const emoji of options.emojiTags) {
|
||||
tags.push(["emoji", emoji.shortcode, emoji.url]);
|
||||
}
|
||||
}
|
||||
|
||||
// Add optional NIP-92 imeta blob tags
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
// Create and sign event
|
||||
const draft = await factory.build({
|
||||
kind: 1111,
|
||||
content,
|
||||
tags,
|
||||
});
|
||||
|
||||
const event = await factory.sign(draft);
|
||||
|
||||
// Publish to conversation relays
|
||||
await publishEventToRelays(event, relays);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a reaction (kind 7)
|
||||
*/
|
||||
async sendReaction(
|
||||
conversation: Conversation,
|
||||
messageId: string,
|
||||
emoji: string,
|
||||
customEmoji?: { shortcode: string; url: string },
|
||||
): Promise<void> {
|
||||
const relays = conversation.metadata?.relays || [];
|
||||
|
||||
// Get active signer
|
||||
const activeAccount = accountManager.active$.value;
|
||||
if (!activeAccount?.signer) {
|
||||
throw new Error("No active account with signer");
|
||||
}
|
||||
|
||||
// Fetch the message event
|
||||
const messageEvent = await eventStore.event(messageId).toPromise();
|
||||
if (!messageEvent) {
|
||||
throw new Error("Message event not found");
|
||||
}
|
||||
|
||||
// Create event factory
|
||||
const factory = new EventFactory();
|
||||
factory.setSigner(activeAccount.signer);
|
||||
|
||||
// Build kind 7 tags
|
||||
const tags: string[][] = [
|
||||
["e", messageId],
|
||||
["k", messageEvent.kind.toString()],
|
||||
["p", messageEvent.pubkey],
|
||||
];
|
||||
|
||||
// Add NIP-30 custom emoji tag if provided
|
||||
if (customEmoji) {
|
||||
tags.push(["emoji", customEmoji.shortcode, customEmoji.url]);
|
||||
}
|
||||
|
||||
// Create and sign reaction event
|
||||
const draft = await factory.build({
|
||||
kind: 7,
|
||||
content: emoji,
|
||||
tags,
|
||||
});
|
||||
|
||||
const event = await factory.sign(draft);
|
||||
|
||||
// Publish to conversation relays
|
||||
await publishEventToRelays(event, relays);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chat capabilities
|
||||
*/
|
||||
getCapabilities(): ChatCapabilities {
|
||||
return {
|
||||
supportsThreading: true, // Nested comment replies
|
||||
supportsReactions: true, // Kind 7
|
||||
supportsZaps: true, // Kind 9735
|
||||
supportsEncryption: false, // Public comments
|
||||
supportsModeration: false, // No relay enforcement
|
||||
supportsRoles: true, // "op" for root author
|
||||
supportsGroupManagement: false,
|
||||
requiresRelay: false, // Multi-relay distribution
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a replied-to message
|
||||
*/
|
||||
async loadReplyMessage(
|
||||
conversation: Conversation,
|
||||
eventId: string,
|
||||
): Promise<NostrEvent | null> {
|
||||
const event = await eventStore.event(eventId).toPromise();
|
||||
return event || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get zap configuration for a message
|
||||
*/
|
||||
getZapConfig(message: Message, conversation: Conversation): ZapConfig {
|
||||
const relays = conversation.metadata?.relays || [];
|
||||
|
||||
return {
|
||||
supported: true,
|
||||
recipientPubkey: message.author, // Zap the commenter
|
||||
eventPointer: {
|
||||
id: message.id,
|
||||
author: message.author,
|
||||
relays,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Private Helper Methods ==========
|
||||
|
||||
/**
|
||||
* Fetch a regular event by ID
|
||||
*/
|
||||
private async fetchEvent(
|
||||
eventId: string,
|
||||
relayHints: string[],
|
||||
): Promise<NostrEvent | null> {
|
||||
// Try EventStore first (local cache)
|
||||
const cached = await eventStore.event(eventId).toPromise();
|
||||
if (cached) return cached;
|
||||
|
||||
// Fetch from relays if not cached
|
||||
const relays = relayHints.length > 0 ? relayHints : AGGREGATOR_RELAYS;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
sub.unsubscribe();
|
||||
resolve(null);
|
||||
}, 5000);
|
||||
|
||||
const sub = pool
|
||||
.subscription(relays, [{ ids: [eventId] }], { eventStore })
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
if (response.type === "event" && response.event.id === eventId) {
|
||||
clearTimeout(timeout);
|
||||
sub.unsubscribe();
|
||||
resolve(response.event);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch an addressable event (kind 30000-39999)
|
||||
*/
|
||||
private async fetchAddressableEvent(
|
||||
kind: number,
|
||||
pubkey: string,
|
||||
identifier: string,
|
||||
relayHints: string[],
|
||||
): Promise<NostrEvent | null> {
|
||||
// Try EventStore first (local cache)
|
||||
const cached = await eventStore
|
||||
.replaceable(kind, pubkey, identifier)
|
||||
.toPromise();
|
||||
if (cached) return cached;
|
||||
|
||||
// Fetch from relays if not cached
|
||||
const relays = relayHints.length > 0 ? relayHints : AGGREGATOR_RELAYS;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
sub.unsubscribe();
|
||||
resolve(null);
|
||||
}, 5000);
|
||||
|
||||
const sub = pool
|
||||
.subscription(
|
||||
relays,
|
||||
[
|
||||
{
|
||||
kinds: [kind],
|
||||
authors: [pubkey],
|
||||
"#d": [identifier],
|
||||
limit: 1,
|
||||
},
|
||||
],
|
||||
{ eventStore },
|
||||
)
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
if (response.type === "event") {
|
||||
clearTimeout(timeout);
|
||||
sub.unsubscribe();
|
||||
resolve(response.event);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine relays for comment thread
|
||||
*/
|
||||
private async getCommentThreadRelays(
|
||||
rootEvent: NostrEvent,
|
||||
providedHints: string[],
|
||||
): Promise<string[]> {
|
||||
const relaySet = new Set<string>();
|
||||
|
||||
// 1. Add provided relay hints (highest priority)
|
||||
for (const relay of providedHints) {
|
||||
if (relay) {
|
||||
relaySet.add(normalizeURL(relay));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Add root author's outbox relays (NIP-65)
|
||||
try {
|
||||
const rootOutbox = await this.getOutboxRelays(rootEvent.pubkey);
|
||||
for (const relay of rootOutbox.slice(0, 3)) {
|
||||
relaySet.add(normalizeURL(relay));
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
// 3. Add active user's outbox relays (for receiving responses)
|
||||
const activeAccount = accountManager.active$.value;
|
||||
if (activeAccount?.pubkey) {
|
||||
try {
|
||||
const userOutbox = await this.getOutboxRelays(activeAccount.pubkey);
|
||||
for (const relay of userOutbox.slice(0, 3)) {
|
||||
relaySet.add(normalizeURL(relay));
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Add aggregator relays as fallback
|
||||
if (relaySet.size < 5) {
|
||||
for (const relay of AGGREGATOR_RELAYS.slice(0, 5 - relaySet.size)) {
|
||||
relaySet.add(normalizeURL(relay));
|
||||
}
|
||||
}
|
||||
|
||||
// Limit to 10 relays for performance
|
||||
return Array.from(relaySet).slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get outbox relays for a pubkey (NIP-65)
|
||||
*/
|
||||
private async getOutboxRelays(pubkey: string): Promise<string[]> {
|
||||
const relayList = await eventStore
|
||||
.replaceable(10002, pubkey, "")
|
||||
.toPromise();
|
||||
|
||||
if (!relayList) return [];
|
||||
|
||||
// Extract write relays (r tags with "write" marker or no marker)
|
||||
const relays: string[] = [];
|
||||
for (const tag of relayList.tags) {
|
||||
if (tag[0] === "r") {
|
||||
const relayUrl = tag[1];
|
||||
const marker = tag[2];
|
||||
if (!marker || marker === "write") {
|
||||
relays.push(relayUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return relays;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert root event to message (first message in thread)
|
||||
*/
|
||||
private rootEventToMessage(
|
||||
event: NostrEvent,
|
||||
conversationId: string,
|
||||
): Message {
|
||||
return {
|
||||
id: event.id,
|
||||
conversationId,
|
||||
author: event.pubkey,
|
||||
content: event.content,
|
||||
timestamp: event.created_at,
|
||||
type: "system", // Root event is special
|
||||
protocol: "nip-22",
|
||||
metadata: {
|
||||
isRootMessage: true, // Flag for special rendering
|
||||
encrypted: false,
|
||||
},
|
||||
event,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert event to message
|
||||
*/
|
||||
private eventToMessage(
|
||||
event: NostrEvent,
|
||||
conversationId: string,
|
||||
rootEventId: string,
|
||||
): Message | null {
|
||||
// Handle kind 9735 (zap receipts) -> convert to zap message
|
||||
if (event.kind === 9735) {
|
||||
return this.zapToMessage(event, conversationId);
|
||||
}
|
||||
|
||||
// Skip kind 7 (reactions) - handled via MessageReactions component
|
||||
if (event.kind === 7) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle kind 1111 (comments)
|
||||
if (event.kind === 1111) {
|
||||
// Determine reply target using NIP-22 structure
|
||||
let replyTo: string | undefined;
|
||||
|
||||
// Check lowercase e tag (parent comment)
|
||||
const parentETag = event.tags.find(
|
||||
(t) => t[0] === "e" && t[1] !== rootEventId,
|
||||
);
|
||||
if (parentETag) {
|
||||
replyTo = parentETag[1];
|
||||
}
|
||||
|
||||
// If no parent comment, top-level comment (no replyTo)
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
conversationId,
|
||||
author: event.pubkey,
|
||||
content: event.content,
|
||||
timestamp: event.created_at,
|
||||
type: "user",
|
||||
replyTo,
|
||||
protocol: "nip-22",
|
||||
metadata: {
|
||||
encrypted: false,
|
||||
},
|
||||
event,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert zap receipt (kind 9735) to zap message
|
||||
*/
|
||||
private zapToMessage(
|
||||
event: NostrEvent,
|
||||
conversationId: string,
|
||||
): Message | null {
|
||||
try {
|
||||
// Extract zap metadata
|
||||
const amount = getZapAmount(event);
|
||||
const sender = getZapSender(event);
|
||||
const comment = getZapComment(event);
|
||||
|
||||
if (!amount || !sender) {
|
||||
return null; // Invalid zap
|
||||
}
|
||||
|
||||
// Convert msats to sats
|
||||
const sats = Math.floor(amount / 1000);
|
||||
|
||||
// Find zapped event/comment
|
||||
const eTag = event.tags.find((t) => t[0] === "e");
|
||||
const zappedEventId = eTag?.[1];
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
conversationId,
|
||||
author: sender,
|
||||
content: comment || "", // Zap comment (optional)
|
||||
timestamp: event.created_at,
|
||||
type: "zap",
|
||||
replyTo: zappedEventId, // Link to zapped comment
|
||||
protocol: "nip-22",
|
||||
metadata: {
|
||||
encrypted: false,
|
||||
zapAmount: sats,
|
||||
},
|
||||
event,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,10 @@ import type { NostrEvent } from "nostr-tools";
|
||||
import type { NostrFilter } from "@/types/nostr";
|
||||
import { getNip10References } from "applesauce-common/helpers/threading";
|
||||
import { getCommentReplyPointer } from "applesauce-common/helpers/comment";
|
||||
import { getArticleTitle } from "applesauce-common/helpers";
|
||||
import { getTagValue } from "applesauce-core/helpers";
|
||||
import type { EventPointer, AddressPointer } from "nostr-tools/nip19";
|
||||
import { EVENT_KINDS } from "@/constants/kinds";
|
||||
|
||||
export function derivePlaceholderName(pubkey: string): string {
|
||||
return `${pubkey.slice(0, 4)}:${pubkey.slice(-4)}`;
|
||||
@@ -75,6 +78,45 @@ export function getDisplayName(
|
||||
return derivePlaceholderName(pubkey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a display title from a root event being commented on
|
||||
* Uses kind-specific logic to generate appropriate titles
|
||||
*/
|
||||
export function getRootEventTitle(event: NostrEvent): string {
|
||||
switch (event.kind) {
|
||||
case 30023: // Long-form article
|
||||
return getArticleTitle(event) || "Article";
|
||||
case 30311: // Live activity
|
||||
return getTagValue(event, "title") || "Live Activity";
|
||||
case 30024: // Draft long-form article
|
||||
return getArticleTitle(event) || "Draft Article";
|
||||
case 1: // Note
|
||||
// Take first line or first 50 chars
|
||||
const firstLine = event.content.split("\n")[0];
|
||||
return firstLine.length > 50
|
||||
? firstLine.slice(0, 50).trim() + "..."
|
||||
: firstLine.trim() || "Note";
|
||||
case 30078: // Application-specific data
|
||||
return getTagValue(event, "d") || "Application Data";
|
||||
case 30040: // Video event
|
||||
return getTagValue(event, "title") || "Video";
|
||||
case 30041: // Audio event (podcast episode, music track)
|
||||
return getTagValue(event, "title") || "Audio";
|
||||
case 31922: // Date-based calendar event
|
||||
case 31923: // Time-based calendar event
|
||||
return (
|
||||
getTagValue(event, "name") ||
|
||||
getTagValue(event, "title") ||
|
||||
"Calendar Event"
|
||||
);
|
||||
default: {
|
||||
// Fallback to kind name from registry, or generic message
|
||||
const kindInfo = EVENT_KINDS[event.kind];
|
||||
return kindInfo?.name || `Kind ${event.kind} Event`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve $me and $contacts aliases in a Nostr filter (case-insensitive)
|
||||
* @param filter - Filter that may contain $me or $contacts aliases
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { NostrEvent } from "./nostr";
|
||||
*/
|
||||
export const CHAT_KINDS = [
|
||||
9, // NIP-29: Group chat messages
|
||||
1111, // NIP-22: Comments
|
||||
9321, // NIP-61: Nutzaps (ecash zaps in groups/live chats)
|
||||
1311, // NIP-53: Live chat messages
|
||||
9735, // NIP-57: Zap receipts (part of chat context)
|
||||
@@ -16,11 +17,12 @@ export const CHAT_KINDS = [
|
||||
*/
|
||||
export type ChatProtocol =
|
||||
| "nip-c7"
|
||||
| "nip-10"
|
||||
| "nip-17"
|
||||
| "nip-22"
|
||||
| "nip-28"
|
||||
| "nip-29"
|
||||
| "nip-53"
|
||||
| "nip-10";
|
||||
| "nip-53";
|
||||
|
||||
/**
|
||||
* Conversation type
|
||||
@@ -88,6 +90,10 @@ 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 comment thread
|
||||
rootEventKind?: number; // Kind of root event being commented on
|
||||
commentCount?: number; // Number of comments (updated reactively)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,6 +125,8 @@ export interface MessageMetadata {
|
||||
zapRecipient?: string; // Pubkey of zap recipient
|
||||
// NIP-61 nutzap-specific metadata
|
||||
nutzapUnit?: string; // Unit for nutzap amount (sat, usd, eur, etc.)
|
||||
// NIP-22 comment thread metadata
|
||||
isRootMessage?: boolean; // If true, this is the root event being commented on
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -234,6 +242,26 @@ export interface ThreadIdentifier {
|
||||
relays?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* NIP-22 comment thread identifier (any event with kind 1111 comments)
|
||||
*/
|
||||
export interface CommentThreadIdentifier {
|
||||
type: "comment-thread";
|
||||
/** Event or address pointer to the root event being commented on */
|
||||
value: {
|
||||
// For regular events (note1, nevent1)
|
||||
id?: string;
|
||||
relays?: string[];
|
||||
author?: string;
|
||||
kind?: number;
|
||||
// For addressable events (naddr1)
|
||||
pubkey?: string;
|
||||
identifier?: string;
|
||||
};
|
||||
/** Relay hints from encoding */
|
||||
relays?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Protocol-specific identifier - discriminated union
|
||||
* Returned by adapter parseIdentifier()
|
||||
@@ -245,7 +273,8 @@ export type ProtocolIdentifier =
|
||||
| NIP05Identifier
|
||||
| ChannelIdentifier
|
||||
| GroupListIdentifier
|
||||
| ThreadIdentifier;
|
||||
| ThreadIdentifier
|
||||
| CommentThreadIdentifier;
|
||||
|
||||
/**
|
||||
* Chat command parsing result
|
||||
@@ -281,9 +310,11 @@ export interface CreateConversationParams {
|
||||
export interface ChatCapabilities {
|
||||
supportsEncryption: boolean;
|
||||
supportsThreading: boolean;
|
||||
supportsReactions?: boolean;
|
||||
supportsZaps?: boolean;
|
||||
supportsModeration: boolean;
|
||||
supportsRoles: boolean;
|
||||
supportsGroupManagement: boolean;
|
||||
canCreateConversations: boolean;
|
||||
canCreateConversations?: boolean;
|
||||
requiresRelay: boolean;
|
||||
}
|
||||
|
||||
@@ -562,15 +562,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 multiple protocols: NIP-10 thread chat (kind 1 notes), NIP-22 comment threads (any event kind), NIP-29 relay-based groups, NIP-53 live activity chat, and multi-room group list interface. For NIP-10, pass a note or nevent identifier. For NIP-22, pass any event identifier (articles, live streams, etc.). 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. The protocol is auto-detected from the identifier format.",
|
||||
options: [
|
||||
{
|
||||
flag: "<identifier>",
|
||||
description:
|
||||
"NIP-29 group (relay'group-id), NIP-53 live activity (naddr1... kind 30311), or group list (naddr1... kind 10009)",
|
||||
"Event identifier (note1.../nevent1.../naddr1...), NIP-29 group (relay'group-id), NIP-53 live activity (naddr1... kind 30311), or group list (naddr1... kind 10009)",
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
"chat note1abc... Open NIP-10 thread chat for kind 1 note",
|
||||
"chat nevent1qqsxyz... Open thread or comment chat with relay hints",
|
||||
"chat naddr1...30023... Open NIP-22 comment thread for article",
|
||||
"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",
|
||||
|
||||
Reference in New Issue
Block a user