Add Direct Messages — NIP-04 encrypted DMs (roadmap #13)

Nostr layer:
- fetchDMConversations: fetches all kind-4 events to/from the user
  (both directions in parallel), deduplicates, newest-first
- fetchDMThread: fetches both directions for a specific conversation,
  sorted oldest-first for display
- sendDM: NIP-04 encrypts content via NDK signer, publishes kind 4
- decryptDM: decrypts regardless of direction (ECDH shared secret is
  symmetric — always pass the other party to signer.decrypt)

UI (DMView):
- Two-panel layout: conversation list (w-56) + active thread
- ConvRow: avatar, name, time; shows "🔒 encrypted" preview to avoid
  decrypting the whole inbox on load
- MessageBubble: decrypts lazily on mount; mine right-aligned,
  theirs left-aligned; shows "Could not decrypt" on failure
- ThreadPanel: loads full thread, auto-scrolls to bottom, re-fetches
  after send; Ctrl+Enter to send
- NewConvInput: start a new conversation by pasting an npub1 or hex
  pubkey; validates and resolves before opening thread
- Read-only (npub) accounts see a clear "nsec required" message

Navigation:
- ✉ messages added to sidebar nav
- openDM(pubkey) in UI store → navigates to dm view with pending pubkey
- ProfileView: "✉ message" button in action row opens DM thread

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jure
2026-03-10 20:29:05 +01:00
parent 65d103f252
commit 6464ba2a79
7 changed files with 459 additions and 3 deletions

View File

@@ -305,6 +305,66 @@ export async function publishContactList(pubkeys: string[]): Promise<void> {
await event.publish();
}
// ── Direct Messages (NIP-04) ─────────────────────────────────────────────────
export async function fetchDMConversations(myPubkey: string): Promise<NDKEvent[]> {
const instance = getNDK();
const [received, sent] = await Promise.all([
instance.fetchEvents(
{ kinds: [NDKKind.EncryptedDirectMessage], "#p": [myPubkey], limit: 500 },
{ cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY }
),
instance.fetchEvents(
{ kinds: [NDKKind.EncryptedDirectMessage], authors: [myPubkey], limit: 500 },
{ cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY }
),
]);
const seen = new Set<string>();
return [...Array.from(received), ...Array.from(sent)]
.filter((e) => { if (seen.has(e.id!)) return false; seen.add(e.id!); return true; })
.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0));
}
export async function fetchDMThread(myPubkey: string, theirPubkey: string): Promise<NDKEvent[]> {
const instance = getNDK();
const [fromThem, fromMe] = await Promise.all([
instance.fetchEvents(
{ kinds: [NDKKind.EncryptedDirectMessage], "#p": [myPubkey], authors: [theirPubkey], limit: 200 },
{ cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY }
),
instance.fetchEvents(
{ kinds: [NDKKind.EncryptedDirectMessage], "#p": [theirPubkey], authors: [myPubkey], limit: 200 },
{ cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY }
),
]);
return [...Array.from(fromThem), ...Array.from(fromMe)]
.sort((a, b) => (a.created_at ?? 0) - (b.created_at ?? 0));
}
export async function sendDM(recipientPubkey: string, content: string): Promise<void> {
const instance = getNDK();
if (!instance.signer) throw new Error("Not logged in");
const recipient = instance.getUser({ pubkey: recipientPubkey });
const encrypted = await instance.signer.encrypt(recipient, content, "nip04");
const event = new NDKEvent(instance);
event.kind = NDKKind.EncryptedDirectMessage;
event.content = encrypted;
event.tags = [["p", recipientPubkey]];
await event.publish();
}
export async function decryptDM(event: NDKEvent, myPubkey: string): Promise<string> {
const instance = getNDK();
if (!instance.signer) throw new Error("No signer");
// ECDH shared secret is symmetric — always pass the OTHER party
const otherPubkey =
event.pubkey === myPubkey
? (event.tags.find((t) => t[0] === "p")?.[1] ?? "")
: event.pubkey;
const otherUser = instance.getUser({ pubkey: otherPubkey });
return instance.signer.decrypt(otherUser, event.content, "nip04");
}
export async function fetchZapsReceived(pubkey: string, limit = 50): Promise<NDKEvent[]> {
const instance = getNDK();
const filter: NDKFilter = { kinds: [NDKKind.Zap], "#p": [pubkey], limit };

View File

@@ -1 +1 @@
export { getNDK, connectToRelays, fetchGlobalFeed, fetchFollowFeed, fetchReplies, publishNote, publishArticle, publishProfile, publishReaction, publishRepost, publishQuote, publishReply, publishContactList, fetchReactionCount, fetchUserNotes, fetchProfile, fetchZapsReceived, fetchZapsSent, fetchMuteList, publishMuteList, getStoredRelayUrls, addRelay, removeRelay, searchNotes, searchUsers } from "./client";
export { getNDK, connectToRelays, fetchGlobalFeed, fetchFollowFeed, fetchReplies, publishNote, publishArticle, publishProfile, publishReaction, publishRepost, publishQuote, publishReply, publishContactList, fetchReactionCount, fetchUserNotes, fetchProfile, fetchZapsReceived, fetchZapsSent, fetchDMConversations, fetchDMThread, sendDM, decryptDM, fetchMuteList, publishMuteList, getStoredRelayUrls, addRelay, removeRelay, searchNotes, searchUsers } from "./client";