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}

} +
+