feat: Improve thread reply resolution and focusing

- Fetch BOTH kind 1 (NIP-10) and kind 1111 (NIP-22) replies for all root events
- Track original event ID when opening thread from a reply
- Auto-expand parent if focused event is a 2nd level reply
- Highlight focused event with ring-2 ring-primary/50
- Auto-scroll to focused event on mount
- Determine thread kind dynamically per event instead of globally
- Show clicked reply even when opening thread from nested reply
This commit is contained in:
Claude
2026-01-17 19:46:24 +00:00
parent 2515545e36
commit 1266003c4b
2 changed files with 85 additions and 32 deletions

View File

@@ -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<string, NostrEvent[]>();
// 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<Set<string>>(new Set());
// Ref for the focused event element
const focusedRef = useRef<HTMLDivElement>(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 (
<div key={node.event.id}>
{/* First-level reply */}
<div className="relative">
<div ref={isFocused ? focusedRef : undefined} className="relative">
{/* Collapse toggle button (only if has children) */}
{hasChildren && (
<button
@@ -166,19 +193,34 @@ export function ThreadConversation({
</button>
)}
<EventErrorBoundary event={node.event}>
<ThreadCommentRenderer event={node.event} />
</EventErrorBoundary>
<div
className={isFocused ? "ring-2 ring-primary/50 rounded" : ""}
>
<EventErrorBoundary event={node.event}>
<ThreadCommentRenderer event={node.event} />
</EventErrorBoundary>
</div>
</div>
{/* Second-level replies (nested, indented) */}
{hasChildren && !isCollapsed && (
<div className="ml-8 mt-2 space-y-0 border-l-2 border-border pl-4">
{node.children.map((child) => (
<EventErrorBoundary key={child.id} event={child}>
<ThreadCommentRenderer event={child} />
</EventErrorBoundary>
))}
{node.children.map((child) => {
const isChildFocused = focusedEventId === child.id;
return (
<div
key={child.id}
ref={isChildFocused ? focusedRef : undefined}
className={
isChildFocused ? "ring-2 ring-primary/50 rounded" : ""
}
>
<EventErrorBoundary event={child}>
<ThreadCommentRenderer event={child} />
</EventErrorBoundary>
</div>
);
})}
</div>
)}
</div>

View File

@@ -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) {
<ThreadConversation
rootEventId={rootEvent.id}
replies={replies}
threadKind={rootEvent.kind === 1 ? "nip10" : "nip22"}
focusedEventId={
originalEventId !== rootEvent.id ? originalEventId : undefined
}
/>
) : (
<div className="text-sm text-muted-foreground italic p-2">