import { useEffect, useMemo, useRef, useState } from "react"; import { NDKEvent, NDKKind, nip19 } from "@nostr-dev-kit/ndk"; import { openUrl } from "@tauri-apps/plugin-opener"; import { useUserStore } from "../../stores/user"; import { useUIStore } from "../../stores/ui"; import { useNotificationsStore } from "../../stores/notifications"; import { fetchDMConversations, fetchDMThread, sendDM, decryptDM, getNDK } from "../../lib/nostr"; import { useProfile } from "../../hooks/useProfile"; import { timeAgo, shortenPubkey, profileName } from "../../lib/utils"; import { debug } from "../../lib/debug"; import { parseContent } from "../../lib/parsing"; import { tryHandleUrlInternally, tryOpenNostrEntity } from "../feed/TextSegments"; // ── 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 = profileName(profile, shortenPubkey(partnerPubkey)); const time = lastEvent.created_at ? timeAgo(lastEvent.created_at) : ""; return ( ); } // ── DM text renderer ───────────────────────────────────────────────────────── function DMText({ text }: { text: string }) { const segments = useMemo(() => parseContent(text), [text]); return ( {segments.map((seg, i) => { if (seg.type === "link") { return ( { e.preventDefault(); if (!tryHandleUrlInternally(seg.value)) openUrl(seg.value).catch(() => {}); }} > {seg.display || seg.value} ); } if (seg.type === "image") { return ( { (e.target as HTMLImageElement).style.display = "none"; }} /> ); } if (seg.type === "naddr" || seg.type === "mention") { return ( tryOpenNostrEntity(seg.value)} > {seg.type === "naddr" ? "🔗 " : "@"}{String(seg.display ?? seg.value).slice(0, 20)}… ); } if (seg.type === "quote") { return ( ↩ quoted note ); } // video/audio/youtube/etc. — show URL as a plain link if (["video", "audio", "youtube", "vimeo", "spotify", "tidal", "fountain"].includes(seg.type)) { return ( { e.preventDefault(); openUrl(seg.value).catch(() => {}); }} > {seg.value} ); } return {seg.value}; })} ); } // ── 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 ? ( ) : ( )}
{time}
); } // ── Thread panel ────────────────────────────────────────────────────────────── function ThreadPanel({ partnerPubkey, myPubkey, }: { partnerPubkey: string; myPubkey: string; }) { const { openProfile } = useUIStore(); const profile = useProfile(partnerPubkey); const name = profileName(profile, 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) .catch((err) => debug.error("Failed to fetch DM thread:", err)) .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 { debug.log("[DM-UI] sending to", partnerPubkey.slice(0, 8)); await sendDM(partnerPubkey, content); debug.log("[DM-UI] send completed, re-fetching thread"); setText(""); // Re-fetch thread to include the sent message const updated = await fetchDMThread(myPubkey, partnerPubkey); debug.log("[DM-UI] re-fetch got", updated.length, "messages"); setMessages(updated); textareaRef.current?.focus(); } catch (err) { debug.error("[DM-UI] send failed:", 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 && ( {`${name}'s )}
{/* Messages */}
{loading && (
Loading messages…
)} {!loading && messages.length === 0 && (
No messages yet. Say hello!
)} {messages.map((e) => ( ))}
{/* Compose */}
{sendError &&

{sendError}

}