mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-19 19:28:19 +02:00
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:
@@ -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":
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user