import { useEffect, useRef, useState } from "react"; import { NDKEvent } from "@nostr-dev-kit/ndk"; import { useAutoResize } from "../../hooks/useAutoResize"; import { useUIStore } from "../../stores/ui"; import { useUserStore } from "../../stores/user"; import { useMuteStore } from "../../stores/mute"; import { fetchNoteById, fetchThreadEvents, fetchAncestors, publishReply, getNDK, ensureConnected } from "../../lib/nostr"; import { buildThreadTree, getRootEventId } from "../../lib/threadTree"; import type { ThreadNode } from "../../lib/threadTree"; import { AncestorChain } from "./AncestorChain"; import { ThreadNodeComponent } from "./ThreadNode"; import { NoteCard } from "../feed/NoteCard"; import { EmojiPicker } from "../shared/EmojiPicker"; export function ThreadView() { const { selectedNote, goBack } = useUIStore(); const { loggedIn } = useUserStore(); const { mutedPubkeys, contentMatchesMutedKeyword } = useMuteStore(); const [rootEvent, setRootEvent] = useState(null); const [ancestors, setAncestors] = useState([]); const [tree, setTree] = useState(null); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(null); const [showRootReply, setShowRootReply] = useState(false); const [replyText, setReplyText] = useState(""); const [replying, setReplying] = useState(false); const [replySent, setReplySent] = useState(false); const [showReplyEmoji, setShowReplyEmoji] = useState(false); const autoResize = useAutoResize(2, 8); const replyRef = useRef(null); const scrollRef = useRef(null); const [retryCount, setRetryCount] = useState(0); // Guard AFTER all hooks to satisfy React rules of hooks if (!selectedNote) { goBack(); return null; } const focusedEvent = selectedNote; useEffect(() => { let cancelled = false; async function loadThread() { setLoadError(null); setTree(null); setAncestors([]); setRootEvent(null); setShowRootReply(false); // Show focused note immediately as a minimal tree (no waiting) const minimalTree = buildThreadTree(focusedEvent.id, [focusedEvent]); setRootEvent(focusedEvent); setTree(minimalTree); setLoading(false); try { // Ensure we have relay connectivity before fetching const connected = await ensureConnected(); if (!connected && !cancelled) { setLoadError("No relay connections available. Check your network."); return; } const rootId = getRootEventId(focusedEvent); let root: NDKEvent = focusedEvent; let fetchedAncestors: NDKEvent[] = []; if (rootId && rootId !== focusedEvent.id) { // Fetch root and ancestors in parallel with thread replies const [fetched, ancestorResult] = await Promise.all([ fetchNoteById(rootId), fetchAncestors(focusedEvent), ]); if (fetched) { root = fetched; fetchedAncestors = ancestorResult.filter((a) => a.id !== root.id); if (!cancelled) setAncestors(fetchedAncestors); } else if (!cancelled) { setLoadError("Could not fetch the root note — relay may be slow."); } } if (cancelled) return; setRootEvent(root); const events = await fetchThreadEvents(root.id); if (cancelled) return; // Build event list: root + thread replies + focused event + ancestors const allEvents = [root, ...events.filter((e) => e.id !== root.id)]; if (focusedEvent.id !== root.id && !allEvents.some((e) => e.id === focusedEvent.id)) { allEvents.push(focusedEvent); } for (const anc of fetchedAncestors) { if (!allEvents.some((e) => e.id === anc.id)) { allEvents.push(anc); } } const built = buildThreadTree(root.id, allEvents); setTree(built); } catch (err) { console.error("Failed to load thread:", err); if (!cancelled) setLoadError(`Failed to load: ${err}`); } } loadThread(); return () => { cancelled = true; }; }, [focusedEvent.id, retryCount]); // Scroll to focused note after tree fully loads. // Use a short delay after each tree update; the last one wins. const scrollTimer = useRef>(undefined); useEffect(() => { if (focusedEvent.id === rootEvent?.id) return; clearTimeout(scrollTimer.current); scrollTimer.current = setTimeout(() => { const el = document.querySelector(`[data-note-id="${focusedEvent.id}"]`); el?.scrollIntoView({ behavior: "smooth", block: "center" }); }, 400); return () => clearTimeout(scrollTimer.current); }, [tree, focusedEvent.id, rootEvent?.id]); // Called when any inline reply box publishes a reply const handleReplyPublished = (reply: NDKEvent) => { if (tree && rootEvent) { const allEvents = collectEvents(tree); allEvents.push(reply); const rebuilt = buildThreadTree(rootEvent.id, allEvents); setTree(rebuilt); } }; // Root-level reply (reply to the root note) const handleRootReply = async () => { if (!replyText.trim() || replying || !rootEvent) return; setReplying(true); try { const reply = await publishReply(replyText.trim(), { id: rootEvent.id, pubkey: rootEvent.pubkey }); setReplyText(""); setReplySent(true); setShowRootReply(false); handleReplyPublished(reply); setTimeout(() => setReplySent(false), 2000); } finally { setReplying(false); } }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) handleRootReply(); if (e.key === "Escape") replyRef.current?.blur(); }; return (
{/* Header */}

Thread

{/* Error banner */} {loadError && !loading && (
{loadError}
)} {/* Loading shimmer */} {loading && (
)} {!loading && tree && rootEvent && ( <> {/* Ancestors (when opening a deep reply) */} {/* Root note */}
setShowRootReply((v) => !v)} />
{/* Root reply box (inline, right below root) */} {showRootReply && loggedIn && !!getNDK().signer && (