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

@@ -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" && <ArticleEditor />}
{currentView === "about" && <AboutView />}
{currentView === "zaps" && <ZapHistoryView />}
{currentView === "dm" && <DMView />}
</main>
</div>
);

View File

@@ -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<string, NDKEvent[]> {
const map = new Map<string, NDKEvent[]>();
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 (
<button
onClick={onSelect}
className={`w-full flex items-center gap-2.5 px-3 py-2.5 text-left transition-colors ${
selected ? "bg-accent/10 border-l-2 border-accent" : "hover:bg-bg-hover border-l-2 border-transparent"
}`}
>
{profile?.picture ? (
<img src={profile.picture} alt="" className="w-8 h-8 rounded-sm object-cover shrink-0"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
) : (
<div className="w-8 h-8 rounded-sm bg-bg-raised border border-border flex items-center justify-center text-text-dim text-xs shrink-0">
{name.charAt(0).toUpperCase()}
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-baseline justify-between gap-1">
<span className="text-text text-[12px] font-medium truncate">{name}</span>
<span className="text-text-dim text-[10px] shrink-0">{time}</span>
</div>
<div className="text-text-dim text-[11px] truncate">🔒 encrypted</div>
</div>
</button>
);
}
// ── Message bubble ────────────────────────────────────────────────────────────
function MessageBubble({ event, myPubkey }: { event: NDKEvent; myPubkey: string }) {
const isMine = event.pubkey === myPubkey;
const [text, setText] = useState<string | null>(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 (
<div className={`flex ${isMine ? "justify-end" : "justify-start"} mb-1.5`}>
<div
className={`max-w-[75%] px-3 py-2 text-[12px] leading-relaxed break-words ${
isMine
? "bg-accent/15 text-text"
: "bg-bg-raised border border-border text-text"
}`}
>
{error ? (
<span className="text-text-dim italic">Could not decrypt</span>
) : text === null ? (
<span className="text-text-dim"></span>
) : (
text
)}
<div className={`text-[10px] mt-1 ${isMine ? "text-accent/60" : "text-text-dim"}`}>
{time}
</div>
</div>
</div>
);
}
// ── 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<NDKEvent[]>([]);
const [loading, setLoading] = useState(true);
const [text, setText] = useState("");
const [sending, setSending] = useState(false);
const [sendError, setSendError] = useState<string | null>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(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 (
<div className="flex-1 flex flex-col min-w-0">
{/* Header */}
<div className="border-b border-border px-4 py-2.5 flex items-center gap-3 shrink-0">
{profile?.picture && (
<img src={profile.picture} alt="" className="w-7 h-7 rounded-sm object-cover" />
)}
<button
onClick={() => openProfile(partnerPubkey)}
className="text-text text-[13px] font-medium hover:text-accent transition-colors"
>
{name}
</button>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 py-3">
{loading && (
<div className="text-text-dim text-[12px] text-center py-8">Loading messages</div>
)}
{!loading && messages.length === 0 && (
<div className="text-text-dim text-[12px] text-center py-8">
No messages yet. Say hello!
</div>
)}
{messages.map((e) => (
<MessageBubble key={e.id} event={e} myPubkey={myPubkey} />
))}
<div ref={bottomRef} />
</div>
{/* Compose */}
<div className="border-t border-border px-4 py-3 shrink-0">
{sendError && <p className="text-danger text-[11px] mb-2">{sendError}</p>}
<div className="flex gap-2">
<textarea
ref={textareaRef}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={`Message ${name}`}
rows={2}
className="flex-1 bg-bg border border-border px-3 py-2 text-text text-[12px] resize-none focus:outline-none focus:border-accent/50 placeholder:text-text-dim"
style={{ WebkitUserSelect: "text", userSelect: "text" } as React.CSSProperties}
/>
<button
onClick={handleSend}
disabled={!text.trim() || sending}
className="px-3 self-end py-2 text-[11px] bg-accent hover:bg-accent-hover text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed shrink-0"
>
{sending ? "…" : "send"}
</button>
</div>
<p className="text-text-dim text-[10px] mt-1">Ctrl+Enter to send · NIP-04 encrypted</p>
</div>
</div>
);
}
// ── New conversation input ────────────────────────────────────────────────────
function NewConvInput({ onStart }: { onStart: (pubkey: string) => void }) {
const [input, setInput] = useState("");
const [error, setError] = useState<string | null>(null);
const handleStart = () => {
const raw = input.trim();
if (!raw) return;
try {
if (raw.startsWith("npub1")) {
const decoded = nip19.decode(raw);
if (decoded.type !== "npub") throw new Error("Not an npub");
onStart(decoded.data as string);
} else if (/^[0-9a-f]{64}$/.test(raw)) {
onStart(raw);
} else {
setError("Enter an npub1… or hex pubkey");
}
} catch {
setError("Invalid public key");
}
};
return (
<div className="px-3 py-3 border-t border-border">
<div className="flex gap-1.5">
<input
value={input}
onChange={(e) => { setInput(e.target.value); setError(null); }}
onKeyDown={(e) => e.key === "Enter" && handleStart()}
placeholder="npub1… or hex pubkey"
className="flex-1 bg-bg border border-border px-2 py-1.5 text-text text-[11px] font-mono focus:outline-none focus:border-accent/50 placeholder:text-text-dim"
style={{ WebkitUserSelect: "text", userSelect: "text" } as React.CSSProperties}
/>
<button
onClick={handleStart}
disabled={!input.trim()}
className="px-2 py-1.5 text-[10px] border border-border text-text-dim hover:text-accent hover:border-accent/40 transition-colors disabled:opacity-30 shrink-0"
>
start
</button>
</div>
{error && <p className="text-danger text-[10px] mt-1">{error}</p>}
</div>
);
}
// ── Main view ─────────────────────────────────────────────────────────────────
export function DMView() {
const { pubkey, loggedIn } = useUserStore();
const { pendingDMPubkey } = useUIStore();
const [conversations, setConversations] = useState<Map<string, NDKEvent[]>>(new Map());
const [selectedPubkey, setSelectedPubkey] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const hasSigner = !!getNDK().signer;
// Handle navigation from ProfileView
useEffect(() => {
if (pendingDMPubkey) {
setSelectedPubkey(pendingDMPubkey);
useUIStore.setState({ pendingDMPubkey: null });
}
}, [pendingDMPubkey]);
// Load conversations
useEffect(() => {
if (!pubkey || !hasSigner) return;
setLoading(true);
fetchDMConversations(pubkey)
.then((events) => {
const grouped = groupConversations(events, pubkey);
setConversations(grouped);
// Auto-select first if none chosen and no pending
if (!selectedPubkey && !pendingDMPubkey && grouped.size > 0) {
setSelectedPubkey(Array.from(grouped.keys())[0]);
}
})
.finally(() => setLoading(false));
}, [pubkey, hasSigner]);
if (!loggedIn || !pubkey) {
return (
<div className="h-full flex items-center justify-center text-text-dim text-[12px]">
Log in to send and receive direct messages.
</div>
);
}
if (!hasSigner) {
return (
<div className="h-full flex flex-col items-center justify-center gap-2 px-8 text-center">
<p className="text-text-dim text-[12px]">Direct messages require a private key login.</p>
<p className="text-text-dim text-[11px] opacity-70">Read-only (npub) accounts cannot encrypt or decrypt DMs.</p>
</div>
);
}
// Build sorted conversation list (newest first)
const sortedPartners = Array.from(conversations.entries())
.sort((a, b) => (b[1][0]?.created_at ?? 0) - (a[1][0]?.created_at ?? 0));
return (
<div className="h-full flex">
{/* Left: conversation list */}
<div className="w-56 border-r border-border flex flex-col shrink-0">
<div className="border-b border-border px-3 py-2.5 shrink-0">
<h2 className="text-text text-[12px] font-medium">Messages</h2>
</div>
<div className="flex-1 overflow-y-auto">
{loading && (
<div className="px-3 py-6 text-text-dim text-[11px] text-center">Loading</div>
)}
{!loading && conversations.size === 0 && (
<div className="px-3 py-6 text-text-dim text-[11px] text-center">
No messages yet.
</div>
)}
{sortedPartners.map(([partner, events]) => (
<ConvRow
key={partner}
partnerPubkey={partner}
lastEvent={events[0]}
selected={selectedPubkey === partner}
onSelect={() => setSelectedPubkey(partner)}
/>
))}
</div>
<NewConvInput
onStart={(pk) => {
setSelectedPubkey(pk);
// Add to conversations map if not already there
if (!conversations.has(pk)) {
setConversations((prev) => new Map(prev).set(pk, []));
}
}}
/>
</div>
{/* Right: thread or empty state */}
{selectedPubkey ? (
<ThreadPanel partnerPubkey={selectedPubkey} myPubkey={pubkey} />
) : (
<div className="flex-1 flex items-center justify-center text-text-dim text-[12px]">
Select a conversation or start a new one.
</div>
)}
</div>
);
}

View File

@@ -212,7 +212,7 @@ function EditProfileForm({ pubkey, onSaved }: { pubkey: string; onSaved: () => v
}
export function ProfileView() {
const { selectedPubkey, goBack } = useUIStore();
const { selectedPubkey, goBack, openDM } = useUIStore();
const { pubkey: ownPubkey, profile: ownProfile, loggedIn, follows, follow, unfollow } = useUserStore();
const pubkey = selectedPubkey!;
const isOwn = pubkey === ownPubkey;
@@ -310,6 +310,12 @@ export function ProfileView() {
>
{isMuted ? "unmute" : "mute"}
</button>
<button
onClick={() => openDM(pubkey)}
className="text-[11px] px-3 py-1 border border-border text-text-muted hover:text-accent hover:border-accent/40 transition-colors"
>
message
</button>
</div>
)}
</header>

View File

@@ -7,6 +7,7 @@ import { AccountSwitcher } from "./AccountSwitcher";
const NAV_ITEMS = [
{ id: "feed" as const, label: "feed", icon: "◈" },
{ id: "search" as const, label: "search", icon: "⌕" },
{ id: "dm" as const, label: "messages", icon: "✉" },
{ id: "zaps" as const, label: "zaps", icon: "⚡" },
{ id: "relays" as const, label: "relays", icon: "⟐" },
{ id: "settings" as const, label: "settings", icon: "⚙" },

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";

View File

@@ -2,7 +2,7 @@ import { create } from "zustand";
import { NDKEvent } from "@nostr-dev-kit/ndk";
type View = "feed" | "search" | "relays" | "settings" | "profile" | "thread" | "article-editor" | "about" | "zaps";
type View = "feed" | "search" | "relays" | "settings" | "profile" | "thread" | "article-editor" | "about" | "zaps" | "dm";
interface UIState {
currentView: View;
@@ -11,10 +11,12 @@ interface UIState {
selectedNote: NDKEvent | null;
previousView: View;
pendingSearch: string | null;
pendingDMPubkey: string | null;
setView: (view: View) => void;
openProfile: (pubkey: string) => void;
openThread: (note: NDKEvent, from: View) => void;
openSearch: (query: string) => void;
openDM: (pubkey: string) => void;
goBack: () => void;
toggleSidebar: () => void;
}
@@ -28,10 +30,12 @@ export const useUIStore = create<UIState>((set, _get) => ({
selectedNote: null,
previousView: "feed",
pendingSearch: null,
pendingDMPubkey: null,
setView: (currentView) => set({ currentView }),
openProfile: (pubkey) => set((s) => ({ currentView: "profile", selectedPubkey: pubkey, previousView: s.currentView as View })),
openThread: (note, from) => set({ currentView: "thread", selectedNote: note, previousView: from }),
openSearch: (query) => set({ currentView: "search", pendingSearch: query }),
openDM: (pubkey) => set({ currentView: "dm", pendingDMPubkey: pubkey }),
goBack: () => set((s) => ({
currentView: s.previousView !== s.currentView ? s.previousView : "feed",
selectedNote: null,