diff --git a/src/components/InboxViewer.tsx b/src/components/InboxViewer.tsx index 77507cb..8a6ba00 100644 --- a/src/components/InboxViewer.tsx +++ b/src/components/InboxViewer.tsx @@ -41,39 +41,44 @@ export function InboxViewer() { const privateMessagesEnabled = state.privateMessagesEnabled ?? false; const autoDecrypt = state.autoDecryptGiftWraps ?? false; - // Get pending count + // Get pending count (gift wraps not yet decrypted) const pendingCount = useLiveQuery(async () => { if (!activeAccount?.pubkey) return 0; - return giftWrapLoader.getPendingCount(activeAccount.pubkey); + return db.giftWraps + .where("[recipientPubkey+status]") + .equals([activeAccount.pubkey, "pending"]) + .count(); }, [activeAccount?.pubkey]); - // Get conversation count - const conversationCount = useLiveQuery(async () => { - if (!activeAccount?.pubkey) return 0; - return giftWrapLoader.getConversationCount(activeAccount.pubkey); - }, [activeAccount?.pubkey]); - - // Get unread count - const unreadCount = useLiveQuery(async () => { - if (!activeAccount?.pubkey) return 0; - return giftWrapLoader.getUnreadCount(activeAccount.pubkey); - }, [activeAccount?.pubkey]); - - // Get conversations - const conversations = useLiveQuery(async () => { - if (!activeAccount?.pubkey) return []; - return getConversations(activeAccount.pubkey); - }, [activeAccount?.pubkey]); - - // Get decrypted messages count + // Get decrypted count (successfully decrypted) const decryptedCount = useLiveQuery(async () => { if (!activeAccount?.pubkey) return 0; - return db.decryptedRumors - .where("recipientPubkey") - .equals(activeAccount.pubkey) + return db.giftWraps + .where("[recipientPubkey+status]") + .equals([activeAccount.pubkey, "decrypted"]) .count(); }, [activeAccount?.pubkey]); + // Get failed count (decryption failed) + const failedCount = useLiveQuery(async () => { + if (!activeAccount?.pubkey) return 0; + return db.giftWraps + .where("[recipientPubkey+status]") + .equals([activeAccount.pubkey, "failed"]) + .count(); + }, [activeAccount?.pubkey]); + + // Get conversations (from decrypted rumors) + const conversations = useLiveQuery(async () => { + if (!activeAccount?.pubkey) return []; + const convos = await getConversations(activeAccount.pubkey); + console.log( + `[InboxViewer] Found ${convos.length} conversations for ${activeAccount.pubkey.slice(0, 8)}`, + convos, + ); + return convos; + }, [activeAccount?.pubkey]); + // Get loader state for relay info const [loaderState, setLoaderState] = useState(null); @@ -156,18 +161,7 @@ export function InboxViewer() { {/* Stats */} -
- - - - Conversations - - - -
{conversationCount ?? 0}
-
-
- +
@@ -175,18 +169,22 @@ export function InboxViewer() { -
{decryptedCount ?? 0}
+
+ {decryptedCount ?? 0} +
- Unread + Failed -
{unreadCount ?? 0}
+
+ {failedCount ?? 0} +
@@ -197,7 +195,9 @@ export function InboxViewer() { -
{pendingCount ?? 0}
+
+ {pendingCount ?? 0} +
@@ -416,25 +416,32 @@ export function InboxViewer() { )} {/* Conversations List */} - {privateMessagesEnabled && - conversations && - conversations.length > 0 && ( - - -
- - Conversations -
-
- + {privateMessagesEnabled && ( + + +
+ + + Conversations ({conversations?.length ?? 0}) + +
+
+ + {conversations && conversations.length > 0 ? (
{conversations.map((conv) => ( ))}
-
-
- )} + ) : ( +
+ No conversations yet. Decrypt pending messages to see + conversations. +
+ )} +
+
+ )} {/* Help Text */} {!privateMessagesEnabled && ( diff --git a/src/lib/chat-parser.ts b/src/lib/chat-parser.ts index 8a0778f..193e8d5 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,11 +62,11 @@ 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 NipC7Adapter(), // Phase 1 - Simple chat (disabled for now) + new Nip17Adapter(), // Encrypted DMs (NIP-17 via NIP-59 gift wraps) + // new Nip28Adapter(), // Public channels (coming soon) + new Nip29Adapter(), // Relay groups + new Nip53Adapter(), // Live activity chat + // new NipC7Adapter(), // Simple chat (disabled for now) ]; for (const adapter of adapters) { @@ -84,6 +84,11 @@ export function parseChatCommand(args: string[]): ChatCommandResult { `Unable to determine chat protocol from identifier: ${identifier} Currently supported formats: + - npub/nprofile/hex pubkey (NIP-17 encrypted DMs, requires private messages enabled) + Examples: + chat npub1... + chat nprofile1... + chat 3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d - relay.com'group-id (NIP-29 relay group, wss:// prefix optional) Examples: chat relay.example.com'bitcoin-dev @@ -99,7 +104,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..4a87c82 --- /dev/null +++ b/src/lib/chat/adapters/nip-17-adapter.ts @@ -0,0 +1,259 @@ +/** + * NIP-17 Adapter - Encrypted Direct Messages via Gift Wraps + * + * This adapter provides read-only access to NIP-17 encrypted DMs + * that have been received and decrypted via gift wraps (NIP-59). + * + * Messages are loaded from the local decryptedRumors database table, + * which is populated by the gift-wrap-loader service. + * + * Protocol: https://github.com/nostr-protocol/nips/blob/master/17.md + */ + +import { Observable } from "rxjs"; +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 db from "@/services/db"; +import eventStore from "@/services/event-store"; + +export class Nip17Adapter extends ChatProtocolAdapter { + readonly protocol = "nip-17" as const; + readonly type = "dm" as const; + + /** + * Parse identifier - accepts npub, nprofile, or hex pubkey + */ + parseIdentifier(input: string): ProtocolIdentifier | null { + // Try nip19 formats + if (input.startsWith("npub1") || input.startsWith("nprofile1")) { + try { + const decoded = nip19.decode(input); + if (decoded.type === "npub") { + return { + type: "dm-recipient", + value: decoded.data, + }; + } else if (decoded.type === "nprofile") { + return { + type: "dm-recipient", + value: decoded.data.pubkey, + relays: decoded.data.relays, + }; + } + } catch { + return null; + } + } + + // Try hex pubkey (64 char hex string) + if (/^[0-9a-f]{64}$/i.test(input)) { + return { + type: "dm-recipient", + value: input.toLowerCase(), + }; + } + + return null; + } + + /** + * Resolve conversation metadata + * Returns basic info about the DM conversation + */ + async resolveConversation( + identifier: ProtocolIdentifier, + ): Promise { + if (identifier.type !== "dm-recipient") { + throw new Error("Invalid identifier type for NIP-17"); + } + + const peerPubkey = identifier.value; + + // Get active account pubkey + const activePubkey = await this.getActivePubkey(); + + // Try to get conversation metadata from database + const conversationId = `${peerPubkey}:${activePubkey}`; + const conversation = await db.conversations.get(conversationId); + + // Get profile from eventStore + const profile = eventStore.getReplaceable(0, peerPubkey, ""); + + // Get peer participant + const peerParticipant: Participant = { + pubkey: peerPubkey, + }; + + // Get self participant + const selfParticipant: Participant = { + pubkey: activePubkey, + }; + + return { + id: conversationId, + type: "dm", + protocol: "nip-17", + title: profile?.content + ? JSON.parse(profile.content).name || peerPubkey.slice(0, 8) + : peerPubkey.slice(0, 8), + participants: [peerParticipant, selfParticipant], + unreadCount: conversation?.unreadCount ?? 0, + metadata: { + encrypted: true, + giftWrapped: true, + }, + }; + } + + /** + * Load messages from decryptedRumors table + * Returns an Observable with all messages for this conversation + */ + loadMessages( + conversation: Conversation, + options?: LoadMessagesOptions, + ): Observable { + // Parse peer pubkey from conversation ID + const [peerPubkey] = conversation.id.split(":"); + + // Get messages from database (async) + const messagesPromise = this.loadMessagesFromDb( + peerPubkey, + conversation.id, + options?.limit ?? 100, + ); + + // Convert promise to observable + return new Observable((subscriber) => { + messagesPromise + .then((messages) => { + subscriber.next(messages); + subscriber.complete(); + }) + .catch((err) => subscriber.error(err)); + }); + } + + /** + * Load more historical messages (pagination) + */ + async loadMoreMessages( + conversation: Conversation, + before: number, + ): Promise { + const [peerPubkey] = conversation.id.split(":"); + return this.loadMessagesFromDb(peerPubkey, conversation.id, 50, before); + } + + /** + * Load messages from database + */ + private async loadMessagesFromDb( + peerPubkey: string, + conversationId: string, + limit: number, + before?: number, + ): Promise { + const activePubkey = await this.getActivePubkey(); + + // Query decryptedRumors table for messages with this peer + let receivedQuery = db.decryptedRumors + .where("[recipientPubkey+senderPubkey]") + .equals([activePubkey, peerPubkey]); + + if (before) { + receivedQuery = receivedQuery.filter((r) => r.rumorCreatedAt < before); + } + + const rumors = await receivedQuery.reverse().limit(limit).toArray(); + + // Also get messages I sent to them (if any) + let sentQuery = db.decryptedRumors + .where("[recipientPubkey+senderPubkey]") + .equals([peerPubkey, activePubkey]); + + if (before) { + sentQuery = sentQuery.filter((r) => r.rumorCreatedAt < before); + } + + const sentRumors = await sentQuery.reverse().limit(limit).toArray(); + + // Combine and sort by timestamp + const allRumors = [...rumors, ...sentRumors].sort( + (a, b) => a.rumorCreatedAt - b.rumorCreatedAt, + ); + + // Convert to Message format + return allRumors + .filter((r) => r.rumorKind === 14) // Only chat messages (kind 14) + .map((r) => ({ + id: r.giftWrapId, + conversationId, + author: r.senderPubkey, + content: r.rumor.content, + timestamp: r.rumorCreatedAt, + protocol: "nip-17" as const, + event: r.rumor, + })); + } + + /** + * Send message - NOT IMPLEMENTED (read-only for now) + */ + async sendMessage( + _conversation: Conversation, + _content: string, + _options?: SendMessageOptions, + ): Promise { + throw new Error( + "Sending NIP-17 messages is not yet implemented. This adapter is read-only.", + ); + } + + /** + * Get capabilities - read-only for now + */ + getCapabilities(): ChatCapabilities { + return { + supportsEncryption: true, // NIP-17 is encrypted + supportsThreading: false, // DMs don't have threading + supportsModeration: false, // No moderation in DMs + supportsRoles: false, // No roles in 1-on-1 DMs + supportsGroupManagement: false, // Not a group + canCreateConversations: false, // Read-only for now + requiresRelay: false, // Uses DM relay lists, not specific relay + }; + } + + /** + * Load reply message - not implemented for NIP-17 + */ + async loadReplyMessage( + _conversation: Conversation, + _eventId: string, + ): Promise { + return null; + } + + /** + * Get active pubkey from account manager + */ + private async getActivePubkey(): Promise { + // Import dynamically to avoid circular dependency + const { default: accountManager } = await import("@/services/accounts"); + const account = accountManager.active; + if (!account) { + throw new Error("No active account"); + } + return account.pubkey; + } +} diff --git a/src/types/man.ts b/src/types/man.ts index 4e38f28..9c78b2c 100644 --- a/src/types/man.ts +++ b/src/types/man.ts @@ -476,15 +476,16 @@ export const manPages: Record = { section: "1", synopsis: "chat ", description: - "Join and participate in Nostr chat conversations. Supports NIP-29 relay-based groups, NIP-53 live activity chat, and multi-room group list interface. For NIP-29 groups, use format 'relay'group-id' where relay is the WebSocket URL (wss:// prefix optional). For NIP-53 live activities, pass the naddr of a kind 30311 live event. For multi-room interface, pass the naddr of a kind 10009 group list event.", + "Join and participate in Nostr chat conversations. Supports NIP-17 encrypted DMs (requires private messages enabled), NIP-29 relay-based groups, NIP-53 live activity chat, and multi-room group list interface. For NIP-17 DMs, use npub/nprofile/hex pubkey. For NIP-29 groups, use format 'relay'group-id' where relay is the WebSocket URL (wss:// prefix optional). For NIP-53 live activities, pass the naddr of a kind 30311 live event. For multi-room interface, pass the naddr of a kind 10009 group list event.", options: [ { flag: "", description: - "NIP-29 group (relay'group-id), NIP-53 live activity (naddr1... kind 30311), or group list (naddr1... kind 10009)", + "NIP-17 DM (npub/nprofile/hex), NIP-29 group (relay'group-id), NIP-53 live activity (naddr1... kind 30311), or group list (naddr1... kind 10009)", }, ], examples: [ + "chat npub1... Open NIP-17 encrypted DM (read-only)", "chat relay.example.com'bitcoin-dev Join NIP-29 relay group", "chat wss://nos.lol'welcome Join NIP-29 group with explicit protocol", "chat naddr1...30311... Join NIP-53 live activity chat",