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:
Claude
2026-01-19 21:16:11 +00:00
parent 83b3b0e416
commit ef9794ae1d
7 changed files with 1065 additions and 12 deletions

View File

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

View File

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

View 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,
);
});
});
});

View 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;
}
}
}

View File

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

View File

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

View File

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