mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-11 16:07:15 +02:00
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:
@@ -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>
|
||||
|
||||
173
src/components/ThreadComposer.tsx
Normal file
173
src/components/ThreadComposer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user