mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 08:27:27 +02:00
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:
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user