Fix thread reply UX: inline reply boxes below each note, scroll-to-parent

Reply boxes now open directly below the note you're replying to instead of
scrolling to a top-level composer. "Replying to" link scrolls to the parent
note when already visible in the thread instead of re-pushing the same thread.
This commit is contained in:
Jure
2026-03-21 15:41:48 +01:00
parent acb0d531c0
commit af32952fd8
3 changed files with 149 additions and 70 deletions

View File

@@ -1,11 +1,16 @@
import { useState } from "react";
import { useState, useRef } from "react";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import type { ThreadNode as ThreadNodeType } from "../../lib/threadTree";
import { NoteCard } from "../feed/NoteCard";
import { publishReply } from "../../lib/nostr";
import { useProfile } from "../../hooks/useProfile";
import { shortenPubkey } from "../../lib/utils";
import { EmojiPicker } from "../shared/EmojiPicker";
interface ThreadNodeProps {
node: ThreadNodeType;
onReplyInThread: (event: NDKEvent) => void;
rootEvent: NDKEvent;
onReplyPublished: (reply: NDKEvent) => void;
focusedId?: string;
mutedPubkeys: string[];
contentMatchesMutedKeyword: (content: string) => boolean;
@@ -14,8 +19,96 @@ interface ThreadNodeProps {
const MAX_VISIBLE_CHILDREN = 3;
const MAX_INDENT_DEPTH = 4;
export function ThreadNodeComponent({ node, onReplyInThread, focusedId, mutedPubkeys, contentMatchesMutedKeyword }: ThreadNodeProps) {
function InlineThreadReply({ replyTo, rootEvent, onPublished }: {
replyTo: NDKEvent;
rootEvent: NDKEvent;
onPublished: (reply: NDKEvent) => void;
}) {
const profile = useProfile(replyTo.pubkey);
const name = profile?.displayName || profile?.name || shortenPubkey(replyTo.pubkey);
const [text, setText] = useState("");
const [replying, setReplying] = useState(false);
const [sent, setSent] = useState(false);
const [showEmoji, setShowEmoji] = useState(false);
const ref = useRef<HTMLTextAreaElement>(null);
const handleSubmit = async () => {
if (!text.trim() || replying) return;
setReplying(true);
try {
const rootArg = replyTo.id !== rootEvent.id
? { id: rootEvent.id, pubkey: rootEvent.pubkey }
: undefined;
const reply = await publishReply(text.trim(), { id: replyTo.id, pubkey: replyTo.pubkey }, rootArg);
setText("");
setSent(true);
onPublished(reply);
setTimeout(() => setSent(false), 2000);
} finally {
setReplying(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) handleSubmit();
if (e.key === "Escape") ref.current?.blur();
};
return (
<div className="border-l-2 border-accent/40 ml-3 pl-3 py-2">
<div className="text-text-dim text-[10px] mb-1">replying to <span className="text-accent">@{name}</span></div>
<textarea
ref={ref}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Write a reply..."
rows={2}
className="w-full bg-transparent text-text text-[12px] placeholder:text-text-dim resize-none focus:outline-none"
autoFocus
/>
<div className="flex items-center justify-end gap-2 mt-1">
<div className="relative">
<button
onClick={() => setShowEmoji((v) => !v)}
title="Insert emoji"
className="text-text-dim hover:text-text text-[12px] transition-colors"
>
</button>
{showEmoji && (
<EmojiPicker
onSelect={(emoji) => {
const ta = ref.current;
if (ta) {
const start = ta.selectionStart ?? text.length;
const end = ta.selectionEnd ?? text.length;
setText(text.slice(0, start) + emoji + text.slice(end));
setTimeout(() => { ta.selectionStart = ta.selectionEnd = start + emoji.length; ta.focus(); }, 0);
} else {
setText((t) => t + emoji);
}
}}
onClose={() => setShowEmoji(false)}
/>
)}
</div>
<span className="text-text-dim text-[10px]">Ctrl+Enter</span>
<button
onClick={handleSubmit}
disabled={!text.trim() || replying}
className="px-2 py-0.5 text-[10px] bg-accent hover:bg-accent-hover text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
{sent ? "replied ✓" : replying ? "posting..." : "reply"}
</button>
</div>
</div>
);
}
export function ThreadNodeComponent({ node, rootEvent, onReplyPublished, focusedId, mutedPubkeys, contentMatchesMutedKeyword }: ThreadNodeProps) {
const [expanded, setExpanded] = useState(false);
const [showReplyBox, setShowReplyBox] = useState(false);
// Filter out muted children
const visibleChildren = node.children.filter(
@@ -38,14 +131,28 @@ export function ThreadNodeComponent({ node, onReplyInThread, focusedId, mutedPub
<NoteCard
event={node.event}
focused={isFocused}
onReplyInThread={onReplyInThread}
onReplyInThread={() => setShowReplyBox((v) => !v)}
/>
{showReplyBox && (
<div style={indent > 0 ? { marginLeft: "16px" } : undefined}>
<InlineThreadReply
replyTo={node.event}
rootEvent={rootEvent}
onPublished={(reply) => {
setShowReplyBox(false);
onReplyPublished(reply);
}}
/>
</div>
)}
{shownChildren.map((child) => (
<ThreadNodeComponent
key={child.event.id}
node={child}
onReplyInThread={onReplyInThread}
rootEvent={rootEvent}
onReplyPublished={onReplyPublished}
focusedId={focusedId}
mutedPubkeys={mutedPubkeys}
contentMatchesMutedKeyword={contentMatchesMutedKeyword}