mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-04 09:41:13 +02:00
feat: nip-22 threads
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -50,6 +50,7 @@ export function EventJsonDialog({
|
||||
: nip19.neventEncode({
|
||||
id: event.id,
|
||||
author: event.pubkey,
|
||||
kind: event.kind,
|
||||
relays,
|
||||
});
|
||||
}, [event]);
|
||||
|
||||
@@ -115,6 +115,7 @@ export function ChatMessageContextMenu({
|
||||
const nevent = nip19.neventEncode({
|
||||
id: event.id,
|
||||
author: event.pubkey,
|
||||
kind: event.kind,
|
||||
relays: relays,
|
||||
});
|
||||
copy(nevent);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
1134
src/lib/chat/adapters/nip-22-adapter.ts
Normal file
1134
src/lib/chat/adapters/nip-22-adapter.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"), "$");
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user