diff --git a/src/components/ThreadCommentRenderer.tsx b/src/components/ThreadCommentRenderer.tsx index 90188c4..6ff1629 100644 --- a/src/components/ThreadCommentRenderer.tsx +++ b/src/components/ThreadCommentRenderer.tsx @@ -1,18 +1,25 @@ import { NostrEvent } from "@/types/nostr"; import { UserName } from "./nostr/UserName"; -import { EventMenu } from "./nostr/kinds/BaseEventRenderer"; import { RichText } from "./nostr/RichText"; import { formatTimestamp } from "@/hooks/useLocale"; import { useGrimoire } from "@/core/state"; +import { Reply } from "lucide-react"; /** * Compact renderer for comments in thread view * - No reply preview * - No footer * - Minimal padding + * - Reply button instead of menu * - Used for both kind 1 and kind 1111 in thread context */ -export function ThreadCommentRenderer({ event }: { event: NostrEvent }) { +export function ThreadCommentRenderer({ + event, + onReply, +}: { + event: NostrEvent; + onReply?: (eventId: string) => void; +}) { const { locale } = useGrimoire(); // Format relative time for display @@ -41,7 +48,15 @@ export function ThreadCommentRenderer({ event }: { event: NostrEvent }) { {relativeTime} - + {onReply && ( + + )} diff --git a/src/components/ThreadComposer.tsx b/src/components/ThreadComposer.tsx new file mode 100644 index 0000000..0cb0af5 --- /dev/null +++ b/src/components/ThreadComposer.tsx @@ -0,0 +1,173 @@ +import { useState, useRef, useMemo } from "react"; +import { use$ } from "applesauce-react/hooks"; +import { Button } from "./ui/button"; +import { Loader2, X } from "lucide-react"; +import { + MentionEditor, + type MentionEditorHandle, +} from "./editor/MentionEditor"; +import type { NostrEvent } from "@/types/nostr"; +import { publishEventToRelays } from "@/services/hub"; +import { toast } from "sonner"; +import { UserName } from "./nostr/UserName"; +import { EventFactory } from "applesauce-core"; +import accountManager from "@/services/accounts"; +import type { ProfileSearchResult } from "@/services/profile-search"; +import { getDisplayName } from "@/lib/nostr-utils"; + +interface ThreadComposerProps { + rootEvent: NostrEvent; + replyToEvent: NostrEvent; + participants: string[]; // All thread participants for autocomplete + onCancel: () => void; + onSuccess: () => void; +} + +/** + * ThreadComposer - Inline composer for replying to thread comments + * - Posts kind 1111 (NIP-22 comments) + * - Autocomplete from thread participants + * - Shows preview of comment being replied to + */ +export function ThreadComposer({ + rootEvent, + replyToEvent, + participants, + onCancel, + onSuccess, +}: ThreadComposerProps) { + const [isSending, setIsSending] = useState(false); + const editorRef = useRef(null); + const activeAccount = use$(accountManager.active$); + + // Search profiles for autocomplete (thread participants only) + const searchProfiles = useMemo(() => { + return async (query: string): Promise => { + if (!query) return []; + + const normalizedQuery = query.toLowerCase(); + + // Filter participants that match the query + const matches = participants + .filter((pubkey) => { + // TODO: Could fetch profiles and search by name + // For now just match by pubkey prefix + return pubkey.toLowerCase().includes(normalizedQuery); + }) + .slice(0, 10) + .map((pubkey) => ({ + pubkey, + displayName: getDisplayName(pubkey, undefined), + })); + + return matches; + }; + }, [participants]); + + const handleSend = async (content: string) => { + if (!activeAccount || isSending || !content.trim()) return; + + setIsSending(true); + try { + // Create kind 1111 comment with NIP-22 tags + // Uppercase tags (E, A, K, P) = root + // Lowercase tags (e, a, k, p) = parent (immediate reply) + + const rootTags: string[][] = []; + const parentTags: string[][] = []; + + // Add root tags (uppercase) + rootTags.push(["E", rootEvent.id]); + rootTags.push(["K", String(rootEvent.kind)]); + rootTags.push(["P", rootEvent.pubkey]); + + // Add parent tags (lowercase) - the comment we're replying to + parentTags.push(["e", replyToEvent.id]); + parentTags.push(["k", String(replyToEvent.kind)]); + parentTags.push(["p", replyToEvent.pubkey]); + + // Also tag all mentioned participants + const allMentionedPubkeys = [rootEvent.pubkey, replyToEvent.pubkey]; + const uniquePubkeys = Array.from(new Set(allMentionedPubkeys)); + + // Create event factory and sign event + const factory = new EventFactory(); + factory.setSigner(activeAccount.signer); + + const draft = await factory.build({ + kind: 1111, + content: content.trim(), + tags: [ + ...rootTags, + ...parentTags, + ...uniquePubkeys.map((pk) => ["p", pk]), + ], + }); + + const event = await factory.sign(draft); + + // Publish to relays (using default relay set) + await publishEventToRelays(event, []); + + toast.success("Reply posted!"); + onSuccess(); + } catch (error) { + console.error("[ThreadComposer] Failed to send reply:", error); + const errorMessage = + error instanceof Error ? error.message : "Failed to post reply"; + toast.error(errorMessage); + } finally { + setIsSending(false); + } + }; + + return ( +
+ {/* Reply preview */} +
+
+ + Replying to + + +
+ +
+ + {/* Composer */} +
+ { + if (content.trim()) { + handleSend(content); + } + }} + className="flex-1 min-w-0" + /> + +
+
+ ); +} diff --git a/src/components/ThreadConversation.tsx b/src/components/ThreadConversation.tsx index 81a548e..726a700 100644 --- a/src/components/ThreadConversation.tsx +++ b/src/components/ThreadConversation.tsx @@ -4,11 +4,14 @@ import { getNip10References } from "applesauce-common/helpers/threading"; import { getCommentReplyPointer } from "applesauce-common/helpers/comment"; import { EventErrorBoundary } from "./EventErrorBoundary"; import { ThreadCommentRenderer } from "./ThreadCommentRenderer"; +import { ThreadComposer } from "./ThreadComposer"; import type { NostrEvent } from "@/types/nostr"; export interface ThreadConversationProps { rootEventId: string; + rootEvent: NostrEvent; replies: NostrEvent[]; + participants: string[]; focusedEventId?: string; // Event to highlight and scroll to (if not root) } @@ -119,7 +122,9 @@ function buildThreadTree( */ export function ThreadConversation({ rootEventId, + rootEvent, replies, + participants, focusedEventId, }: ThreadConversationProps) { // Build tree structure @@ -131,6 +136,15 @@ export function ThreadConversation({ // Track collapse state per event ID const [collapsedIds, setCollapsedIds] = useState>(new Set()); + // Track reply state + const [replyToId, setReplyToId] = useState(); + + // Find the event being replied to + const replyToEvent = useMemo(() => { + if (!replyToId) return undefined; + return replies.find((r) => r.id === replyToId) || rootEvent; + }, [replyToId, replies, rootEvent]); + // Ref for the focused event element const focusedRef = useRef(null); @@ -165,67 +179,89 @@ export function ThreadConversation({ } return ( -
- {initialTree.map((node) => { - const isCollapsed = collapsedIds.has(node.event.id); - const hasChildren = node.children.length > 0; + <> +
+ {initialTree.map((node) => { + 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 && ( - - )} + 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) => { - const isChildFocused = focusedEventId === child.id; - return ( -
- - - -
- ); - })} +
+ + + +
- )} -
- ); - })} -
+ + {/* Second-level replies (nested, indented) */} + {hasChildren && !isCollapsed && ( +
+ {node.children.map((child) => { + const isChildFocused = focusedEventId === child.id; + return ( +
+ + + +
+ ); + })} +
+ )} +
+ ); + })} +
+ + {/* Reply Composer */} + {replyToId && replyToEvent && ( + setReplyToId(undefined)} + onSuccess={() => setReplyToId(undefined)} + /> + )} + ); } diff --git a/src/components/ThreadViewer.tsx b/src/components/ThreadViewer.tsx index 08d60c3..87d28d0 100644 --- a/src/components/ThreadViewer.tsx +++ b/src/components/ThreadViewer.tsx @@ -357,7 +357,9 @@ export function ThreadViewer({ pointer }: ThreadViewerProps) { {replies && replies.length > 0 ? (