feat: nip-22 threads

This commit is contained in:
Alejandro Gómez
2026-04-05 22:09:51 +02:00
parent ca6c1a6625
commit 85fe5bee65
17 changed files with 2013 additions and 131 deletions

View File

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

View File

@@ -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<string | undefined>();
@@ -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 */}
<div className="flex items-center gap-1.5 text-xs">
{(conversation.type === "group" ||
conversation.type === "live-chat") && (
<button
onClick={(e) => {
e.stopPropagation();
handleNipClick();
}}
className="rounded bg-tooltip-foreground/20 px-1.5 py-0.5 font-mono hover:bg-tooltip-foreground/30 transition-colors cursor-pointer"
>
{conversation.protocol.toUpperCase()}
</button>
)}
{(conversation.type === "group" ||
conversation.type === "live-chat") && (
<span className="opacity-60"></span>
)}
<button
onClick={(e) => {
e.stopPropagation();
handleNipClick();
}}
className="rounded bg-tooltip-foreground/20 px-1.5 py-0.5 font-mono hover:bg-tooltip-foreground/30 transition-colors cursor-pointer"
>
{conversation.protocol.toUpperCase()}
</button>
<span className="opacity-60"></span>
{conversation.protocol === "nip-10" ? (
<span className="flex items-center gap-1 opacity-80">
<FileText className="size-3" />
Thread
</span>
) : conversation.protocol === "nip-22" ? (
<span className="flex items-center gap-1 opacity-80">
<MessageSquare className="size-3" />
Comments
</span>
) : (
<span className="capitalize opacity-80">
{conversation.type}
@@ -1100,15 +1182,12 @@ export function ChatViewer({
<div className="flex items-center gap-2 text-xs text-muted-foreground p-1">
<MembersDropdown participants={derivedParticipants} />
<RelaysDropdown conversation={conversation} />
{(conversation.type === "group" ||
conversation.type === "live-chat") && (
<button
onClick={handleNipClick}
className="rounded bg-muted px-1.5 py-0.5 font-mono hover:bg-muted/80 transition-colors cursor-pointer"
>
{conversation.protocol.toUpperCase()}
</button>
)}
<button
onClick={handleNipClick}
className="rounded bg-muted px-1.5 py-0.5 font-mono hover:bg-muted/80 transition-colors cursor-pointer"
>
{conversation.protocol.toUpperCase()}
</button>
</div>
</div>
</div>
@@ -1130,28 +1209,51 @@ export function ChatViewer({
}}
alignToBottom
components={{
Header: () =>
hasMore &&
conversationResult.status === "success" &&
protocol !== "nip-10" ? (
<div className="flex justify-center py-2">
<Button
onClick={handleLoadOlder}
disabled={isLoadingOlder}
variant="ghost"
size="sm"
>
{isLoadingOlder ? (
<>
<Loader2 className="size-3 animate-spin" />
<span className="text-xs">Loading...</span>
</>
) : (
"Load older messages"
)}
</Button>
</div>
) : null,
Header: () => {
// NIP-22 external root header (hashtag, URL, country, etc.)
if (
protocol === "nip-22" &&
conversation.metadata?.commentRootType === "external" &&
conversation.metadata?.commentRootExternal
) {
return (
<ExternalRootHeader
external={conversation.metadata.commentRootExternal}
kValue={conversation.metadata.commentRootKind || "web"}
/>
);
}
// "Load older" for protocols that support it
if (
hasMore &&
conversationResult.status === "success" &&
protocol !== "nip-10" &&
protocol !== "nip-22"
) {
return (
<div className="flex justify-center py-2">
<Button
onClick={handleLoadOlder}
disabled={isLoadingOlder}
variant="ghost"
size="sm"
>
{isLoadingOlder ? (
<>
<Loader2 className="size-3 animate-spin" />
<span className="text-xs">Loading...</span>
</>
) : (
"Load older messages"
)}
</Button>
</div>
);
}
return null;
},
Footer: () => <div className="h-1" />,
}}
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 (
<div key={item.data.id}>
<div className="[&>*]:border-b-0">
<KindRenderer event={item.data.event} />
</div>
<div className="px-3 pb-2">
<MessageReactions
messageId={item.data.id}
relays={conversationRelays}
adapter={adapter}
conversation={conversation}
/>
</div>
</div>
);
}
return (
<MessageItem
key={item.data.id}
@@ -1289,6 +1413,57 @@ export function ChatViewer({
);
}
/**
* External root header for NIP-22 comment threads on external identifiers.
*/
function ExternalRootHeader({
external,
kValue,
}: {
external: string;
kValue: string;
}) {
const { locale: userLocale } = useLocale();
// ISO 3166 — locale-aware country/region name with emoji flag
if (kValue === "iso3166" || external.startsWith("iso3166:")) {
const code = external.startsWith("iso3166:")
? external.slice(8).toUpperCase()
: external.toUpperCase();
return (
<div className="flex items-center gap-2 px-4 py-3">
<span className="text-2xl flex-shrink-0">{regionToEmoji(code)}</span>
<span className="text-sm font-medium truncate">
{getLocalizedRegionName(code, userLocale)}
</span>
</div>
);
}
const Icon = getExternalIdentifierIcon(kValue);
const label = getExternalIdentifierLabel(external, kValue);
const href = getExternalIdentifierHref(external);
return (
<div className="flex items-center gap-2 px-4 py-3">
<Icon className="size-5 text-muted-foreground flex-shrink-0" />
{href ? (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium hover:underline truncate"
>
{label}
</a>
) : (
<span className="text-sm font-medium truncate">{label}</span>
)}
</div>
);
}
/**
* 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)

View File

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

View File

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

View File

@@ -50,6 +50,7 @@ export function EventJsonDialog({
: nip19.neventEncode({
id: event.id,
author: event.pubkey,
kind: event.kind,
relays,
});
}, [event]);

View File

@@ -115,6 +115,7 @@ export function ChatMessageContextMenu({
const nevent = nip19.neventEncode({
id: event.id,
author: event.pubkey,
kind: event.kind,
relays: relays,
});
copy(nevent);

View File

@@ -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 className="size-4 mr-2 text-yellow-500" />
Zap
</Item>
{event.kind === 1 && (
<Item onClick={actions.openChatWindow}>
<MessageSquare className="size-4 mr-2" />
Chat
</Item>
)}
<Item onClick={actions.openChatWindow}>
<MessageSquare className="size-4 mr-2" />
Chat
</Item>
{canSign && onReactClick && (
<Item onClick={onReactClick}>
<SmilePlus className="size-4 mr-2" />

View File

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

View File

@@ -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 <identifier>",
);
});
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");
});
});
});

View File

@@ -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<ChatCommandResult> {
if (args.length === 0) {
throw new Error("Chat identifier required. Usage: chat <identifier>");
}
@@ -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<ChatCommandResult | null> {
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;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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"), "$");
});

View File

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

View File

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

View File

@@ -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<string[]> {
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<string[]> {
// 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<string[]> {
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;
}

View File

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

View File

@@ -578,12 +578,12 @@ 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 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: "<identifier>",
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<string, ManPageEntry> = {
"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,