feat: Add inline reply composer for thread comments

- Created ThreadComposer component for posting kind 1111 replies
- Supports MentionEditor with thread participant autocomplete
- Posts NIP-22 comments with proper uppercase/lowercase tag structure
- Shows "Replying to [user]" preview with cancel button
- Integrated into ThreadConversation with reply state management
- ThreadCommentRenderer now has Reply button instead of menu
- Uses EventFactory and publishEventToRelays for posting
This commit is contained in:
Claude
2026-01-17 20:16:12 +00:00
parent 64a1c8bbf2
commit 24c5b96cff
4 changed files with 285 additions and 59 deletions

View File

@@ -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}
</span>
</div>
<EventMenu event={event} />
{onReply && (
<button
onClick={() => onReply(event.id)}
className="hover:text-foreground text-muted-foreground transition-colors"
aria-label="Reply"
>
<Reply className="size-3" />
</button>
)}
</div>
<RichText event={event} className="text-sm" />
</div>

View File

@@ -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<MentionEditorHandle>(null);
const activeAccount = use$(accountManager.active$);
// Search profiles for autocomplete (thread participants only)
const searchProfiles = useMemo(() => {
return async (query: string): Promise<ProfileSearchResult[]> => {
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 (
<div className="border-t border-border bg-muted/20 px-2 py-2">
{/* Reply preview */}
<div className="flex items-center justify-between gap-2 mb-2 px-2 py-1 bg-muted/30 rounded text-xs">
<div className="flex items-center gap-1.5 min-w-0 flex-1">
<span className="text-muted-foreground flex-shrink-0">
Replying to
</span>
<UserName
pubkey={replyToEvent.pubkey}
className="font-semibold flex-shrink-0"
/>
</div>
<button
onClick={onCancel}
className="text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
>
<X className="size-3" />
</button>
</div>
{/* Composer */}
<div className="flex gap-1.5 items-center">
<MentionEditor
ref={editorRef}
placeholder="Write a reply..."
searchProfiles={searchProfiles}
onSubmit={(content: string) => {
if (content.trim()) {
handleSend(content);
}
}}
className="flex-1 min-w-0"
/>
<Button
type="button"
variant="secondary"
size="sm"
className="flex-shrink-0 h-7 px-2 text-xs"
disabled={isSending}
onClick={() => {
editorRef.current?.submit();
}}
>
{isSending ? <Loader2 className="size-3 animate-spin" /> : "Reply"}
</Button>
</div>
</div>
);
}

View File

@@ -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<Set<string>>(new Set());
// Track reply state
const [replyToId, setReplyToId] = useState<string | undefined>();
// 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<HTMLDivElement>(null);
@@ -165,67 +179,89 @@ export function ThreadConversation({
}
return (
<div className="space-y-0">
{initialTree.map((node) => {
const isCollapsed = collapsedIds.has(node.event.id);
const hasChildren = node.children.length > 0;
<>
<div className="space-y-0">
{initialTree.map((node) => {
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 ref={isFocused ? focusedRef : undefined} className="relative">
{/* Collapse toggle button (only if has children) */}
{hasChildren && (
<button
onClick={() => toggleCollapse(node.event.id)}
className="absolute -left-6 top-2 text-muted-foreground hover:text-foreground transition-colors"
aria-label={
isCollapsed ? "Expand replies" : "Collapse replies"
}
>
{isCollapsed ? (
<ChevronRight className="size-4" />
) : (
<ChevronDown className="size-4" />
)}
</button>
)}
const isFocused = focusedEventId === node.event.id;
return (
<div key={node.event.id}>
{/* First-level reply */}
<div
className={isFocused ? "ring-2 ring-primary/50 rounded" : ""}
ref={isFocused ? focusedRef : undefined}
className="relative"
>
<EventErrorBoundary event={node.event}>
<ThreadCommentRenderer event={node.event} />
</EventErrorBoundary>
</div>
</div>
{/* Collapse toggle button (only if has children) */}
{hasChildren && (
<button
onClick={() => toggleCollapse(node.event.id)}
className="absolute -left-6 top-2 text-muted-foreground hover:text-foreground transition-colors"
aria-label={
isCollapsed ? "Expand replies" : "Collapse replies"
}
>
{isCollapsed ? (
<ChevronRight className="size-4" />
) : (
<ChevronDown className="size-4" />
)}
</button>
)}
{/* Second-level replies (nested, indented) */}
{hasChildren && !isCollapsed && (
<div className="ml-6 mt-1 space-y-0 border-l-2 border-border pl-3">
{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
className={isFocused ? "ring-2 ring-primary/50 rounded" : ""}
>
<EventErrorBoundary event={node.event}>
<ThreadCommentRenderer
event={node.event}
onReply={setReplyToId}
/>
</EventErrorBoundary>
</div>
</div>
)}
</div>
);
})}
</div>
{/* Second-level replies (nested, indented) */}
{hasChildren && !isCollapsed && (
<div className="ml-6 mt-1 space-y-0 border-l-2 border-border pl-3">
{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}
onReply={setReplyToId}
/>
</EventErrorBoundary>
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
{/* Reply Composer */}
{replyToId && replyToEvent && (
<ThreadComposer
rootEvent={rootEvent}
replyToEvent={replyToEvent}
participants={participants}
onCancel={() => setReplyToId(undefined)}
onSuccess={() => setReplyToId(undefined)}
/>
)}
</>
);
}

View File

@@ -357,7 +357,9 @@ export function ThreadViewer({ pointer }: ThreadViewerProps) {
{replies && replies.length > 0 ? (
<ThreadConversation
rootEventId={rootEvent.id}
rootEvent={rootEvent}
replies={replies}
participants={participants}
focusedEventId={
originalEventId !== rootEvent.id ? originalEventId : undefined
}