feat: implement NIP-10 thread chat support

Add complete NIP-10 thread chat implementation enabling "chat nevent..." to
display kind 1 threaded conversations as chat interfaces.

**Type Definitions**:
- Add ThreadIdentifier for nevent/note event pointers
- Add "nip-10" to ChatProtocol type
- Extend ConversationMetadata with thread-specific fields (rootEventId,
  providedEventId, threadDepth, relays)

**NIP-10 Adapter** (src/lib/chat/adapters/nip-10-adapter.ts):
- parseIdentifier: Decode nevent/note format, reject non-kind-1 events
- resolveConversation: Fetch provided event, find root via NIP-10 refs,
  determine conversation relays (merge hints, outbox, fallbacks)
- loadMessages: Subscribe to kind 1 replies, kind 7 reactions, kind 9735 zaps
- sendMessage: Build proper NIP-10 tags (root/reply markers), add p-tags for
  all participants
- sendReaction: Send kind 7 with proper event/author references
- Smart relay selection: Merges seen relays, nevent hints, author outbox,
  user outbox (limit 7 relays for performance)

**ChatViewer Updates**:
- Detect NIP-10 threads (protocol === "nip-10")
- Fetch and display root event at top (centered with KindRenderer)
- Show visual separator ("Replies") between root and messages
- Update empty state message for threads ("No replies yet...")
- Enhanced header: Show "Author • Preview" for thread chats
- Update getAdapter to handle "nip-10" protocol

**Chat Parser**:
- Add Nip10Adapter to priority list (before other adapters to catch
  nevent/note)
- Update error message with nevent/note format examples
- Update adapter priority documentation

**Component Enhancements**:
- ReplyPreview: Show "thread root" when replying to root event (NIP-10)
- RelaysDropdown: Support conversation.metadata.relays for thread relay
  breakdown
- ChatMessageContextMenu: Add "Zap" option to context menu (opens ZapWindow)

**Features**:
- Root event displayed with full feed renderer (can interact: like, zap, etc.)
- All replies shown as chat messages with proper threading
- Reply/React/Zap options on all messages
- Relay dropdown shows breakdown of thread relays
- Participants dropdown shows all thread participants
- @ mention autocomplete works for participants
- Proper NIP-10 tag structure for nested replies
- Smart relay selection for maximum reach

**Usage**:
  chat nevent1qqsxyz...  # Thread with relay hints
  chat note1abc...       # Thread with event ID only

Root event is centered at top, all replies below as chat messages. Sending
replies creates kind 1 events with proper NIP-10 root/reply markers and
p-tags for all participants.
This commit is contained in:
Claude
2026-01-19 10:56:10 +00:00
parent bd8cd1f387
commit 67440c55d6
7 changed files with 1059 additions and 95 deletions

View File

