diff --git a/src/components/feed/InlineReplyBox.tsx b/src/components/feed/InlineReplyBox.tsx index ace45be..4f1324c 100644 --- a/src/components/feed/InlineReplyBox.tsx +++ b/src/components/feed/InlineReplyBox.tsx @@ -7,9 +7,10 @@ import { EmojiPicker } from "../shared/EmojiPicker"; interface InlineReplyBoxProps { event: NDKEvent; name: string; + rootEvent?: { id: string; pubkey: string }; } -export function InlineReplyBox({ event, name }: InlineReplyBoxProps) { +export function InlineReplyBox({ event, name, rootEvent }: InlineReplyBoxProps) { const [replyText, setReplyText] = useState(""); const [replying, setReplying] = useState(false); const [replyError, setReplyError] = useState(null); @@ -23,7 +24,7 @@ export function InlineReplyBox({ event, name }: InlineReplyBoxProps) { setReplying(true); setReplyError(null); try { - await publishReply(replyText.trim(), { id: event.id, pubkey: event.pubkey }); + await publishReply(replyText.trim(), { id: event.id, pubkey: event.pubkey }, rootEvent); setReplyText(""); setReplySent(true); adjustReplyCount(1); diff --git a/src/components/feed/NoteCard.tsx b/src/components/feed/NoteCard.tsx index a9c760e..922d49f 100644 --- a/src/components/feed/NoteCard.tsx +++ b/src/components/feed/NoteCard.tsx @@ -7,6 +7,7 @@ import { useMuteStore } from "../../stores/mute"; import { useUIStore } from "../../stores/ui"; import { timeAgo, shortenPubkey } from "../../lib/utils"; import { getNDK, fetchNoteById } from "../../lib/nostr"; +import { getParentEventId } from "../../lib/threadTree"; import { NoteContent } from "./NoteContent"; import { NoteActions, LoggedOutStats } from "./NoteActions"; import { InlineReplyBox } from "./InlineReplyBox"; @@ -14,14 +15,7 @@ import { InlineReplyBox } from "./InlineReplyBox"; interface NoteCardProps { event: NDKEvent; focused?: boolean; -} - -function getParentEventId(event: NDKEvent): string | null { - const eTags = event.tags.filter((t) => t[0] === "e"); - if (eTags.length === 0) return null; - return eTags.find((t) => t[3] === "reply")?.[1] - ?? eTags.find((t) => t[3] === "root")?.[1] - ?? eTags[eTags.length - 1][1]; + onReplyInThread?: (event: NDKEvent) => void; } function ParentAuthorName({ pubkey }: { pubkey: string }) { @@ -30,7 +24,7 @@ function ParentAuthorName({ pubkey }: { pubkey: string }) { return @{name}; } -export function NoteCard({ event, focused }: NoteCardProps) { +export function NoteCard({ event, focused, onReplyInThread }: NoteCardProps) { const profile = useProfile(event.pubkey); const name = profile?.displayName || profile?.name || shortenPubkey(event.pubkey); const avatar = profile?.picture; @@ -57,6 +51,7 @@ export function NoteCard({ event, focused }: NoteCardProps) { return (
@@ -160,8 +155,14 @@ export function NoteCard({ event, focused }: NoteCardProps) { {loggedIn && !!getNDK().signer && ( setShowReply((v) => !v)} - showReply={showReply} + onReplyToggle={() => { + if (onReplyInThread) { + onReplyInThread(event); + } else { + setShowReply((v) => !v); + } + }} + showReply={showReply && !onReplyInThread} /> )} diff --git a/src/components/thread/AncestorChain.tsx b/src/components/thread/AncestorChain.tsx new file mode 100644 index 0000000..65a339c --- /dev/null +++ b/src/components/thread/AncestorChain.tsx @@ -0,0 +1,60 @@ +import { NDKEvent } from "@nostr-dev-kit/ndk"; +import { useProfile } from "../../hooks/useProfile"; +import { useUIStore } from "../../stores/ui"; +import { shortenPubkey, timeAgo } from "../../lib/utils"; + +function AncestorCard({ event }: { event: NDKEvent }) { + const profile = useProfile(event.pubkey); + const name = profile?.displayName || profile?.name || shortenPubkey(event.pubkey); + const avatar = profile?.picture; + const time = event.created_at ? timeAgo(event.created_at) : ""; + const { openThread } = useUIStore(); + + const truncated = event.content.length > 120 + ? event.content.slice(0, 120) + "..." + : event.content; + + return ( + + ); +} + +interface AncestorChainProps { + ancestors: NDKEvent[]; +} + +export function AncestorChain({ ancestors }: AncestorChainProps) { + if (ancestors.length === 0) return null; + + return ( +
+
+ {ancestors.length} parent {ancestors.length === 1 ? "note" : "notes"} above +
+ {ancestors.map((a) => ( + + ))} +
+ ); +} diff --git a/src/components/thread/ThreadNode.tsx b/src/components/thread/ThreadNode.tsx new file mode 100644 index 0000000..3db735f --- /dev/null +++ b/src/components/thread/ThreadNode.tsx @@ -0,0 +1,75 @@ +import { useState } from "react"; +import { NDKEvent } from "@nostr-dev-kit/ndk"; +import type { ThreadNode as ThreadNodeType } from "../../lib/threadTree"; +import { NoteCard } from "../feed/NoteCard"; + +interface ThreadNodeProps { + node: ThreadNodeType; + onReplyInThread: (event: NDKEvent) => void; + focusedId?: string; + mutedPubkeys: string[]; + contentMatchesMutedKeyword: (content: string) => boolean; +} + +const MAX_VISIBLE_CHILDREN = 3; +const MAX_INDENT_DEPTH = 4; + +export function ThreadNodeComponent({ node, onReplyInThread, focusedId, mutedPubkeys, contentMatchesMutedKeyword }: ThreadNodeProps) { + const [expanded, setExpanded] = useState(false); + + // Filter out muted children + const visibleChildren = node.children.filter( + (c) => !mutedPubkeys.includes(c.event.pubkey) && !contentMatchesMutedKeyword(c.event.content) + ); + + const hiddenCount = node.children.length - visibleChildren.length; + const shouldCollapse = visibleChildren.length > MAX_VISIBLE_CHILDREN && !expanded; + const shownChildren = shouldCollapse ? visibleChildren.slice(0, 2) : visibleChildren; + const remainingCount = visibleChildren.length - shownChildren.length; + + const indent = Math.min(node.depth, MAX_INDENT_DEPTH); + const isFocused = node.event.id === focusedId; + + return ( +
0 ? "border-l-2 border-border" : ""} + style={indent > 0 ? { marginLeft: `${indent * 16}px` } : undefined} + > + + + {shownChildren.map((child) => ( + + ))} + + {remainingCount > 0 && ( + + )} + + {hiddenCount > 0 && ( +
+ {hiddenCount} {hiddenCount === 1 ? "reply" : "replies"} hidden (muted) +
+ )} +
+ ); +} diff --git a/src/components/thread/ThreadView.tsx b/src/components/thread/ThreadView.tsx index fbabccf..5508091 100644 --- a/src/components/thread/ThreadView.tsx +++ b/src/components/thread/ThreadView.tsx @@ -3,143 +3,24 @@ import { NDKEvent } from "@nostr-dev-kit/ndk"; import { useUIStore } from "../../stores/ui"; import { useUserStore } from "../../stores/user"; import { useMuteStore } from "../../stores/mute"; +import { fetchNoteById, fetchThreadEvents, fetchAncestors, publishReply, getNDK } from "../../lib/nostr"; +import { buildThreadTree, getRootEventId } from "../../lib/threadTree"; +import type { ThreadNode } from "../../lib/threadTree"; import { useProfile } from "../../hooks/useProfile"; -import { useReactionCount } from "../../hooks/useReactionCount"; -import { useZapCount } from "../../hooks/useZapCount"; -import { fetchReplies, publishReaction, publishReply, publishRepost, getNDK } from "../../lib/nostr"; -import { QuoteModal } from "../feed/QuoteModal"; -import { EmojiPicker } from "../shared/EmojiPicker"; -import { shortenPubkey, timeAgo } from "../../lib/utils"; -import { NoteContent } from "../feed/NoteContent"; +import { shortenPubkey } from "../../lib/utils"; +import { AncestorChain } from "./AncestorChain"; +import { ThreadNodeComponent } from "./ThreadNode"; import { NoteCard } from "../feed/NoteCard"; -import { ZapModal } from "../zap/ZapModal"; +import { EmojiPicker } from "../shared/EmojiPicker"; -function RootNote({ event }: { event: NDKEvent }) { - const { openProfile } = useUIStore(); - const { loggedIn } = useUserStore(); +function ReplyTargetBadge({ event, onClear }: { event: NDKEvent; onClear: () => void }) { const profile = useProfile(event.pubkey); const name = profile?.displayName || profile?.name || shortenPubkey(event.pubkey); - const avatar = profile?.picture; - const nip05 = profile?.nip05; - const time = event.created_at ? timeAgo(event.created_at) : ""; - const [reactionCount, adjustReactionCount] = useReactionCount(event.id); - const zapData = useZapCount(event.id); - const [liked, setLiked] = useState(() => { - try { return new Set(JSON.parse(localStorage.getItem("wrystr_liked") || "[]")).has(event.id); } - catch { return false; } - }); - const [liking, setLiking] = useState(false); - const [showZap, setShowZap] = useState(false); - const [reposting, setReposting] = useState(false); - const [reposted, setReposted] = useState(false); - const [showQuote, setShowQuote] = useState(false); - const hasLightning = !!(profile?.lud16 || profile?.lud06); - - const handleLike = async () => { - if (!loggedIn || liked || liking) return; - setLiking(true); - try { - await publishReaction(event.id, event.pubkey); - const likedSet = new Set(JSON.parse(localStorage.getItem("wrystr_liked") || "[]")); - likedSet.add(event.id); - localStorage.setItem("wrystr_liked", JSON.stringify(Array.from(likedSet))); - setLiked(true); - adjustReactionCount(1); - } finally { - setLiking(false); - } - }; - - const handleRepost = async () => { - if (reposting || reposted) return; - setReposting(true); - try { - await publishRepost(event); - setReposted(true); - } finally { - setReposting(false); - } - }; - return ( -
-
-
openProfile(event.pubkey)}> - {avatar ? ( - - ) : ( -
- {name.charAt(0).toUpperCase()} -
- )} -
-
- openProfile(event.pubkey)} - >{name} - {nip05 &&
{nip05}
} -
-
- -
{time}
- - {/* Action row */} - {loggedIn && !!getNDK().signer && ( -
- - - - {hasLightning && ( - - )} -
- )} - - {showZap && ( - setShowZap(false)} - /> - )} - - {showQuote && ( - setShowQuote(false)} - /> - )} +
+ replying to + @{name} +
); } @@ -149,34 +30,119 @@ export function ThreadView() { const { loggedIn } = useUserStore(); const { mutedPubkeys, contentMatchesMutedKeyword } = useMuteStore(); if (!selectedNote) { goBack(); return null; } - const event = selectedNote; + const focusedEvent = selectedNote; - const [replies, setReplies] = useState([]); + const [rootEvent, setRootEvent] = useState(null); + const [ancestors, setAncestors] = useState([]); + const [tree, setTree] = useState(null); const [loading, setLoading] = useState(true); + const [replyTarget, setReplyTarget] = useState(null); const [replyText, setReplyText] = useState(""); const [replying, setReplying] = useState(false); const [replySent, setReplySent] = useState(false); const [showReplyEmoji, setShowReplyEmoji] = useState(false); const replyRef = useRef(null); + const scrollRef = useRef(null); useEffect(() => { - fetchReplies(event.id).then((r) => { - setReplies(r); - setLoading(false); - }).catch(() => setLoading(false)); - }, [event.id]); + let cancelled = false; + + async function loadThread() { + setLoading(true); + setTree(null); + setAncestors([]); + setRootEvent(null); + setReplyTarget(null); + + try { + // Determine root + const rootId = getRootEventId(focusedEvent); + let root: NDKEvent; + + if (!rootId || rootId === focusedEvent.id) { + // This IS the root + root = focusedEvent; + } else { + // Fetch the root event + const fetched = await fetchNoteById(rootId); + if (fetched) { + root = fetched; + // Fetch ancestors between root and focused + const anc = await fetchAncestors(focusedEvent); + if (!cancelled) setAncestors(anc.filter((a) => a.id !== root.id)); + } else { + // Root not found, treat focused as root + root = focusedEvent; + } + } + + if (cancelled) return; + setRootEvent(root); + + // Fetch all thread events and build tree + const events = await fetchThreadEvents(root.id); + if (cancelled) return; + + // Include root in the event set + const allEvents = [root, ...events.filter((e) => e.id !== root.id)]; + const built = buildThreadTree(root.id, allEvents); + setTree(built); + } catch (err) { + console.error("Failed to load thread:", err); + } finally { + if (!cancelled) setLoading(false); + } + } + + loadThread(); + return () => { cancelled = true; }; + }, [focusedEvent.id]); + + // Scroll to focused note after tree renders (if not root) + useEffect(() => { + if (!loading && rootEvent && focusedEvent.id !== rootEvent.id) { + // Small delay to allow DOM to render + const timer = setTimeout(() => { + const el = document.querySelector(`[data-note-id="${focusedEvent.id}"]`); + el?.scrollIntoView({ behavior: "smooth", block: "center" }); + }, 100); + return () => clearTimeout(timer); + } + }, [loading, rootEvent?.id, focusedEvent.id]); + + const handleReplyInThread = (event: NDKEvent) => { + setReplyTarget(event); + setTimeout(() => replyRef.current?.focus(), 50); + }; + + const effectiveReplyTarget = replyTarget ?? rootEvent; const handleReply = async () => { - if (!replyText.trim() || replying) return; + if (!replyText.trim() || replying || !rootEvent) return; setReplying(true); try { - const replyEvent = await publishReply(replyText.trim(), { id: event.id, pubkey: event.pubkey }); + const target = effectiveReplyTarget ?? rootEvent; + const rootArg = target.id !== rootEvent.id + ? { id: rootEvent.id, pubkey: rootEvent.pubkey } + : undefined; + + const replyEvent = await publishReply( + replyText.trim(), + { id: target.id, pubkey: target.pubkey }, + rootArg, + ); setReplyText(""); setReplySent(true); - // Inject reply locally so it appears immediately - setReplies((prev) => [...prev, replyEvent]); - // Also try fetching from relay in background - fetchReplies(event.id).then((updated) => setReplies(updated)); + setReplyTarget(null); + + // Optimistically insert into tree + if (tree) { + const allEvents = collectEvents(tree); + allEvents.push(replyEvent); + const rebuilt = buildThreadTree(rootEvent.id, allEvents); + setTree(rebuilt); + } + setTimeout(() => setReplySent(false), 2000); } finally { setReplying(false); @@ -201,79 +167,123 @@ export function ThreadView() {

Thread

-
- {/* Root note */} - - - {/* Reply composer */} - {loggedIn && ( -
-