mirror of
https://github.com/hoornet/vega.git
synced 2026-05-07 20:59:12 -07:00
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.
183 lines
6.3 KiB
TypeScript
183 lines
6.3 KiB
TypeScript
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;
|
|
rootEvent: NDKEvent;
|
|
onReplyPublished: (reply: NDKEvent) => void;
|
|
focusedId?: string;
|
|
mutedPubkeys: string[];
|
|
contentMatchesMutedKeyword: (content: string) => boolean;
|
|
}
|
|
|
|
const MAX_VISIBLE_CHILDREN = 3;
|
|
const MAX_INDENT_DEPTH = 4;
|
|
|
|
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(
|
|
(c) => !mutedPubkeys.includes(c.event.pubkey) && !contentMatchesMutedKeyword(c.event.content)
|
|
);
|
|
|
|
const hiddenCount = node.children.length - visibleChildren.length;
|
|
const shouldCollapse = visibleChildren.length > MAX_VISIBLE_CHILDREN && !expanded;
|
|
const shownChildren = shouldCollapse ? visibleChildren.slice(0, 2) : visibleChildren;
|
|
const remainingCount = visibleChildren.length - shownChildren.length;
|
|
|
|
const indent = Math.min(node.depth, MAX_INDENT_DEPTH);
|
|
const isFocused = node.event.id === focusedId;
|
|
|
|
return (
|
|
<div
|
|
className={indent > 0 ? "border-l-2 border-border" : ""}
|
|
style={indent > 0 ? { marginLeft: `${indent * 16}px` } : undefined}
|
|
>
|
|
<NoteCard
|
|
event={node.event}
|
|
focused={isFocused}
|
|
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}
|
|
rootEvent={rootEvent}
|
|
onReplyPublished={onReplyPublished}
|
|
focusedId={focusedId}
|
|
mutedPubkeys={mutedPubkeys}
|
|
contentMatchesMutedKeyword={contentMatchesMutedKeyword}
|
|
/>
|
|
))}
|
|
|
|
{remainingCount > 0 && (
|
|
<button
|
|
onClick={() => setExpanded(true)}
|
|
className="text-accent hover:text-accent-hover text-[11px] py-1.5 transition-colors"
|
|
style={{ marginLeft: `${(indent + 1) * 16}px` }}
|
|
>
|
|
show {remainingCount} more {remainingCount === 1 ? "reply" : "replies"}
|
|
</button>
|
|
)}
|
|
|
|
{hiddenCount > 0 && (
|
|
<div
|
|
className="text-text-dim text-[10px] py-1 italic"
|
|
style={{ marginLeft: `${(indent + 1) * 16}px` }}
|
|
>
|
|
{hiddenCount} {hiddenCount === 1 ? "reply" : "replies"} hidden (muted)
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|