diff --git a/src/components/ThreadConversation.tsx b/src/components/ThreadConversation.tsx index 3b34527..9ba0051 100644 --- a/src/components/ThreadConversation.tsx +++ b/src/components/ThreadConversation.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from "react"; +import { useState, useMemo, useEffect, useRef } from "react"; import { ChevronDown, ChevronRight } from "lucide-react"; import { getNip10References } from "applesauce-common/helpers/threading"; import { getCommentReplyPointer } from "applesauce-common/helpers/comment"; @@ -9,7 +9,7 @@ import type { NostrEvent } from "@/types/nostr"; export interface ThreadConversationProps { rootEventId: string; replies: NostrEvent[]; - threadKind: "nip10" | "nip22"; // NIP-10 (kind 1) or NIP-22 (kind 1111) + focusedEventId?: string; // Event to highlight and scroll to (if not root) } interface ThreadNode { @@ -60,7 +60,7 @@ function getParentId( function buildThreadTree( rootId: string, replies: NostrEvent[], - threadKind: "nip10" | "nip22", + focusedEventId?: string, ): ThreadNode[] { // Sort all replies chronologically (oldest first) const sortedReplies = [...replies].sort( @@ -75,7 +75,9 @@ function buildThreadTree( const firstLevel: NostrEvent[] = []; const childrenByParent = new Map(); + // Determine thread kind dynamically per event sortedReplies.forEach((event) => { + const threadKind = event.kind === 1111 ? "nip22" : "nip10"; const parentId = getParentId(event, threadKind); if (parentId === rootId) { @@ -94,11 +96,18 @@ function buildThreadTree( }); // Build thread nodes - return firstLevel.map((event) => ({ - event, - children: childrenByParent.get(event.id) || [], - isCollapsed: false, // Start expanded - })); + // Auto-expand parent if focusedEventId is in its children + return firstLevel.map((event) => { + const children = childrenByParent.get(event.id) || []; + const hasFocusedChild = + focusedEventId && children.some((c) => c.id === focusedEventId); + + return { + event, + children, + isCollapsed: hasFocusedChild ? false : false, // Start expanded (could make collapsible later) + }; + }); } /** @@ -111,17 +120,20 @@ function buildThreadTree( export function ThreadConversation({ rootEventId, replies, - threadKind, + focusedEventId, }: ThreadConversationProps) { // Build tree structure const initialTree = useMemo( - () => buildThreadTree(rootEventId, replies, threadKind), - [rootEventId, replies, threadKind], + () => buildThreadTree(rootEventId, replies, focusedEventId), + [rootEventId, replies, focusedEventId], ); // Track collapse state per event ID const [collapsedIds, setCollapsedIds] = useState>(new Set()); + // Ref for the focused event element + const focusedRef = useRef(null); + // Toggle collapse for a specific event const toggleCollapse = (eventId: string) => { setCollapsedIds((prev) => { @@ -135,6 +147,19 @@ export function ThreadConversation({ }); }; + // Scroll to focused event on mount + useEffect(() => { + if (focusedEventId && focusedRef.current) { + // Small delay to ensure rendering is complete + setTimeout(() => { + focusedRef.current?.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + }, 100); + } + }, [focusedEventId]); + if (initialTree.length === 0) { return null; } @@ -145,10 +170,12 @@ export function ThreadConversation({ const isCollapsed = collapsedIds.has(node.event.id); const hasChildren = node.children.length > 0; + const isFocused = focusedEventId === node.event.id; + return (
{/* First-level reply */} -
+
{/* Collapse toggle button (only if has children) */} {hasChildren && (
{/* Second-level replies (nested, indented) */} {hasChildren && !isCollapsed && (
- {node.children.map((child) => ( - - - - ))} + {node.children.map((child) => { + const isChildFocused = focusedEventId === child.id; + return ( +
+ + + +
+ ); + })}
)}
diff --git a/src/components/ThreadViewer.tsx b/src/components/ThreadViewer.tsx index 2bd8cad..08d60c3 100644 --- a/src/components/ThreadViewer.tsx +++ b/src/components/ThreadViewer.tsx @@ -99,6 +99,12 @@ export function ThreadViewer({ pointer }: ThreadViewerProps) { const event = useNostrEvent(pointer); const { relays: relayStates } = useRelayState(); + // Store the original event ID (the one that was clicked) + const originalEventId = useMemo(() => { + if (!event) return undefined; + return event.id; + }, [event?.id]); + // Get thread root const rootPointer = useMemo(() => { if (!event) return undefined; @@ -116,23 +122,26 @@ export function ThreadViewer({ pointer }: ThreadViewerProps) { }, [rootEvent]); // Load all replies to the root - const replyFilter = useMemo(() => { - if (!rootEvent) return null; + // Fetch BOTH kind 1 (NIP-10) and kind 1111 (NIP-22) replies for all roots + const replyFilters = useMemo(() => { + if (!rootEvent) return []; - // For kind 1: load kind 1 replies with "e" tag pointing to root - if (rootEvent.kind === 1) { - return { kinds: [1], "#e": [rootEvent.id] }; - } + const filters = []; - // For other kinds: load kind 1111 comments with "E" tag pointing to root - return { kinds: [1111], "#E": [rootEvent.id] }; + // Always fetch kind 1 replies with "e" tag (NIP-10) + filters.push({ kinds: [1], "#e": [rootEvent.id] }); + + // Always fetch kind 1111 comments with "E" tag (NIP-22) + filters.push({ kinds: [1111], "#E": [rootEvent.id] }); + + return filters; }, [rootEvent]); // Subscribe to replies timeline const replies = use$(() => { - if (!replyFilter) return eventStore.timeline([]); - return eventStore.timeline([replyFilter]); - }, [replyFilter]); + if (replyFilters.length === 0) return eventStore.timeline([]); + return eventStore.timeline(replyFilters); + }, [replyFilters]); // Extract all participants (unique pubkeys from root + all replies) const participants = useMemo(() => { @@ -349,7 +358,9 @@ export function ThreadViewer({ pointer }: ThreadViewerProps) { ) : (