mirror of
https://github.com/hoornet/vega.git
synced 2026-05-07 04:39:12 -07:00
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:
383
src/components/dm/DMView.tsx
Normal file
383
src/components/dm/DMView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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: "⚙" },
|
||||
|
||||
Reference in New Issue
Block a user