From 6dca82d65896b4ce927cc9f4278b08df5eaf4269 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Jan 2026 10:00:00 +0000 Subject: [PATCH] feat: Add read-only NIP-17 chat adapter for private DMs - Create NIP-17 chat adapter supporting npub, nprofile, hex pubkey, NIP-05 - Support self-messages ("Saved Messages"), groups, and 1-on-1 conversations - Add gift wrap event persistence to Dexie (version 17) - Load stored gift wraps into EventStore on startup - Export Rumor type from gift-wrap service for adapter use - Wire up NIP-17 adapter to chat parser and ChatViewer - Add kind 14 to CHAT_KINDS for consistent message filtering - Update tests to reflect NIP-17 support for npub identifiers Chat command formats now supported: - chat npub1.../nprofile1.../hex pubkey (single recipient) - chat user@example.com (NIP-05 resolution) - chat npub1...,npub2... (group conversation) --- src/components/ChatViewer.tsx | 5 +- src/lib/chat-parser.test.ts | 13 +- src/lib/chat-parser.ts | 17 +- src/lib/chat/adapters/nip-17-adapter.ts | 436 ++++++++++++++++++++++++ src/services/db.ts | 54 +++ src/services/gift-wrap.ts | 44 ++- src/types/chat.ts | 1 + 7 files changed, 555 insertions(+), 15 deletions(-) create mode 100644 src/lib/chat/adapters/nip-17-adapter.ts diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx index ad7c931..21b8242 100644 --- a/src/components/ChatViewer.tsx +++ b/src/components/ChatViewer.tsx @@ -25,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 { Nip17Adapter } from "@/lib/chat/adapters/nip-17-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"; @@ -1046,10 +1047,10 @@ function getAdapter(protocol: ChatProtocol): ChatProtocolAdapter { switch (protocol) { // case "nip-c7": // Phase 1 - Simple chat (coming soon) // return new NipC7Adapter(); + case "nip-17": + return new Nip17Adapter(); case "nip-29": return new Nip29Adapter(); - // case "nip-17": // Phase 2 - Encrypted DMs (coming soon) - // return new Nip17Adapter(); // case "nip-28": // Phase 3 - Public channels (coming soon) // return new Nip28Adapter(); case "nip-53": diff --git a/src/lib/chat-parser.test.ts b/src/lib/chat-parser.test.ts index d0f430c..4c77428 100644 --- a/src/lib/chat-parser.test.ts +++ b/src/lib/chat-parser.test.ts @@ -89,10 +89,15 @@ describe("parseChatCommand", () => { ); }); - it("should throw error for npub (NIP-C7 disabled)", () => { - expect(() => parseChatCommand(["npub1xyz"])).toThrow( - /Unable to determine chat protocol/, - ); + it("should parse npub as NIP-17 private DM", () => { + // Valid npub format (64 hex chars encoded) + const result = parseChatCommand([ + "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6", + ]); + + expect(result.protocol).toBe("nip-17"); + expect(result.identifier.type).toBe("dm-recipient"); + expect(result.adapter.protocol).toBe("nip-17"); }); it("should throw error for note/nevent (NIP-28 not implemented)", () => { diff --git a/src/lib/chat-parser.ts b/src/lib/chat-parser.ts index 8a0778f..4d5420e 100644 --- a/src/lib/chat-parser.ts +++ b/src/lib/chat-parser.ts @@ -1,10 +1,10 @@ import type { ChatCommandResult, GroupListIdentifier } from "@/types/chat"; // import { NipC7Adapter } from "./chat/adapters/nip-c7-adapter"; +import { Nip17Adapter } from "./chat/adapters/nip-17-adapter"; import { Nip29Adapter } from "./chat/adapters/nip-29-adapter"; import { Nip53Adapter } from "./chat/adapters/nip-53-adapter"; import { nip19 } from "nostr-tools"; // Import other adapters as they're implemented -// import { Nip17Adapter } from "./chat/adapters/nip-17-adapter"; // import { Nip28Adapter } from "./chat/adapters/nip-28-adapter"; /** @@ -62,10 +62,10 @@ export function parseChatCommand(args: string[]): ChatCommandResult { // Try each adapter in priority order const adapters = [ - // new Nip17Adapter(), // Phase 2 - // new Nip28Adapter(), // Phase 3 - new Nip29Adapter(), // Phase 4 - Relay groups - new Nip53Adapter(), // Phase 5 - Live activity chat + new Nip17Adapter(), // Private DMs (gift-wrapped) + // new Nip28Adapter(), // Phase 3 - Public channels + new Nip29Adapter(), // Relay groups + new Nip53Adapter(), // Live activity chat // new NipC7Adapter(), // Phase 1 - Simple chat (disabled for now) ]; @@ -84,6 +84,12 @@ export function parseChatCommand(args: string[]): ChatCommandResult { `Unable to determine chat protocol from identifier: ${identifier} Currently supported formats: + - npub1.../nprofile1.../hex pubkey (NIP-17 private DMs) + Examples: + chat npub1abc... + chat nprofile1... + chat user@example.com (NIP-05) + chat npub1...,npub2...,npub3... (group chat) - relay.com'group-id (NIP-29 relay group, wss:// prefix optional) Examples: chat relay.example.com'bitcoin-dev @@ -99,7 +105,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)`, ); } diff --git a/src/lib/chat/adapters/nip-17-adapter.ts b/src/lib/chat/adapters/nip-17-adapter.ts new file mode 100644 index 0000000..53dd5e2 --- /dev/null +++ b/src/lib/chat/adapters/nip-17-adapter.ts @@ -0,0 +1,436 @@ +import { Observable, of } from "rxjs"; +import { map } from "rxjs/operators"; +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 giftWrapService, { type Rumor } from "@/services/gift-wrap"; +import accountManager from "@/services/accounts"; +import { resolveNip05 } from "@/lib/nip05"; + +/** Kind 14: Private direct message (NIP-17) */ +const PRIVATE_DM_KIND = 14; + +/** + * Compute a stable conversation ID from sorted participant pubkeys + */ +function computeConversationId(participants: string[]): string { + const sorted = [...participants].sort(); + return `nip17:${sorted.join(",")}`; +} + +/** + * Parse participants from a comma-separated list or single identifier + * Supports: npub, nprofile, hex pubkey (32 bytes), NIP-05 + */ +async function parseParticipants(input: string): Promise { + const parts = input + .split(",") + .map((p) => p.trim()) + .filter(Boolean); + const pubkeys: string[] = []; + + for (const part of parts) { + const pubkey = await resolveToPubkey(part); + if (pubkey && !pubkeys.includes(pubkey)) { + pubkeys.push(pubkey); + } + } + + return pubkeys; +} + +/** + * Resolve an identifier to a hex pubkey + */ +async function resolveToPubkey(input: string): Promise { + // Try npub + if (input.startsWith("npub1")) { + try { + const decoded = nip19.decode(input); + if (decoded.type === "npub") { + return decoded.data; + } + } catch { + // Not a valid npub + } + } + + // Try nprofile + if (input.startsWith("nprofile1")) { + try { + const decoded = nip19.decode(input); + if (decoded.type === "nprofile") { + return decoded.data.pubkey; + } + } catch { + // Not a valid nprofile + } + } + + // Try hex pubkey (64 chars) + if (/^[0-9a-fA-F]{64}$/.test(input)) { + return input.toLowerCase(); + } + + // Try NIP-05 (contains @ or is bare domain) + if (input.includes("@") || input.includes(".")) { + try { + const pubkey = await resolveNip05(input); + if (pubkey) { + return pubkey; + } + } catch { + // NIP-05 resolution failed + } + } + + return null; +} + +/** + * NIP-17 Adapter - Private Direct Messages (Gift Wrapped) + * + * Features: + * - End-to-end encrypted messages via NIP-59 gift wraps + * - 1-on-1 conversations + * - Group conversations (multiple recipients) + * - Self-messages ("saved messages") + * - Read-only for now (sending messages coming later) + * + * Identifier formats: + * - npub1... (single recipient) + * - nprofile1... (single recipient with relay hints) + * - hex pubkey (64 chars) + * - NIP-05 address (user@domain.com or _@domain.com) + * - Comma-separated list of any of the above for groups + */ +export class Nip17Adapter extends ChatProtocolAdapter { + readonly protocol = "nip-17" as const; + readonly type = "dm" as const; + + /** + * Parse identifier - accepts pubkeys, npubs, nprofiles, NIP-05, or comma-separated list + */ + parseIdentifier(input: string): ProtocolIdentifier | null { + // Quick check: must look like a pubkey identifier or NIP-05 + const trimmed = input.trim(); + + // Check for npub, nprofile, hex, or NIP-05 patterns + const looksLikePubkey = + trimmed.startsWith("npub1") || + trimmed.startsWith("nprofile1") || + /^[0-9a-fA-F]{64}$/.test(trimmed) || + trimmed.includes("@") || + (trimmed.includes(".") && + !trimmed.includes("'") && + !trimmed.includes("/")); + + // Also check for comma-separated list + const looksLikeList = + trimmed.includes(",") && + trimmed + .split(",") + .some( + (p) => + p.trim().startsWith("npub1") || + p.trim().startsWith("nprofile1") || + /^[0-9a-fA-F]{64}$/.test(p.trim()) || + p.trim().includes("@"), + ); + + if (!looksLikePubkey && !looksLikeList) { + return null; + } + + // Return a placeholder identifier - actual resolution happens in resolveConversation + return { + type: "dm-recipient", + value: trimmed, // Will be resolved later + relays: [], + }; + } + + /** + * Resolve conversation from DM identifier + */ + async resolveConversation( + identifier: ProtocolIdentifier, + ): Promise { + if ( + identifier.type !== "dm-recipient" && + identifier.type !== "chat-partner" + ) { + throw new Error( + `NIP-17 adapter cannot handle identifier type: ${identifier.type}`, + ); + } + + const activePubkey = accountManager.active$.value?.pubkey; + if (!activePubkey) { + throw new Error("No active account"); + } + + // Check if private messages are enabled + const settings = giftWrapService.settings$.value; + if (!settings.enabled) { + throw new Error( + "Private messages are not enabled. Enable them in the inbox settings.", + ); + } + + // Parse the identifier to get participant pubkeys + const inputPubkeys = await parseParticipants(identifier.value); + if (inputPubkeys.length === 0) { + throw new Error( + `Could not resolve any pubkeys from: ${identifier.value}`, + ); + } + + // Build full participant list (always include self) + const allParticipants = [ + activePubkey, + ...inputPubkeys.filter((p) => p !== activePubkey), + ]; + const uniqueParticipants = [...new Set(allParticipants)]; + + // Determine conversation type + const isSelfChat = uniqueParticipants.length === 1; // Only self + const isGroup = uniqueParticipants.length > 2; // More than 2 people + + // Create conversation ID from participants + const conversationId = computeConversationId(uniqueParticipants); + + // Build title + let title: string; + if (isSelfChat) { + title = "Saved Messages"; + } else if (isGroup) { + title = `Group (${uniqueParticipants.length})`; + } else { + // 1-on-1: use the other person's pubkey for title + const otherPubkey = uniqueParticipants.find((p) => p !== activePubkey); + title = otherPubkey ? `${otherPubkey.slice(0, 8)}...` : "Private Chat"; + } + + // Build participants array + const participants: Participant[] = uniqueParticipants.map((pubkey) => ({ + pubkey, + role: pubkey === activePubkey ? "member" : undefined, + })); + + return { + id: conversationId, + type: "dm", + protocol: "nip-17", + title, + participants, + metadata: { + encrypted: true, + giftWrapped: true, + }, + unreadCount: 0, + }; + } + + /** + * Load messages for a conversation + * Filters decrypted rumors to match conversation participants + */ + loadMessages( + conversation: Conversation, + _options?: LoadMessagesOptions, + ): Observable { + const participantSet = new Set( + conversation.participants.map((p) => p.pubkey), + ); + + return giftWrapService.decryptedRumors$.pipe( + map((rumors) => { + // Filter rumors that belong to this conversation + const conversationRumors = rumors.filter(({ rumor }) => { + // Only include kind 14 (private DMs) + if (rumor.kind !== PRIVATE_DM_KIND) return false; + + // Get all participants from the rumor + const rumorParticipants = this.getRumorParticipants(rumor); + + // Check if participants match (same set of pubkeys) + if (rumorParticipants.size !== participantSet.size) return false; + for (const p of rumorParticipants) { + if (!participantSet.has(p)) return false; + } + return true; + }); + + // Convert to Message format + return conversationRumors.map(({ giftWrap, rumor }) => + this.rumorToMessage(conversation.id, giftWrap, rumor), + ); + }), + ); + } + + /** + * Get all participants from a rumor (author + all p-tag recipients) + */ + private getRumorParticipants(rumor: Rumor): Set { + const participants = new Set(); + participants.add(rumor.pubkey); // Author + + // Add all p-tag recipients + for (const tag of rumor.tags) { + if (tag[0] === "p" && tag[1]) { + participants.add(tag[1]); + } + } + + return participants; + } + + /** + * Convert a rumor to a Message + */ + private rumorToMessage( + conversationId: string, + giftWrap: NostrEvent, + rumor: Rumor, + ): Message { + // Find reply-to from e tags + let replyTo: string | undefined; + for (const tag of rumor.tags) { + if (tag[0] === "e" && tag[1]) { + // NIP-10: last e tag is usually the reply target + replyTo = tag[1]; + } + } + + return { + id: rumor.id, + conversationId, + author: rumor.pubkey, + content: rumor.content, + timestamp: rumor.created_at, + type: "user", + replyTo, + metadata: { + encrypted: true, + }, + protocol: "nip-17", + // Use gift wrap as the event since rumor is unsigned + event: giftWrap, + }; + } + + /** + * Load more historical messages (pagination) + */ + async loadMoreMessages( + _conversation: Conversation, + _before: number, + ): Promise { + // For now, all messages are loaded at once from the gift wrap service + // Pagination would require fetching more gift wraps from relays + return []; + } + + /** + * Send a message (not implemented yet - read-only for now) + */ + async sendMessage( + _conversation: Conversation, + _content: string, + _options?: SendMessageOptions, + ): Promise { + throw new Error( + "Sending messages is not yet implemented for NIP-17. Coming soon!", + ); + } + + /** + * Get capabilities + */ + getCapabilities(): ChatCapabilities { + return { + supportsEncryption: true, + supportsThreading: true, // via e tags + supportsModeration: false, + supportsRoles: false, + supportsGroupManagement: false, + canCreateConversations: false, // read-only for now + requiresRelay: false, // uses inbox relays from profile + }; + } + + /** + * Load a replied-to message by ID + */ + async loadReplyMessage( + _conversation: Conversation, + eventId: string, + ): Promise { + // Check decrypted rumors for the message + const rumors = giftWrapService.decryptedRumors$.value; + const found = rumors.find(({ rumor }) => rumor.id === eventId); + if (found) { + // Return the gift wrap event + return found.giftWrap; + } + return null; + } + + /** + * Load conversation list from gift wrap service + */ + loadConversationList(): Observable { + const activePubkey = accountManager.active$.value?.pubkey; + if (!activePubkey) { + return of([]); + } + + return giftWrapService.conversations$.pipe( + map((conversations) => + conversations.map((conv) => ({ + id: conv.id, + type: "dm" as const, + protocol: "nip-17" as const, + title: this.getConversationTitle(conv.participants, activePubkey), + participants: conv.participants.map((pubkey) => ({ pubkey })), + metadata: { + encrypted: true, + giftWrapped: true, + }, + lastMessage: conv.lastMessage + ? this.rumorToMessage(conv.id, conv.lastGiftWrap!, conv.lastMessage) + : undefined, + unreadCount: 0, + })), + ), + ); + } + + /** + * Get conversation title from participants + */ + private getConversationTitle( + participants: string[], + activePubkey: string, + ): string { + const others = participants.filter((p) => p !== activePubkey); + + if (others.length === 0) { + return "Saved Messages"; + } else if (others.length === 1) { + return `${others[0].slice(0, 8)}...`; + } else { + return `Group (${participants.length})`; + } + } +} diff --git a/src/services/db.ts b/src/services/db.ts index 249f643..c441b51 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -67,6 +67,13 @@ export interface EncryptedContentEntry { savedAt: number; } +export interface StoredGiftWrap { + id: string; // Event ID + event: NostrEvent; // Full gift wrap event + userPubkey: string; // Recipient pubkey (from #p tag) + savedAt: number; +} + /** * Get all stored encrypted content IDs * Used to check which gift wraps have already been decrypted @@ -76,6 +83,36 @@ export async function getStoredEncryptedContentIds(): Promise> { return new Set(entries.map((e) => e.id)); } +/** + * Save gift wrap events to Dexie for persistence across sessions + */ +export async function saveGiftWraps( + events: NostrEvent[], + userPubkey: string, +): Promise { + const entries: StoredGiftWrap[] = events.map((event) => ({ + id: event.id, + event, + userPubkey, + savedAt: Date.now(), + })); + + await db.giftWraps.bulkPut(entries); +} + +/** + * Load stored gift wrap events for a user + */ +export async function loadStoredGiftWraps( + userPubkey: string, +): Promise { + const entries = await db.giftWraps + .where("userPubkey") + .equals(userPubkey) + .toArray(); + return entries.map((e) => e.event); +} + export interface LocalSpell { id: string; // UUID for local-only spells, or event ID for published spells alias?: string; // Optional local-only quick name (e.g., "btc") @@ -114,6 +151,7 @@ class GrimoireDb extends Dexie { spells!: Table; spellbooks!: Table; encryptedContent!: Table; + giftWraps!: Table; constructor(name: string) { super(name); @@ -364,6 +402,22 @@ class GrimoireDb extends Dexie { spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt", encryptedContent: "&id, savedAt", }); + + // Version 17: Add gift wrap event storage for NIP-17 chat + this.version(17).stores({ + profiles: "&pubkey", + nip05: "&nip05", + nips: "&id", + relayInfo: "&url", + relayAuthPreferences: "&url", + relayLists: "&pubkey, updatedAt", + relayLiveness: "&url", + blossomServers: "&pubkey, updatedAt", + spells: "&id, alias, createdAt, isPublished, deletedAt", + spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt", + encryptedContent: "&id, savedAt", + giftWraps: "&id, userPubkey, savedAt", + }); } } diff --git a/src/services/gift-wrap.ts b/src/services/gift-wrap.ts index f707986..4363204 100644 --- a/src/services/gift-wrap.ts +++ b/src/services/gift-wrap.ts @@ -14,7 +14,12 @@ import type { NostrEvent } from "@/types/nostr"; import type { ISigner } from "applesauce-signers"; import eventStore from "./event-store"; import pool from "./relay-pool"; -import { encryptedContentStorage, getStoredEncryptedContentIds } from "./db"; +import { + encryptedContentStorage, + getStoredEncryptedContentIds, + saveGiftWraps, + loadStoredGiftWraps, +} from "./db"; import { AGGREGATOR_RELAYS } from "./loaders"; import relayListCache from "./relay-list-cache"; @@ -25,7 +30,7 @@ const DM_RELAY_LIST_KIND = 10050; const PRIVATE_DM_KIND = 14; /** Rumor is an unsigned event - used for gift wrap contents */ -interface Rumor { +export interface Rumor { id: string; pubkey: string; created_at: number; @@ -177,12 +182,33 @@ class GiftWrapService { }); this.subscriptions.push(updateSub); - // If enabled, start syncing + // If enabled, load stored gift wraps and start syncing if (this.settings$.value.enabled) { + await this.loadStoredGiftWraps(); this.startSync(); } } + /** Load stored gift wraps from Dexie into EventStore */ + private async loadStoredGiftWraps() { + if (!this.userPubkey) return; + + try { + const storedEvents = await loadStoredGiftWraps(this.userPubkey); + if (storedEvents.length > 0) { + console.log( + `[GiftWrap] Loading ${storedEvents.length} stored gift wraps into EventStore`, + ); + // Add stored events to EventStore - this triggers the timeline subscription + for (const event of storedEvents) { + eventStore.add(event); + } + } + } catch (err) { + console.warn(`[GiftWrap] Error loading stored gift wraps:`, err); + } + } + /** Update settings */ updateSettings(settings: Partial) { const newSettings = { ...this.settings$.value, ...settings }; @@ -321,6 +347,11 @@ class GiftWrapService { .timeline(reqFilter) .pipe(map((events) => events.sort((a, b) => b.created_at - a.created_at))) .subscribe((giftWraps) => { + // Find new gift wraps that we haven't seen before + const newGiftWraps = giftWraps.filter( + (gw) => !this.giftWraps.some((existing) => existing.id === gw.id), + ); + this.giftWraps = giftWraps; this.giftWraps$.next(giftWraps); @@ -340,6 +371,13 @@ class GiftWrapService { this.decryptStates$.next(new Map(this.decryptStates)); this.updatePendingCount(); + // Persist new gift wraps to Dexie for fast loading on next startup + if (newGiftWraps.length > 0 && this.userPubkey) { + saveGiftWraps(newGiftWraps, this.userPubkey).catch((err) => { + console.warn(`[GiftWrap] Error saving gift wraps:`, err); + }); + } + // Update conversations this.updateConversations(); diff --git a/src/types/chat.ts b/src/types/chat.ts index f81b518..f03bb6f 100644 --- a/src/types/chat.ts +++ b/src/types/chat.ts @@ -6,6 +6,7 @@ import type { NostrEvent } from "./nostr"; */ export const CHAT_KINDS = [ 9, // NIP-29: Group chat messages + 14, // NIP-17: Private direct messages (inside gift wrap) 9321, // NIP-61: Nutzaps (ecash zaps in groups/live chats) 1311, // NIP-53: Live chat messages 9735, // NIP-57: Zap receipts (part of chat context)