Add nested reply threading in note detail views (#68)

* Initial plan

* Add nested reply threading to note detail view

Derive parent/child relationships from NIP-10 references and render replies
beneath their logical parent instead of as a flat list. Replies can be
answered inline at any point in the thread, with proper root/reply markers
and p tags. Deep nesting is capped so threads stay readable on mobile, the
conversation exposes tree semantics to assistive technology, and malformed,
cyclic, or incomplete reference chains render safely in a separate section
instead of breaking the thread.

Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>

* Render orphaned reply sub-trees and open the composer for orphaned notes

A reply whose parent was never fetched is promoted out of the thread, but
its own children resolved against it fine — keep them nested beneath it
instead of dropping them, and mount the inline composer when an orphaned
note is the reply target so the Reply button never claims a composer that
isn't there (aria-expanded/aria-controls now always point at a real node).

Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>

* Restore package-lock.json platform flags dropped by a stray npm install

No dependency changed; keep the lockfile byte-identical to the base branch.

Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>

* Apply remaining changes

Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>

* Address review feedback on nested reply threading

- Remove invalid aria-setsize={-1}; the real set size isn't tracked here.
- Make aria-expanded an explicit boolean and keep aria-controls consistent
  across active/inactive Reply buttons.
- Fix cycle field docstring to match its boolean type.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
Co-authored-by: highperfocused <highperfocused@pm.me>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Copilot
2026-09-07 22:34:08 +02:00
committed by GitHub
parent 411aef9135
commit 77329b9a59
4 changed files with 641 additions and 30 deletions

View File

@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react';
@@ -15,7 +15,15 @@ import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { absoluteTime, decodeRelayHints, displayName } from '@/lib/nostrUtils';
import {
absoluteTime,
buildReplyTree,
decodeRelayHints,
displayName,
tagValues,
type ReplyNode,
} from '@/lib/nostrUtils';
import { cn } from '@/lib/utils';
import type { AppProps } from '@/os/types';
function useNote(id: string | undefined, relays: string[] | undefined) {
@@ -61,13 +69,38 @@ export default function NotesApp({ params, setTitle, setParams }: AppProps) {
const note = useNote(id, relays);
const replies = useReplies(id, relays);
const author = useAuthor(note.data?.pubkey);
// The reply the inline composer is attached to, if any (otherwise the root).
const [replyTarget, setReplyTarget] = useState<NostrEvent | null>(null);
const composerRef = useRef<HTMLDivElement>(null);
const name = note.data ? displayName(note.data.pubkey, author.data?.metadata) : undefined;
const root = note.data ?? undefined;
const tree = useMemo(
() => (root && replies.data ? buildReplyTree(root.id, replies.data) : []),
[root, replies.data],
);
// Replies that could not be placed under their NIP-10 parent still render,
// but apart from the thread so a broken chain never looks like a real one.
const wellPlaced = useMemo(() => tree.filter((node) => !node.misplaced), [tree]);
const orphaned = useMemo(() => tree.filter((node) => node.misplaced), [tree]);
useEffect(() => {
setTitle(id ? (name ? `Note by ${name}` : 'Note') : 'New Note');
}, [id, name, setTitle]);
// Moving the composer to another reply brings it into view and hands focus
// to the textarea, so keyboard and pointer users land in the same place.
useEffect(() => {
if (!replyTarget) return;
const target = composerRef.current;
if (!target) return;
if (typeof target.scrollIntoView === 'function') {
target.scrollIntoView({ block: 'nearest' });
}
target.querySelector('textarea')?.focus();
}, [replyTarget]);
if (!id) {
return <DraftNote onPublished={(publishedId) => setParams({ ...params, id: publishedId })} />;
}
@@ -97,11 +130,37 @@ export default function NotesApp({ params, setTitle, setParams }: AppProps) {
}
const event = note.data;
// NIP-10: mark the note we are replying to as the root and carry its author.
const replyTags = [
['e', event.id, '', 'root'],
['p', event.pubkey],
];
const isRootReply = !replyTarget;
// NIP-10 tags for the note being composed: a top-level reply marks only the
// root; a nested reply also marks its parent and carries the thread's
// participants as p tags.
const replyTags = replyTarget
? [
['e', event.id, '', 'root'],
['e', replyTarget.id, '', 'reply'],
...[event.pubkey, replyTarget.pubkey, ...tagValues(replyTarget, 'p')]
.filter((pubkey, index, all) => all.indexOf(pubkey) === index)
.map((pubkey) => ['p', pubkey]),
]
: [
['e', event.id, '', 'root'],
['p', event.pubkey],
];
const composer = user ? (
<div ref={composerRef} id="reply-composer">
<ReplyingToBar target={replyTarget} onCancel={() => setReplyTarget(null)} />
<Composer
key={replyTarget?.id ?? 'root'}
replyTags={replyTags}
placeholder={replyTarget ? 'Reply to this note…' : 'Write a reply…'}
onPublished={() => {
setReplyTarget(null);
replies.refetch();
}}
/>
</div>
) : null;
return (
<AppLayout>
@@ -130,20 +189,182 @@ export default function NotesApp({ params, setTitle, setParams }: AppProps) {
</div>
</div>
{user && (
<Composer
replyTags={replyTags}
placeholder="Write a reply…"
onPublished={() => replies.refetch()}
/>
)}
{isRootReply && composer}
{replies.data && replies.data.length > 0 ? (
replies.data.map((reply) => <NoteCard key={reply.id} event={reply} />)
) : (
{wellPlaced.length > 0 ? (
<div role="tree" aria-label="Replies">
{wellPlaced.map((node) => (
<ThreadNode
key={node.event.id}
node={node}
depth={1}
onReply={user ? setReplyTarget : undefined}
composer={!isRootReply ? composer : null}
replyTargetId={replyTarget?.id}
/>
))}
</div>
) : orphaned.length === 0 ? (
<EmptyState title="No replies yet" hint={user ? 'Be the first to answer.' : undefined} />
) : null}
{orphaned.length > 0 && (
<section
aria-label="Replies with missing or conflicting parents"
className="border-t border-border bg-muted/30"
>
<p className="px-4 pt-3 text-xs text-muted-foreground">
Couldnt be placed in the thread their parent note is missing or their references
conflict.
</p>
{orphaned.map((node) => (
<div key={node.event.id}>
<NoteCard
event={node.event}
onReply={user ? setReplyTarget : undefined}
replyOpen={node.event.id === replyTarget?.id}
/>
{node.event.id === replyTarget?.id && composer}
{node.children.length > 0 && (
// The orphan's own replies resolved against it fine, so they
// stay nested beneath it even though it sits apart.
<div className="border-l-2 border-border/70 pl-2 ml-8 sm:pl-3">
{node.children.map((child) => (
<OrphanBranch
key={child.event.id}
node={child}
onReply={user ? setReplyTarget : undefined}
composer={composer}
replyTargetId={replyTarget?.id}
/>
))}
</div>
)}
</div>
))}
</section>
)}
</AppBody>
</AppLayout>
);
}
/** Banner above the inline composer saying which note is being answered. */
function ReplyingToBar({
target,
onCancel,
}: {
target: NostrEvent | null;
onCancel: () => void;
}) {
if (!target) return null;
return (
<div className="flex items-center gap-2 border-b border-border bg-muted/40 px-4 py-1.5 text-xs text-muted-foreground">
<span className="min-w-0 flex-1 truncate">
Replying to <AuthorName pubkey={target.pubkey} />
</span>
<Button variant="ghost" size="sm" className="h-6 px-2 text-xs" onClick={onCancel}>
Cancel
</Button>
</div>
);
}
/** An author's display name, with the kind-0 lookup resolved inline. */
function AuthorName({ pubkey }: { pubkey: string }) {
const author = useAuthor(pubkey);
return <span className="font-medium text-foreground">{displayName(pubkey, author.data?.metadata)}</span>;
}
/** A well-placed sub-branch beneath an orphaned note (no tree roles — the orphan sits outside the tree). */
function OrphanBranch({
node,
onReply,
composer,
replyTargetId,
}: {
node: ReplyNode;
onReply?: (event: NostrEvent) => void;
composer?: React.ReactNode;
replyTargetId?: string;
}) {
return (
<div>
{node.parentPubkey && (
<p className="pt-2 pl-11 text-xs text-muted-foreground">
Replying to <AuthorName pubkey={node.parentPubkey} />
</p>
)}
<NoteCard event={node.event} onReply={onReply} replyOpen={node.event.id === replyTargetId} />
{node.event.id === replyTargetId && composer}
{node.children.length > 0 && (
<div className="border-l-2 border-border/70 pl-2 ml-8 sm:pl-3">
{node.children.map((child) => (
<OrphanBranch
key={child.event.id}
node={child}
onReply={onReply}
composer={composer}
replyTargetId={replyTargetId}
/>
))}
</div>
)}
</div>
);
}
/** One reply and its children, nested with a thread line. */
function ThreadNode({
node,
depth,
onReply,
composer,
replyTargetId,
}: {
node: ReplyNode;
depth: number;
onReply?: (event: NostrEvent) => void;
composer?: React.ReactNode;
replyTargetId?: string;
}) {
const isRootReply = depth === 1;
return (
<div
role="treeitem"
aria-level={depth}
aria-expanded={node.children.length > 0 ? true : undefined}
className={cn(
!isRootReply &&
// Indent one avatar-width per level (capped at 10rem) with a thread
// line, so deep conversations stay readable instead of running off
// screen. Plain min() — theme() is unreliable inside arbitrary values.
'ml-[min(calc(var(--depth)*2rem),10rem)] border-l-2 border-border/70 pl-2 sm:pl-3',
)}
style={!isRootReply ? ({ '--depth': depth - 1 } as React.CSSProperties) : undefined}
>
{!isRootReply && node.parentPubkey && (
<p className="pt-2 pl-11 text-xs text-muted-foreground">
Replying to <AuthorName pubkey={node.parentPubkey} />
</p>
)}
<NoteCard event={node.event} onReply={onReply} replyOpen={node.event.id === replyTargetId} />
{node.event.id === replyTargetId && composer}
{node.children.length > 0 && (
<div role="group">
{node.children.map((child) => (
<ThreadNode
key={child.event.id}
node={child}
depth={depth + 1}
onReply={onReply}
composer={composer}
replyTargetId={replyTargetId}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import { MessageSquare, Repeat2 } from 'lucide-react';
import { MessageSquare, MessageSquareReply, Repeat2 } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools';
import { AuthorLine } from './AuthorLine';
@@ -12,12 +12,17 @@ import { Button } from '@/components/ui/button';
import { useWindowManager } from '@/os/useWindowManager';
import { useToast } from '@/hooks/useToast';
import { useRelayHints } from '@/hooks/useRelayHints';
import { genUserName } from '@/lib/nostrUtils';
import { cn } from '@/lib/utils';
interface NoteCardProps {
event: NostrEvent;
/** Hides the reply affordance when the note is already the open thread root. */
/** Hides the action row (used where actions would be redundant). */
compact?: boolean;
/** Turns the thread button into an inline reply affordance for this note. */
onReply?: (event: NostrEvent) => void;
/** True while this note is the one being answered in the inline composer. */
replyOpen?: boolean;
className?: string;
}
@@ -25,7 +30,7 @@ interface NoteCardProps {
* One note in a list. Dense by design: a 44px-ish header, the content, and a
* thin action row — no oversized card padding.
*/
export function NoteCard({ event, compact, className }: NoteCardProps) {
export function NoteCard({ event, compact, onReply, replyOpen, className }: NoteCardProps) {
const { openApp } = useWindowManager();
const { toast } = useToast();
const hints = useRelayHints();
@@ -48,6 +53,7 @@ export function NoteCard({ event, compact, className }: NoteCardProps) {
return (
<article
aria-label={`Note by ${genUserName(event.pubkey)}`}
className={cn(
'group border-b border-border px-4 py-3 transition-colors last:border-b-0 hover:bg-muted/40',
className,
@@ -62,15 +68,29 @@ export function NoteCard({ event, compact, className }: NoteCardProps) {
{!compact && (
<div className="mt-2 flex flex-wrap items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 gap-1.5 px-2 text-xs text-muted-foreground"
onClick={() => openApp('notes', { id: event.id })}
>
<MessageSquare className="size-3.5" aria-hidden />
Open thread
</Button>
{onReply ? (
<Button
variant="ghost"
size="sm"
className="h-7 gap-1.5 px-2 text-xs text-muted-foreground"
onClick={() => onReply(event)}
aria-expanded={!!replyOpen}
aria-controls="reply-composer"
>
<MessageSquareReply className="size-3.5" aria-hidden />
Reply
</Button>
) : (
<Button
variant="ghost"
size="sm"
className="h-7 gap-1.5 px-2 text-xs text-muted-foreground"
onClick={() => openApp('notes', { id: event.id })}
>
<MessageSquare className="size-3.5" aria-hidden />
Open thread
</Button>
)}
<Button
variant="ghost"
size="sm"

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import type { NostrEvent } from '@nostrify/nostrify';
import { isReply, rootReference } from './nostrUtils';
import { buildReplyTree, isReply, replyReference, rootReference, type ReplyNode } from './nostrUtils';
function note(tags: string[][]): NostrEvent {
return {
@@ -14,6 +14,25 @@ function note(tags: string[][]): NostrEvent {
};
}
function reply(
id: string,
tags: string[][],
createdAt: number,
pubkey = `author-of-${id}`,
): NostrEvent {
return { ...note(tags), id, pubkey, created_at: createdAt };
}
/** Flattened "id>child" shape for terse tree assertions. */
function shape(nodes: ReplyNode[]): unknown[] {
return nodes.map((node) => [
node.event.id,
shape(node.children),
...(node.misplaced ? ['misplaced'] : []),
...(node.cycle ? ['cycle'] : []),
]);
}
describe('isReply', () => {
it('is false for a root note with no e tag', () => {
expect(isReply(note([]))).toBe(false);
@@ -76,3 +95,230 @@ describe('rootReference', () => {
expect(rootReference(event)).toBe('root-id');
});
});
describe('replyReference', () => {
it('prefers the marked reply tag over the root tag', () => {
const event = note([
['e', 'root-id', '', 'root'],
['e', 'parent-id', '', 'reply'],
]);
expect(replyReference(event)).toBe('parent-id');
});
it('returns the root tag for a direct reply to the root', () => {
expect(replyReference(note([['e', 'root-id', '', 'root']]))).toBe('root-id');
});
it('falls back to the last positional e tag per the deprecated scheme', () => {
const event = note([
['e', 'root-id'],
['e', 'parent-id'],
]);
expect(replyReference(event)).toBe('parent-id');
});
it('uses the only positional e tag as the parent', () => {
expect(replyReference(note([['e', 'parent-id']]))).toBe('parent-id');
});
it('ignores mentions', () => {
const event = note([
['e', 'mentioned-id', '', 'mention'],
['e', 'parent-id', '', 'reply'],
]);
expect(replyReference(event)).toBe('parent-id');
});
it('skips a self-reference in the positional scheme', () => {
const event = note([
['e', 'root-id'],
['e', 'x'],
]);
expect(replyReference(event)).toBe('root-id');
});
it('skips a self-referencing reply marker and falls back to the root', () => {
const event = note([
['e', 'root-id', '', 'root'],
['e', 'x', '', 'reply'],
]);
expect(replyReference(event)).toBe('root-id');
});
it('is undefined for a root note', () => {
expect(replyReference(note([]))).toBeUndefined();
});
it('is undefined for a note that only mentions another event', () => {
expect(replyReference(note([['e', 'mentioned-id', '', 'mention']]))).toBeUndefined();
});
});
describe('buildReplyTree', () => {
it('returns an empty list when there are no replies', () => {
expect(buildReplyTree('root', [])).toEqual([]);
});
it('nests replies beneath their marked parent', () => {
const events = [
reply('a', [['e', 'root', '', 'root']], 1),
reply(
'b',
[
['e', 'root', '', 'root'],
['e', 'a', '', 'reply'],
],
2,
),
reply(
'c',
[
['e', 'root', '', 'root'],
['e', 'b', '', 'reply'],
],
3,
),
];
expect(shape(buildReplyTree('root', events))).toEqual([
['a', [['b', [['c', []]]]]],
]);
});
it('keeps direct replies to the root at the top level', () => {
const events = [
reply('a', [['e', 'root', '', 'root']], 1),
reply('b', [['e', 'root', '', 'root']], 2),
];
expect(shape(buildReplyTree('root', events))).toEqual([
['a', []],
['b', []],
]);
});
it('nests positional replies beneath the last e tag', () => {
const events = [
reply('a', [['e', 'root']], 1),
reply(
'b',
[
['e', 'root'],
['e', 'a'],
],
2,
),
];
expect(shape(buildReplyTree('root', events))).toEqual([['a', [['b', []]]]]);
});
it('sorts siblings by creation time, not input order', () => {
const events = [
reply('b', [['e', 'root', '', 'root']], 2),
reply('a', [['e', 'root', '', 'root']], 1),
];
expect(shape(buildReplyTree('root', events))).toEqual([
['a', []],
['b', []],
]);
});
it('attaches replies before parents that arrive later', () => {
const events = [
reply(
'b',
[
['e', 'root', '', 'root'],
['e', 'a', '', 'reply'],
],
2,
),
reply('a', [['e', 'root', '', 'root']], 3),
];
expect(shape(buildReplyTree('root', events))).toEqual([['a', [['b', []]]]]);
});
it('promotes replies whose parent was never fetched, marked misplaced', () => {
const events = [
reply(
'b',
[
['e', 'root', '', 'root'],
['e', 'missing', '', 'reply'],
],
1,
),
];
expect(shape(buildReplyTree('root', events))).toEqual([['b', [], 'misplaced']]);
});
it('promotes replies with a malformed reference instead of dropping them', () => {
const events = [reply('a', [['e', '', '', 'reply']], 1)];
const nodes = buildReplyTree('root', events);
expect(nodes).toHaveLength(1);
expect(nodes[0].event.id).toBe('a');
});
it('promotes a self-referencing reply to the root', () => {
const events = [reply('a', [['e', 'a', '', 'reply']], 1)];
expect(shape(buildReplyTree('root', events))).toEqual([['a', []]]);
});
it('breaks cycles so every event renders exactly once', () => {
const events = [
reply('a', [['e', 'b', '', 'reply']], 1),
reply('b', [['e', 'a', '', 'reply']], 2),
];
const nodes = buildReplyTree('root', events);
// Neither side of the loop has a trustworthy parent, so both surface at
// the root flagged as a cycle instead of pretending a nesting.
expect(shape(nodes)).toEqual([
['a', [], 'misplaced', 'cycle'],
['b', [], 'misplaced', 'cycle'],
]);
});
it('resolves conflicting markers through the marked reply tag', () => {
const events = [
reply('a', [['e', 'root', '', 'root']], 1),
reply('b', [['e', 'root', '', 'root']], 2),
reply(
'c',
[
['e', 'b'],
['e', 'a', '', 'reply'],
],
3,
),
];
expect(shape(buildReplyTree('root', events))).toEqual([
['a', [['c', []]]],
['b', []],
]);
});
it('drops duplicate event ids and events that repeat the root id', () => {
const events = [
reply('a', [['e', 'root', '', 'root']], 1),
reply('a', [['e', 'root', '', 'root']], 1),
reply('root', [['e', 'root', '', 'root']], 2),
];
expect(shape(buildReplyTree('root', events))).toEqual([['a', []]]);
});
it('records the parent author for attribution', () => {
const events = [
reply('a', [['e', 'root', '', 'root']], 1, 'alice'),
reply(
'b',
[
['e', 'root', '', 'root'],
['e', 'a', '', 'reply'],
],
2,
'bob',
),
];
const [a] = buildReplyTree('root', events);
expect(a.parentPubkey).toBeUndefined();
expect(a.children[0].parentPubkey).toBe('alice');
});
});

View File

@@ -115,3 +115,127 @@ export function rootReference(event: NostrEvent): string | undefined {
export function isReply(event: NostrEvent): boolean {
return event.tags.some(([name, , , marker]) => name === 'e' && marker !== 'mention');
}
/**
* The event a reply is directly responding to, following NIP-10: prefer an
* explicit `reply` marker, fall back to the last *unmarked* `e` tag (the
* deprecated positional scheme puts the direct parent last), then to the
* marked `root` (a top-level reply carries only that tag). Mentions are never
* a parent, and an event never parents itself.
*/
export function replyReference(event: NostrEvent): string | undefined {
const marked = event.tags.find(([name, , , marker]) => name === 'e' && marker === 'reply');
if (marked?.[1] && marked[1] !== event.id) return marked[1];
const positional = event.tags.filter(([name, , , marker]) => name === 'e' && !marker);
for (let index = positional.length - 1; index >= 0; index -= 1) {
const value = positional[index][1];
if (value && value !== event.id) return value;
}
const root = rootReference(event);
return root && root !== event.id ? root : undefined;
}
export interface ReplyNode {
event: NostrEvent;
/** Pubkey of the direct parent's author, for "Replying to …" attribution. */
parentPubkey?: string;
children: ReplyNode[];
/** True when the event landed here as a fallback, not via its NIP-10 tags. */
misplaced: boolean;
/** Whether this event's chain loops back on itself. */
cycle?: boolean;
}
/**
* Groups replies beneath their NIP-10 parent, defensively:
*
* - Parents missing from `events` (delayed or never fetched) promote their
* children to the root list, marked `misplaced` — nothing disappears.
* - Cyclic chains (a replies to b, b replies to a) are broken by detaching
* the edge that closes the loop, so rendering always terminates.
* - Conflicting markers resolve in `replyReference`'s fixed order, so every
* event lands under exactly one parent; duplicate event ids collapse.
*/
export function buildReplyTree(rootId: string, events: NostrEvent[]): ReplyNode[] {
const unique = new Map<string, NostrEvent>();
for (const event of events) {
if (event.id !== rootId && !unique.has(event.id)) unique.set(event.id, event);
}
const nodes = new Map<string, ReplyNode>();
const roots: ReplyNode[] = [];
const nodeFor = (event: NostrEvent): ReplyNode => {
let node = nodes.get(event.id);
if (!node) {
node = { event, children: [], misplaced: false };
nodes.set(event.id, node);
}
return node;
};
const attach = (parentId: string, child: ReplyNode, misplaced: boolean) => {
if (parentId === rootId) {
child.misplaced = misplaced;
roots.push(child);
return;
}
const parentEvent = unique.get(parentId);
if (!parentEvent) {
// The parent was never fetched — surface the reply at the root instead
// of dropping a whole branch of the conversation.
child.misplaced = true;
roots.push(child);
return;
}
child.parentPubkey = parentEvent.pubkey;
child.misplaced = misplaced;
nodeFor(parentEvent).children.push(child);
};
const sorted = [...unique.values()].sort((a, b) => a.created_at - b.created_at);
for (const event of sorted) {
const node = nodeFor(event);
const parentId = replyReference(event);
if (!parentId || parentId === event.id) {
attach(rootId, node, Boolean(parentId));
continue;
}
// Walking the chain towards the root; hitting this event's own
// descendants means its `reply` tag closes a cycle.
let cycle = false;
const seen = new Set<string>([event.id]);
let cursor: string | undefined = parentId;
while (cursor && cursor !== rootId) {
if (seen.has(cursor)) {
cycle = true;
break;
}
seen.add(cursor);
const parent: NostrEvent | undefined = unique.get(cursor);
if (!parent) break;
cursor = replyReference(parent);
}
if (cycle) {
node.cycle = true;
attach(rootId, node, true);
} else {
attach(parentId, node, false);
}
}
// Children arrived in created_at order; only cycles can attach late.
const sortChildren = (list: ReplyNode[]) => {
list.sort((a, b) => a.event.created_at - b.event.created_at);
for (const child of list) sortChildren(child.children);
};
sortChildren(roots);
return roots;
}