mirror of
https://github.com/layer-systems/website.git
synced 2026-09-12 05:33:12 +02:00
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:
@@ -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">
|
||||
Couldn’t 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user