@@ -24,6 +24,7 @@ import type {
} from "@/types/chat";
import { CHAT_KINDS } from "@/types/chat";
// import { NipC7Adapter } from "@/lib/chat/adapters/nip-c7-adapter"; // Coming soon
import { Nip10Adapter } from "@/lib/chat/adapters/nip-10-adapter";
import { 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";
@@ -33,6 +34,7 @@ import { parseSlashCommand } from "@/lib/chat/slash-command-parser";
import { UserName } from "./nostr/UserName";
import { RichText } from "./nostr/RichText";
import Timestamp from "./Timestamp";
import { KindRenderer } from "./nostr/kinds";
import { ReplyPreview } from "./chat/ReplyPreview";
import { MembersDropdown } from "./chat/MembersDropdown";
import { RelaysDropdown } from "./chat/RelaysDropdown";
@@ -507,6 +509,16 @@ export function ChatViewer({
? conversationResult.conversation
: null;
// Check if this is a NIP-10 thread
const isThreadChat = protocol === "nip-10";
// Fetch root event for NIP-10 threads
const rootEventId = conversation?.metadata?.rootEventId;
const rootEvent = use$(
() => (rootEventId ? eventStore.event(rootEventId) : undefined),
[rootEventId],
);
// Slash command search for action autocomplete
// Context-aware: only shows relevant actions based on membership status
const searchCommands = useCallback(
@@ -821,10 +833,23 @@ export function ChatViewer({
<Tooltip open={tooltipOpen} onOpenChange={setTooltipOpen}>
<TooltipTrigger asChild>
<button
className="text-sm font-semibold truncate cursor-help text-left"
className="text-sm font-semibold truncate cursor-help text-left flex items-center gap-1.5"
onClick={() => setTooltipOpen(!tooltipOpen)}
>
{customTitle || conversation.title}
{isThreadChat && rootEvent ? (
<>
<UserName
pubkey={rootEvent.pubkey}
className="text-sm font-semibold flex-shrink-0"
/>
<span className="text-muted-foreground"></span>
<span className="truncate">
{customTitle || conversation.title}
</span>
</>
) : (
customTitle || conversation.title
)}
</button>
</TooltipTrigger>
<TooltipContent
@@ -936,69 +961,90 @@ export function ChatViewer({
</div>
{/* Message timeline with virtualization */}
<div className="flex-1 overflow-hidden">
{messagesWithMarkers && messagesWithMarkers.length > 0 ? (
<Virtuoso
ref={virtuosoRef}
data={messagesWithMarkers}
initialTopMostItemIndex={messagesWithMarkers.length - 1}
followOutput="smooth"
alignToBottom
components={{
Header: () =>
hasMore && conversationResult.status === "success" ? (
<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,
Footer: () => <div className="h-1" />,
}}
itemContent={(_index, item) => {
if (item.type === "day-marker") {
return (
<div
className="flex justify-center py-2"
key={`marker-${item.timestamp}`}
>
<Label className="text-[10px] text-muted-foreground">
{item.data}
</Label>
</div>
);
}
return (
<MessageItem
key={item.data.id}
message={item.data}
adapter={adapter}
conversation={conversation}
onReply={handleReply}
canReply={canSign}
onScrollToMessage={handleScrollToMessage}
/>
);
}}
style={{ height: "100%" }}
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No messages yet. Start the conversation!
<div className="flex-1 overflow-hidden flex flex-col">
{/* NIP-10 Thread: Show root event at top */}
{isThreadChat && rootEvent && (
<div className="border-b bg-muted/10 flex-shrink-0">
<div className="max-w-2xl mx-auto py-4 px-3">
{/* Use KindRenderer to render root with full feed functionality */}
<KindRenderer event={rootEvent} depth={0} />
</div>
{/* Visual separator */}
<div className="flex items-center gap-2 px-3 py-1 text-xs text-muted-foreground">
<div className="flex-1 h-px bg-border" />
<span>Replies</span>
<div className="flex-1 h-px bg-border" />
</div>
</div>
)}
{/* Scrollable messages list */}
<div className="flex-1 overflow-hidden">
{messagesWithMarkers && messagesWithMarkers.length > 0 ? (
<Virtuoso
ref={virtuosoRef}
data={messagesWithMarkers}
initialTopMostItemIndex={messagesWithMarkers.length - 1}
followOutput="smooth"
alignToBottom
components={{
Header: () =>
hasMore && conversationResult.status === "success" ? (
<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,
Footer: () => <div className="h-1" />,
}}
itemContent={(_index, item) => {
if (item.type === "day-marker") {
return (
<div
className="flex justify-center py-2"
key={`marker-${item.timestamp}`}
>
<Label className="text-[10px] text-muted-foreground">
{item.data}
</Label>
</div>
);
}
return (
<MessageItem
key={item.data.id}
message={item.data}
adapter={adapter}
conversation={conversation}
onReply={handleReply}
canReply={canSign}
onScrollToMessage={handleScrollToMessage}
/>
);
}}
style={{ height: "100%" }}
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{isThreadChat
? "No replies yet. Start the conversation!"
: "No messages yet. Start the conversation!"}
</div>
)}
</div>
</div>
{/* Message composer - only show if user can sign */}
@@ -1070,11 +1116,13 @@ export function ChatViewer({
/**
* Get the appropriate adapter for a protocol
* Currently NIP-29 (relay-based groups) and NIP-53 (live activity chat) are supported
* Currently NIP-10 (thread chat), NIP-29 (relay-based groups) and NIP-53 (live activity chat) are supported
* Other protocols will be enabled in future phases
*/
function getAdapter(protocol: ChatProtocol): ChatProtocolAdapter {
switch (protocol) {
case "nip-10":
return new Nip10Adapter();
// case "nip-c7": // Phase 1 - Simple chat (coming soon)
// return new NipC7Adapter();
case "nip-29":

View File

@@ -133,6 +133,18 @@ export function ChatMessageContextMenu({
setEmojiPickerOpen(true);
};
const openZapWindow = () => {
if (!zapConfig || !zapConfig.supported) return;
addWindow("zap", {
recipientPubkey: zapConfig.recipientPubkey,
eventPointer: zapConfig.eventPointer,
addressPointer: zapConfig.addressPointer,
customTags: zapConfig.customTags,
relays: zapConfig.relays,
});
};
const handleEmojiSelect = async (emoji: string, customEmoji?: EmojiTag) => {
if (!conversation || !adapter) {
console.error(
@@ -148,18 +160,6 @@ export function ChatMessageContextMenu({
}
};
const openZapWindow = () => {
if (!zapConfig || !zapConfig.supported) return;
addWindow("zap", {
recipientPubkey: zapConfig.recipientPubkey,
eventPointer: zapConfig.eventPointer,
addressPointer: zapConfig.addressPointer,
customTags: zapConfig.customTags,
relays: zapConfig.relays,
});
};
return (
<>
<ContextMenu>

View File

@@ -23,12 +23,15 @@ export function RelaysDropdown({ conversation }: RelaysDropdownProps) {
// Get relays for this conversation (immutable pattern)
const liveActivityRelays = conversation.metadata?.liveActivity?.relays;
const metadataRelays = conversation.metadata?.relays;
const relays: string[] =
Array.isArray(liveActivityRelays) && liveActivityRelays.length > 0
? liveActivityRelays
: conversation.metadata?.relayUrl
? [conversation.metadata.relayUrl]
: [];
: Array.isArray(metadataRelays) && metadataRelays.length > 0
? metadataRelays
: conversation.metadata?.relayUrl
? [conversation.metadata.relayUrl]
: [];
// Pre-compute normalized URLs and state lookups in a single pass (O(n))
const relayData = relays.map((url) => {

View File

@@ -26,6 +26,9 @@ export const ReplyPreview = memo(function ReplyPreview({
// Load the event being replied to (reactive - updates when event arrives)
const replyEvent = use$(() => eventStore.event(replyToId), [replyToId]);
// Check if replying to thread root (NIP-10)
const isRoot = conversation.metadata?.rootEventId === replyToId;
// Fetch event from relays if not in store
useEffect(() => {
if (!replyEvent) {
@@ -47,7 +50,7 @@ export const ReplyPreview = memo(function ReplyPreview({
if (!replyEvent) {
return (
<div className="text-xs text-muted-foreground mb-0.5">
Replying to {replyToId.slice(0, 8)}...
Replying to {isRoot ? "thread root" : replyToId.slice(0, 8)}...
</div>
);
}
@@ -59,10 +62,14 @@ export const ReplyPreview = memo(function ReplyPreview({
title="Click to scroll to message"
>
<span className="flex-shrink-0"></span>
<UserName
pubkey={replyEvent.pubkey}
className="font-medium flex-shrink-0"
/>
{isRoot ? (
<span className="font-medium flex-shrink-0">thread root</span>
) : (
<UserName
pubkey={replyEvent.pubkey}
className="font-medium flex-shrink-0"
/>
)}
<div className="line-clamp-1 overflow-hidden flex-1 min-w-0">
<RichText
event={replyEvent}