diff --git a/src/apps/notes/index.tsx b/src/apps/notes/index.tsx index c706c25..bb521be 100644 --- a/src/apps/notes/index.tsx +++ b/src/apps/notes/index.tsx @@ -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(null); + const composerRef = useRef(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 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 ? ( +
+ setReplyTarget(null)} /> + { + setReplyTarget(null); + replies.refetch(); + }} + /> +
+ ) : null; return ( @@ -130,20 +189,182 @@ export default function NotesApp({ params, setTitle, setParams }: AppProps) { - {user && ( - replies.refetch()} - /> - )} + {isRootReply && composer} - {replies.data && replies.data.length > 0 ? ( - replies.data.map((reply) => ) - ) : ( + {wellPlaced.length > 0 ? ( +
+ {wellPlaced.map((node) => ( + + ))} +
+ ) : orphaned.length === 0 ? ( + ) : null} + + {orphaned.length > 0 && ( +
+

+ Couldn’t be placed in the thread — their parent note is missing or their references + conflict. +

+ {orphaned.map((node) => ( +
+ + {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. +
+ {node.children.map((child) => ( + + ))} +
+ )} +
+ ))} +
)}
); } + +/** 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 ( +
+ + Replying to + + +
+ ); +} + +/** An author's display name, with the kind-0 lookup resolved inline. */ +function AuthorName({ pubkey }: { pubkey: string }) { + const author = useAuthor(pubkey); + return {displayName(pubkey, author.data?.metadata)}; +} + +/** 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 ( +
+ {node.parentPubkey && ( +

+ Replying to +

+ )} + + {node.event.id === replyTargetId && composer} + {node.children.length > 0 && ( +
+ {node.children.map((child) => ( + + ))} +
+ )} +
+ ); +} + +/** 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 ( +
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 && ( +

+ Replying to +

+ )} + + {node.event.id === replyTargetId && composer} + {node.children.length > 0 && ( +
+ {node.children.map((child) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/components/nostr/NoteCard.tsx b/src/components/nostr/NoteCard.tsx index e851c01..889fde3 100644 --- a/src/components/nostr/NoteCard.tsx +++ b/src/components/nostr/NoteCard.tsx @@ -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 (
- + {onReply ? ( + + ) : ( + + )}