mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 08:27:27 +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}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ChatCommandResult, GroupListIdentifier } from "@/types/chat";
|
||||
// import { NipC7Adapter } from "./chat/adapters/nip-c7-adapter";
|
||||
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 { nip19 } from "nostr-tools";
|
||||
@@ -11,11 +12,12 @@ 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-17 (encrypted DMs) - prioritized for privacy
|
||||
* 2. NIP-28 (channels) - specific event format (kind 40)
|
||||
* 3. NIP-29 (groups) - specific group ID format
|
||||
* 4. NIP-53 (live chat) - specific addressable format (kind 30311)
|
||||
* 5. NIP-C7 (simple chat) - fallback for generic pubkeys
|
||||
* 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)
|
||||
* 6. NIP-C7 (simple chat) - fallback for generic pubkeys
|
||||
*
|
||||
* @param args - Command arguments (first arg is the identifier)
|
||||
* @returns Parsed result with protocol and identifier
|
||||
@@ -62,6 +64,7 @@ export function parseChatCommand(args: string[]): ChatCommandResult {
|
||||
|
||||
// Try each adapter in priority order
|
||||
const adapters = [
|
||||
new Nip10Adapter(), // NIP-10 - Thread chat (nevent/note)
|
||||
// new Nip17Adapter(), // Phase 2
|
||||
// new Nip28Adapter(), // Phase 3
|
||||
new Nip29Adapter(), // Phase 4 - Relay groups
|
||||
@@ -84,6 +87,10 @@ export function parseChatCommand(args: string[]): ChatCommandResult {
|
||||
`Unable to determine chat protocol from identifier: ${identifier}
|
||||
|
||||
Currently supported formats:
|
||||
- nevent1.../note1... (NIP-10 thread chat, kind 1 notes)
|
||||
Examples:
|
||||
chat nevent1qqsxyz... (thread with relay hints)
|
||||
chat note1abc... (thread with event ID only)
|
||||
- relay.com'group-id (NIP-29 relay group, wss:// prefix optional)
|
||||
Examples:
|
||||
chat relay.example.com'bitcoin-dev
|
||||
@@ -99,7 +106,6 @@ Currently supported formats:
|
||||
chat naddr1... (group list address)
|
||||
|
||||
More formats coming soon:
|
||||
- npub/nprofile/hex pubkey (NIP-C7/NIP-17 direct messages)
|
||||
- note/nevent (NIP-28 public channels)`,
|
||||
- npub/nprofile/hex pubkey (NIP-C7/NIP-17 direct messages)`,
|
||||
);
|
||||
}
|
||||
|
||||
871
src/lib/chat/adapters/nip-10-adapter.ts
Normal file
871
src/lib/chat/adapters/nip-10-adapter.ts
Normal file
@@ -0,0 +1,871 @@
|
||||
import { Observable, firstValueFrom } from "rxjs";
|
||||
import { map, first, toArray } from "rxjs/operators";
|
||||
import type { Filter } from "nostr-tools";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { ChatProtocolAdapter, type SendMessageOptions } from "./base-adapter";
|
||||
import type {
|
||||
Conversation,
|
||||
Message,
|
||||
ProtocolIdentifier,
|
||||
ChatCapabilities,
|
||||
LoadMessagesOptions,
|
||||
Participant,
|
||||
} from "@/types/chat";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import eventStore from "@/services/event-store";
|
||||
import pool from "@/services/relay-pool";
|
||||
import { publishEventToRelays } from "@/services/hub";
|
||||
import accountManager from "@/services/accounts";
|
||||
import { getTagValues } from "@/lib/nostr-utils";
|
||||
import { normalizeURL } from "applesauce-core/helpers";
|
||||
import { EventFactory } from "applesauce-core/event-factory";
|
||||
import { getNip10References } from "applesauce-common/helpers";
|
||||
import {
|
||||
getZapAmount,
|
||||
getZapSender,
|
||||
getZapRecipient,
|
||||
} from "applesauce-common/helpers";
|
||||
|
||||
/**
|
||||
* NIP-10 Adapter - Threaded Notes as Chat
|
||||
*
|
||||
* Features:
|
||||
* - Turn any kind 1 note thread into a chat interface
|
||||
* - Root event displayed prominently at top
|
||||
* - All replies shown as chat messages
|
||||
* - Proper NIP-10 tag structure (root/reply markers)
|
||||
* - Smart relay selection (merges multiple sources)
|
||||
*
|
||||
* Thread ID format: nevent1... or note1...
|
||||
* Events use "e" tags with markers ("root", "reply")
|
||||
*/
|
||||
export class Nip10Adapter extends ChatProtocolAdapter {
|
||||
readonly protocol = "nip-10" as const;
|
||||
readonly type = "group" as const; // Threads are multi-participant like groups
|
||||
|
||||
/**
|
||||
* Parse identifier - accepts nevent or note format
|
||||
* Examples:
|
||||
* - nevent1qqsxyz... (with relay hints, author, kind)
|
||||
* - note1abc... (simple event ID)
|
||||
*/
|
||||
parseIdentifier(input: string): ProtocolIdentifier | null {
|
||||
// Try note format first (simpler)
|
||||
if (input.startsWith("note1")) {
|
||||
try {
|
||||
const decoded = nip19.decode(input);
|
||||
if (decoded.type === "note") {
|
||||
const eventId = decoded.data as string;
|
||||
return {
|
||||
type: "thread",
|
||||
value: { id: eventId },
|
||||
relays: [],
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Try nevent format (includes relay hints)
|
||||
if (input.startsWith("nevent1")) {
|
||||
try {
|
||||
const decoded = nip19.decode(input);
|
||||
if (decoded.type === "nevent") {
|
||||
const { id, relays, author, kind } = decoded.data;
|
||||
|
||||
// If kind is specified and NOT kind 1, let other adapters handle
|
||||
if (kind !== undefined && kind !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "thread",
|
||||
value: { id, relays, author, kind },
|
||||
relays: relays || [],
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve conversation from thread identifier
|
||||
*/
|
||||
async resolveConversation(
|
||||
identifier: ProtocolIdentifier,
|
||||
): Promise<Conversation> {
|
||||
if (identifier.type !== "thread") {
|
||||
throw new Error(
|
||||
`NIP-10 adapter cannot handle identifier type: ${identifier.type}`,
|
||||
);
|
||||
}
|
||||
|
||||
const pointer = identifier.value;
|
||||
const relayHints = identifier.relays || [];
|
||||
|
||||
console.log(`[NIP-10] Fetching event ${pointer.id.slice(0, 8)}...`);
|
||||
|
||||
// 1. Fetch the provided event
|
||||
const providedEvent = await this.fetchEvent(pointer.id, relayHints);
|
||||
if (!providedEvent) {
|
||||
throw new Error("Event not found");
|
||||
}
|
||||
|
||||
if (providedEvent.kind !== 1) {
|
||||
throw new Error(`Expected kind 1 note, got kind ${providedEvent.kind}`);
|
||||
}
|
||||
|
||||
// 2. Parse NIP-10 references to find root
|
||||
const refs = getNip10References(providedEvent);
|
||||
let rootEvent: NostrEvent;
|
||||
let rootId: string;
|
||||
|
||||
if (refs.root?.e) {
|
||||
// This is a reply - fetch the root
|
||||
rootId = refs.root.e.id;
|
||||
console.log(`[NIP-10] Fetching root event ${rootId.slice(0, 8)}...`);
|
||||
|
||||
const fetchedRoot = await this.fetchEvent(
|
||||
rootId,
|
||||
refs.root.e.relays || [],
|
||||
);
|
||||
if (!fetchedRoot) {
|
||||
throw new Error("Thread root not found");
|
||||
}
|
||||
rootEvent = fetchedRoot;
|
||||
} else {
|
||||
// No root reference - this IS the root
|
||||
rootEvent = providedEvent;
|
||||
rootId = providedEvent.id;
|
||||
console.log(`[NIP-10] Provided event is the root`);
|
||||
}
|
||||
|
||||
// 3. Determine conversation relays
|
||||
const conversationRelays = await this.getThreadRelays(
|
||||
rootEvent,
|
||||
providedEvent,
|
||||
relayHints,
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[NIP-10] Using ${conversationRelays.length} relays:`,
|
||||
conversationRelays,
|
||||
);
|
||||
|
||||
// 4. Extract title from root content
|
||||
const title = this.extractTitle(rootEvent);
|
||||
|
||||
// 5. Build participants list from root and provided event
|
||||
const participants = this.extractParticipants(rootEvent, providedEvent);
|
||||
|
||||
// 6. Build conversation object
|
||||
return {
|
||||
id: `nip-10:${rootId}`,
|
||||
type: "group",
|
||||
protocol: "nip-10",
|
||||
title,
|
||||
participants,
|
||||
metadata: {
|
||||
rootEventId: rootId,
|
||||
providedEventId: providedEvent.id,
|
||||
description: rootEvent.content.slice(0, 200), // First 200 chars
|
||||
relays: conversationRelays,
|
||||
},
|
||||
unreadCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load messages for a thread
|
||||
*/
|
||||
loadMessages(
|
||||
conversation: Conversation,
|
||||
options?: LoadMessagesOptions,
|
||||
): Observable<Message[]> {
|
||||
const rootEventId = conversation.metadata?.rootEventId;
|
||||
const relays = conversation.metadata?.relays || [];
|
||||
|
||||
if (!rootEventId) {
|
||||
throw new Error("Root event ID required");
|
||||
}
|
||||
|
||||
console.log(`[NIP-10] Loading thread ${rootEventId.slice(0, 8)}...`);
|
||||
|
||||
// Build filter for all thread events:
|
||||
// - kind 1: replies to root
|
||||
// - kind 7: reactions
|
||||
// - kind 9735: zap receipts
|
||||
const filters: Filter[] = [
|
||||
// Replies: kind 1 events with e-tag pointing to root
|
||||
{
|
||||
kinds: [1],
|
||||
"#e": [rootEventId],
|
||||
limit: options?.limit || 100,
|
||||
},
|
||||
// Reactions: kind 7 events with e-tag pointing to root or replies
|
||||
{
|
||||
kinds: [7],
|
||||
"#e": [rootEventId],
|
||||
limit: 200, // Reactions are small, fetch more
|
||||
},
|
||||
// Zaps: kind 9735 receipts with e-tag pointing to root or replies
|
||||
{
|
||||
kinds: [9735],
|
||||
"#e": [rootEventId],
|
||||
limit: 100,
|
||||
},
|
||||
];
|
||||
|
||||
if (options?.before) {
|
||||
filters[0].until = options.before;
|
||||
}
|
||||
if (options?.after) {
|
||||
filters[0].since = options.after;
|
||||
}
|
||||
|
||||
// Clean up any existing subscription
|
||||
const conversationId = `nip-10:${rootEventId}`;
|
||||
this.cleanup(conversationId);
|
||||
|
||||
// Start persistent subscription
|
||||
const subscription = pool
|
||||
.subscription(relays, filters, { eventStore })
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
if (typeof response === "string") {
|
||||
console.log("[NIP-10] EOSE received");
|
||||
} else {
|
||||
console.log(
|
||||
`[NIP-10] Received event k${response.kind}: ${response.id.slice(0, 8)}...`,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Store subscription for cleanup
|
||||
this.subscriptions.set(conversationId, subscription);
|
||||
|
||||
// Return observable from EventStore
|
||||
return eventStore
|
||||
.timeline({ kinds: [1, 7, 9735], "#e": [rootEventId] })
|
||||
.pipe(
|
||||
map((events) => {
|
||||
// Filter out the root event itself (we don't want it in messages list)
|
||||
const threadEvents = events.filter((e) => e.id !== rootEventId);
|
||||
|
||||
// Convert events to messages
|
||||
const messages = threadEvents
|
||||
.map((event) =>
|
||||
this.eventToMessage(event, conversationId, rootEventId),
|
||||
)
|
||||
.filter((msg): msg is Message => msg !== null);
|
||||
|
||||
console.log(`[NIP-10] Timeline has ${messages.length} messages`);
|
||||
|
||||
// Sort by timestamp ascending (chronological order)
|
||||
return messages.sort((a, b) => a.timestamp - b.timestamp);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load more historical messages (pagination)
|
||||
*/
|
||||
async loadMoreMessages(
|
||||
conversation: Conversation,
|
||||
before: number,
|
||||
): Promise<Message[]> {
|
||||
const rootEventId = conversation.metadata?.rootEventId;
|
||||
const relays = conversation.metadata?.relays || [];
|
||||
|
||||
if (!rootEventId) {
|
||||
throw new Error("Root event ID required");
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[NIP-10] Loading older messages for ${rootEventId.slice(0, 8)} before ${before}`,
|
||||
);
|
||||
|
||||
// Same filters as loadMessages but with until for pagination
|
||||
const filters: Filter[] = [
|
||||
{
|
||||
kinds: [1],
|
||||
"#e": [rootEventId],
|
||||
until: before,
|
||||
limit: 50,
|
||||
},
|
||||
{
|
||||
kinds: [7],
|
||||
"#e": [rootEventId],
|
||||
until: before,
|
||||
limit: 100,
|
||||
},
|
||||
{
|
||||
kinds: [9735],
|
||||
"#e": [rootEventId],
|
||||
until: before,
|
||||
limit: 50,
|
||||
},
|
||||
];
|
||||
|
||||
// One-shot request to fetch older messages
|
||||
const events = await firstValueFrom(
|
||||
pool.request(relays, filters, { eventStore }).pipe(toArray()),
|
||||
);
|
||||
|
||||
console.log(`[NIP-10] Loaded ${events.length} older events`);
|
||||
|
||||
const conversationId = `nip-10:${rootEventId}`;
|
||||
|
||||
// Convert events to messages
|
||||
const messages = events
|
||||
.map((event) => this.eventToMessage(event, conversationId, rootEventId))
|
||||
.filter((msg): msg is Message => msg !== null);
|
||||
|
||||
// Reverse for ascending chronological order
|
||||
return messages.reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message (reply) to the thread
|
||||
*/
|
||||
async sendMessage(
|
||||
conversation: Conversation,
|
||||
content: string,
|
||||
options?: SendMessageOptions,
|
||||
): Promise<void> {
|
||||
const activePubkey = accountManager.active$.value?.pubkey;
|
||||
const activeSigner = accountManager.active$.value?.signer;
|
||||
|
||||
if (!activePubkey || !activeSigner) {
|
||||
throw new Error("No active account or signer");
|
||||
}
|
||||
|
||||
const rootEventId = conversation.metadata?.rootEventId;
|
||||
const relays = conversation.metadata?.relays || [];
|
||||
|
||||
if (!rootEventId) {
|
||||
throw new Error("Root event ID required");
|
||||
}
|
||||
|
||||
// Fetch root event for building tags
|
||||
const rootEvent = await firstValueFrom(eventStore.event(rootEventId), {
|
||||
defaultValue: undefined,
|
||||
});
|
||||
if (!rootEvent) {
|
||||
throw new Error("Root event not found in store");
|
||||
}
|
||||
|
||||
// Create event factory
|
||||
const factory = new EventFactory();
|
||||
factory.setSigner(activeSigner);
|
||||
|
||||
// Build NIP-10 tags
|
||||
const tags: string[][] = [];
|
||||
|
||||
// Determine if we're replying to root or to another reply
|
||||
if (options?.replyTo && options.replyTo !== rootEventId) {
|
||||
// Replying to another reply
|
||||
const parentEvent = await firstValueFrom(
|
||||
eventStore.event(options.replyTo),
|
||||
{ defaultValue: undefined },
|
||||
);
|
||||
|
||||
if (!parentEvent) {
|
||||
throw new Error("Parent event not found");
|
||||
}
|
||||
|
||||
// Add root marker (always first)
|
||||
tags.push(["e", rootEventId, relays[0] || "", "root", rootEvent.pubkey]);
|
||||
|
||||
// Add reply marker (the direct parent)
|
||||
tags.push([
|
||||
"e",
|
||||
options.replyTo,
|
||||
relays[0] || "",
|
||||
"reply",
|
||||
parentEvent.pubkey,
|
||||
]);
|
||||
|
||||
// Add p-tag for root author
|
||||
tags.push(["p", rootEvent.pubkey]);
|
||||
|
||||
// Add p-tag for parent author (if different)
|
||||
if (parentEvent.pubkey !== rootEvent.pubkey) {
|
||||
tags.push(["p", parentEvent.pubkey]);
|
||||
}
|
||||
|
||||
// Add p-tags from parent event (all mentioned users)
|
||||
for (const tag of parentEvent.tags) {
|
||||
if (tag[0] === "p" && tag[1]) {
|
||||
const pubkey = tag[1];
|
||||
// Don't duplicate tags
|
||||
if (!tags.some((t) => t[0] === "p" && t[1] === pubkey)) {
|
||||
tags.push(["p", pubkey]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Replying directly to root
|
||||
tags.push(["e", rootEventId, relays[0] || "", "root", rootEvent.pubkey]);
|
||||
|
||||
// Add p-tag for root author
|
||||
tags.push(["p", rootEvent.pubkey]);
|
||||
|
||||
// Add p-tags from root event
|
||||
for (const tag of rootEvent.tags) {
|
||||
if (tag[0] === "p" && tag[1]) {
|
||||
const pubkey = tag[1];
|
||||
// Don't duplicate tags
|
||||
if (!tags.some((t) => t[0] === "p" && t[1] === pubkey)) {
|
||||
tags.push(["p", pubkey]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add NIP-30 emoji tags
|
||||
if (options?.emojiTags) {
|
||||
for (const emoji of options.emojiTags) {
|
||||
tags.push(["emoji", emoji.shortcode, emoji.url]);
|
||||
}
|
||||
}
|
||||
|
||||
// Add NIP-92 imeta tags for blob attachments
|
||||
if (options?.blobAttachments) {
|
||||
for (const blob of options.blobAttachments) {
|
||||
const imetaParts = [`url ${blob.url}`];
|
||||
if (blob.sha256) imetaParts.push(`x ${blob.sha256}`);
|
||||
if (blob.mimeType) imetaParts.push(`m ${blob.mimeType}`);
|
||||
if (blob.size) imetaParts.push(`size ${blob.size}`);
|
||||
tags.push(["imeta", ...imetaParts]);
|
||||
}
|
||||
}
|
||||
|
||||
// Create and sign kind 1 event
|
||||
const draft = await factory.build({ kind: 1, content, tags });
|
||||
const event = await factory.sign(draft);
|
||||
|
||||
console.log(
|
||||
`[NIP-10] Publishing reply with ${tags.length} tags to ${relays.length} relays`,
|
||||
);
|
||||
|
||||
// Publish to conversation relays
|
||||
await publishEventToRelays(event, relays);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a reaction (kind 7) to a message in the thread
|
||||
*/
|
||||
async sendReaction(
|
||||
conversation: Conversation,
|
||||
messageId: string,
|
||||
emoji: string,
|
||||
customEmoji?: { shortcode: string; url: string },
|
||||
): Promise<void> {
|
||||
const activePubkey = accountManager.active$.value?.pubkey;
|
||||
const activeSigner = accountManager.active$.value?.signer;
|
||||
|
||||
if (!activePubkey || !activeSigner) {
|
||||
throw new Error("No active account or signer");
|
||||
}
|
||||
|
||||
const relays = conversation.metadata?.relays || [];
|
||||
|
||||
// Fetch the message being reacted to
|
||||
const messageEvent = await firstValueFrom(eventStore.event(messageId), {
|
||||
defaultValue: undefined,
|
||||
});
|
||||
|
||||
if (!messageEvent) {
|
||||
throw new Error("Message event not found");
|
||||
}
|
||||
|
||||
// Create event factory
|
||||
const factory = new EventFactory();
|
||||
factory.setSigner(activeSigner);
|
||||
|
||||
const tags: string[][] = [
|
||||
["e", messageId], // Event being reacted to
|
||||
["k", "1"], // Kind of event being reacted to
|
||||
["p", messageEvent.pubkey], // Author of message
|
||||
];
|
||||
|
||||
// Add NIP-30 custom emoji tag if provided
|
||||
if (customEmoji) {
|
||||
tags.push(["emoji", customEmoji.shortcode, customEmoji.url]);
|
||||
}
|
||||
|
||||
// Create and sign kind 7 event
|
||||
const draft = await factory.build({ kind: 7, content: emoji, tags });
|
||||
const event = await factory.sign(draft);
|
||||
|
||||
console.log(`[NIP-10] Publishing reaction to ${relays.length} relays`);
|
||||
|
||||
// Publish to conversation relays
|
||||
await publishEventToRelays(event, relays);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a replied-to message by ID
|
||||
*/
|
||||
async loadReplyMessage(
|
||||
conversation: Conversation,
|
||||
eventId: string,
|
||||
): Promise<NostrEvent | null> {
|
||||
// First check EventStore - might already be loaded
|
||||
const cachedEvent = await eventStore
|
||||
.event(eventId)
|
||||
.pipe(first())
|
||||
.toPromise();
|
||||
if (cachedEvent) {
|
||||
return cachedEvent;
|
||||
}
|
||||
|
||||
// Not in store, fetch from conversation relays
|
||||
const relays = conversation.metadata?.relays || [];
|
||||
if (relays.length === 0) {
|
||||
console.warn("[NIP-10] No relays for loading reply message");
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[NIP-10] Fetching reply message ${eventId.slice(0, 8)} from ${relays.length} relays`,
|
||||
);
|
||||
|
||||
const filter: Filter = {
|
||||
ids: [eventId],
|
||||
limit: 1,
|
||||
};
|
||||
|
||||
const events = await firstValueFrom(
|
||||
pool.request(relays, [filter], { eventStore }).pipe(toArray()),
|
||||
);
|
||||
|
||||
return events[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get capabilities of NIP-10 protocol
|
||||
*/
|
||||
getCapabilities(): ChatCapabilities {
|
||||
return {
|
||||
supportsEncryption: false,
|
||||
supportsThreading: true,
|
||||
supportsModeration: false,
|
||||
supportsRoles: false,
|
||||
supportsGroupManagement: false,
|
||||
canCreateConversations: false,
|
||||
requiresRelay: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a readable title from root event content
|
||||
*/
|
||||
private extractTitle(rootEvent: NostrEvent): string {
|
||||
const content = rootEvent.content.trim();
|
||||
if (!content) return `Thread by ${rootEvent.pubkey.slice(0, 8)}...`;
|
||||
|
||||
// Try to get first line
|
||||
const firstLine = content.split("\n")[0];
|
||||
if (firstLine && firstLine.length <= 50) {
|
||||
return firstLine;
|
||||
}
|
||||
|
||||
// Truncate to 50 chars
|
||||
if (content.length <= 50) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return content.slice(0, 47) + "...";
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract unique participants from thread
|
||||
*/
|
||||
private extractParticipants(
|
||||
rootEvent: NostrEvent,
|
||||
providedEvent: NostrEvent,
|
||||
): Participant[] {
|
||||
const participants = new Map<string, Participant>();
|
||||
|
||||
// Root author is always first
|
||||
participants.set(rootEvent.pubkey, {
|
||||
pubkey: rootEvent.pubkey,
|
||||
role: "admin", // Root author is "admin" of the thread
|
||||
});
|
||||
|
||||
// Add p-tags from root event
|
||||
for (const tag of rootEvent.tags) {
|
||||
if (tag[0] === "p" && tag[1] && tag[1] !== rootEvent.pubkey) {
|
||||
participants.set(tag[1], {
|
||||
pubkey: tag[1],
|
||||
role: "member",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add provided event author (if different)
|
||||
if (providedEvent.pubkey !== rootEvent.pubkey) {
|
||||
participants.set(providedEvent.pubkey, {
|
||||
pubkey: providedEvent.pubkey,
|
||||
role: "member",
|
||||
});
|
||||
}
|
||||
|
||||
// Add p-tags from provided event
|
||||
for (const tag of providedEvent.tags) {
|
||||
if (tag[0] === "p" && tag[1] && tag[1] !== providedEvent.pubkey) {
|
||||
participants.set(tag[1], {
|
||||
pubkey: tag[1],
|
||||
role: "member",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(participants.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine best relays for the thread
|
||||
*/
|
||||
private async getThreadRelays(
|
||||
rootEvent: NostrEvent,
|
||||
providedEvent: NostrEvent,
|
||||
providedRelays: string[],
|
||||
): Promise<string[]> {
|
||||
const relays = new Set<string>();
|
||||
|
||||
// 1. Provided relay hints
|
||||
providedRelays.forEach((r) => relays.add(normalizeURL(r)));
|
||||
|
||||
// 2. Root author's outbox relays (NIP-65)
|
||||
try {
|
||||
const rootOutbox = await this.getOutboxRelays(rootEvent.pubkey);
|
||||
rootOutbox.slice(0, 3).forEach((r) => relays.add(normalizeURL(r)));
|
||||
} catch (err) {
|
||||
console.warn("[NIP-10] Failed to get root author outbox:", err);
|
||||
}
|
||||
|
||||
// 3. Active user's outbox (for publishing replies)
|
||||
const activePubkey = accountManager.active$.value?.pubkey;
|
||||
if (activePubkey) {
|
||||
try {
|
||||
const userOutbox = await this.getOutboxRelays(activePubkey);
|
||||
userOutbox.slice(0, 2).forEach((r) => relays.add(normalizeURL(r)));
|
||||
} catch (err) {
|
||||
console.warn("[NIP-10] Failed to get user outbox:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fallback to popular relays if we have too few
|
||||
if (relays.size < 3) {
|
||||
[
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
"wss://relay.nostr.band",
|
||||
].forEach((r) => relays.add(r));
|
||||
}
|
||||
|
||||
// Limit to 7 relays max for performance
|
||||
return Array.from(relays).slice(0, 7);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Get outbox relays for a pubkey (NIP-65)
|
||||
*/
|
||||
private async getOutboxRelays(pubkey: string): Promise<string[]> {
|
||||
const relayList = await firstValueFrom(
|
||||
eventStore.replaceable(10002, pubkey, ""),
|
||||
{ defaultValue: undefined },
|
||||
);
|
||||
|
||||
if (!relayList) return [];
|
||||
|
||||
// Extract write relays (r tags with "write" or no marker)
|
||||
return relayList.tags
|
||||
.filter((t) => {
|
||||
if (t[0] !== "r") return false;
|
||||
const marker = t[2];
|
||||
return !marker || marker === "write";
|
||||
})
|
||||
.map((t) => normalizeURL(t[1]))
|
||||
.slice(0, 5); // Limit to 5
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Fetch an event by ID from relays
|
||||
*/
|
||||
private async fetchEvent(
|
||||
eventId: string,
|
||||
relayHints: string[] = [],
|
||||
): Promise<NostrEvent | null> {
|
||||
// Check EventStore first
|
||||
const cached = await firstValueFrom(eventStore.event(eventId), {
|
||||
defaultValue: undefined,
|
||||
});
|
||||
if (cached) return cached;
|
||||
|
||||
// Not in store - fetch from relays
|
||||
const relays =
|
||||
relayHints.length > 0 ? relayHints : await this.getDefaultRelays();
|
||||
|
||||
const filter: Filter = {
|
||||
ids: [eventId],
|
||||
limit: 1,
|
||||
};
|
||||
|
||||
const events: NostrEvent[] = [];
|
||||
const obs = pool.subscription(relays, [filter], { eventStore });
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
console.log(`[NIP-10] Fetch timeout for ${eventId.slice(0, 8)}...`);
|
||||
resolve();
|
||||
}, 5000);
|
||||
|
||||
const sub = obs.subscribe({
|
||||
next: (response) => {
|
||||
if (typeof response === "string") {
|
||||
// EOSE received
|
||||
clearTimeout(timeout);
|
||||
sub.unsubscribe();
|
||||
resolve();
|
||||
} else {
|
||||
// Event received
|
||||
events.push(response);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
clearTimeout(timeout);
|
||||
console.error(`[NIP-10] Fetch error:`, err);
|
||||
sub.unsubscribe();
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return events[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Get default relays to use when no hints provided
|
||||
*/
|
||||
private async getDefaultRelays(): Promise<string[]> {
|
||||
const activePubkey = accountManager.active$.value?.pubkey;
|
||||
if (activePubkey) {
|
||||
const outbox = await this.getOutboxRelays(activePubkey);
|
||||
if (outbox.length > 0) return outbox.slice(0, 5);
|
||||
}
|
||||
|
||||
// Fallback to popular relays
|
||||
return ["wss://relay.damus.io", "wss://nos.lol", "wss://relay.nostr.band"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Nostr event to Message object
|
||||
*/
|
||||
private eventToMessage(
|
||||
event: NostrEvent,
|
||||
conversationId: string,
|
||||
rootEventId: string,
|
||||
): Message | null {
|
||||
// Handle zap receipts (kind 9735)
|
||||
if (event.kind === 9735) {
|
||||
return this.zapToMessage(event, conversationId);
|
||||
}
|
||||
|
||||
// Handle reactions (kind 7) - skip for now, handled via MessageReactions
|
||||
if (event.kind === 7) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle replies (kind 1)
|
||||
if (event.kind === 1) {
|
||||
const refs = getNip10References(event);
|
||||
|
||||
// Determine what this reply is responding to
|
||||
let replyTo: string | undefined;
|
||||
|
||||
if (refs.reply?.e) {
|
||||
// Replying to another reply
|
||||
replyTo = refs.reply.e.id;
|
||||
} else if (refs.root?.e) {
|
||||
// Replying directly to root
|
||||
replyTo = refs.root.e.id;
|
||||
} else {
|
||||
// Malformed or legacy reply - assume replying to root
|
||||
replyTo = rootEventId;
|
||||
}
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
conversationId,
|
||||
author: event.pubkey,
|
||||
content: event.content,
|
||||
timestamp: event.created_at,
|
||||
type: "user",
|
||||
replyTo,
|
||||
protocol: "nip-10",
|
||||
metadata: {
|
||||
encrypted: false,
|
||||
},
|
||||
event,
|
||||
};
|
||||
}
|
||||
|
||||
console.warn(`[NIP-10] Unknown event kind: ${event.kind}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert zap receipt to Message object
|
||||
*/
|
||||
private zapToMessage(
|
||||
zapReceipt: NostrEvent,
|
||||
conversationId: string,
|
||||
): Message {
|
||||
// Extract zap metadata using applesauce helpers
|
||||
const amount = getZapAmount(zapReceipt);
|
||||
const sender = getZapSender(zapReceipt);
|
||||
const recipient = getZapRecipient(zapReceipt);
|
||||
|
||||
// Find what event is being zapped (e-tag in zap receipt)
|
||||
const eTag = zapReceipt.tags.find((t) => t[0] === "e");
|
||||
const replyTo = eTag?.[1];
|
||||
|
||||
// Get zap request event for comment
|
||||
const zapRequestTag = zapReceipt.tags.find((t) => t[0] === "description");
|
||||
let comment = "";
|
||||
if (zapRequestTag && zapRequestTag[1]) {
|
||||
try {
|
||||
const zapRequest = JSON.parse(zapRequestTag[1]) as NostrEvent;
|
||||
comment = zapRequest.content || "";
|
||||
} catch {
|
||||
// Invalid JSON
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: zapReceipt.id,
|
||||
conversationId,
|
||||
author: sender || zapReceipt.pubkey,
|
||||
content: comment,
|
||||
timestamp: zapReceipt.created_at,
|
||||
type: "zap",
|
||||
replyTo,
|
||||
protocol: "nip-10",
|
||||
metadata: {
|
||||
zapAmount: amount,
|
||||
zapRecipient: recipient,
|
||||
},
|
||||
event: zapReceipt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,13 @@ export const CHAT_KINDS = [
|
||||
/**
|
||||
* Chat protocol identifier
|
||||
*/
|
||||
export type ChatProtocol = "nip-c7" | "nip-17" | "nip-28" | "nip-29" | "nip-53";
|
||||
export type ChatProtocol =
|
||||
| "nip-c7"
|
||||
| "nip-17"
|
||||
| "nip-28"
|
||||
| "nip-29"
|
||||
| "nip-53"
|
||||
| "nip-10";
|
||||
|
||||
/**
|
||||
* Conversation type
|
||||
@@ -61,8 +67,8 @@ export interface ConversationMetadata {
|
||||
|
||||
// NIP-29 group
|
||||
groupId?: string; // host'group-id format
|
||||
relayUrl?: string; // Relay enforcing group rules
|
||||
description?: string; // Group description
|
||||
relayUrl?: string; // Relay URL for single-relay protocols
|
||||
description?: string; // Group/thread description
|
||||
icon?: string; // Group icon/picture URL
|
||||
|
||||
// NIP-53 live chat
|
||||
@@ -76,6 +82,12 @@ export interface ConversationMetadata {
|
||||
// NIP-17 DM
|
||||
encrypted?: boolean;
|
||||
giftWrapped?: boolean;
|
||||
|
||||
// NIP-10 thread
|
||||
rootEventId?: string; // Thread root event ID
|
||||
providedEventId?: string; // Original event from nevent (may be reply)
|
||||
threadDepth?: number; // Approximate depth of thread
|
||||
relays?: string[]; // Relays for this conversation
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -206,6 +218,22 @@ export interface GroupListIdentifier {
|
||||
relays?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* NIP-10 thread identifier (kind 1 note thread)
|
||||
*/
|
||||
export interface ThreadIdentifier {
|
||||
type: "thread";
|
||||
/** Event pointer to the provided event (may be root or a reply) */
|
||||
value: {
|
||||
id: string;
|
||||
relays?: string[];
|
||||
author?: string;
|
||||
kind?: number;
|
||||
};
|
||||
/** Relay hints from nevent encoding */
|
||||
relays?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Protocol-specific identifier - discriminated union
|
||||
* Returned by adapter parseIdentifier()
|
||||
@@ -216,7 +244,8 @@ export type ProtocolIdentifier =
|
||||
| DMIdentifier
|
||||
| NIP05Identifier
|
||||
| ChannelIdentifier
|
||||
| GroupListIdentifier;
|
||||
| GroupListIdentifier
|
||||
| ThreadIdentifier;
|
||||
|
||||
/**
|
||||
* Chat command parsing result
|
||||
|
||||
Reference in New Issue
Block a user