mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-07-25 14:27:50 +02:00
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:
@@ -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 && (
|
||||
|
||||
@@ -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)`,
|
||||
);
|
||||
}
|
||||
|
||||
259
src/lib/chat/adapters/nip-17-adapter.ts
Normal file
259
src/lib/chat/adapters/nip-17-adapter.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user