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

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