From 6464ba2a79d12b56a3d65986cde3a0a531044cdc Mon Sep 17 00:00:00 2001 From: Jure <44338+hoornet@users.noreply.github.com> Date: Tue, 10 Mar 2026 20:29:05 +0100 Subject: [PATCH] =?UTF-8?q?Add=20Direct=20Messages=20=E2=80=94=20NIP-04=20?= =?UTF-8?q?encrypted=20DMs=20(roadmap=20#13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/App.tsx | 2 + src/components/dm/DMView.tsx | 383 +++++++++++++++++++++++++ src/components/profile/ProfileView.tsx | 8 +- src/components/sidebar/Sidebar.tsx | 1 + src/lib/nostr/client.ts | 60 ++++ src/lib/nostr/index.ts | 2 +- src/stores/ui.ts | 6 +- 7 files changed, 459 insertions(+), 3 deletions(-) create mode 100644 src/components/dm/DMView.tsx diff --git a/src/App.tsx b/src/App.tsx index afe9101..276cd73 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import { ArticleEditor } from "./components/article/ArticleEditor"; import { OnboardingFlow } from "./components/onboarding/OnboardingFlow"; import { AboutView } from "./components/shared/AboutView"; import { ZapHistoryView } from "./components/zap/ZapHistoryView"; +import { DMView } from "./components/dm/DMView"; import { useUIStore } from "./stores/ui"; function App() { @@ -35,6 +36,7 @@ function App() { {currentView === "article-editor" && } {currentView === "about" && } {currentView === "zaps" && } + {currentView === "dm" && } ); diff --git a/src/components/dm/DMView.tsx b/src/components/dm/DMView.tsx new file mode 100644 index 0000000..0638bb8 --- /dev/null +++ b/src/components/dm/DMView.tsx @@ -0,0 +1,383 @@ +import { useEffect, useRef, useState } from "react"; +import { NDKEvent, nip19 } from "@nostr-dev-kit/ndk"; +import { useUserStore } from "../../stores/user"; +import { useUIStore } from "../../stores/ui"; +import { fetchDMConversations, fetchDMThread, sendDM, decryptDM, getNDK } from "../../lib/nostr"; +import { useProfile } from "../../hooks/useProfile"; +import { timeAgo, shortenPubkey } from "../../lib/utils"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function partnerOf(event: NDKEvent, myPubkey: string): string { + return event.pubkey === myPubkey + ? (event.tags.find((t) => t[0] === "p")?.[1] ?? "") + : event.pubkey; +} + +function groupConversations(events: NDKEvent[], myPubkey: string): Map { + const map = new Map(); + for (const e of events) { + const partner = partnerOf(e, myPubkey); + if (!partner) continue; + const list = map.get(partner) ?? []; + list.push(e); + map.set(partner, list); + } + // Sort each conversation newest-first + for (const [k, list] of map) { + map.set(k, list.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))); + } + return map; +} + +// ── Conversation list row ───────────────────────────────────────────────────── + +function ConvRow({ + partnerPubkey, + lastEvent, + selected, + onSelect, +}: { + partnerPubkey: string; + lastEvent: NDKEvent; + selected: boolean; + onSelect: () => void; +}) { + const profile = useProfile(partnerPubkey); + const name = profile?.displayName || profile?.name || shortenPubkey(partnerPubkey); + const time = lastEvent.created_at ? timeAgo(lastEvent.created_at) : ""; + + return ( + + ); +} + +// ── Message bubble ──────────────────────────────────────────────────────────── + +function MessageBubble({ event, myPubkey }: { event: NDKEvent; myPubkey: string }) { + const isMine = event.pubkey === myPubkey; + const [text, setText] = useState(null); + const [error, setError] = useState(false); + + useEffect(() => { + decryptDM(event, myPubkey) + .then(setText) + .catch(() => setError(true)); + }, [event.id]); + + const time = event.created_at ? timeAgo(event.created_at) : ""; + + return ( +
+
+ {error ? ( + Could not decrypt + ) : text === null ? ( + … + ) : ( + text + )} +
+ {time} +
+
+
+ ); +} + +// ── Thread panel ────────────────────────────────────────────────────────────── + +function ThreadPanel({ + partnerPubkey, + myPubkey, +}: { + partnerPubkey: string; + myPubkey: string; +}) { + const { openProfile } = useUIStore(); + const profile = useProfile(partnerPubkey); + const name = profile?.displayName || profile?.name || shortenPubkey(partnerPubkey); + + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(true); + const [text, setText] = useState(""); + const [sending, setSending] = useState(false); + const [sendError, setSendError] = useState(null); + const bottomRef = useRef(null); + const textareaRef = useRef(null); + + useEffect(() => { + setLoading(true); + setMessages([]); + fetchDMThread(myPubkey, partnerPubkey) + .then(setMessages) + .finally(() => setLoading(false)); + }, [partnerPubkey, myPubkey]); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + const handleSend = async () => { + const content = text.trim(); + if (!content || sending) return; + setSending(true); + setSendError(null); + try { + await sendDM(partnerPubkey, content); + setText(""); + // Re-fetch thread to include the sent message + const updated = await fetchDMThread(myPubkey, partnerPubkey); + setMessages(updated); + textareaRef.current?.focus(); + } catch (err) { + setSendError(String(err)); + } finally { + setSending(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) handleSend(); + }; + + return ( +
+ {/* Header */} +
+ {profile?.picture && ( + + )} + +
+ + {/* Messages */} +
+ {loading && ( +
Loading messages…
+ )} + {!loading && messages.length === 0 && ( +
+ No messages yet. Say hello! +
+ )} + {messages.map((e) => ( + + ))} +
+
+ + {/* Compose */} +
+ {sendError &&

{sendError}

} +
+