feat: Add NIP-17 encrypted DM support and simplify inbox stats

- Simplified inbox stats to show only decrypted/failed/pending counts
- Created read-only NIP-17 adapter for encrypted DMs via gift wraps
- Integrated NIP-17 into chat command (supports npub/nprofile/hex pubkey)
- Updated chat command documentation with NIP-17 examples
- Always show conversations section even when empty
- Fixed inbox queries to use giftWraps table status field
This commit is contained in:
Claude
2026-01-15 22:41:45 +00:00
parent c9e86d0767
commit e3abfbe01c
4 changed files with 334 additions and 63 deletions

View File

@@ -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<any>(null);
@@ -156,18 +161,7 @@ export function InboxViewer() {
</div>
{/* Stats */}
<div className="grid grid-cols-4 gap-4">
<Card>
<CardHeader className="p-4">
<CardTitle className="text-sm font-medium text-muted-foreground">
Conversations
</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="text-2xl font-bold">{conversationCount ?? 0}</div>
</CardContent>
</Card>
<div className="grid grid-cols-3 gap-4">
<Card>
<CardHeader className="p-4">
<CardTitle className="text-sm font-medium text-muted-foreground">
@@ -175,18 +169,22 @@ export function InboxViewer() {
</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="text-2xl font-bold">{decryptedCount ?? 0}</div>
<div className="text-2xl font-bold text-green-600">
{decryptedCount ?? 0}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="p-4">
<CardTitle className="text-sm font-medium text-muted-foreground">
Unread
Failed
</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="text-2xl font-bold">{unreadCount ?? 0}</div>
<div className="text-2xl font-bold text-red-600">
{failedCount ?? 0}
</div>
</CardContent>
</Card>
@@ -197,7 +195,9 @@ export function InboxViewer() {
</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="text-2xl font-bold">{pendingCount ?? 0}</div>
<div className="text-2xl font-bold text-yellow-600">
{pendingCount ?? 0}
</div>
</CardContent>
</Card>
</div>
@@ -416,25 +416,32 @@ export function InboxViewer() {
)}
{/* Conversations List */}
{privateMessagesEnabled &&
conversations &&
conversations.length > 0 && (
<Card>
<CardHeader className="p-4">
<div className="flex items-center gap-2">
<MessageSquare className="h-4 w-4" />
<CardTitle className="text-base">Conversations</CardTitle>
</div>
</CardHeader>
<CardContent className="p-4 pt-0">
{privateMessagesEnabled && (
<Card>
<CardHeader className="p-4">
<div className="flex items-center gap-2">
<MessageSquare className="h-4 w-4" />
<CardTitle className="text-base">
Conversations ({conversations?.length ?? 0})
</CardTitle>
</div>
</CardHeader>
<CardContent className="p-4 pt-0">
{conversations && conversations.length > 0 ? (
<div className="space-y-2">
{conversations.map((conv) => (
<ConversationItem key={conv.id} conversation={conv} />
))}
</div>
</CardContent>
</Card>
)}
) : (
<div className="text-sm text-muted-foreground">
No conversations yet. Decrypt pending messages to see
conversations.
</div>
)}
</CardContent>
</Card>
)}
{/* Help Text */}
{!privateMessagesEnabled && (

View File

@@ -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)`,
);
}

View File

@@ -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<Conversation> {
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<Message[]> {
// 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<Message[]> {
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<Message[]> {
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<void> {
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<NostrEvent | null> {
return null;
}
/**
* Get active pubkey from account manager
*/
private async getActivePubkey(): Promise<string> {
// 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;
}
}

View File

@@ -476,15 +476,16 @@ export const manPages: Record<string, ManPageEntry> = {
section: "1",
synopsis: "chat <identifier>",
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: "<identifier>",
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",