mirror of
https://github.com/hoornet/vega.git
synced 2026-05-06 20:29:12 -07:00
Add nested thread trees, recursive reply fetching, multi-level back navigation
Overhauls the thread view from flat single-level replies to proper nested conversation trees. Fixes NIP-10 tagging (root + reply markers), adds 2-round-trip recursive thread fetch, ancestor chain display, reply-to-any-note targeting, view stack navigation (up to 20 levels), and loading shimmer.
This commit is contained in:
@@ -7,9 +7,10 @@ import { EmojiPicker } from "../shared/EmojiPicker";
|
||||
interface InlineReplyBoxProps {
|
||||
event: NDKEvent;
|
||||
name: string;
|
||||
rootEvent?: { id: string; pubkey: string };
|
||||
}
|
||||
|
||||
export function InlineReplyBox({ event, name }: InlineReplyBoxProps) {
|
||||
export function InlineReplyBox({ event, name, rootEvent }: InlineReplyBoxProps) {
|
||||
const [replyText, setReplyText] = useState("");
|
||||
const [replying, setReplying] = useState(false);
|
||||
const [replyError, setReplyError] = useState<string | null>(null);
|
||||
@@ -23,7 +24,7 @@ export function InlineReplyBox({ event, name }: InlineReplyBoxProps) {
|
||||
setReplying(true);
|
||||
setReplyError(null);
|
||||
try {
|
||||
await publishReply(replyText.trim(), { id: event.id, pubkey: event.pubkey });
|
||||
await publishReply(replyText.trim(), { id: event.id, pubkey: event.pubkey }, rootEvent);
|
||||
setReplyText("");
|
||||
setReplySent(true);
|
||||
adjustReplyCount(1);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useMuteStore } from "../../stores/mute";
|
||||
import { useUIStore } from "../../stores/ui";
|
||||
import { timeAgo, shortenPubkey } from "../../lib/utils";
|
||||
import { getNDK, fetchNoteById } from "../../lib/nostr";
|
||||
import { getParentEventId } from "../../lib/threadTree";
|
||||
import { NoteContent } from "./NoteContent";
|
||||
import { NoteActions, LoggedOutStats } from "./NoteActions";
|
||||
import { InlineReplyBox } from "./InlineReplyBox";
|
||||
@@ -14,14 +15,7 @@ import { InlineReplyBox } from "./InlineReplyBox";
|
||||
interface NoteCardProps {
|
||||
event: NDKEvent;
|
||||
focused?: boolean;
|
||||
}
|
||||
|
||||
function getParentEventId(event: NDKEvent): string | null {
|
||||
const eTags = event.tags.filter((t) => t[0] === "e");
|
||||
if (eTags.length === 0) return null;
|
||||
return eTags.find((t) => t[3] === "reply")?.[1]
|
||||
?? eTags.find((t) => t[3] === "root")?.[1]
|
||||
?? eTags[eTags.length - 1][1];
|
||||
onReplyInThread?: (event: NDKEvent) => void;
|
||||
}
|
||||
|
||||
function ParentAuthorName({ pubkey }: { pubkey: string }) {
|
||||
@@ -30,7 +24,7 @@ function ParentAuthorName({ pubkey }: { pubkey: string }) {
|
||||
return <span className="text-accent">@{name}</span>;
|
||||
}
|
||||
|
||||
export function NoteCard({ event, focused }: NoteCardProps) {
|
||||
export function NoteCard({ event, focused, onReplyInThread }: NoteCardProps) {
|
||||
const profile = useProfile(event.pubkey);
|
||||
const name = profile?.displayName || profile?.name || shortenPubkey(event.pubkey);
|
||||
const avatar = profile?.picture;
|
||||
@@ -57,6 +51,7 @@ export function NoteCard({ event, focused }: NoteCardProps) {
|
||||
return (
|
||||
<article
|
||||
ref={cardRef}
|
||||
data-note-id={event.id}
|
||||
className={`border-b border-border px-4 py-3 hover:bg-bg-hover transition-colors duration-100 group/card${focused ? " ring-1 ring-inset ring-accent/30" : ""}`}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
@@ -160,8 +155,14 @@ export function NoteCard({ event, focused }: NoteCardProps) {
|
||||
{loggedIn && !!getNDK().signer && (
|
||||
<NoteActions
|
||||
event={event}
|
||||
onReplyToggle={() => setShowReply((v) => !v)}
|
||||
showReply={showReply}
|
||||
onReplyToggle={() => {
|
||||
if (onReplyInThread) {
|
||||
onReplyInThread(event);
|
||||
} else {
|
||||
setShowReply((v) => !v);
|
||||
}
|
||||
}}
|
||||
showReply={showReply && !onReplyInThread}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
60
src/components/thread/AncestorChain.tsx
Normal file
60
src/components/thread/AncestorChain.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { useProfile } from "../../hooks/useProfile";
|
||||
import { useUIStore } from "../../stores/ui";
|
||||
import { shortenPubkey, timeAgo } from "../../lib/utils";
|
||||
|
||||
function AncestorCard({ event }: { event: NDKEvent }) {
|
||||
const profile = useProfile(event.pubkey);
|
||||
const name = profile?.displayName || profile?.name || shortenPubkey(event.pubkey);
|
||||
const avatar = profile?.picture;
|
||||
const time = event.created_at ? timeAgo(event.created_at) : "";
|
||||
const { openThread } = useUIStore();
|
||||
|
||||
const truncated = event.content.length > 120
|
||||
? event.content.slice(0, 120) + "..."
|
||||
: event.content;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => openThread(event)}
|
||||
className="w-full text-left px-4 py-2 border-b border-border hover:bg-bg-hover transition-colors flex gap-2.5 items-start"
|
||||
>
|
||||
<div className="shrink-0 mt-0.5">
|
||||
{avatar ? (
|
||||
<img src={avatar} alt="" className="w-6 h-6 rounded-sm object-cover bg-bg-raised" loading="lazy" />
|
||||
) : (
|
||||
<div className="w-6 h-6 rounded-sm bg-bg-raised border border-border flex items-center justify-center text-text-dim text-[9px]">
|
||||
{name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2 mb-0.5">
|
||||
<span className="text-text font-medium text-[11px] truncate">{name}</span>
|
||||
<span className="text-text-dim text-[10px] shrink-0">{time}</span>
|
||||
</div>
|
||||
<div className="text-text-dim text-[11px] line-clamp-2 break-words">{truncated}</div>
|
||||
</div>
|
||||
<div className="text-text-dim text-[10px] shrink-0 mt-0.5">↑</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface AncestorChainProps {
|
||||
ancestors: NDKEvent[];
|
||||
}
|
||||
|
||||
export function AncestorChain({ ancestors }: AncestorChainProps) {
|
||||
if (ancestors.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-bg-raised/50">
|
||||
<div className="px-4 py-1.5 text-text-dim text-[10px] border-b border-border">
|
||||
{ancestors.length} parent {ancestors.length === 1 ? "note" : "notes"} above
|
||||
</div>
|
||||
{ancestors.map((a) => (
|
||||
<AncestorCard key={a.id} event={a} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
src/components/thread/ThreadNode.tsx
Normal file
75
src/components/thread/ThreadNode.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useState } from "react";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import type { ThreadNode as ThreadNodeType } from "../../lib/threadTree";
|
||||
import { NoteCard } from "../feed/NoteCard";
|
||||
|
||||
interface ThreadNodeProps {
|
||||
node: ThreadNodeType;
|
||||
onReplyInThread: (event: NDKEvent) => void;
|
||||
focusedId?: string;
|
||||
mutedPubkeys: string[];
|
||||
contentMatchesMutedKeyword: (content: string) => boolean;
|
||||
}
|
||||
|
||||
const MAX_VISIBLE_CHILDREN = 3;
|
||||
const MAX_INDENT_DEPTH = 4;
|
||||
|
||||
export function ThreadNodeComponent({ node, onReplyInThread, focusedId, mutedPubkeys, contentMatchesMutedKeyword }: ThreadNodeProps) {
|
||||
const [expanded, setExpanded] = 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={onReplyInThread}
|
||||
/>
|
||||
|
||||
{shownChildren.map((child) => (
|
||||
<ThreadNodeComponent
|
||||
key={child.event.id}
|
||||
node={child}
|
||||
onReplyInThread={onReplyInThread}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -3,143 +3,24 @@ import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { useUIStore } from "../../stores/ui";
|
||||
import { useUserStore } from "../../stores/user";
|
||||
import { useMuteStore } from "../../stores/mute";
|
||||
import { fetchNoteById, fetchThreadEvents, fetchAncestors, publishReply, getNDK } from "../../lib/nostr";
|
||||
import { buildThreadTree, getRootEventId } from "../../lib/threadTree";
|
||||
import type { ThreadNode } from "../../lib/threadTree";
|
||||
import { useProfile } from "../../hooks/useProfile";
|
||||
import { useReactionCount } from "../../hooks/useReactionCount";
|
||||
import { useZapCount } from "../../hooks/useZapCount";
|
||||
import { fetchReplies, publishReaction, publishReply, publishRepost, getNDK } from "../../lib/nostr";
|
||||
import { QuoteModal } from "../feed/QuoteModal";
|
||||
import { EmojiPicker } from "../shared/EmojiPicker";
|
||||
import { shortenPubkey, timeAgo } from "../../lib/utils";
|
||||
import { NoteContent } from "../feed/NoteContent";
|
||||
import { shortenPubkey } from "../../lib/utils";
|
||||
import { AncestorChain } from "./AncestorChain";
|
||||
import { ThreadNodeComponent } from "./ThreadNode";
|
||||
import { NoteCard } from "../feed/NoteCard";
|
||||
import { ZapModal } from "../zap/ZapModal";
|
||||
import { EmojiPicker } from "../shared/EmojiPicker";
|
||||
|
||||
function RootNote({ event }: { event: NDKEvent }) {
|
||||
const { openProfile } = useUIStore();
|
||||
const { loggedIn } = useUserStore();
|
||||
function ReplyTargetBadge({ event, onClear }: { event: NDKEvent; onClear: () => void }) {
|
||||
const profile = useProfile(event.pubkey);
|
||||
const name = profile?.displayName || profile?.name || shortenPubkey(event.pubkey);
|
||||
const avatar = profile?.picture;
|
||||
const nip05 = profile?.nip05;
|
||||
const time = event.created_at ? timeAgo(event.created_at) : "";
|
||||
const [reactionCount, adjustReactionCount] = useReactionCount(event.id);
|
||||
const zapData = useZapCount(event.id);
|
||||
const [liked, setLiked] = useState(() => {
|
||||
try { return new Set<string>(JSON.parse(localStorage.getItem("wrystr_liked") || "[]")).has(event.id); }
|
||||
catch { return false; }
|
||||
});
|
||||
const [liking, setLiking] = useState(false);
|
||||
const [showZap, setShowZap] = useState(false);
|
||||
const [reposting, setReposting] = useState(false);
|
||||
const [reposted, setReposted] = useState(false);
|
||||
const [showQuote, setShowQuote] = useState(false);
|
||||
const hasLightning = !!(profile?.lud16 || profile?.lud06);
|
||||
|
||||
const handleLike = async () => {
|
||||
if (!loggedIn || liked || liking) return;
|
||||
setLiking(true);
|
||||
try {
|
||||
await publishReaction(event.id, event.pubkey);
|
||||
const likedSet = new Set<string>(JSON.parse(localStorage.getItem("wrystr_liked") || "[]"));
|
||||
likedSet.add(event.id);
|
||||
localStorage.setItem("wrystr_liked", JSON.stringify(Array.from(likedSet)));
|
||||
setLiked(true);
|
||||
adjustReactionCount(1);
|
||||
} finally {
|
||||
setLiking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRepost = async () => {
|
||||
if (reposting || reposted) return;
|
||||
setReposting(true);
|
||||
try {
|
||||
await publishRepost(event);
|
||||
setReposted(true);
|
||||
} finally {
|
||||
setReposting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-4 py-4 border-b border-border">
|
||||
<div className="flex gap-3 mb-3">
|
||||
<div className="shrink-0 cursor-pointer" onClick={() => openProfile(event.pubkey)}>
|
||||
{avatar ? (
|
||||
<img src={avatar} alt="" className="w-10 h-10 rounded-sm object-cover bg-bg-raised hover:opacity-80 transition-opacity" />
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-sm bg-bg-raised border border-border flex items-center justify-center text-text-dim text-sm">
|
||||
{name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span
|
||||
className="text-text font-medium text-[13px] cursor-pointer hover:text-accent transition-colors"
|
||||
onClick={() => openProfile(event.pubkey)}
|
||||
>{name}</span>
|
||||
{nip05 && <div className="text-text-dim text-[10px]">{nip05}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<NoteContent content={event.content} />
|
||||
<div className="text-text-dim text-[10px] mt-3">{time}</div>
|
||||
|
||||
{/* Action row */}
|
||||
{loggedIn && !!getNDK().signer && (
|
||||
<div className="flex items-center gap-4 mt-3">
|
||||
<button
|
||||
onClick={handleLike}
|
||||
disabled={liked || liking}
|
||||
className={`text-[11px] transition-colors ${
|
||||
liked ? "text-accent" : "text-text-dim hover:text-accent"
|
||||
} disabled:cursor-default`}
|
||||
>
|
||||
{liked ? "♥" : "♡"}{reactionCount !== null && reactionCount > 0 ? ` ${reactionCount}` : liked ? " liked" : " like"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRepost}
|
||||
disabled={reposting || reposted}
|
||||
className={`text-[11px] transition-colors disabled:cursor-default ${
|
||||
reposted ? "text-accent" : "text-text-dim hover:text-accent"
|
||||
}`}
|
||||
>
|
||||
{reposted ? "reposted ✓" : reposting ? "…" : "repost"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowQuote(true)}
|
||||
className="text-[11px] text-text-dim hover:text-text transition-colors"
|
||||
>
|
||||
quote
|
||||
</button>
|
||||
{hasLightning && (
|
||||
<button
|
||||
onClick={() => setShowZap(true)}
|
||||
className="text-[11px] text-text-dim hover:text-zap transition-colors"
|
||||
>
|
||||
{zapData && zapData.totalSats > 0
|
||||
? `⚡ ${zapData.totalSats.toLocaleString()} sats`
|
||||
: "⚡ zap"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showZap && (
|
||||
<ZapModal
|
||||
target={{ type: "note", event, recipientPubkey: event.pubkey }}
|
||||
recipientName={name}
|
||||
onClose={() => setShowZap(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showQuote && (
|
||||
<QuoteModal
|
||||
event={event}
|
||||
authorName={name}
|
||||
authorAvatar={avatar}
|
||||
onClose={() => setShowQuote(false)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mb-1.5 text-[11px]">
|
||||
<span className="text-text-dim">replying to</span>
|
||||
<span className="text-accent font-medium">@{name}</span>
|
||||
<button onClick={onClear} className="text-text-dim hover:text-text transition-colors">x</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -149,34 +30,119 @@ export function ThreadView() {
|
||||
const { loggedIn } = useUserStore();
|
||||
const { mutedPubkeys, contentMatchesMutedKeyword } = useMuteStore();
|
||||
if (!selectedNote) { goBack(); return null; }
|
||||
const event = selectedNote;
|
||||
const focusedEvent = selectedNote;
|
||||
|
||||
const [replies, setReplies] = useState<NDKEvent[]>([]);
|
||||
const [rootEvent, setRootEvent] = useState<NDKEvent | null>(null);
|
||||
const [ancestors, setAncestors] = useState<NDKEvent[]>([]);
|
||||
const [tree, setTree] = useState<ThreadNode | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [replyTarget, setReplyTarget] = useState<NDKEvent | null>(null);
|
||||
const [replyText, setReplyText] = useState("");
|
||||
const [replying, setReplying] = useState(false);
|
||||
const [replySent, setReplySent] = useState(false);
|
||||
const [showReplyEmoji, setShowReplyEmoji] = useState(false);
|
||||
const replyRef = useRef<HTMLTextAreaElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReplies(event.id).then((r) => {
|
||||
setReplies(r);
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
}, [event.id]);
|
||||
let cancelled = false;
|
||||
|
||||
async function loadThread() {
|
||||
setLoading(true);
|
||||
setTree(null);
|
||||
setAncestors([]);
|
||||
setRootEvent(null);
|
||||
setReplyTarget(null);
|
||||
|
||||
try {
|
||||
// Determine root
|
||||
const rootId = getRootEventId(focusedEvent);
|
||||
let root: NDKEvent;
|
||||
|
||||
if (!rootId || rootId === focusedEvent.id) {
|
||||
// This IS the root
|
||||
root = focusedEvent;
|
||||
} else {
|
||||
// Fetch the root event
|
||||
const fetched = await fetchNoteById(rootId);
|
||||
if (fetched) {
|
||||
root = fetched;
|
||||
// Fetch ancestors between root and focused
|
||||
const anc = await fetchAncestors(focusedEvent);
|
||||
if (!cancelled) setAncestors(anc.filter((a) => a.id !== root.id));
|
||||
} else {
|
||||
// Root not found, treat focused as root
|
||||
root = focusedEvent;
|
||||
}
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
setRootEvent(root);
|
||||
|
||||
// Fetch all thread events and build tree
|
||||
const events = await fetchThreadEvents(root.id);
|
||||
if (cancelled) return;
|
||||
|
||||
// Include root in the event set
|
||||
const allEvents = [root, ...events.filter((e) => e.id !== root.id)];
|
||||
const built = buildThreadTree(root.id, allEvents);
|
||||
setTree(built);
|
||||
} catch (err) {
|
||||
console.error("Failed to load thread:", err);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadThread();
|
||||
return () => { cancelled = true; };
|
||||
}, [focusedEvent.id]);
|
||||
|
||||
// Scroll to focused note after tree renders (if not root)
|
||||
useEffect(() => {
|
||||
if (!loading && rootEvent && focusedEvent.id !== rootEvent.id) {
|
||||
// Small delay to allow DOM to render
|
||||
const timer = setTimeout(() => {
|
||||
const el = document.querySelector(`[data-note-id="${focusedEvent.id}"]`);
|
||||
el?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [loading, rootEvent?.id, focusedEvent.id]);
|
||||
|
||||
const handleReplyInThread = (event: NDKEvent) => {
|
||||
setReplyTarget(event);
|
||||
setTimeout(() => replyRef.current?.focus(), 50);
|
||||
};
|
||||
|
||||
const effectiveReplyTarget = replyTarget ?? rootEvent;
|
||||
|
||||
const handleReply = async () => {
|
||||
if (!replyText.trim() || replying) return;
|
||||
if (!replyText.trim() || replying || !rootEvent) return;
|
||||
setReplying(true);
|
||||
try {
|
||||
const replyEvent = await publishReply(replyText.trim(), { id: event.id, pubkey: event.pubkey });
|
||||
const target = effectiveReplyTarget ?? rootEvent;
|
||||
const rootArg = target.id !== rootEvent.id
|
||||
? { id: rootEvent.id, pubkey: rootEvent.pubkey }
|
||||
: undefined;
|
||||
|
||||
const replyEvent = await publishReply(
|
||||
replyText.trim(),
|
||||
{ id: target.id, pubkey: target.pubkey },
|
||||
rootArg,
|
||||
);
|
||||
setReplyText("");
|
||||
setReplySent(true);
|
||||
// Inject reply locally so it appears immediately
|
||||
setReplies((prev) => [...prev, replyEvent]);
|
||||
// Also try fetching from relay in background
|
||||
fetchReplies(event.id).then((updated) => setReplies(updated));
|
||||
setReplyTarget(null);
|
||||
|
||||
// Optimistically insert into tree
|
||||
if (tree) {
|
||||
const allEvents = collectEvents(tree);
|
||||
allEvents.push(replyEvent);
|
||||
const rebuilt = buildThreadTree(rootEvent.id, allEvents);
|
||||
setTree(rebuilt);
|
||||
}
|
||||
|
||||
setTimeout(() => setReplySent(false), 2000);
|
||||
} finally {
|
||||
setReplying(false);
|
||||
@@ -201,79 +167,123 @@ export function ThreadView() {
|
||||
<h1 className="text-text text-sm font-medium">Thread</h1>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Root note */}
|
||||
<RootNote event={event} />
|
||||
|
||||
{/* Reply composer */}
|
||||
{loggedIn && (
|
||||
<div className="border-b border-border px-4 py-3">
|
||||
<textarea
|
||||
ref={replyRef}
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Write a reply…"
|
||||
rows={2}
|
||||
className="w-full bg-transparent text-text text-[13px] placeholder:text-text-dim resize-none focus:outline-none"
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2 mt-1">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowReplyEmoji((v) => !v)}
|
||||
title="Insert emoji"
|
||||
className="text-text-dim hover:text-text text-[12px] transition-colors"
|
||||
>
|
||||
☺
|
||||
</button>
|
||||
{showReplyEmoji && (
|
||||
<EmojiPicker
|
||||
onSelect={(emoji) => {
|
||||
const ta = replyRef.current;
|
||||
if (ta) {
|
||||
const start = ta.selectionStart ?? replyText.length;
|
||||
const end = ta.selectionEnd ?? replyText.length;
|
||||
setReplyText(replyText.slice(0, start) + emoji + replyText.slice(end));
|
||||
setTimeout(() => { ta.selectionStart = ta.selectionEnd = start + emoji.length; ta.focus(); }, 0);
|
||||
} else {
|
||||
setReplyText((t) => t + emoji);
|
||||
}
|
||||
}}
|
||||
onClose={() => setShowReplyEmoji(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-text-dim text-[10px]">Ctrl+Enter</span>
|
||||
<button
|
||||
onClick={handleReply}
|
||||
disabled={!replyText.trim() || replying}
|
||||
className="px-3 py-1 text-[11px] bg-accent hover:bg-accent-hover text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
{replySent ? "replied ✓" : replying ? "posting…" : "reply"}
|
||||
</button>
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
||||
{/* Loading shimmer */}
|
||||
{loading && (
|
||||
<div className="px-4 py-6 space-y-4">
|
||||
<div className="animate-pulse space-y-3">
|
||||
<div className="h-10 bg-bg-raised rounded" />
|
||||
<div className="h-24 bg-bg-raised rounded" />
|
||||
<div className="h-16 bg-bg-raised rounded ml-4" />
|
||||
<div className="h-16 bg-bg-raised rounded ml-4" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Replies */}
|
||||
{loading && (
|
||||
<div className="px-4 py-6 text-text-dim text-[12px] text-center">
|
||||
Loading replies…
|
||||
</div>
|
||||
{!loading && tree && rootEvent && (
|
||||
<>
|
||||
{/* Ancestors (when opening a deep reply) */}
|
||||
<AncestorChain ancestors={ancestors} />
|
||||
|
||||
{/* Root note rendered via tree */}
|
||||
<div data-note-id={tree.event.id}>
|
||||
<NoteCard
|
||||
event={tree.event}
|
||||
focused={tree.event.id === focusedEvent.id}
|
||||
onReplyInThread={handleReplyInThread}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Reply composer */}
|
||||
{loggedIn && !!getNDK().signer && (
|
||||
<div className="border-b border-border px-4 py-3">
|
||||
{replyTarget && replyTarget.id !== rootEvent.id && (
|
||||
<ReplyTargetBadge event={replyTarget} onClear={() => setReplyTarget(null)} />
|
||||
)}
|
||||
<textarea
|
||||
ref={replyRef}
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Write a reply..."
|
||||
rows={2}
|
||||
className="w-full bg-transparent text-text text-[13px] placeholder:text-text-dim resize-none focus:outline-none"
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2 mt-1">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowReplyEmoji((v) => !v)}
|
||||
title="Insert emoji"
|
||||
className="text-text-dim hover:text-text text-[12px] transition-colors"
|
||||
>
|
||||
☺
|
||||
</button>
|
||||
{showReplyEmoji && (
|
||||
<EmojiPicker
|
||||
onSelect={(emoji) => {
|
||||
const ta = replyRef.current;
|
||||
if (ta) {
|
||||
const start = ta.selectionStart ?? replyText.length;
|
||||
const end = ta.selectionEnd ?? replyText.length;
|
||||
setReplyText(replyText.slice(0, start) + emoji + replyText.slice(end));
|
||||
setTimeout(() => { ta.selectionStart = ta.selectionEnd = start + emoji.length; ta.focus(); }, 0);
|
||||
} else {
|
||||
setReplyText((t) => t + emoji);
|
||||
}
|
||||
}}
|
||||
onClose={() => setShowReplyEmoji(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-text-dim text-[10px]">Ctrl+Enter</span>
|
||||
<button
|
||||
onClick={handleReply}
|
||||
disabled={!replyText.trim() || replying}
|
||||
className="px-3 py-1 text-[11px] bg-accent hover:bg-accent-hover text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
{replySent ? "replied ✓" : replying ? "posting..." : "reply"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Thread tree (children of root) */}
|
||||
{tree.children.length === 0 && (
|
||||
<div className="px-4 py-6 text-text-dim text-[12px] text-center">
|
||||
No replies yet.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tree.children
|
||||
.filter((c) => !mutedPubkeys.includes(c.event.pubkey) && !contentMatchesMutedKeyword(c.event.content))
|
||||
.map((child) => (
|
||||
<ThreadNodeComponent
|
||||
key={child.event.id}
|
||||
node={child}
|
||||
onReplyInThread={handleReplyInThread}
|
||||
focusedId={focusedEvent.id}
|
||||
mutedPubkeys={mutedPubkeys}
|
||||
contentMatchesMutedKeyword={contentMatchesMutedKeyword}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && replies.length === 0 && (
|
||||
{!loading && !tree && (
|
||||
<div className="px-4 py-6 text-text-dim text-[12px] text-center">
|
||||
No replies yet.
|
||||
Could not load thread.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{replies
|
||||
.filter((r) => !mutedPubkeys.includes(r.pubkey) && !contentMatchesMutedKeyword(r.content))
|
||||
.map((reply) => (
|
||||
<NoteCard key={reply.id} event={reply} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Collect all events from a tree into a flat array. */
|
||||
function collectEvents(node: ThreadNode): NDKEvent[] {
|
||||
const result: NDKEvent[] = [node.event];
|
||||
for (const child of node.children) {
|
||||
result.push(...collectEvents(child));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { getNDK, connectToRelays, getStoredRelayUrls, addRelay, removeRelay } from "./core";
|
||||
export { fetchGlobalFeed, fetchFollowFeed, fetchUserNotes, fetchUserNotesNIP65, fetchNoteById, fetchReplies, publishNote, publishReply, publishRepost, publishQuote, fetchHashtagFeed } from "./notes";
|
||||
export { fetchGlobalFeed, fetchFollowFeed, fetchUserNotes, fetchUserNotesNIP65, fetchNoteById, fetchReplies, publishNote, publishReply, publishRepost, publishQuote, fetchHashtagFeed, fetchThreadEvents, fetchAncestors } from "./notes";
|
||||
export { publishProfile, publishContactList, fetchProfile, fetchFollowSuggestions, fetchMentions, fetchNewFollowers } from "./social";
|
||||
export { publishArticle, fetchArticle, fetchAuthorArticles, fetchArticleFeed, searchArticles, fetchByAddr } from "./articles";
|
||||
export { publishReaction, fetchReactionCount, fetchReplyCount, fetchZapCount, fetchBatchEngagement, fetchZapsReceived, fetchZapsSent } from "./engagement";
|
||||
|
||||
@@ -94,17 +94,33 @@ export async function publishNote(content: string): Promise<NDKEvent> {
|
||||
return event;
|
||||
}
|
||||
|
||||
export async function publishReply(content: string, replyTo: { id: string; pubkey: string }): Promise<NDKEvent> {
|
||||
export async function publishReply(
|
||||
content: string,
|
||||
replyTo: { id: string; pubkey: string },
|
||||
rootEvent?: { id: string; pubkey: string },
|
||||
): Promise<NDKEvent> {
|
||||
const instance = getNDK();
|
||||
if (!instance.signer) throw new Error("Not logged in");
|
||||
|
||||
const event = new NDKEvent(instance);
|
||||
event.kind = NDKKind.Text;
|
||||
event.content = content;
|
||||
event.tags = [
|
||||
["e", replyTo.id, "", "reply"],
|
||||
["p", replyTo.pubkey],
|
||||
];
|
||||
|
||||
if (rootEvent && rootEvent.id !== replyTo.id) {
|
||||
// Replying to a reply — emit both root and reply markers (NIP-10)
|
||||
const pTags = new Set([rootEvent.pubkey, replyTo.pubkey]);
|
||||
event.tags = [
|
||||
["e", rootEvent.id, "", "root"],
|
||||
["e", replyTo.id, "", "reply"],
|
||||
...Array.from(pTags).map((p) => ["p", p]),
|
||||
];
|
||||
} else {
|
||||
// Replying directly to root
|
||||
event.tags = [
|
||||
["e", replyTo.id, "", "root"],
|
||||
["p", replyTo.pubkey],
|
||||
];
|
||||
}
|
||||
await event.publish();
|
||||
return event;
|
||||
}
|
||||
@@ -140,6 +156,55 @@ export async function publishQuote(content: string, quotedEvent: NDKEvent): Prom
|
||||
await note.publish();
|
||||
}
|
||||
|
||||
export async function fetchThreadEvents(rootId: string): Promise<NDKEvent[]> {
|
||||
const instance = getNDK();
|
||||
|
||||
// Round-trip 1: all events tagging the root
|
||||
const directFilter: NDKFilter = { kinds: [NDKKind.Text], "#e": [rootId] };
|
||||
const directEvents = await instance.fetchEvents(directFilter, {
|
||||
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
|
||||
});
|
||||
|
||||
const allEvents = new Map<string, NDKEvent>();
|
||||
for (const e of directEvents) allEvents.set(e.id, e);
|
||||
|
||||
// Round-trip 2: replies to any event in the thread (catches deep replies that only tag parent)
|
||||
const knownIds = Array.from(allEvents.keys());
|
||||
if (knownIds.length > 0) {
|
||||
const deepFilter: NDKFilter = { kinds: [NDKKind.Text], "#e": knownIds };
|
||||
const deepEvents = await instance.fetchEvents(deepFilter, {
|
||||
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
|
||||
});
|
||||
for (const e of deepEvents) allEvents.set(e.id, e);
|
||||
}
|
||||
|
||||
return Array.from(allEvents.values());
|
||||
}
|
||||
|
||||
export async function fetchAncestors(event: NDKEvent, maxDepth = 5): Promise<NDKEvent[]> {
|
||||
const ancestors: NDKEvent[] = [];
|
||||
let current = event;
|
||||
|
||||
for (let i = 0; i < maxDepth; i++) {
|
||||
const eTags = current.tags.filter((t) => t[0] === "e");
|
||||
if (eTags.length === 0) break;
|
||||
|
||||
// Walk up: prefer "reply" marker, then "root", then last e-tag
|
||||
const parentId =
|
||||
eTags.find((t) => t[3] === "reply")?.[1] ??
|
||||
eTags.find((t) => t[3] === "root")?.[1] ??
|
||||
eTags[eTags.length - 1][1];
|
||||
|
||||
if (!parentId) break;
|
||||
const parent = await fetchNoteById(parentId);
|
||||
if (!parent) break;
|
||||
ancestors.unshift(parent); // root-first order
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return ancestors;
|
||||
}
|
||||
|
||||
export async function fetchHashtagFeed(tag: string, limit = 100): Promise<NDKEvent[]> {
|
||||
const instance = getNDK();
|
||||
const filter: NDKFilter = {
|
||||
|
||||
104
src/lib/threadTree.ts
Normal file
104
src/lib/threadTree.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
|
||||
export interface ThreadNode {
|
||||
event: NDKEvent;
|
||||
children: ThreadNode[];
|
||||
depth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the parent event ID from an event's e-tags.
|
||||
* Priority: "reply" marker > "root" marker > last e-tag (deprecated positional).
|
||||
*/
|
||||
export function getParentEventId(event: NDKEvent): string | null {
|
||||
const eTags = event.tags.filter((t) => t[0] === "e");
|
||||
if (eTags.length === 0) return null;
|
||||
return eTags.find((t) => t[3] === "reply")?.[1]
|
||||
?? eTags.find((t) => t[3] === "root")?.[1]
|
||||
?? eTags[eTags.length - 1][1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the root event ID from an event's e-tags.
|
||||
*/
|
||||
export function getRootEventId(event: NDKEvent): string | null {
|
||||
const eTags = event.tags.filter((t) => t[0] === "e");
|
||||
if (eTags.length === 0) return null;
|
||||
const root = eTags.find((t) => t[3] === "root");
|
||||
if (root) return root[1];
|
||||
// If only one e-tag with no marker, it's the root
|
||||
if (eTags.length === 1 && !eTags[0][3]) return eTags[0][1];
|
||||
// Deprecated positional: first e-tag is root
|
||||
return eTags[0][1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a tree structure from a flat list of events.
|
||||
* Returns the root node, or null if rootId not found in events.
|
||||
*/
|
||||
export function buildThreadTree(rootId: string, events: NDKEvent[]): ThreadNode | null {
|
||||
const eventMap = new Map<string, NDKEvent>();
|
||||
for (const e of events) {
|
||||
eventMap.set(e.id, e);
|
||||
}
|
||||
|
||||
const rootEvent = eventMap.get(rootId);
|
||||
if (!rootEvent) return null;
|
||||
|
||||
const nodeMap = new Map<string, ThreadNode>();
|
||||
for (const e of events) {
|
||||
nodeMap.set(e.id, { event: e, children: [], depth: 0 });
|
||||
}
|
||||
|
||||
const rootNode = nodeMap.get(rootId)!;
|
||||
|
||||
// Link children to parents
|
||||
for (const e of events) {
|
||||
if (e.id === rootId) continue;
|
||||
const parentId = getParentId(e, rootId);
|
||||
const parentNode = parentId ? nodeMap.get(parentId) : null;
|
||||
const childNode = nodeMap.get(e.id)!;
|
||||
if (parentNode) {
|
||||
parentNode.children.push(childNode);
|
||||
} else {
|
||||
// Orphan — attach to root
|
||||
rootNode.children.push(childNode);
|
||||
}
|
||||
}
|
||||
|
||||
// Set depths and sort children by created_at
|
||||
setDepths(rootNode, 0);
|
||||
|
||||
return rootNode;
|
||||
}
|
||||
|
||||
function getParentId(event: NDKEvent, rootId: string): string | null {
|
||||
const eTags = event.tags.filter((t) => t[0] === "e");
|
||||
if (eTags.length === 0) return rootId;
|
||||
|
||||
// Prefer "reply" marker
|
||||
const reply = eTags.find((t) => t[3] === "reply");
|
||||
if (reply) return reply[1];
|
||||
|
||||
// If there's a "root" marker and it's the only e-tag, parent is the root
|
||||
const root = eTags.find((t) => t[3] === "root");
|
||||
if (root && eTags.length === 1) return root[1];
|
||||
|
||||
// If there's a root marker and other e-tags without markers, use the last non-root e-tag
|
||||
if (root) {
|
||||
const nonRoot = eTags.filter((t) => t[3] !== "root");
|
||||
if (nonRoot.length > 0) return nonRoot[nonRoot.length - 1][1];
|
||||
return root[1];
|
||||
}
|
||||
|
||||
// Deprecated positional: last e-tag is the reply target
|
||||
return eTags[eTags.length - 1][1];
|
||||
}
|
||||
|
||||
function setDepths(node: ThreadNode, depth: number) {
|
||||
node.depth = depth;
|
||||
node.children.sort((a, b) => (a.event.created_at ?? 0) - (b.event.created_at ?? 0));
|
||||
for (const child of node.children) {
|
||||
setDepths(child, depth + 1);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ describe("useUIStore", () => {
|
||||
selectedPubkey: null,
|
||||
selectedNote: null,
|
||||
previousView: "feed",
|
||||
viewStack: [],
|
||||
feedTab: "global",
|
||||
pendingSearch: null,
|
||||
pendingDMPubkey: null,
|
||||
|
||||
@@ -5,12 +5,21 @@ import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
type View = "feed" | "search" | "relays" | "settings" | "profile" | "thread" | "article-editor" | "article" | "articles" | "media" | "podcasts" | "about" | "zaps" | "dm" | "notifications" | "bookmarks" | "hashtag";
|
||||
type FeedTab = "global" | "following" | "trending";
|
||||
|
||||
interface ViewStackEntry {
|
||||
view: View;
|
||||
selectedNote: NDKEvent | null;
|
||||
selectedPubkey: string | null;
|
||||
}
|
||||
|
||||
const MAX_STACK = 20;
|
||||
|
||||
interface UIState {
|
||||
currentView: View;
|
||||
sidebarCollapsed: boolean;
|
||||
selectedPubkey: string | null;
|
||||
selectedNote: NDKEvent | null;
|
||||
previousView: View;
|
||||
viewStack: ViewStackEntry[];
|
||||
feedTab: FeedTab;
|
||||
pendingSearch: string | null;
|
||||
pendingDMPubkey: string | null;
|
||||
@@ -22,7 +31,7 @@ interface UIState {
|
||||
setView: (view: View) => void;
|
||||
setFeedTab: (tab: FeedTab) => void;
|
||||
openProfile: (pubkey: string) => void;
|
||||
openThread: (note: NDKEvent, from: View) => void;
|
||||
openThread: (note: NDKEvent, from?: View) => void;
|
||||
openSearch: (query: string) => void;
|
||||
openHashtag: (tag: string) => void;
|
||||
openDM: (pubkey: string) => void;
|
||||
@@ -41,6 +50,7 @@ export const useUIStore = create<UIState>((set, _get) => ({
|
||||
selectedPubkey: null,
|
||||
selectedNote: null,
|
||||
previousView: "feed",
|
||||
viewStack: [],
|
||||
feedTab: "global",
|
||||
pendingSearch: null,
|
||||
pendingDMPubkey: null,
|
||||
@@ -51,17 +61,32 @@ export const useUIStore = create<UIState>((set, _get) => ({
|
||||
feedLanguageFilter: null,
|
||||
setView: (currentView) => set({ currentView }),
|
||||
setFeedTab: (feedTab) => set({ feedTab }),
|
||||
openProfile: (pubkey) => set((s) => ({ currentView: "profile", selectedPubkey: pubkey, previousView: s.currentView as View })),
|
||||
openThread: (note, from) => set({ currentView: "thread", selectedNote: note, previousView: from }),
|
||||
openProfile: (pubkey) => set((s) => {
|
||||
const stack = [...s.viewStack, { view: s.currentView, selectedNote: s.selectedNote, selectedPubkey: s.selectedPubkey }].slice(-MAX_STACK);
|
||||
return { currentView: "profile", selectedPubkey: pubkey, previousView: s.currentView as View, viewStack: stack };
|
||||
}),
|
||||
openThread: (note, _from) => set((s) => {
|
||||
const stack = [...s.viewStack, { view: s.currentView, selectedNote: s.selectedNote, selectedPubkey: s.selectedPubkey }].slice(-MAX_STACK);
|
||||
return { currentView: "thread", selectedNote: note, previousView: s.currentView as View, viewStack: stack };
|
||||
}),
|
||||
openSearch: (query) => set({ currentView: "search", pendingSearch: query }),
|
||||
openHashtag: (tag) => set((s) => ({ currentView: "hashtag", pendingHashtag: tag, previousView: s.currentView as View })),
|
||||
openHashtag: (tag) => set((s) => {
|
||||
const stack = [...s.viewStack, { view: s.currentView, selectedNote: s.selectedNote, selectedPubkey: s.selectedPubkey }].slice(-MAX_STACK);
|
||||
return { currentView: "hashtag", pendingHashtag: tag, previousView: s.currentView as View, viewStack: stack };
|
||||
}),
|
||||
openDM: (pubkey) => set({ currentView: "dm", pendingDMPubkey: pubkey }),
|
||||
openArticle: (naddr, event) => set((s) => ({ currentView: "article", pendingArticleNaddr: naddr, pendingArticleEvent: event ?? null, previousView: s.currentView as View })),
|
||||
goBack: () => set((s) => ({
|
||||
showHelp: false,
|
||||
currentView: s.previousView !== s.currentView ? s.previousView : "feed",
|
||||
selectedNote: null,
|
||||
})),
|
||||
openArticle: (naddr, event) => set((s) => {
|
||||
const stack = [...s.viewStack, { view: s.currentView, selectedNote: s.selectedNote, selectedPubkey: s.selectedPubkey }].slice(-MAX_STACK);
|
||||
return { currentView: "article", pendingArticleNaddr: naddr, pendingArticleEvent: event ?? null, previousView: s.currentView as View, viewStack: stack };
|
||||
}),
|
||||
goBack: () => set((s) => {
|
||||
const stack = [...s.viewStack];
|
||||
const prev = stack.pop();
|
||||
if (prev) {
|
||||
return { showHelp: false, currentView: prev.view, selectedNote: prev.selectedNote, selectedPubkey: prev.selectedPubkey, viewStack: stack };
|
||||
}
|
||||
return { showHelp: false, currentView: "feed", selectedNote: null, viewStack: [] };
|
||||
}),
|
||||
setFeedLanguageFilter: (feedLanguageFilter) => set({ feedLanguageFilter }),
|
||||
toggleSidebar: () => set((s) => {
|
||||
const next = !s.sidebarCollapsed;
|
||||
|
||||
Reference in New Issue
Block a user