From 85fe5bee65990cf3bae73214bae65a9c60cc61ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Sun, 5 Apr 2026 22:09:51 +0200 Subject: [PATCH] feat: nip-22 threads --- CLAUDE.md | 1 + src/components/ChatViewer.tsx | 293 ++++- src/components/DynamicWindowTitle.tsx | 14 +- src/components/EventDetailViewer.tsx | 1 + src/components/EventJsonDialog.tsx | 1 + .../chat/ChatMessageContextMenu.tsx | 1 + .../nostr/kinds/BaseEventRenderer.tsx | 46 +- src/lib/blueprints.ts | 26 + src/lib/chat-parser.test.ts | 128 +- src/lib/chat-parser.ts | 183 ++- src/lib/chat/adapters/nip-22-adapter.ts | 1134 +++++++++++++++++ src/lib/command-parser.ts | 15 +- src/lib/command-reconstructor.ts | 63 + src/lib/nip73-helpers.ts | 73 +- src/services/relay-selection.ts | 105 ++ src/types/chat.ts | 49 +- src/types/man.ts | 11 +- 17 files changed, 2013 insertions(+), 131 deletions(-) create mode 100644 src/lib/chat/adapters/nip-22-adapter.ts diff --git a/CLAUDE.md b/CLAUDE.md index 30fab07..87bca12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -327,6 +327,7 @@ This allows `applyTheme()` to switch themes at runtime. - **Styling**: Tailwind v4 + HSL CSS variables (theme tokens defined in `index.css`) - **Types**: Prefer types from `applesauce-core`, extend in `src/types/` when needed - **No Inline Imports**: Never use `import("module").Type` in type annotations. Always use top-level `import type` statements. +- **nevent Encoding**: Always include `kind` (and `author`, `relays` when available) in `nip19.neventEncode()`. Kind metadata enables correct adapter dispatch (e.g., NIP-10 vs NIP-22) without needing to fetch the event first. Never encode a bare `{ id }` when kind is known. - **Locale-Aware Formatting** (`src/hooks/useLocale.ts`): All date, time, number, and currency formatting MUST use the user's locale: - **`useLocale()` hook**: Returns `{ locale, language, region, timezone, timeFormat }` - use in components that need locale config - **`formatTimestamp(timestamp, style)`**: Preferred utility for all timestamp formatting: diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx index 5488b99..aafdec4 100644 --- a/src/components/ChatViewer.tsx +++ b/src/components/ChatViewer.tsx @@ -12,6 +12,7 @@ import { Copy, CopyCheck, FileText, + MessageSquare, } from "lucide-react"; import { nip19 } from "nostr-tools"; import type { EventPointer, AddressPointer } from "nostr-tools/nip19"; @@ -27,6 +28,7 @@ import type { } from "@/types/chat"; import { CHAT_KINDS } from "@/types/chat"; import { Nip10Adapter } from "@/lib/chat/adapters/nip-10-adapter"; +import { Nip22Adapter } from "@/lib/chat/adapters/nip-22-adapter"; import { Nip29Adapter } from "@/lib/chat/adapters/nip-29-adapter"; import { Nip53Adapter } from "@/lib/chat/adapters/nip-53-adapter"; import type { ChatProtocolAdapter } from "@/lib/chat/adapters/base-adapter"; @@ -60,7 +62,16 @@ import { useProfileSearch } from "@/hooks/useProfileSearch"; import { useEmojiSearch } from "@/hooks/useEmojiSearch"; import { useCopy } from "@/hooks/useCopy"; import { useAccount } from "@/hooks/useAccount"; +import { useLocale } from "@/hooks/useLocale"; import { Label } from "./ui/label"; +import { KindRenderer } from "./nostr/kinds"; +import { + getExternalIdentifierIcon, + getExternalIdentifierLabel, + getExternalIdentifierHref, + getLocalizedRegionName, + regionToEmoji, +} from "@/lib/nip73-helpers"; import { Tooltip, TooltipContent, @@ -157,6 +168,14 @@ function getConversationRelays(conversation: Conversation): string[] { } } + // NIP-22 comments and NIP-10 threads: Use relays from metadata + if ( + conversation.protocol === "nip-22" || + conversation.protocol === "nip-10" + ) { + return conversation.metadata?.relays || []; + } + // NIP-29 groups and fallback: Use single relay URL const relayUrl = conversation.metadata?.relayUrl; return relayUrl ? [relayUrl] : []; @@ -196,6 +215,37 @@ function getChatIdentifier(conversation: Conversation): string | null { }); } + if (conversation.protocol === "nip-22") { + const meta = conversation.metadata; + const relays = (meta?.relays || []).slice(0, 3); + + if (meta?.commentRootType === "external" && meta?.commentRootExternal) { + return meta.commentRootExternal; + } + + if (meta?.commentRootType === "address" && meta?.commentRootAddress) { + return nip19.naddrEncode({ + kind: meta.commentRootAddress.kind, + pubkey: meta.commentRootAddress.pubkey, + identifier: meta.commentRootAddress.identifier, + relays, + }); + } + + if (meta?.commentRootEventId) { + const kind = meta.commentRootKind + ? parseInt(meta.commentRootKind, 10) + : undefined; + return nip19.neventEncode({ + id: meta.commentRootEventId, + kind: Number.isFinite(kind) ? kind : undefined, + relays, + }); + } + + return null; + } + return null; } @@ -609,6 +659,12 @@ export function ChatViewer({ ? conversationResult.conversation : null; + // Relays for this conversation (used for reactions on root post, etc.) + const conversationRelays = useMemo( + () => (conversation ? getConversationRelays(conversation) : []), + [conversation], + ); + // Slash command search for action autocomplete // Context-aware: only shows relevant actions based on membership status const searchCommands = useCallback( @@ -649,8 +705,20 @@ export function ChatViewer({ const messagesWithMarkers = useMemo(() => { if (!messages || messages.length === 0) return []; + // For NIP-22, ensure root event is always first regardless of timestamp + let orderedMessages = messages; + const nip22RootId = + protocol === "nip-22" + ? conversation?.metadata?.commentRootEventId + : undefined; + if (nip22RootId) { + const rootMsg = messages.find((m) => m.id === nip22RootId); + const rest = messages.filter((m) => m.id !== nip22RootId); + orderedMessages = rootMsg ? [rootMsg, ...rest] : rest; + } + // First, group consecutive system messages - const groupedMessages = groupSystemMessages(messages); + const groupedMessages = groupSystemMessages(orderedMessages); const items: Array< | { type: "message"; data: Message } @@ -664,7 +732,14 @@ export function ChatViewer({ : item.timestamp; // Add day marker if this is the first message or if day changed - if (index === 0) { + // For NIP-22: skip marker before root (index 0), but always add one + // before the first comment (index 1) to separate it from the root + const isNip22Root = + nip22RootId && !isGroupedSystemMessage(item) && item.id === nip22RootId; + if (isNip22Root) { + // No day marker before root — KindRenderer shows its own timestamp + } else if (index === 0 || (nip22RootId && index === 1)) { + // First message (or first comment after NIP-22 root) items.push({ type: "day-marker", data: formatDayMarker(timestamp), @@ -693,7 +768,7 @@ export function ChatViewer({ }); return items; - }, [messages]); + }, [messages, protocol, conversation?.metadata?.commentRootEventId]); // Track reply context (which message is being replied to) const [replyTo, setReplyTo] = useState(); @@ -874,6 +949,8 @@ export function ChatViewer({ const handleNipClick = useCallback(() => { if (conversation?.protocol === "nip-10") { addWindow("nip", { number: 10 }); + } else if (conversation?.protocol === "nip-22") { + addWindow("nip", { number: 22 }); } else if (conversation?.protocol === "nip-29") { addWindow("nip", { number: 29 }); } else if (conversation?.protocol === "nip-53") { @@ -888,23 +965,28 @@ export function ChatViewer({ ? conversation?.metadata?.liveActivity : undefined; - // Derive participants from messages for live activities and NIP-10 threads + // Derive participants from messages for live activities, NIP-10 threads, and NIP-22 comments const derivedParticipants = useMemo(() => { - // NIP-10 threads: derive from messages with OP first - if (protocol === "nip-10" && messages && conversation) { - const rootAuthor = conversation.metadata?.rootEventId - ? messages.find((m) => m.id === conversation.metadata?.rootEventId) - ?.author + // NIP-10 threads and NIP-22 comments: derive from messages with OP first + if ( + (protocol === "nip-10" || protocol === "nip-22") && + messages && + conversation + ) { + const rootId = + protocol === "nip-10" + ? conversation.metadata?.rootEventId + : conversation.metadata?.commentRootEventId; + const rootAuthor = rootId + ? messages.find((m) => m.id === rootId)?.author : undefined; const participants: { pubkey: string; role: "op" | "member" }[] = []; - // OP (root author) always first if (rootAuthor) { participants.push({ pubkey: rootAuthor, role: "op" }); } - // Add other participants from messages (excluding OP) const seen = new Set(rootAuthor ? [rootAuthor] : []); for (const msg of messages) { if (msg.type !== "system" && !seen.has(msg.author)) { @@ -945,6 +1027,7 @@ export function ChatViewer({ conversation?.type, conversation?.participants, conversation?.metadata?.rootEventId, + conversation?.metadata?.commentRootEventId, messages, liveActivity?.hostPubkey, ]); @@ -1031,27 +1114,26 @@ export function ChatViewer({ )} {/* Protocol Type - Clickable */}
- {(conversation.type === "group" || - conversation.type === "live-chat") && ( - - )} - {(conversation.type === "group" || - conversation.type === "live-chat") && ( - - )} + + {conversation.protocol === "nip-10" ? ( Thread + ) : conversation.protocol === "nip-22" ? ( + + + Comments + ) : ( {conversation.type} @@ -1100,15 +1182,12 @@ export function ChatViewer({
- {(conversation.type === "group" || - conversation.type === "live-chat") && ( - - )} +
@@ -1130,28 +1209,51 @@ export function ChatViewer({ }} alignToBottom components={{ - Header: () => - hasMore && - conversationResult.status === "success" && - protocol !== "nip-10" ? ( -
- -
- ) : null, + Header: () => { + // NIP-22 external root header (hashtag, URL, country, etc.) + if ( + protocol === "nip-22" && + conversation.metadata?.commentRootType === "external" && + conversation.metadata?.commentRootExternal + ) { + return ( + + ); + } + + // "Load older" for protocols that support it + if ( + hasMore && + conversationResult.status === "success" && + protocol !== "nip-10" && + protocol !== "nip-22" + ) { + return ( +
+ +
+ ); + } + + return null; + }, Footer: () =>
, }} itemContent={(_index, item) => { @@ -1182,6 +1284,28 @@ export function ChatViewer({ protocol === "nip-10" && conversation.metadata?.rootEventId === item.data.id; + // NIP-22 root: render with feed KindRenderer (no border) + const isNip22Root = + protocol === "nip-22" && + item.data.id === conversation.metadata?.commentRootEventId; + if (isNip22Root && item.data.event) { + return ( +
+
+ +
+
+ +
+
+ ); + } + return ( + {regionToEmoji(code)} + + {getLocalizedRegionName(code, userLocale)} + +
+ ); + } + + const Icon = getExternalIdentifierIcon(kValue); + const label = getExternalIdentifierLabel(external, kValue); + const href = getExternalIdentifierHref(external); + + return ( +
+ + {href ? ( + + {label} + + ) : ( + {label} + )} +
+ ); +} + /** * 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 @@ -1298,6 +1473,8 @@ 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) diff --git a/src/components/DynamicWindowTitle.tsx b/src/components/DynamicWindowTitle.tsx index a3575ee..bf15b8f 100644 --- a/src/components/DynamicWindowTitle.tsx +++ b/src/components/DynamicWindowTitle.tsx @@ -153,7 +153,12 @@ function generateRawCommand(appId: string, props: any): string { if (props.pointer) { try { if ("id" in props.pointer) { - const nevent = nip19.neventEncode({ id: props.pointer.id }); + const nevent = nip19.neventEncode({ + id: props.pointer.id, + kind: props.pointer.kind, + author: props.pointer.author, + relays: props.pointer.relays, + }); return `open ${nevent}`; } else if ("kind" in props.pointer && "pubkey" in props.pointer) { const naddr = nip19.naddrEncode({ @@ -282,7 +287,12 @@ function generateRawCommand(appId: string, props: any): string { let result = `zap ${npub}`; if (props.eventPointer) { if ("id" in props.eventPointer) { - const nevent = nip19.neventEncode({ id: props.eventPointer.id }); + const nevent = nip19.neventEncode({ + id: props.eventPointer.id, + kind: props.eventPointer.kind, + author: props.eventPointer.author, + relays: props.eventPointer.relays, + }); result += ` ${nevent}`; } else if ( "kind" in props.eventPointer && diff --git a/src/components/EventDetailViewer.tsx b/src/components/EventDetailViewer.tsx index 75f7d29..6d436b3 100644 --- a/src/components/EventDetailViewer.tsx +++ b/src/components/EventDetailViewer.tsx @@ -55,6 +55,7 @@ export function EventDetailViewer({ pointer }: EventDetailViewerProps) { id: event.id, relays: relays, author: event.pubkey, + kind: event.kind, }) : nip19.naddrEncode({ kind: event.kind, diff --git a/src/components/EventJsonDialog.tsx b/src/components/EventJsonDialog.tsx index a13f868..263ffc1 100644 --- a/src/components/EventJsonDialog.tsx +++ b/src/components/EventJsonDialog.tsx @@ -50,6 +50,7 @@ export function EventJsonDialog({ : nip19.neventEncode({ id: event.id, author: event.pubkey, + kind: event.kind, relays, }); }, [event]); diff --git a/src/components/chat/ChatMessageContextMenu.tsx b/src/components/chat/ChatMessageContextMenu.tsx index d4c25f8..e4030e8 100644 --- a/src/components/chat/ChatMessageContextMenu.tsx +++ b/src/components/chat/ChatMessageContextMenu.tsx @@ -115,6 +115,7 @@ export function ChatMessageContextMenu({ const nevent = nip19.neventEncode({ id: event.id, author: event.pubkey, + kind: event.kind, relays: relays, }); copy(nevent); diff --git a/src/components/nostr/kinds/BaseEventRenderer.tsx b/src/components/nostr/kinds/BaseEventRenderer.tsx index 791fdfd..d082b6e 100644 --- a/src/components/nostr/kinds/BaseEventRenderer.tsx +++ b/src/components/nostr/kinds/BaseEventRenderer.tsx @@ -176,6 +176,7 @@ function useEventActions(event: NostrEvent) { nip19.neventEncode({ id: event.id, author: event.pubkey, + kind: event.kind, relays, }), ); @@ -209,10 +210,11 @@ function useEventActions(event: NostrEvent) { }, [event, addWindow]); const openChatWindow = useCallback(() => { - if (event.kind === 1) { - const seenRelaysSet = getSeenRelays(event); - const relays = seenRelaysSet ? Array.from(seenRelaysSet) : []; + const seenRelaysSet = getSeenRelays(event); + const relays = seenRelaysSet ? Array.from(seenRelaysSet) : []; + if (event.kind === 1) { + // Kind 1 → NIP-10 thread chat addWindow("chat", { protocol: "nip-10", identifier: { @@ -226,6 +228,33 @@ function useEventActions(event: NostrEvent) { relays, }, }); + } else { + // All other kinds → NIP-22 comment thread + const dTag = isAddressableKind(event.kind) + ? getTagValue(event, "d") + : undefined; + + addWindow("chat", { + protocol: "nip-22", + identifier: { + type: "comment", + value: { + eventId: event.id, + address: + dTag !== undefined + ? { + kind: event.kind, + pubkey: event.pubkey, + identifier: dTag, + } + : undefined, + relays, + author: event.pubkey, + kind: event.kind, + }, + relays, + }, + }); } }, [event, addWindow]); @@ -264,7 +293,6 @@ interface EventMenuItemsProps { function EventMenuItems({ Item, Separator, - event, actions, onReactClick, canSign, @@ -282,12 +310,10 @@ function EventMenuItems({ Zap - {event.kind === 1 && ( - - - Chat - - )} + + + Chat + {canSign && onReactClick && ( diff --git a/src/lib/blueprints.ts b/src/lib/blueprints.ts index ef51c98..eb3b470 100644 --- a/src/lib/blueprints.ts +++ b/src/lib/blueprints.ts @@ -37,6 +37,11 @@ import { setReaction, setReactionParent, } from "applesauce-common/operations/reaction"; +import { setParent as setCommentParent } from "applesauce-common/operations/comment"; +import { + COMMENT_KIND, + type CommentPointer, +} from "applesauce-common/helpers/comment"; import { GROUP_MESSAGE_KIND, type GroupPointer, @@ -179,3 +184,24 @@ export function ReactionBlueprint( typeof emoji !== "string" ? includeEmojisWithAddress([emoji]) : undefined, ); } + +// --------------------------------------------------------------------------- +// CommentBlueprint (NIP-22 kind 1111) +// --------------------------------------------------------------------------- + +export type CommentBlueprintOptions = TextContentOptionsWithAddress & + MetaTagOptions; + +export function CommentBlueprint( + parent: NostrEvent | CommentPointer, + content: string, + options?: CommentBlueprintOptions, +) { + return blueprint( + COMMENT_KIND, + setCommentParent(parent), + setShortTextContent(content, { ...options, emojis: undefined }), + options?.emojis ? includeEmojisWithAddress(options.emojis) : undefined, + setMetaTags(options), + ); +} diff --git a/src/lib/chat-parser.test.ts b/src/lib/chat-parser.test.ts index 80f010d..6cfd70a 100644 --- a/src/lib/chat-parser.test.ts +++ b/src/lib/chat-parser.test.ts @@ -4,8 +4,8 @@ import { parseChatCommand } from "./chat-parser"; describe("parseChatCommand", () => { describe("NIP-29 relay groups", () => { - it("should parse NIP-29 group ID without protocol (single arg)", () => { - const result = parseChatCommand(["groups.0xchat.com'chachi"]); + it("should parse NIP-29 group ID without protocol (single arg)", async () => { + const result = await parseChatCommand(["groups.0xchat.com'chachi"]); expect(result.protocol).toBe("nip-29"); expect(result.identifier).toEqual({ @@ -16,9 +16,9 @@ describe("parseChatCommand", () => { expect(result.adapter.protocol).toBe("nip-29"); }); - it("should parse NIP-29 group ID when split by shell-quote", () => { + it("should parse NIP-29 group ID when split by shell-quote", async () => { // shell-quote splits on ' so "groups.0xchat.com'chachi" becomes ["groups.0xchat.com", "chachi"] - const result = parseChatCommand(["groups.0xchat.com", "chachi"]); + const result = await parseChatCommand(["groups.0xchat.com", "chachi"]); expect(result.protocol).toBe("nip-29"); expect(result.identifier).toEqual({ @@ -29,8 +29,8 @@ describe("parseChatCommand", () => { expect(result.adapter.protocol).toBe("nip-29"); }); - it("should parse NIP-29 group ID with wss:// protocol (single arg)", () => { - const result = parseChatCommand(["wss://groups.0xchat.com'chachi"]); + it("should parse NIP-29 group ID with wss:// protocol (single arg)", async () => { + const result = await parseChatCommand(["wss://groups.0xchat.com'chachi"]); expect(result.protocol).toBe("nip-29"); expect(result.identifier).toEqual({ @@ -40,8 +40,11 @@ describe("parseChatCommand", () => { }); }); - it("should parse NIP-29 group ID with wss:// when split by shell-quote", () => { - const result = parseChatCommand(["wss://groups.0xchat.com", "chachi"]); + it("should parse NIP-29 group ID with wss:// when split by shell-quote", async () => { + const result = await parseChatCommand([ + "wss://groups.0xchat.com", + "chachi", + ]); expect(result.protocol).toBe("nip-29"); expect(result.identifier).toEqual({ @@ -51,24 +54,27 @@ describe("parseChatCommand", () => { }); }); - it("should parse NIP-29 group with different relay and group-id (single arg)", () => { - const result = parseChatCommand(["relay.example.com'bitcoin-dev"]); + it("should parse NIP-29 group with different relay and group-id (single arg)", async () => { + const result = await parseChatCommand(["relay.example.com'bitcoin-dev"]); expect(result.protocol).toBe("nip-29"); expect(result.identifier.value).toBe("bitcoin-dev"); expect(result.identifier.relays).toEqual(["wss://relay.example.com"]); }); - it("should parse NIP-29 group with different relay when split", () => { - const result = parseChatCommand(["relay.example.com", "bitcoin-dev"]); + it("should parse NIP-29 group with different relay when split", async () => { + const result = await parseChatCommand([ + "relay.example.com", + "bitcoin-dev", + ]); expect(result.protocol).toBe("nip-29"); expect(result.identifier.value).toBe("bitcoin-dev"); expect(result.identifier.relays).toEqual(["wss://relay.example.com"]); }); - it("should parse NIP-29 group from nos.lol", () => { - const result = parseChatCommand(["nos.lol'welcome"]); + it("should parse NIP-29 group from nos.lol", async () => { + const result = await parseChatCommand(["nos.lol'welcome"]); expect(result.protocol).toBe("nip-29"); expect(result.identifier.value).toBe("welcome"); @@ -77,39 +83,33 @@ describe("parseChatCommand", () => { }); describe("error handling", () => { - it("should throw error when no identifier provided", () => { - expect(() => parseChatCommand([])).toThrow( + it("should throw error when no identifier provided", async () => { + await expect(parseChatCommand([])).rejects.toThrow( "Chat identifier required. Usage: chat ", ); }); - it("should throw error for unsupported identifier format", () => { - expect(() => parseChatCommand(["unsupported-format"])).toThrow( + it("should throw error for unsupported identifier format", async () => { + await expect(parseChatCommand(["unsupported-format"])).rejects.toThrow( /Unable to determine chat protocol/, ); }); - it("should throw error for npub (DMs not yet supported)", () => { - expect(() => parseChatCommand(["npub1xyz"])).toThrow( + it("should throw error for npub (DMs not yet supported)", async () => { + await expect(parseChatCommand(["npub1xyz"])).rejects.toThrow( /Unable to determine chat protocol/, ); }); - it("should throw error for note/nevent (NIP-28 not implemented)", () => { - expect(() => parseChatCommand(["note1xyz"])).toThrow( - /Unable to determine chat protocol/, - ); - }); - - it("should throw error for malformed naddr", () => { - expect(() => parseChatCommand(["naddr1xyz"])).toThrow( + it("should throw error for malformed naddr", async () => { + await expect(parseChatCommand(["naddr1xyz"])).rejects.toThrow( /Unable to determine chat protocol/, ); }); }); describe("NIP-53 live activity chat", () => { - it("should parse NIP-53 live activity naddr", () => { + it("should parse NIP-53 live activity naddr", async () => { const naddr = nip19.naddrEncode({ kind: 30311, pubkey: @@ -118,7 +118,7 @@ describe("parseChatCommand", () => { relays: ["wss://relay.example.com"], }); - const result = parseChatCommand([naddr]); + const result = await parseChatCommand([naddr]); expect(result.protocol).toBe("nip-53"); expect(result.identifier).toEqual({ @@ -134,7 +134,7 @@ describe("parseChatCommand", () => { expect(result.adapter.protocol).toBe("nip-53"); }); - it("should parse NIP-53 live activity naddr with multiple relays", () => { + it("should parse NIP-53 live activity naddr with multiple relays", async () => { const naddr = nip19.naddrEncode({ kind: 30311, pubkey: @@ -143,7 +143,7 @@ describe("parseChatCommand", () => { relays: ["wss://relay1.example.com", "wss://relay2.example.com"], }); - const result = parseChatCommand([naddr]); + const result = await parseChatCommand([naddr]); expect(result.protocol).toBe("nip-53"); expect(result.identifier.value).toEqual({ @@ -158,7 +158,7 @@ describe("parseChatCommand", () => { ]); }); - it("should not parse NIP-29 group naddr as NIP-53", () => { + it("should not parse NIP-29 group naddr as NIP-53", async () => { const naddr = nip19.naddrEncode({ kind: 39000, pubkey: @@ -168,9 +168,69 @@ describe("parseChatCommand", () => { }); // NIP-29 adapter should handle kind 39000 - const result = parseChatCommand([naddr]); + const result = await parseChatCommand([naddr]); expect(result.protocol).toBe("nip-29"); }); }); + + describe("NIP-22 comments", () => { + it("should parse URL as NIP-22 external identifier", async () => { + const result = await parseChatCommand(["https://example.com/article"]); + + expect(result.protocol).toBe("nip-22"); + expect(result.identifier).toEqual({ + type: "comment", + value: { external: "https://example.com/article" }, + relays: [], + }); + }); + + it("should parse hashtag as NIP-22 external identifier", async () => { + const result = await parseChatCommand(["#bitcoin"]); + + expect(result.protocol).toBe("nip-22"); + expect(result.identifier).toEqual({ + type: "comment", + value: { external: "#bitcoin" }, + relays: [], + }); + }); + + it("should parse naddr with non-NIP-53/NIP-29 kind as NIP-22", async () => { + const naddr = nip19.naddrEncode({ + kind: 30023, + pubkey: + "0000000000000000000000000000000000000000000000000000000000000001", + identifier: "my-article", + relays: ["wss://relay.example.com"], + }); + + const result = await parseChatCommand([naddr]); + + expect(result.protocol).toBe("nip-22"); + expect(result.identifier.type).toBe("comment"); + if (result.identifier.type === "comment") { + expect(result.identifier.value.address).toEqual({ + kind: 30023, + pubkey: + "0000000000000000000000000000000000000000000000000000000000000001", + identifier: "my-article", + }); + } + }); + + it("should parse nevent with explicit non-kind-1 as NIP-22", async () => { + const nevent = nip19.neventEncode({ + id: "0000000000000000000000000000000000000000000000000000000000000001", + kind: 1111, + relays: ["wss://relay.example.com"], + }); + + const result = await parseChatCommand([nevent]); + + expect(result.protocol).toBe("nip-22"); + expect(result.identifier.type).toBe("comment"); + }); + }); }); diff --git a/src/lib/chat-parser.ts b/src/lib/chat-parser.ts index 3ddf3f9..8fb6d87 100644 --- a/src/lib/chat-parser.ts +++ b/src/lib/chat-parser.ts @@ -2,7 +2,16 @@ 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 { firstValueFrom } from "rxjs"; +import { toArray, catchError } from "rxjs/operators"; +import { timeout as rxTimeout, of } from "rxjs"; +import { getOutboxes } from "applesauce-core/helpers/mailboxes"; +import { mergeRelaySets } from "applesauce-core/helpers"; +import eventStore from "@/services/event-store"; +import pool from "@/services/relay-pool"; +import { AGGREGATOR_RELAYS } from "@/services/loaders"; // Import other adapters as they're implemented // import { Nip17Adapter } from "./chat/adapters/nip-17-adapter"; // import { Nip28Adapter } from "./chat/adapters/nip-28-adapter"; @@ -10,18 +19,23 @@ 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 - * 5. NIP-53 (live chat) - specific addressable format (kind 30311) + * Adapter priority: + * 1. NIP-10 (thread chat) - nevent with kind=1, note1 + * 2. NIP-29 (groups) - relay'group-id format, naddr kind 39000 + * 3. NIP-53 (live chat) - naddr kind 30311 + * 4. NIP-22 (comments) - catch-all: nevent with explicit non-1/30311 kind, + * non-NIP-29/53 naddr, URLs, hashtags + * + * For nevent/note without kind metadata, fetches the event first and + * dispatches to the correct adapter based on actual kind. * * @param args - Command arguments (first arg is the identifier) * @returns Parsed result with protocol and identifier * @throws Error if no adapter can parse the identifier */ -export function parseChatCommand(args: string[]): ChatCommandResult { +export async function parseChatCommand( + args: string[], +): Promise { if (args.length === 0) { throw new Error("Chat identifier required. Usage: chat "); } @@ -30,8 +44,6 @@ export function parseChatCommand(args: string[]): ChatCommandResult { // If we have 2 args and they look like relay + group-id, join them with ' let identifier = args[0]; if (args.length === 2 && args[0].includes(".") && !args[0].includes("'")) { - // Looks like "relay.com" "group-id" split by shell-quote - // Rejoin with apostrophe for NIP-29 format identifier = `${args[0]}'${args[1]}`; } @@ -50,23 +62,31 @@ export function parseChatCommand(args: string[]): ChatCommandResult { relays: decoded.data.relays, }; return { - protocol: "nip-29", // Use nip-29 as the protocol designation + protocol: "nip-29", identifier: groupListIdentifier, - adapter: null, // No adapter needed for group list view + adapter: null, }; } - } catch (e) { + } catch { // Not a valid naddr, continue to adapter parsing } } + // For nevent/note without kind metadata, fetch the event first and + // dispatch based on actual kind. This MUST run before the adapter loop + // because NIP-10 claims nevent without kind, which would fail at resolve + // time for non-kind-1 events. + const resolved = await resolveAmbiguousIdentifier(identifier); + if (resolved) return resolved; + // Try each adapter in priority order const adapters = [ - new Nip10Adapter(), // NIP-10 - Thread chat (nevent/note) + new Nip10Adapter(), // NIP-10 - Thread chat (nevent kind=1 or note1) // new Nip17Adapter(), // Phase 2 // new Nip28Adapter(), // Phase 3 new Nip29Adapter(), // NIP-29 - Relay groups new Nip53Adapter(), // NIP-53 - Live activity chat + new Nip22Adapter(), // NIP-22 - Comments (catch-all) ]; for (const adapter of adapters) { @@ -94,15 +114,150 @@ Currently supported formats: chat wss://relay.example.com'nostr-dev - naddr1... (NIP-29 group metadata, kind 39000) Example: - chat naddr1qqxnzdesxqmnxvpexqmny... + chat naddr1qqxnzdesxqmny... - naddr1... (NIP-53 live activity chat, kind 30311) Example: chat naddr1... (live stream address) - naddr1... (Multi-room group list, kind 10009) Example: chat naddr1... (group list address) + - nevent1.../naddr1... (NIP-22 comments on any event kind) + Examples: + chat nevent1... (comment on article, issue, etc.) + chat naddr1... (comment on addressable event) + - https://... (NIP-22 comments on a URL) + Example: + chat https://example.com/article + - #hashtag (NIP-22 comments on a hashtag) + Example: + chat #bitcoin More formats coming soon: - npub/nprofile/hex pubkey (NIP-17 direct messages)`, ); } + +/** + * For nevent/note identifiers without kind metadata, fetch the event + * to determine which adapter should handle it. + * + * Returns null for identifiers that already have kind info (adapters handle those) + * or for non-nevent/note formats. + */ +async function resolveAmbiguousIdentifier( + input: string, +): Promise { + let eventId: string | null = null; + let relayHints: string[] = []; + let author: string | undefined; + + if (input.startsWith("note1")) { + try { + const decoded = nip19.decode(input); + if (decoded.type === "note") { + eventId = decoded.data as string; + } + } catch { + return null; + } + } else if (input.startsWith("nevent1")) { + try { + const decoded = nip19.decode(input); + if (decoded.type === "nevent") { + // If kind is already defined, let the adapter loop handle it + if (decoded.data.kind !== undefined) return null; + eventId = decoded.data.id; + relayHints = decoded.data.relays || []; + author = decoded.data.author; + } + } catch { + return null; + } + } + + if (!eventId) return null; + + // Fetch the event to determine its kind + const event = await fetchEventForDispatch(eventId, relayHints, author); + if (!event) { + throw new Error( + "Could not fetch event to determine its kind. The event may not exist or the relays may be unreachable.", + ); + } + + // Route based on kind + if (event.kind === 1) { + const adapter = new Nip10Adapter(); + return { + protocol: "nip-10", + identifier: { + type: "thread", + value: { id: eventId, relays: relayHints, author, kind: 1 }, + relays: relayHints, + }, + adapter, + }; + } + + // Everything else → NIP-22 + const adapter = new Nip22Adapter(); + return { + protocol: "nip-22", + identifier: { + type: "comment", + value: { + eventId, + relays: relayHints, + author, + kind: event.kind, + }, + relays: relayHints, + }, + adapter, + }; +} + +/** + * Fetch an event by ID to determine its kind for adapter dispatch. + * Checks EventStore cache first, then fetches from relays. + * Includes author's outbox relays when available for better discoverability. + */ +async function fetchEventForDispatch( + eventId: string, + relayHints: string[], + authorPubkey?: string, +): Promise<{ kind: number } | null> { + // Check EventStore cache first (synchronous) + const cached = eventStore.getEvent(eventId); + if (cached) return cached; + + // Build relay list: hints + author outbox + aggregator fallback + const relaySets: string[][] = []; + if (relayHints.length > 0) relaySets.push(relayHints); + + // Include author's outbox relays if we have their pubkey + if (authorPubkey) { + const relayList = eventStore.getReplaceable(10002, authorPubkey, ""); + if (relayList) { + relaySets.push(getOutboxes(relayList).slice(0, 3)); + } + } + + relaySets.push(AGGREGATOR_RELAYS); + const relays = mergeRelaySets(...relaySets); + + const filter = { ids: [eventId], limit: 1 }; + + try { + const events = await firstValueFrom( + pool.request(relays, [filter], { eventStore }).pipe( + rxTimeout(10_000), + toArray(), + catchError(() => of([])), + ), + ); + return events[0] || null; + } catch { + return null; + } +} diff --git a/src/lib/chat/adapters/nip-22-adapter.ts b/src/lib/chat/adapters/nip-22-adapter.ts new file mode 100644 index 0000000..60f3066 --- /dev/null +++ b/src/lib/chat/adapters/nip-22-adapter.ts @@ -0,0 +1,1134 @@ +import { + Observable, + firstValueFrom, + combineLatest, + timeout as rxTimeout, + of, +} from "rxjs"; +import { map, first, toArray, catchError } from "rxjs/operators"; +import type { Filter } from "nostr-tools"; +import { nip19 } from "nostr-tools"; +import type { EventPointer, AddressPointer } from "nostr-tools/nip19"; +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 type { EmojiTag } from "@/lib/emoji-helpers"; +import eventStore from "@/services/event-store"; +import pool from "@/services/relay-pool"; +import { publishEventToRelays } from "@/services/hub"; +import accountManager from "@/services/accounts"; +import { settingsManager } from "@/services/settings"; +import { GRIMOIRE_CLIENT_TAG } from "@/constants/app"; +import { selectRelaysForCommentThread } from "@/services/relay-selection"; +import { mergeRelaySets } from "applesauce-core/helpers"; +import { + createReplaceableAddress, + isAddressableKind, + getTagValue, +} from "applesauce-core/helpers/event"; +import { getOutboxes } from "applesauce-core/helpers/mailboxes"; +import { getEventPointerFromETag } from "applesauce-core/helpers/pointers"; +import { EventFactory } from "applesauce-core/event-factory"; +import { + getCommentRootPointer, + getCommentReplyPointer, + isCommentEventPointer, + isCommentAddressPointer, + type CommentPointer, +} from "applesauce-common/helpers/comment"; +import { + getZapAmount, + getZapSender, + getZapRecipient, +} from "applesauce-common/helpers"; +import { CommentBlueprint, ReactionBlueprint } from "@/lib/blueprints"; +import { + getExternalIdentifierLabel, + inferExternalIdentifierType, +} from "@/lib/nip73-helpers"; +import { AGGREGATOR_RELAYS } from "@/services/loaders"; + +/** + * NIP-22 Adapter - Comment Threading for Any Event Kind + * + * Catch-all adapter for NIP-22 (kind 1111) comment threads. + * Handles any event kind not claimed by other adapters (NIP-10, NIP-29, NIP-53). + * + * Features: + * - Comment on any Nostr event (articles, git issues, highlights, etc.) + * - Comment on external identifiers (URLs, hashtags, podcast GUIDs) + * - Dynamic root resolution (kind 1111 events trace back to actual root) + * - Kind-aware relay selection (e.g., NIP-34 git events use repo relays) + * - Full NIP-22 tag structure (uppercase root, lowercase parent) + * + * Identifier formats: + * - nevent1... (non-kind-1, non-kind-30311 events) + * - naddr1... (non-kind-30311, non-NIP-29 addressable events) + * - https://... or http://... (external URL) + * - #hashtag (hashtag comment thread) + */ +export class Nip22Adapter extends ChatProtocolAdapter { + readonly protocol = "nip-22" as const; + readonly type = "comment-thread" as const; + + /** + * Parse identifier - accepts non-kind-1 nevent, non-NIP-29/53 naddr, URLs, hashtags + */ + parseIdentifier(input: string): ProtocolIdentifier | null { + // URL support + if (input.startsWith("http://") || input.startsWith("https://")) { + return { + type: "comment", + value: { external: input }, + relays: [], + }; + } + + // Hashtag support — store as NIP-73 format: "#" + if (input.startsWith("#") && !input.startsWith("#[")) { + const tag = input.slice(1).toLowerCase(); + if (tag.length > 0) { + return { + type: "comment", + value: { external: `#${tag}` }, + relays: [], + }; + } + } + + // nevent format + if (input.startsWith("nevent1")) { + try { + const decoded = nip19.decode(input); + if (decoded.type === "nevent") { + const { id, relays, author, kind } = decoded.data; + + // Only accept if kind is explicitly set and NOT kind 1 (NIP-10) or 30311 (NIP-53) + // If kind is undefined, return null — handled by parser fetch-then-dispatch + if (kind === undefined) return null; + if (kind === 1) return null; + if (kind === 30311) return null; + + return { + type: "comment", + value: { eventId: id, relays, author, kind }, + relays: relays || [], + }; + } + } catch { + return null; + } + } + + // Generic NIP-73 external identifiers (iso3166:ES, podcast:guid:..., isbn:..., etc.) + const externalType = inferExternalIdentifierType(input); + if (externalType !== "web") { + return { + type: "comment", + value: { external: input }, + relays: [], + }; + } + + // naddr format + if (input.startsWith("naddr1")) { + try { + const decoded = nip19.decode(input); + if (decoded.type === "naddr") { + const { kind, pubkey, identifier, relays } = decoded.data; + + // Reject kinds handled by other adapters + if (kind === 30311) return null; // NIP-53 + if (kind === 10009) return null; // Group list + if (kind >= 39000 && kind <= 39002) return null; // NIP-29 group metadata + + return { + type: "comment", + value: { + address: { kind, pubkey, identifier }, + relays, + kind, + }, + relays: relays || [], + }; + } + } catch { + return null; + } + } + + return null; + } + + /** + * Resolve conversation from comment identifier + */ + async resolveConversation( + identifier: ProtocolIdentifier, + ): Promise { + if (identifier.type !== "comment") { + throw new Error( + `NIP-22 adapter cannot handle identifier type: ${identifier.type}`, + ); + } + + const relayHints = identifier.relays || []; + + // --- External roots (URL, hashtag) --- + if (identifier.value.external) { + return this.resolveExternalConversation( + identifier.value.external, + relayHints, + ); + } + + // --- Address roots (naddr) --- + if (identifier.value.address) { + return this.resolveAddressConversation( + identifier.value.address, + relayHints, + ); + } + + // --- Event roots (nevent) --- + if (identifier.value.eventId) { + return this.resolveEventConversation( + identifier.value.eventId, + relayHints, + identifier.value.author, + identifier.value.kind, + ); + } + + throw new Error("NIP-22: identifier has no event, address, or external"); + } + + /** + * Resolve external identifier conversation (URL, hashtag) + */ + private async resolveExternalConversation( + external: string, + relayHints: string[], + ): Promise { + // Determine the K tag value from the external identifier + const rootKind = this.getExternalKindTag(external); + const title = getExternalIdentifierLabel(external, rootKind); + const conversationId = `nip-22:i:${external}`; + + // Use user's read relays + hints for external identifier threads + const relays = await selectRelaysForCommentThread(null, null, relayHints); + + return { + id: conversationId, + type: "comment-thread", + protocol: "nip-22", + title, + participants: [], + metadata: { + commentRootType: "external", + commentRootExternal: external, + commentRootKind: rootKind, + relays, + }, + unreadCount: 0, + }; + } + + /** + * Resolve addressable event conversation (naddr) + */ + private async resolveAddressConversation( + address: { kind: number; pubkey: string; identifier: string }, + relayHints: string[], + ): Promise { + // Try to fetch the addressable event for metadata + const rootEvent = await firstValueFrom( + eventStore.replaceable(address.kind, address.pubkey, address.identifier), + { defaultValue: undefined }, + ); + + // If not in store, try fetching from relays + let fetchedEvent = rootEvent; + if (!fetchedEvent && relayHints.length > 0) { + const filter: Filter = { + kinds: [address.kind], + authors: [address.pubkey], + "#d": [address.identifier], + limit: 1, + }; + const events = await firstValueFrom( + pool + .request( + relayHints.length > 0 ? relayHints : AGGREGATOR_RELAYS, + [filter], + { eventStore }, + ) + .pipe(toArray()), + ); + fetchedEvent = events[0]; + } + + const aTag = createReplaceableAddress( + address.kind, + address.pubkey, + address.identifier, + ); + const conversationId = `nip-22:a:${aTag}`; + + // Extract title from event or use kind + identifier + const title = fetchedEvent + ? this.extractTitle(fetchedEvent) + : `${address.kind}:${address.identifier}`; + + const relays = await selectRelaysForCommentThread( + fetchedEvent || null, + address.kind, + relayHints, + ); + + const participants: Participant[] = [ + { pubkey: address.pubkey, role: "op" }, + ]; + + return { + id: conversationId, + type: "comment-thread", + protocol: "nip-22", + title, + participants, + metadata: { + commentRootType: "address", + commentRootEventId: fetchedEvent?.id, + commentRootAddress: address, + commentRootKind: String(address.kind), + relays, + }, + unreadCount: 0, + }; + } + + /** + * Resolve event-based conversation (nevent) + * If the event is kind 1111, traces up to the actual root + */ + private async resolveEventConversation( + eventId: string, + relayHints: string[], + _authorHint?: string, + _kindHint?: number, + ): Promise { + // Fetch the provided event + const providedEvent = await this.fetchEvent(eventId, relayHints); + if (!providedEvent) { + throw new Error("Event not found"); + } + + // If this is a kind 1111 comment, trace to the actual root + if (providedEvent.kind === 1111) { + return this.resolveFromComment(providedEvent, relayHints); + } + + // This event IS the root + const conversationId = this.getConversationIdForEvent(providedEvent); + + const title = this.extractTitle(providedEvent); + const relays = await selectRelaysForCommentThread( + providedEvent, + providedEvent.kind, + relayHints, + ); + + const participants: Participant[] = [ + { pubkey: providedEvent.pubkey, role: "op" }, + ]; + + // Check if this is an addressable event — store address metadata too + const isAddressable = isAddressableKind(providedEvent.kind); + const dTag = isAddressable ? getTagValue(providedEvent, "d") : undefined; + + return { + id: conversationId, + type: "comment-thread", + protocol: "nip-22", + title, + participants, + metadata: { + commentRootType: isAddressable ? "address" : "event", + commentRootEventId: providedEvent.id, + commentRootAddress: + isAddressable && dTag !== undefined + ? { + kind: providedEvent.kind, + pubkey: providedEvent.pubkey, + identifier: dTag, + } + : undefined, + commentRootKind: String(providedEvent.kind), + relays, + }, + unreadCount: 0, + }; + } + + /** + * Resolve conversation from a kind 1111 comment event by tracing to root + */ + private async resolveFromComment( + commentEvent: NostrEvent, + relayHints: string[], + ): Promise { + const rootPointer = getCommentRootPointer(commentEvent); + if (!rootPointer) { + throw new Error("NIP-22 comment missing root pointer"); + } + + if (isCommentEventPointer(rootPointer)) { + return this.resolveEventConversation( + rootPointer.id, + rootPointer.relay ? [rootPointer.relay, ...relayHints] : relayHints, + rootPointer.pubkey, + rootPointer.kind, + ); + } + + if (isCommentAddressPointer(rootPointer)) { + const hints = rootPointer.relay + ? [rootPointer.relay, ...relayHints] + : relayHints; + return this.resolveAddressConversation( + { + kind: rootPointer.kind, + pubkey: rootPointer.pubkey, + identifier: rootPointer.identifier, + }, + hints, + ); + } + + // External pointer + if (rootPointer.type === "external") { + return this.resolveExternalConversation( + rootPointer.identifier, + relayHints, + ); + } + + throw new Error("NIP-22: unknown root pointer type"); + } + + /** + * Load messages for a comment thread + */ + loadMessages( + conversation: Conversation, + options?: LoadMessagesOptions, + ): Observable { + const meta = conversation.metadata; + const relays = meta?.relays || []; + const conversationId = conversation.id; + const rootType = meta?.commentRootType; + + // Build comment filter based on root type + const commentFilter = this.buildCommentFilter(conversation, options); + const filters: Filter[] = [commentFilter]; + + // Add interaction filters on root event + if (rootType === "event" && meta?.commentRootEventId) { + const rootId = meta.commentRootEventId; + filters.push( + { kinds: [7], "#e": [rootId], limit: 200 }, + { kinds: [9735], "#e": [rootId], limit: 100 }, + { kinds: [16], "#e": [rootId], limit: 100 }, + ); + } else if (rootType === "address" && meta?.commentRootAddress) { + const aTag = createReplaceableAddress( + meta.commentRootAddress.kind, + meta.commentRootAddress.pubkey, + meta.commentRootAddress.identifier, + ); + filters.push( + { kinds: [7], "#a": [aTag], limit: 200 }, + { kinds: [9735], "#a": [aTag], limit: 100 }, + { kinds: [16], "#a": [aTag], limit: 100 }, + ); + } + + // Clean up existing subscription + this.cleanup(conversationId); + + // Start persistent subscription + const subscription = pool + .subscription(relays, filters, { eventStore }) + .subscribe({ + next: () => { + // Events handled by EventStore + }, + }); + + this.subscriptions.set(conversationId, subscription); + + // Build comment filter (kind 1111 with uppercase root tags) + const commentTimelineFilter = this.buildEventStoreFilter(conversation); + const comments$ = eventStore.timeline(commentTimelineFilter); + + // Build interactions filter (zaps/reposts on root, using lowercase tags) + const interactions$ = this.buildInteractionsObservable(conversation); + + // For event/address roots, include the root event in the stream + if ( + (rootType === "event" || rootType === "address") && + meta?.commentRootEventId + ) { + const rootEvent$ = eventStore.event(meta.commentRootEventId); + const sources = interactions$ + ? [rootEvent$, comments$, interactions$] + : [rootEvent$, comments$]; + + return combineLatest(sources).pipe( + map((results) => { + const messages: Message[] = []; + const rootEvent = results[0] as NostrEvent | undefined; + const commentEvents = results[1] as NostrEvent[]; + const interactionEvents = (results[2] as NostrEvent[]) || []; + + if (rootEvent) { + messages.push(this.rootEventToMessage(rootEvent, conversationId)); + } + + for (const event of commentEvents) { + const msg = this.eventToMessage(event, conversationId, meta); + if (msg) messages.push(msg); + } + + for (const event of interactionEvents) { + const msg = this.eventToMessage(event, conversationId, meta); + if (msg) messages.push(msg); + } + + return messages.sort((a, b) => a.timestamp - b.timestamp); + }), + ); + } + + // External roots or address roots without event ID + const toMessages = (events: NostrEvent[]): Message[] => + events + .map((event) => this.eventToMessage(event, conversationId, meta)) + .filter((msg): msg is Message => msg !== null); + + if (interactions$) { + return combineLatest([comments$, interactions$]).pipe( + map(([commentEvents, interactionEvents]) => + [...toMessages(commentEvents), ...toMessages(interactionEvents)].sort( + (a, b) => a.timestamp - b.timestamp, + ), + ), + ); + } + + return comments$.pipe( + map((commentEvents) => + toMessages(commentEvents).sort((a, b) => a.timestamp - b.timestamp), + ), + ); + } + + /** + * Load more historical messages (pagination) + */ + async loadMoreMessages( + conversation: Conversation, + before: number, + ): Promise { + const meta = conversation.metadata; + const relays = meta?.relays || []; + + const commentFilter = this.buildCommentFilter(conversation, { + before, + limit: 50, + }); + + const events = await firstValueFrom( + pool.request(relays, [commentFilter], { eventStore }).pipe(toArray()), + ); + + const messages = events + .map((event) => this.eventToMessage(event, conversation.id, meta)) + .filter((msg): msg is Message => msg !== null); + + return messages.reverse(); + } + + /** + * Send a comment to the thread + */ + async sendMessage( + conversation: Conversation, + content: string, + options?: SendMessageOptions, + ): Promise { + const activePubkey = accountManager.active$.value?.pubkey; + const activeSigner = accountManager.active$.value?.signer; + + if (!activePubkey || !activeSigner) { + throw new Error("No active account or signer"); + } + + const meta = conversation.metadata; + const relays = meta?.relays || []; + + // Determine parent event + let parentEvent: NostrEvent | undefined; + + if (options?.replyTo) { + // Replying to a specific comment + parentEvent = await firstValueFrom(eventStore.event(options.replyTo), { + defaultValue: undefined, + }); + if (!parentEvent) { + throw new Error("Parent comment event not found"); + } + } else if (meta?.commentRootEventId) { + // Top-level comment on the root event + parentEvent = await firstValueFrom( + eventStore.event(meta.commentRootEventId), + { defaultValue: undefined }, + ); + if (!parentEvent) { + throw new Error("Root event not found in store"); + } + } + + const factory = new EventFactory(); + factory.setSigner(activeSigner); + + let draft; + if (parentEvent) { + // Use CommentBlueprint with the parent event + draft = await factory.create(CommentBlueprint, parentEvent, content, { + emojis: options?.emojiTags?.map((e) => ({ + shortcode: e.shortcode, + url: e.url, + address: e.address, + })), + }); + } else if (meta?.commentRootExternal) { + // External root — create CommentPointer + const pointer = this.buildExternalCommentPointer(meta); + if (!pointer) { + throw new Error("Cannot build comment pointer for external root"); + } + draft = await factory.create(CommentBlueprint, pointer, content, { + emojis: options?.emojiTags?.map((e) => ({ + shortcode: e.shortcode, + url: e.url, + address: e.address, + })), + }); + } else { + throw new Error("No parent event or external root available"); + } + + // Add NIP-92 imeta tags for blob attachments + 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}`); + draft.tags.push(["imeta", ...imetaParts]); + } + } + + // Add client tag if enabled + if (settingsManager.getSetting("post", "includeClientTag")) { + draft.tags.push(GRIMOIRE_CLIENT_TAG); + } + + const event = await factory.sign(draft); + await publishEventToRelays(event, relays); + } + + /** + * Send a reaction to a message + */ + async sendReaction( + conversation: Conversation, + messageId: string, + emoji: string, + customEmoji?: EmojiTag, + ): Promise { + const activePubkey = accountManager.active$.value?.pubkey; + const activeSigner = accountManager.active$.value?.signer; + + if (!activePubkey || !activeSigner) { + throw new Error("No active account or signer"); + } + + const relays = conversation.metadata?.relays || []; + + const messageEvent = await firstValueFrom(eventStore.event(messageId), { + defaultValue: undefined, + }); + + if (!messageEvent) { + throw new Error("Message event not found"); + } + + const factory = new EventFactory(); + factory.setSigner(activeSigner); + + const emojiArg = customEmoji ?? emoji; + const draft = await factory.create( + ReactionBlueprint, + messageEvent, + emojiArg, + ); + + if (settingsManager.getSetting("post", "includeClientTag")) { + draft.tags.push(GRIMOIRE_CLIENT_TAG); + } + + const event = await factory.sign(draft); + await publishEventToRelays(event, relays); + } + + /** + * Get zap configuration for a message + */ + getZapConfig(message: Message, conversation: Conversation): ZapConfig { + const relays = conversation.metadata?.relays || []; + + return { + supported: true, + recipientPubkey: message.author, + eventPointer: { + id: message.id, + author: message.author, + relays, + }, + relays, + }; + } + + /** + * Load a replied-to message by pointer + */ + async loadReplyMessage( + conversation: Conversation, + pointer: EventPointer | AddressPointer, + ): Promise { + const eventId = "id" in pointer ? pointer.id : null; + + if (!eventId) { + console.warn( + "[NIP-22] AddressPointer not supported for loadReplyMessage", + ); + return null; + } + + // Check EventStore first + const cachedEvent = await eventStore + .event(eventId) + .pipe(first()) + .toPromise(); + if (cachedEvent) return cachedEvent; + + // Fetch from relays + const conversationRelays = conversation.metadata?.relays || []; + const relays = mergeRelaySets(conversationRelays, pointer.relays || []); + + if (relays.length === 0) return null; + + const filter: Filter = { ids: [eventId], limit: 1 }; + const events = await firstValueFrom( + pool.request(relays, [filter], { eventStore }).pipe(toArray()), + ); + + return events[0] || null; + } + + /** + * Get capabilities + */ + getCapabilities(): ChatCapabilities { + return { + supportsEncryption: false, + supportsThreading: true, + supportsModeration: false, + supportsRoles: false, + supportsGroupManagement: false, + canCreateConversations: false, + requiresRelay: false, + }; + } + + // --- Private helpers --- + + /** + * Build relay filter for comments based on root type + */ + private buildCommentFilter( + conversation: Conversation, + options?: LoadMessagesOptions, + ): Filter { + const meta = conversation.metadata; + const filter: Filter = { + kinds: [1111], + limit: options?.limit || 100, + }; + + if (options?.before) filter.until = options.before; + if (options?.after) filter.since = options.after; + + if (meta?.commentRootType === "event" && meta?.commentRootEventId) { + filter["#E"] = [meta.commentRootEventId]; + } else if ( + meta?.commentRootType === "address" && + meta?.commentRootAddress + ) { + const aTag = createReplaceableAddress( + meta.commentRootAddress.kind, + meta.commentRootAddress.pubkey, + meta.commentRootAddress.identifier, + ); + filter["#A"] = [aTag]; + } else if ( + meta?.commentRootType === "external" && + meta?.commentRootExternal + ) { + filter["#I"] = [meta.commentRootExternal]; + } + + return filter; + } + + /** + * Build EventStore timeline filter for comments (kind 1111 only). + * Comments use uppercase tags (#E/#A/#I) per NIP-22. + */ + private buildEventStoreFilter(conversation: Conversation): Filter { + const meta = conversation.metadata; + + if (meta?.commentRootType === "event" && meta?.commentRootEventId) { + return { kinds: [1111], "#E": [meta.commentRootEventId] }; + } + + if (meta?.commentRootType === "address" && meta?.commentRootAddress) { + const aTag = createReplaceableAddress( + meta.commentRootAddress.kind, + meta.commentRootAddress.pubkey, + meta.commentRootAddress.identifier, + ); + return { kinds: [1111], "#A": [aTag] }; + } + + if (meta?.commentRootType === "external" && meta?.commentRootExternal) { + return { kinds: [1111], "#I": [meta.commentRootExternal] }; + } + + return { kinds: [1111] }; + } + + /** + * Build observable for interactions on the root (zaps, reposts). + * These use lowercase tags (#e/#a) unlike comments which use uppercase. + * Returns null for external roots (no event to interact with). + */ + private buildInteractionsObservable( + conversation: Conversation, + ): Observable | null { + const meta = conversation.metadata; + + if (meta?.commentRootType === "event" && meta?.commentRootEventId) { + return eventStore.timeline({ + kinds: [9735, 16], + "#e": [meta.commentRootEventId], + }); + } + + if (meta?.commentRootType === "address" && meta?.commentRootAddress) { + const aTag = createReplaceableAddress( + meta.commentRootAddress.kind, + meta.commentRootAddress.pubkey, + meta.commentRootAddress.identifier, + ); + return eventStore.timeline({ + kinds: [9735, 16], + "#a": [aTag], + }); + } + + return null; + } + + /** + * Get conversation ID for an event root + */ + private getConversationIdForEvent(event: NostrEvent): string { + if (isAddressableKind(event.kind)) { + const dTag = getTagValue(event, "d") || ""; + const aTag = createReplaceableAddress(event.kind, event.pubkey, dTag); + return `nip-22:a:${aTag}`; + } + return `nip-22:e:${event.id}`; + } + + /** + * Get NIP-73 K tag value for external identifiers + */ + private getExternalKindTag(external: string): string { + if (external.startsWith("http://") || external.startsWith("https://")) { + return "web"; + } + if (external.startsWith("#")) { + return "#"; + } + // Use inferExternalIdentifierType for other NIP-73 formats + return inferExternalIdentifierType(external); + } + + /** + * Build CommentPointer for external roots + */ + private buildExternalCommentPointer( + meta: Conversation["metadata"], + ): CommentPointer | null { + if (!meta?.commentRootExternal || !meta?.commentRootKind) return null; + + // Build a CommentExternalPointer + // The applesauce type expects { type: "external", kind: string, identifier: string } + return { + type: "external", + kind: meta.commentRootKind, + identifier: meta.commentRootExternal, + } as CommentPointer; + } + + /** + * Extract title from root event + */ + private extractTitle(event: NostrEvent): string { + // Try common title tags + const title = + getTagValue(event, "title") || + getTagValue(event, "name") || + getTagValue(event, "subject"); + if (title) { + return title.length <= 60 ? title : title.slice(0, 57) + "..."; + } + + // Fall back to content + const content = event.content.trim(); + if (!content) return `Comments on ${event.kind}`; + + const firstLine = content.split("\n")[0]; + if (firstLine && firstLine.length <= 50) return firstLine; + if (content.length <= 50) return content; + return content.slice(0, 47) + "..."; + } + + /** + * Convert root event to first Message + */ + private rootEventToMessage( + event: NostrEvent, + conversationId: string, + ): Message { + return { + id: event.id, + conversationId, + author: event.pubkey, + content: event.content, + timestamp: event.created_at, + type: "user", + replyTo: undefined, + protocol: "nip-22", + metadata: { encrypted: false }, + event, + }; + } + + /** + * Convert event to Message + */ + private eventToMessage( + event: NostrEvent, + conversationId: string, + _meta: Conversation["metadata"], + ): Message | null { + // Zap receipts + if (event.kind === 9735) { + return this.zapToMessage(event, conversationId); + } + + // Generic reposts + if (event.kind === 16) { + return this.repostToMessage(event, conversationId); + } + + // Reactions — handled by MessageReactions component + if (event.kind === 7) { + return null; + } + + // Kind 1111 comments + if (event.kind === 1111) { + const replyPointer = getCommentReplyPointer(event); + + // Convert CommentPointer to EventPointer for Message.replyTo + let replyTo: EventPointer | undefined; + if (replyPointer && isCommentEventPointer(replyPointer)) { + replyTo = { + id: replyPointer.id, + relays: replyPointer.relay ? [replyPointer.relay] : undefined, + }; + } + + 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 to Message + */ + private zapToMessage( + zapReceipt: NostrEvent, + conversationId: string, + ): Message { + const amount = getZapAmount(zapReceipt); + const sender = getZapSender(zapReceipt); + const recipient = getZapRecipient(zapReceipt); + const amountInSats = amount ? Math.floor(amount / 1000) : 0; + + const eTag = zapReceipt.tags.find((t) => t[0] === "e"); + const replyTo = eTag + ? (getEventPointerFromETag(eTag) ?? undefined) + : undefined; + + const zapRequestTag = zapReceipt.tags.find((t) => t[0] === "description"); + let comment = ""; + if (zapRequestTag?.[1]) { + try { + const zapRequest = JSON.parse(zapRequestTag[1]) as NostrEvent; + comment = zapRequest.content || ""; + } catch { + // Invalid JSON + } + } + + return { + id: zapReceipt.id, + conversationId, + author: sender || zapReceipt.pubkey, + content: comment, + timestamp: zapReceipt.created_at, + type: "zap", + replyTo, + protocol: "nip-22", + metadata: { zapAmount: amountInSats, zapRecipient: recipient }, + event: zapReceipt, + }; + } + + /** + * Convert repost to system Message + */ + private repostToMessage( + repostEvent: NostrEvent, + conversationId: string, + ): Message { + const eTag = repostEvent.tags.find((t) => t[0] === "e"); + const replyTo = eTag + ? (getEventPointerFromETag(eTag) ?? undefined) + : undefined; + + return { + id: repostEvent.id, + conversationId, + author: repostEvent.pubkey, + content: "reposted", + timestamp: repostEvent.created_at, + type: "system", + replyTo, + protocol: "nip-22", + metadata: {}, + event: repostEvent, + }; + } + + /** + * Fetch an event by ID from relays + */ + private async fetchEvent( + eventId: string, + relayHints: string[] = [], + ): Promise { + // Check EventStore first + const cached = await firstValueFrom(eventStore.event(eventId), { + defaultValue: undefined, + }); + if (cached) return cached; + + // Fetch from relays + const relays = + relayHints.length > 0 ? relayHints : await this.getDefaultRelays(); + + try { + const events = await firstValueFrom( + pool + .request(relays, [{ ids: [eventId], limit: 1 }], { eventStore }) + .pipe( + rxTimeout(10_000), + toArray(), + catchError(() => of([])), + ), + ); + return events[0] || null; + } catch { + return null; + } + } + + /** + * Get default relays when no hints provided + */ + private async getDefaultRelays(): Promise { + const activePubkey = accountManager.active$.value?.pubkey; + if (activePubkey) { + const relayList = await firstValueFrom( + eventStore.replaceable(10002, activePubkey, ""), + { defaultValue: undefined }, + ); + if (relayList) { + const outbox = getOutboxes(relayList).slice(0, 5); + if (outbox.length > 0) return outbox; + } + } + return AGGREGATOR_RELAYS; + } +} diff --git a/src/lib/command-parser.ts b/src/lib/command-parser.ts index 47948c2..1cef457 100644 --- a/src/lib/command-parser.ts +++ b/src/lib/command-parser.ts @@ -32,8 +32,21 @@ export function parseCommandInput(input: string): ParsedCommand { const rawTokens = parseShellTokens(escapedInput); // Convert tokens to strings and restore $ characters + // shell-quote returns { comment: 'text' } for #text — preserve as #text const tokens = rawTokens.map((token) => { - const str = typeof token === "string" ? token : String(token); + let str: string; + if (typeof token === "string") { + str = token; + } else if ( + token && + typeof token === "object" && + "comment" in token && + typeof token.comment === "string" + ) { + str = `#${token.comment}`; + } else { + str = String(token); + } return str.replace(new RegExp(DOLLAR_PLACEHOLDER, "g"), "$"); }); diff --git a/src/lib/command-reconstructor.ts b/src/lib/command-reconstructor.ts index a79dec5..a6eda5f 100644 --- a/src/lib/command-reconstructor.ts +++ b/src/lib/command-reconstructor.ts @@ -204,6 +204,69 @@ export function reconstructCommand(window: WindowInstance): string { } } + // NIP-22 comments: chat nevent1.../naddr1.../URL/#hashtag + if (protocol === "nip-22" && identifier.type === "comment") { + const val = identifier.value; + const relays = (identifier.relays || []).slice(0, 3); + + // External root (URL, hashtag) + if (val.external) { + // Hashtags are stored as "#tag" (NIP-73 format) + // URLs and other identifiers are stored as-is + return `chat ${val.external}`; + } + + // Address root (naddr) + if (val.address) { + try { + const naddr = nip19.naddrEncode({ + kind: val.address.kind, + pubkey: val.address.pubkey, + identifier: val.address.identifier, + relays, + }); + return `chat ${naddr}`; + } catch { + // Fallback + } + } + + // Event root (nevent) + if (val.eventId) { + try { + const nevent = nip19.neventEncode({ + id: val.eventId, + author: val.author, + kind: val.kind, + relays, + }); + return `chat ${nevent}`; + } catch { + // Fallback + } + } + } + + // NIP-10 threads: chat nevent1... + if (protocol === "nip-10" && identifier.type === "thread") { + const val = identifier.value; + const relays = (identifier.relays || []).slice(0, 3); + + if (val.id) { + try { + const nevent = nip19.neventEncode({ + id: val.id, + author: val.author, + kind: val.kind, + relays, + }); + return `chat ${nevent}`; + } catch { + // Fallback + } + } + } + return "chat"; } diff --git a/src/lib/nip73-helpers.ts b/src/lib/nip73-helpers.ts index 824bc08..7c3a63d 100644 --- a/src/lib/nip73-helpers.ts +++ b/src/lib/nip73-helpers.ts @@ -35,7 +35,7 @@ export function getExternalIdentifierIcon(kValue: string): LucideIcon { if (kValue === "doi") return FileText; if (kValue === "geo") return MapPin; if (kValue === "iso3166") return Flag; - if (kValue === "#") return Hash; + if (kValue === "#" || kValue === "hashtag") return Hash; if (kValue === "isan") return Film; // Blockchain types: "bitcoin:tx", "ethereum:1:address", etc. if (kValue.includes(":tx") || kValue.includes(":address")) return Coins; @@ -78,11 +78,17 @@ export function getExternalIdentifierLabel( // Geohash if (kValue === "geo") return `Location ${iValue}`; - // Country codes - if (kValue === "iso3166") return iValue.toUpperCase(); + // ISO 3166 country/region codes + if (kValue === "iso3166" || iValue.startsWith("iso3166:")) { + const code = iValue.startsWith("iso3166:") + ? iValue.slice(8).toUpperCase() + : iValue.toUpperCase(); + return getRegionDisplayName(code); + } - // Hashtag - if (iValue.startsWith("#")) return iValue; + // Hashtag (NIP-73 format: "#bitcoin" or legacy "hashtag:bitcoin") + if (kValue === "#" || iValue.startsWith("#")) return iValue; + if (iValue.startsWith("hashtag:")) return `#${iValue.slice(8)}`; // Blockchain if (iValue.includes(":tx:")) @@ -150,10 +156,65 @@ export function getExternalTypeLabel(kValue: string): string { if (kValue === "isbn") return "Book"; if (kValue === "doi") return "Paper"; if (kValue === "geo") return "Location"; - if (kValue === "iso3166") return "Country"; + if (kValue === "iso3166") return "Country / Region"; if (kValue === "#") return "Hashtag"; if (kValue === "isan") return "Film"; if (kValue.includes(":tx")) return "Transaction"; if (kValue.includes(":address")) return "Address"; return kValue; } + +/** + * Get a localized display name for an ISO 3166 region code. + * Uses Intl.DisplayNames for locale-aware country/region names. + * Supports ISO 3166-1 alpha-2 (ES, BY) and ISO 3166-2 subdivisions (ES-CT). + * + * Returns the emoji flag + localized name when possible, falls back to code. + */ +export function getRegionDisplayName(code: string): string { + const upper = code.toUpperCase(); + + // ISO 3166-2 subdivision (e.g., "ES-CT" for Catalonia) + if (upper.includes("-")) { + const countryCode = upper.split("-")[0]; + const countryName = getLocalizedRegionName(countryCode); + const flag = regionToEmoji(countryCode); + return `${flag} ${countryName} — ${upper}`; + } + + // ISO 3166-1 alpha-2 (e.g., "ES" for Spain) + const name = getLocalizedRegionName(upper); + const flag = regionToEmoji(upper); + return `${flag} ${name}`; +} + +/** + * Get a localized region name using Intl.DisplayNames. + * Accepts an explicit locale string for React components using useLocale/useGrimoire. + */ +export function getLocalizedRegionName(code: string, locale?: string): string { + try { + const displayNames = new Intl.DisplayNames(locale || undefined, { + type: "region", + }); + return displayNames.of(code.toUpperCase()) || code; + } catch { + return code; + } +} + +/** + * Convert an ISO 3166-1 alpha-2 code to its emoji flag. + * Each letter maps to a Regional Indicator Symbol (U+1F1E6..U+1F1FF). + */ +export function regionToEmoji(code: string): string { + // Only works for 2-letter codes; subdivisions (ES-CT) use the country part + const twoLetter = code.includes("-") ? code.split("-")[0] : code; + if (twoLetter.length !== 2) return ""; + const upper = twoLetter.toUpperCase(); + const offset = 0x1f1e6 - 65; // 'A' = 65 + return ( + String.fromCodePoint(upper.charCodeAt(0) + offset) + + String.fromCodePoint(upper.charCodeAt(1) + offset) + ); +} diff --git a/src/services/relay-selection.ts b/src/services/relay-selection.ts index 10fbc0a..a0755bc 100644 --- a/src/services/relay-selection.ts +++ b/src/services/relay-selection.ts @@ -22,8 +22,12 @@ import { } from "applesauce-core/helpers"; import { selectOptimalRelays } from "applesauce-core/helpers"; import { addressLoader, AGGREGATOR_RELAYS } from "./loaders"; +import { parseReplaceableAddress } from "applesauce-core/helpers/pointers"; +import { getRepositoryRelays } from "@/lib/nip34-helpers"; import { normalizeRelayURL } from "@/lib/relay-url"; import liveness from "./relay-liveness"; +import eventStore from "./event-store"; +import accountManager from "./accounts"; import relayListCache from "./relay-list-cache"; import type { RelaySelectionResult, @@ -654,3 +658,104 @@ export async function selectRelaysForInteraction( return relays; } + +// --------------------------------------------------------------------------- +// NIP-22 Comment Thread Relay Selection +// --------------------------------------------------------------------------- + +/** NIP-34 git event kinds that use repo relays */ +const NIP34_KINDS = [ + 1617, 1618, 1619, 1621, 1622, 1630, 1631, 1632, 1633, 30617, 30618, +]; + +/** + * Select relays for a NIP-22 comment thread. + * Combines kind-specific relays, root author outbox, active user outbox, and hints. + * + * @param rootEvent - The root event being commented on (null for external roots) + * @param rootKind - The kind number of the root (null for external roots) + * @param relayHints - Relay hints from identifier encoding + * @returns Deduplicated relay URLs (max 10) + */ +export async function selectRelaysForCommentThread( + rootEvent: NostrEvent | null, + rootKind: number | null, + relayHints: string[], +): Promise { + const relaySets: string[][] = [relayHints]; + + // 1. Kind-specific relays + if (rootKind !== null && rootEvent) { + const kindRelays = await getRelaysByEventKind(rootKind, rootEvent); + relaySets.push(kindRelays); + } + + // 2. Root author outbox + if (rootEvent) { + const outbox = await getOutboxRelaysForPubkey(eventStore, rootEvent.pubkey); + relaySets.push(outbox.slice(0, 3)); + } + + // 3. Active user outbox (for publishing) + const activePubkey = accountManager.active$.value?.pubkey; + if (activePubkey) { + const userOutbox = await getOutboxRelaysForPubkey(eventStore, activePubkey); + relaySets.push(userOutbox.slice(0, 2)); + } + + // Merge + fallback + let relays = mergeRelaySets(...relaySets); + if (relays.length < 3) { + relays = mergeRelaySets(relays, AGGREGATOR_RELAYS); + } + return relays.slice(0, 10); +} + +/** + * Kind-specific relay resolution. + * Returns additional relays based on the event kind. + * Extensible — add new cases as protocols evolve. + */ +async function getRelaysByEventKind( + kind: number, + event: NostrEvent, +): Promise { + // NIP-34 git events: repo relays + OP's inbox (read) relays + if (NIP34_KINDS.includes(kind)) { + return getNip34CommentRelays(event); + } + + // Future: add more kind-specific relay strategies here + return []; +} + +/** + * NIP-34: repo relays (from kind 30617 "relays" tag) + OP's inbox relays + */ +async function getNip34CommentRelays(event: NostrEvent): Promise { + const relays: string[] = []; + + // 1. Repo relays from repository event's "relays" tag + const repoATag = event.tags.find( + (t) => t[0] === "a" && t[1]?.startsWith("30617:"), + ); + if (repoATag && repoATag[1]) { + const address = parseReplaceableAddress(repoATag[1]); + if (address) { + const repoEvent = eventStore.getReplaceable( + address.kind, + address.pubkey, + address.identifier, + ) as NostrEvent | undefined; + if (repoEvent) { + relays.push(...getRepositoryRelays(repoEvent)); + } + } + } + + // 2. OP's inbox (read) relays + const opInbox = await getInboxRelaysForPubkey(eventStore, event.pubkey); + relays.push(...opInbox.slice(0, 3)); + + return relays; +} diff --git a/src/types/chat.ts b/src/types/chat.ts index cdab266..3edb2ab 100644 --- a/src/types/chat.ts +++ b/src/types/chat.ts @@ -10,17 +10,29 @@ export const CHAT_KINDS = [ 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) + 1111, // NIP-22: Comments ] as const; /** * Chat protocol identifier */ -export type ChatProtocol = "nip-17" | "nip-28" | "nip-29" | "nip-53" | "nip-10"; +export type ChatProtocol = + | "nip-17" + | "nip-28" + | "nip-29" + | "nip-53" + | "nip-10" + | "nip-22"; /** * Conversation type */ -export type ConversationType = "dm" | "channel" | "group" | "live-chat"; +export type ConversationType = + | "dm" + | "channel" + | "group" + | "live-chat" + | "comment-thread"; /** * Participant role in a conversation @@ -83,6 +95,13 @@ 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 + commentRootType?: "event" | "address" | "external"; + commentRootEventId?: string; + commentRootAddress?: { kind: number; pubkey: string; identifier: string }; + commentRootExternal?: string; + commentRootKind?: string; // K tag value ("30023", "web", "hashtag", etc.) } /** @@ -229,6 +248,29 @@ export interface ThreadIdentifier { relays?: string[]; } +/** + * NIP-22 comment identifier (catch-all for non-kind-1 events) + * Supports event roots, addressable event roots, and external identifier roots + */ +export interface CommentIdentifier { + type: "comment"; + value: { + /** Event ID for event roots (nevent/note) */ + eventId?: string; + /** Address pointer for addressable event roots (naddr) */ + address?: { kind: number; pubkey: string; identifier: string }; + /** External identifier for I-tag roots (URL, hashtag, podcast GUID, etc.) */ + external?: string; + /** Relay hints */ + relays?: string[]; + /** Author pubkey hint */ + author?: string; + /** Event kind hint (may be 1111 if opened from a comment) */ + kind?: number; + }; + relays?: string[]; +} + /** * Protocol-specific identifier - discriminated union * Returned by adapter parseIdentifier() @@ -240,7 +282,8 @@ export type ProtocolIdentifier = | NIP05Identifier | ChannelIdentifier | GroupListIdentifier - | ThreadIdentifier; + | ThreadIdentifier + | CommentIdentifier; /** * Chat command parsing result diff --git a/src/types/man.ts b/src/types/man.ts index fa41073..41118d3 100644 --- a/src/types/man.ts +++ b/src/types/man.ts @@ -578,12 +578,12 @@ export const manPages: Record = { section: "1", synopsis: "chat ", description: - "Join and participate in Nostr chat conversations. Supports NIP-29 relay-based groups, NIP-53 live activity chat, and multi-room group list interface. For NIP-29 groups, use format 'relay'group-id' where relay is the WebSocket URL (wss:// prefix optional). For NIP-53 live activities, pass the naddr of a kind 30311 live event. For multi-room interface, pass the naddr of a kind 10009 group list event.", + "Join and participate in Nostr chat conversations. Supports NIP-29 relay-based groups, NIP-53 live activity chat, NIP-10 thread chat, NIP-22 comment threads on any event kind, and multi-room group list interface. NIP-22 comments work as a catch-all: any event that isn't kind 1 (NIP-10) or a relay group/live activity gets a comment thread. You can also comment on URLs and hashtags.", options: [ { flag: "", description: - "NIP-29 group (relay'group-id), NIP-53 live activity (naddr1... kind 30311), or group list (naddr1... kind 10009)", + "NIP-29 group (relay'group-id), NIP-53 live activity (naddr1...), NIP-10 thread (nevent1.../note1... kind 1), NIP-22 comments (nevent1.../naddr1... any other kind, URL, or #hashtag)", }, ], examples: [ @@ -591,12 +591,17 @@ export const manPages: Record = { "chat wss://nos.lol'welcome Join NIP-29 group with explicit protocol", "chat naddr1...30311... Join NIP-53 live activity chat", "chat naddr1...10009... Open multi-room group list interface", + "chat nevent1... Comment on any event (NIP-22)", + "chat naddr1...30023... Comment on article (NIP-22)", + "chat https://example.com/post Comment on URL (NIP-22)", + "chat #bitcoin Comment on hashtag (NIP-22)", + "chat iso3166:ES Comment on country/region (NIP-22, uppercase code)", ], seeAlso: ["profile", "open", "req", "live"], appId: "chat", category: "Nostr", argParser: async (args: string[]) => { - const result = parseChatCommand(args); + const result = await parseChatCommand(args); return { protocol: result.protocol, identifier: result.identifier,