From 8f008ddd395a1a1dde732d4eae1c298323273791 Mon Sep 17 00:00:00 2001 From: Alejandro Date: Mon, 19 Jan 2026 16:47:12 +0100 Subject: [PATCH] feat: nip-10 chat interface (#153) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: add comprehensive NIP-10 thread chat design documentation Add detailed design documents for implementing NIP-10 thread chat feature: - nip10-thread-chat-design.md: Full architecture, data structures, adapter implementation plan, relay selection strategy, UI requirements, and 7-phase implementation checklist - nip10-thread-chat-examples.md: Complete code examples showing identifier parsing, conversation resolution, message loading, reply sending with proper NIP-10 tags, and ChatViewer integration - nip10-thread-chat-summary.md: Quick reference with visual comparisons, architecture diagrams, protocol comparison table, data flow, and FAQ The feature will enable "chat nevent1..." to display kind 1 threaded conversations as chat interfaces, with the root event prominently displayed at the top and all replies shown as chat messages below. Key design decisions: - Use e-tags with NIP-10 markers (root/reply) instead of q-tags - Merge multiple relay sources (seen, hints, outbox) for coverage - Display root event centered with full feed renderer - Reuse existing ChatViewer infrastructure via adapter pattern - Support both nevent (with relay hints) and note (ID only) formats * 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. * fix: remove unused imports and mark intentionally unused parameter - Remove unused getTagValues import - Prefix providedEvent parameter with underscore to indicate intentional non-use (reserved for future relay hint extraction) * feat: hide reply preview when directly replying to root in NIP-10 threads In NIP-10 thread chats, the root event is already displayed prominently at the top, so showing a reply preview when replying directly to the root is redundant. Changes: - Skip reply preview for regular messages when replying to thread root - Skip reply preview for zap messages when zapping the thread root - Root event remains visible at top as reference point This reduces visual clutter and makes the chat feel cleaner when replies are directed at the already-visible root post. * refactor: simplify NIP-10 thread chat implementation Major simplifications based on user feedback: - Render root event as regular message instead of special feed renderer - Remove special-case logic for hiding reply preview when replying to root - Include root event in messages list using RxJS combineLatest - Change root author role from "admin" to "op" (original poster) - Fix NIP-10 protocol link handler in chat header - Add "Chat" option to event context menu for kind 1 events - Fix unused parameter warnings in nip-10-adapter This refactoring reduces complexity and makes NIP-10 threads behave more consistently with other chat protocols. * feat: add zap support for NIP-10 thread chat messages Implement getZapConfig method to enable zapping messages in NIP-10 threads: - Returns message author as recipient - Includes event pointer for the message being zapped - Uses conversation metadata relays for zap receipt publishing - Integrates with new zap infrastructure from main branch * fix: pass protocol and identifier directly to chat window Fix 'undefined protocol' error when opening chat from event menu. Instead of passing raw nevent as args, directly pass the parsed protocol and identifier props that ChatViewer expects. * debug: add logging to diagnose chat protocol issue Add console logging to: - chat command argParser to see input args and parsed result - ChatViewer to see received props This will help diagnose why 'undefined protocol' error appears when opening chat via command or event menu. * refactor: improve NIP-10 thread chat UX and relay/participant handling UI Improvements: - Remove "thread root" marker from reply previews - treat all replies uniformly - Hide "load older messages" for NIP-10 threads (all replies loaded reactively) - Display "Thread" with note icon instead of "Group" for NIP-10 conversations - Remove debug logging from chat command parser and ChatViewer Participant Management: - Derive participants dynamically from messages (like live-chat does) - Root author (OP) always listed first with "op" role - All unique message authors included in member list - Updates in real-time as new people reply Relay Management: - Expand relay collection to include participant outbox relays - Fetch relays from root author, provided event author, and p-tagged participants - Check up to 5 participants for relay diversity - Increase max relay limit from 7 to 10 for better coverage - Add logging for relay collection debugging This makes NIP-10 threads feel more like proper chat conversations with accurate participant lists and better relay coverage across the thread. * refactor: remove debug logging from NIP-10 adapter Remove informational console.log statements to reduce noise in production. Keep only console.warn and console.error for actual problems. This makes the adapter production-ready while maintaining useful error and warning messages for debugging issues. * fix: resolve lint errors from logging cleanup - Fix empty block statement by simplifying next handler - Prefix unused parameters with underscore (_response, _err) - All lint checks now pass with 0 errors * refactor: use AGGREGATOR_RELAYS constant and remove design docs Replace hardcoded relay URLs with AGGREGATOR_RELAYS constant: - Import AGGREGATOR_RELAYS from @/services/loaders - Use constant for fallback relays in getThreadRelays() - Use constant for default relays in getDefaultRelays() Remove design documentation files (no longer needed): - docs/nip10-thread-chat-design.md - docs/nip10-thread-chat-examples.md - docs/nip10-thread-chat-summary.md This improves maintainability by centralizing relay configuration and reduces repository clutter. * refactor: remove relay.nostr.band and update AGGREGATOR_RELAYS relay.nostr.band is no longer operational, so remove it from the codebase: AGGREGATOR_RELAYS changes: - Removed: wss://relay.nostr.band/ - Removed: wss://purplepag.es/ - Added: wss://relay.snort.social/ - Added: wss://relay.damus.io/ - New list: nos.lol, relay.snort.social, relay.primal.net, relay.damus.io Updated code: - src/services/loaders.ts: Updated AGGREGATOR_RELAYS constant - src/lib/chat/adapters/nip-53-adapter.ts: Use AGGREGATOR_RELAYS instead of hardcoded relays Updated tests: - All test files updated to expect new relay URLs - Replaced relay.nostr.band references with relay.snort.social - Replaced purplepag.es references with relay.snort.social - Fixed URL formats to include trailing slashes for normalization All 980 tests passing ✓ * fix: change grimRelays from let to const in supporters.ts Fix lint error from rebase - grimRelays is never reassigned so it should use const instead of let. * style: reduce padding on sign-in message to match composer Change from px-3 py-2 to px-2 py-1 to match the horizontal and vertical padding of the logged-in message composer (px-2 py-1), ensuring consistent height between logged-in and logged-out states. * feat: make sign-in message clickable to open login dialog Add clickable 'Sign in' link to the logged-out message composer: - Import LoginDialog component - Add showLogin state management - Make 'Sign in' text an underlined button that opens the login dialog - Add LoginDialog component with controlled state This provides a better UX by allowing users to quickly sign in directly from the chat interface. --------- Co-authored-by: Claude --- src/components/ChatViewer.tsx | 110 +- .../chat/ChatMessageContextMenu.tsx | 24 +- src/components/chat/RelaysDropdown.tsx | 9 +- .../nostr/kinds/BaseEventRenderer.tsx | 39 +- src/lib/chat-parser.ts | 20 +- src/lib/chat/adapters/nip-10-adapter.ts | 944 ++++++++++++++++++ src/lib/chat/adapters/nip-53-adapter.ts | 3 +- src/lib/relay-transformer.test.ts | 2 +- src/lib/zap-relay-selection.test.ts | 9 +- src/services/loaders.test.ts | 4 +- src/services/loaders.ts | 3 +- src/services/relay-selection.test.ts | 8 +- src/services/supporters.ts | 2 +- src/types/chat.ts | 39 +- 14 files changed, 1148 insertions(+), 68 deletions(-) create mode 100644 src/lib/chat/adapters/nip-10-adapter.ts diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx index 2808c46..b62d872 100644 --- a/src/components/ChatViewer.tsx +++ b/src/components/ChatViewer.tsx @@ -11,6 +11,7 @@ import { Paperclip, Copy, CopyCheck, + FileText, } from "lucide-react"; import { nip19 } from "nostr-tools"; import { getZapRequest } from "applesauce-common/helpers/zap"; @@ -24,6 +25,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"; @@ -41,6 +43,7 @@ import { StatusBadge } from "./live/StatusBadge"; import { ChatMessageContextMenu } from "./chat/ChatMessageContextMenu"; import { useGrimoire } from "@/core/state"; import { Button } from "./ui/button"; +import LoginDialog from "./nostr/LoginDialog"; import { MentionEditor, type MentionEditorHandle, @@ -589,6 +592,9 @@ export function ChatViewer({ // State for tooltip open (for mobile tap support) const [tooltipOpen, setTooltipOpen] = useState(false); + // State for login dialog + const [showLogin, setShowLogin] = useState(false); + // Handle sending messages with error handling const handleSend = async ( content: string, @@ -731,7 +737,9 @@ export function ChatViewer({ // Handle NIP badge click const handleNipClick = useCallback(() => { - if (conversation?.protocol === "nip-29") { + if (conversation?.protocol === "nip-10") { + addWindow("nip", { number: 10 }); + } else if (conversation?.protocol === "nip-29") { addWindow("nip", { number: 29 }); } else if (conversation?.protocol === "nip-53") { addWindow("nip", { number: 53 }); @@ -745,33 +753,63 @@ export function ChatViewer({ ? conversation?.metadata?.liveActivity : undefined; - // Derive participants from messages for live activities (unique pubkeys who have chatted) + // Derive participants from messages for live activities and NIP-10 threads const derivedParticipants = useMemo(() => { - if (conversation?.type !== "live-chat" || !messages) { - return conversation?.participants || []; - } + // 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 + : undefined; - const hostPubkey = liveActivity?.hostPubkey; - const participants: { pubkey: string; role: "host" | "member" }[] = []; + const participants: { pubkey: string; role: "op" | "member" }[] = []; - // Host always first - if (hostPubkey) { - participants.push({ pubkey: hostPubkey, role: "host" }); - } - - // Add other participants from messages (excluding host) - const seen = new Set(hostPubkey ? [hostPubkey] : []); - for (const msg of messages) { - if (msg.type !== "system" && !seen.has(msg.author)) { - seen.add(msg.author); - participants.push({ pubkey: msg.author, role: "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)) { + seen.add(msg.author); + participants.push({ pubkey: msg.author, role: "member" }); + } + } + + return participants; } - return participants; + // Live activities: derive from messages with host first + if (conversation?.type === "live-chat" && messages) { + const hostPubkey = liveActivity?.hostPubkey; + const participants: { pubkey: string; role: "host" | "member" }[] = []; + + // Host always first + if (hostPubkey) { + participants.push({ pubkey: hostPubkey, role: "host" }); + } + + // Add other participants from messages (excluding host) + const seen = new Set(hostPubkey ? [hostPubkey] : []); + for (const msg of messages) { + if (msg.type !== "system" && !seen.has(msg.author)) { + seen.add(msg.author); + participants.push({ pubkey: msg.author, role: "member" }); + } + } + + return participants; + } + + // Other protocols: use static participants from conversation + return conversation?.participants || []; }, [ + protocol, conversation?.type, conversation?.participants, + conversation?.metadata?.rootEventId, messages, liveActivity?.hostPubkey, ]); @@ -874,9 +912,16 @@ export function ChatViewer({ conversation.type === "live-chat") && ( )} - - {conversation.type} - + {conversation.protocol === "nip-10" ? ( + + + Thread + + ) : ( + + {conversation.type} + + )} {/* Live Activity Status */} {liveActivity?.status && ( @@ -946,7 +991,9 @@ export function ChatViewer({ alignToBottom components={{ Header: () => - hasMore && conversationResult.status === "success" ? ( + hasMore && + conversationResult.status === "success" && + protocol !== "nip-10" ? (
{" "} + to send messages
)} + + {/* Login dialog */} + ); } /** * 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": diff --git a/src/components/chat/ChatMessageContextMenu.tsx b/src/components/chat/ChatMessageContextMenu.tsx index 6c23fca..b48a59f 100644 --- a/src/components/chat/ChatMessageContextMenu.tsx +++ b/src/components/chat/ChatMessageContextMenu.tsx @@ -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 ( <> diff --git a/src/components/chat/RelaysDropdown.tsx b/src/components/chat/RelaysDropdown.tsx index b6c0927..c00ed2c 100644 --- a/src/components/chat/RelaysDropdown.tsx +++ b/src/components/chat/RelaysDropdown.tsx @@ -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) => { diff --git a/src/components/nostr/kinds/BaseEventRenderer.tsx b/src/components/nostr/kinds/BaseEventRenderer.tsx index 1c43ca3..d9b59fc 100644 --- a/src/components/nostr/kinds/BaseEventRenderer.tsx +++ b/src/components/nostr/kinds/BaseEventRenderer.tsx @@ -10,7 +10,15 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Menu, Copy, Check, FileJson, ExternalLink, Zap } from "lucide-react"; +import { + Menu, + Copy, + Check, + FileJson, + ExternalLink, + Zap, + MessageSquare, +} from "lucide-react"; import { useGrimoire } from "@/core/state"; import { useCopy } from "@/hooks/useCopy"; import { JsonViewer } from "@/components/JsonViewer"; @@ -184,6 +192,29 @@ export function EventMenu({ event }: { event: NostrEvent }) { }); }; + const openChatWindow = () => { + // Only kind 1 notes support NIP-10 thread chat + if (event.kind === 1) { + const seenRelaysSet = getSeenRelays(event); + const relays = seenRelaysSet ? Array.from(seenRelaysSet) : []; + + // Open chat with NIP-10 thread protocol + addWindow("chat", { + protocol: "nip-10", + identifier: { + type: "thread", + value: { + id: event.id, + relays, + author: event.pubkey, + kind: event.kind, + }, + relays, + }, + }); + } + }; + return ( @@ -212,6 +243,12 @@ export function EventMenu({ event }: { event: NostrEvent }) { Zap + {event.kind === 1 && ( + + + Chat + + )} {copied ? ( diff --git a/src/lib/chat-parser.ts b/src/lib/chat-parser.ts index 8a0778f..a393740 100644 --- a/src/lib/chat-parser.ts +++ b/src/lib/chat-parser.ts @@ -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)`, ); } diff --git a/src/lib/chat/adapters/nip-10-adapter.ts b/src/lib/chat/adapters/nip-10-adapter.ts new file mode 100644 index 0000000..e746809 --- /dev/null +++ b/src/lib/chat/adapters/nip-10-adapter.ts @@ -0,0 +1,944 @@ +import { Observable, firstValueFrom, combineLatest } from "rxjs"; +import { map, first, toArray } from "rxjs/operators"; +import type { Filter } from "nostr-tools"; +import { nip19 } from "nostr-tools"; +import { + ChatProtocolAdapter, + type SendMessageOptions, + type ZapConfig, +} 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 { AGGREGATOR_RELAYS } from "@/services/loaders"; +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 { + if (identifier.type !== "thread") { + throw new Error( + `NIP-10 adapter cannot handle identifier type: ${identifier.type}`, + ); + } + + const pointer = identifier.value; + const relayHints = identifier.relays || []; + + // 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; + + 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; + } + + // 3. Determine conversation relays + const conversationRelays = await this.getThreadRelays( + rootEvent, + providedEvent, + relayHints, + ); + + // 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 { + const rootEventId = conversation.metadata?.rootEventId; + const relays = conversation.metadata?.relays || []; + + if (!rootEventId) { + throw new Error("Root event ID required"); + } + + // 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) => { + // EOSE or event - both handled by EventStore + }, + }); + + // Store subscription for cleanup + this.subscriptions.set(conversationId, subscription); + + // Return observable from EventStore + // Combine root event with replies + const rootEvent$ = eventStore.event(rootEventId); + const replies$ = eventStore.timeline({ + kinds: [1, 7, 9735], + "#e": [rootEventId], + }); + + return combineLatest([rootEvent$, replies$]).pipe( + map(([rootEvent, replyEvents]) => { + const messages: Message[] = []; + + // Add root event as first message + if (rootEvent) { + const rootMessage = this.rootEventToMessage( + rootEvent, + conversationId, + rootEventId, + ); + if (rootMessage) { + messages.push(rootMessage); + } + } + + // Convert replies to messages + const replyMessages = replyEvents + .map((event) => + this.eventToMessage(event, conversationId, rootEventId), + ) + .filter((msg): msg is Message => msg !== null); + + messages.push(...replyMessages); + + // 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 { + const rootEventId = conversation.metadata?.rootEventId; + const relays = conversation.metadata?.relays || []; + + if (!rootEventId) { + throw new Error("Root event ID required"); + } + + // 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()), + ); + + 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 { + 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); + + // 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 { + 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); + + // Publish to conversation relays + await publishEventToRelays(event, relays); + } + + /** + * Get zap configuration for a message in a NIP-10 thread + * Returns configuration for how zap requests should be constructed + */ + getZapConfig(message: Message, conversation: Conversation): ZapConfig { + // Get relays from conversation metadata + const relays = conversation.metadata?.relays || []; + + // Build eventPointer for the message being zapped + const eventPointer = { + id: message.id, + author: message.author, + relays, + }; + + // Recipient is the message author + return { + supported: true, + recipientPubkey: message.author, + eventPointer, + relays, + }; + } + + /** + * Load a replied-to message by ID + */ + async loadReplyMessage( + conversation: Conversation, + eventId: string, + ): Promise { + // 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; + } + + 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(); + + // Root author is always first + participants.set(rootEvent.pubkey, { + pubkey: rootEvent.pubkey, + role: "op", // Root author is "op" (original poster) 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 + * Includes relays from root author, provided event author, p-tagged participants, and active user + */ + private async getThreadRelays( + rootEvent: NostrEvent, + providedEvent: NostrEvent, + providedRelays: string[], + ): Promise { + const relays = new Set(); + + // 1. Provided relay hints + providedRelays.forEach((r) => relays.add(normalizeURL(r))); + + // 2. Root author's outbox relays (NIP-65) - highest priority + 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. Collect unique participant pubkeys from both events' p-tags + const participantPubkeys = new Set(); + + // Add p-tags from root event + for (const tag of rootEvent.tags) { + if (tag[0] === "p" && tag[1]) { + participantPubkeys.add(tag[1]); + } + } + + // Add p-tags from provided event + for (const tag of providedEvent.tags) { + if (tag[0] === "p" && tag[1]) { + participantPubkeys.add(tag[1]); + } + } + + // Add provided event author if different from root + if (providedEvent.pubkey !== rootEvent.pubkey) { + participantPubkeys.add(providedEvent.pubkey); + } + + // 4. Fetch outbox relays from participant subset (limit to avoid slowdown) + // Take first 5 participants to get relay diversity without excessive fetching + const participantsToCheck = Array.from(participantPubkeys).slice(0, 5); + for (const pubkey of participantsToCheck) { + try { + const outbox = await this.getOutboxRelays(pubkey); + // Add 1 relay from each participant for diversity + if (outbox.length > 0) { + relays.add(normalizeURL(outbox[0])); + } + } catch (_err) { + // Silently continue if participant has no relay list + } + } + + // 5. Active user's outbox (for publishing replies) + const activePubkey = accountManager.active$.value?.pubkey; + if (activePubkey && !participantPubkeys.has(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); + } + } + + // 6. Fallback to aggregator relays if we have too few + if (relays.size < 3) { + AGGREGATOR_RELAYS.forEach((r) => relays.add(r)); + } + + // Limit to 10 relays max for performance + return Array.from(relays).slice(0, 10); + } + + /** + * Helper: Get outbox relays for a pubkey (NIP-65) + */ + private async getOutboxRelays(pubkey: string): Promise { + 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 { + // 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((resolve) => { + const timeout = setTimeout(() => { + 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 { + 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 aggregator relays + return AGGREGATOR_RELAYS; + } + + /** + * Convert root event to Message object + */ + private rootEventToMessage( + event: NostrEvent, + conversationId: string, + _rootEventId: string, + ): Message | null { + if (event.kind !== 1) { + return null; + } + + // Root event has no replyTo field + return { + id: event.id, + conversationId, + author: event.pubkey, + content: event.content, + timestamp: event.created_at, + type: "user", + replyTo: undefined, + protocol: "nip-10", + metadata: { + encrypted: false, + }, + event, + }; + } + + /** + * 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, + }; + } +} diff --git a/src/lib/chat/adapters/nip-53-adapter.ts b/src/lib/chat/adapters/nip-53-adapter.ts index 4f71468..412f910 100644 --- a/src/lib/chat/adapters/nip-53-adapter.ts +++ b/src/lib/chat/adapters/nip-53-adapter.ts @@ -21,6 +21,7 @@ import eventStore from "@/services/event-store"; import pool from "@/services/relay-pool"; import { publishEventToRelays } from "@/services/hub"; import accountManager from "@/services/accounts"; +import { AGGREGATOR_RELAYS } from "@/services/loaders"; import { parseLiveActivity, getLiveStatus, @@ -720,7 +721,7 @@ export class Nip53Adapter extends ChatProtocolAdapter { } // Default fallback relays for live activities - return ["wss://relay.damus.io", "wss://nos.lol", "wss://purplepag.es"]; + return AGGREGATOR_RELAYS; } /** diff --git a/src/lib/relay-transformer.test.ts b/src/lib/relay-transformer.test.ts index 0cae894..d29d233 100644 --- a/src/lib/relay-transformer.test.ts +++ b/src/lib/relay-transformer.test.ts @@ -296,7 +296,7 @@ describe("relayReferences transformer", () => { "wss://relay.damus.io", "wss://nos.lol", "wss://relay.snort.social", - "wss://purplepag.es", + "wss://relay.primal.net", "wss://nostr.wine", ]; diff --git a/src/lib/zap-relay-selection.test.ts b/src/lib/zap-relay-selection.test.ts index ce9bf09..31f61ef 100644 --- a/src/lib/zap-relay-selection.test.ts +++ b/src/lib/zap-relay-selection.test.ts @@ -11,9 +11,10 @@ vi.mock("@/services/relay-list-cache", () => ({ // Mock the loaders for AGGREGATOR_RELAYS vi.mock("@/services/loaders", () => ({ AGGREGATOR_RELAYS: [ - "wss://relay.damus.io", - "wss://nos.lol", - "wss://relay.nostr.band", + "wss://nos.lol/", + "wss://relay.snort.social/", + "wss://relay.primal.net/", + "wss://relay.damus.io/", ], })); @@ -152,7 +153,7 @@ describe("selectZapRelays", () => { expect(result.relays.length).toBeGreaterThan(0); expect(result.sources.fallback.length).toBeGreaterThan(0); - expect(result.relays).toContain("wss://relay.damus.io"); + expect(result.relays).toContain("wss://relay.damus.io/"); }); it("should use fallback when recipient has empty relay list", async () => { diff --git a/src/services/loaders.test.ts b/src/services/loaders.test.ts index 4c5ebb6..ba151b9 100644 --- a/src/services/loaders.test.ts +++ b/src/services/loaders.test.ts @@ -385,9 +385,9 @@ describe("eventLoader", () => { // Should only have aggregator relays (normalized with trailing slash) expect(relays).toContain("wss://nos.lol/"); - expect(relays).toContain("wss://nos.lol/"); - expect(relays).toContain("wss://purplepag.es/"); + expect(relays).toContain("wss://relay.snort.social/"); expect(relays).toContain("wss://relay.primal.net/"); + expect(relays).toContain("wss://relay.damus.io/"); }); it("should limit cached relays to 3", () => { diff --git a/src/services/loaders.ts b/src/services/loaders.ts index 706265e..46a8729 100644 --- a/src/services/loaders.ts +++ b/src/services/loaders.ts @@ -53,8 +53,9 @@ function extractRelayContext(event: NostrEvent): { // IMPORTANT: URLs must be normalized (trailing slash, lowercase) to match RelayStateManager keys export const AGGREGATOR_RELAYS = [ "wss://nos.lol/", - "wss://purplepag.es/", + "wss://relay.snort.social/", "wss://relay.primal.net/", + "wss://relay.damus.io/", ]; // Base event loader (used internally) diff --git a/src/services/relay-selection.test.ts b/src/services/relay-selection.test.ts index 3b694f0..b1e78d1 100644 --- a/src/services/relay-selection.test.ts +++ b/src/services/relay-selection.test.ts @@ -76,7 +76,7 @@ describe("selectRelaysForFilter", () => { const relayListEvent = createRelayListEvent(testSecretKeys[0], [ ["r", "wss://relay.damus.io"], ["r", "wss://nos.lol"], - ["r", "wss://purplepag.es", "read"], + ["r", "wss://relay.snort.social", "read"], ]); // Add to event store @@ -99,7 +99,7 @@ describe("selectRelaysForFilter", () => { result.relays.includes("wss://nos.lol/"); expect(hasWriteRelay).toBe(true); // Should NOT include read-only relay - expect(result.relays).not.toContain("wss://purplepag.es/"); + expect(result.relays).not.toContain("wss://relay.snort.social/"); }); it("should handle multiple authors", async () => { @@ -141,7 +141,7 @@ describe("selectRelaysForFilter", () => { const relayListEvent = createRelayListEvent(testSecretKeys[2], [ ["r", "wss://relay.damus.io", "write"], ["r", "wss://nos.lol", "read"], - ["r", "wss://purplepag.es", "read"], + ["r", "wss://relay.snort.social", "read"], ]); eventStore.add(relayListEvent); @@ -160,7 +160,7 @@ describe("selectRelaysForFilter", () => { // Should include at least one read relay - selectOptimalRelays may pick subset const hasReadRelay = result.relays.includes("wss://nos.lol/") || - result.relays.includes("wss://purplepag.es/"); + result.relays.includes("wss://relay.snort.social/"); expect(hasReadRelay).toBe(true); // Should NOT include write-only relay expect(result.relays).not.toContain("wss://relay.damus.io/"); diff --git a/src/services/supporters.ts b/src/services/supporters.ts index 2d867ff..bee563c 100644 --- a/src/services/supporters.ts +++ b/src/services/supporters.ts @@ -70,7 +70,7 @@ class SupportersService { private async subscribeToZapReceipts() { try { // Start with hardcoded relays for immediate cold start - let grimRelays = [...GRIMOIRE_ZAP_RELAYS]; + const grimRelays = [...GRIMOIRE_ZAP_RELAYS]; // Fetch relay list in background (non-blocking) // Don't await - let it happen in parallel with subscription diff --git a/src/types/chat.ts b/src/types/chat.ts index 6030e08..98c3ef0 100644 --- a/src/types/chat.ts +++ b/src/types/chat.ts @@ -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 @@ -24,7 +30,7 @@ export type ConversationType = "dm" | "channel" | "group" | "live-chat"; /** * Participant role in a conversation */ -export type ParticipantRole = "admin" | "moderator" | "member" | "host"; +export type ParticipantRole = "admin" | "moderator" | "member" | "host" | "op"; /** * Participant in a conversation @@ -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