From f3b64fdf64a38fe7c9abe33f10953bc55afbb330 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Feb 2026 21:04:23 +0000 Subject: [PATCH] feat(kind-1111): show root scope and parent context per NIP-22 The Kind 1111 renderer previously only showed the immediate parent reply, missing the root scope that defines what a comment thread is about. Now shows both the root item (blog post, file, URL, podcast, etc.) and the parent reply (when replying to another comment), giving full threading context. - Add NIP-22 helper library using applesauce pointer helpers (getEventPointerFromETag, getAddressPointerFromATag, getProfilePointerFromPTag) for tag parsing - Support external identifiers (I-tags) with NIP-73 type-specific icons - Unified ScopeRow component for consistent inline display - Only show parent reply line for nested comments (not top-level) https://claude.ai/code/session_01Dxedi6VWdZG8nFpba211oR --- .../nostr/kinds/Kind1111Renderer.tsx | 227 +++++++++++----- src/lib/nip22-helpers.ts | 244 ++++++++++++++++++ 2 files changed, 406 insertions(+), 65 deletions(-) create mode 100644 src/lib/nip22-helpers.ts diff --git a/src/components/nostr/kinds/Kind1111Renderer.tsx b/src/components/nostr/kinds/Kind1111Renderer.tsx index 0dfe83f..5011ed5 100644 --- a/src/components/nostr/kinds/Kind1111Renderer.tsx +++ b/src/components/nostr/kinds/Kind1111Renderer.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from "react"; import { RichText } from "../RichText"; import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer"; import { @@ -8,22 +9,24 @@ import { } from "applesauce-common/helpers/comment"; import { useNostrEvent } from "@/hooks/useNostrEvent"; import { UserName } from "../UserName"; -import { Reply } from "lucide-react"; +import { Reply, type LucideIcon } from "lucide-react"; import { useGrimoire } from "@/core/state"; import { InlineReplySkeleton } from "@/components/ui/skeleton"; import { KindBadge } from "@/components/KindBadge"; +import { getKindInfo } from "@/constants/kinds"; import { getEventDisplayTitle } from "@/lib/event-title"; import type { NostrEvent } from "@/types/nostr"; import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; + getCommentRootScope, + isTopLevelComment, + getExternalIdentifierIcon, + getExternalIdentifierLabel, + type CommentRootScope, + type CommentScope, +} from "@/lib/nip22-helpers"; /** * Convert CommentPointer to pointer format for useNostrEvent - * Preserves relay hints from the "a"/"e" tags for better event discovery */ function convertCommentPointer( commentPointer: CommentPointer | null, @@ -50,67 +53,161 @@ function convertCommentPointer( } /** - * Parent event card component - compact single line + * Convert a CommentScope to a useNostrEvent-compatible pointer. + * Event and address scopes already carry EventPointer/AddressPointer fields + * from applesauce helpers, so we just strip the discriminant. */ -function ParentEventCard({ - parentEvent, +function scopeToPointer( + scope: CommentScope, +): + | { id: string; relays?: string[] } + | { kind: number; pubkey: string; identifier: string; relays?: string[] } + | undefined { + if (scope.type === "event") { + const { type: _, ...pointer } = scope; + return pointer; + } + if (scope.type === "address") { + const { type: _, ...pointer } = scope; + return pointer; + } + return undefined; +} + +function getKindIcon(kind: number): LucideIcon { + const info = getKindInfo(kind); + return info?.icon || Reply; +} + +/** + * Uniform inline scope row — icon + label text. + * Used for both root scope and parent reply, regardless of Nostr event or external identifier. + */ +function ScopeRow({ icon: Icon, - tooltipText, - onClickHandler, + label, + onClick, + href, }: { - parentEvent: NostrEvent; - icon: typeof Reply; - tooltipText: string; - onClickHandler: () => void; + icon: LucideIcon; + label: ReactNode; + onClick?: () => void; + href?: string; }) { - // Don't show kind badge for kind 1 (most common, adds clutter) - const showKindBadge = parentEvent.kind !== 1; + const className = + "flex items-center gap-1.5 text-xs text-muted-foreground overflow-hidden min-w-0" + + (onClick + ? " cursor-crosshair hover:text-foreground transition-colors" + : ""); + + const inner = ( + <> + + {label} + + ); + + if (href) { + return ( + + {inner} + + ); + } return ( -
- - - - - -

{tooltipText}

-
-
- {showKindBadge && } - -
- {getEventDisplayTitle(parentEvent, false) || ( - - )} -
+
+ {inner}
); } /** - * Renderer for Kind 1111 - Post (NIP-22) - * Shows immediate parent (reply) only for cleaner display + * Builds the label ReactNode for a loaded Nostr event scope row. + */ +function NostrEventLabel({ nostrEvent }: { nostrEvent: NostrEvent }) { + const title = getEventDisplayTitle(nostrEvent, false); + return ( + <> + + + + {title || nostrEvent.content.slice(0, 80)} + + + ); +} + +/** + * Root scope display — loads and renders the root Nostr event, or shows external identifier + */ +function RootScopeDisplay({ + root, + event, +}: { + root: CommentRootScope; + event: NostrEvent; +}) { + const { addWindow } = useGrimoire(); + const pointer = scopeToPointer(root.scope); + const rootEvent = useNostrEvent(pointer, event); + + // External identifier (I-tag) + if (root.scope.type === "external") { + const Icon = getExternalIdentifierIcon(root.kind); + const label = getExternalIdentifierLabel(root.scope.value, root.kind); + return ( + + ); + } + + if (!pointer) return null; + + // Loading + if (!rootEvent) { + return ( + + } + /> + ); + } + + return ( + } + onClick={() => addWindow("open", { pointer })} + /> + ); +} + +/** + * Renderer for Kind 1111 - Comment (NIP-22) + * Shows root scope (what the thread is about) and parent reply (if nested) */ export function Kind1111Renderer({ event, depth = 0 }: BaseEventProps) { const { addWindow } = useGrimoire(); + const root = getCommentRootScope(event); + const topLevel = isTopLevelComment(event); - // Use NIP-22 specific helpers to get reply pointer + // Parent pointer (for reply-to-comment case) const replyPointerRaw = getCommentReplyPointer(event); - - // Convert to useNostrEvent format const replyPointer = convertCommentPointer(replyPointerRaw); - - // Fetch reply event - const replyEvent = useNostrEvent(replyPointer, event); + const replyEvent = useNostrEvent(!topLevel ? replyPointer : undefined, event); const handleReplyClick = () => { if (!replyEvent || !replyPointer) return; @@ -119,21 +216,21 @@ export function Kind1111Renderer({ event, depth = 0 }: BaseEventProps) { return ( - - {/* Show reply event (immediate parent) */} - {replyPointer && !replyEvent && ( - } /> - )} + {/* Root scope — what this comment thread is about */} + {root && } - {replyPointer && replyEvent && ( - - )} - + {/* Parent reply — only shown for nested comments (reply to another comment) */} + {!topLevel && replyPointer && !replyEvent && ( + } /> + )} + + {!topLevel && replyPointer && replyEvent && ( + } + onClick={handleReplyClick} + /> + )} diff --git a/src/lib/nip22-helpers.ts b/src/lib/nip22-helpers.ts new file mode 100644 index 0000000..a1dacbc --- /dev/null +++ b/src/lib/nip22-helpers.ts @@ -0,0 +1,244 @@ +import { getOrComputeCachedValue } from "applesauce-core/helpers"; +import { + getEventPointerFromETag, + getAddressPointerFromATag, + getProfilePointerFromPTag, +} from "applesauce-core/helpers/pointers"; +import type { NostrEvent } from "nostr-tools"; +import type { + EventPointer, + AddressPointer, + ProfilePointer, +} from "nostr-tools/nip19"; +import type { LucideIcon } from "lucide-react"; +import { + Globe, + Podcast, + BookOpen, + FileText, + MapPin, + Hash, + Coins, + Film, + Flag, + ExternalLink, +} from "lucide-react"; + +// --- Types --- + +export type CommentExternalPointer = { + type: "external"; + value: string; + hint?: string; +}; + +export type CommentScope = + | ({ type: "event" } & EventPointer) + | ({ type: "address" } & AddressPointer) + | CommentExternalPointer; + +export type CommentRootScope = { + scope: CommentScope; + kind: string; // K tag value — a number string or external type like "web" + author?: ProfilePointer; +}; + +export type CommentParent = { + scope: CommentScope; + kind: string; // k tag value + author?: ProfilePointer; +}; + +// --- Cache symbols --- + +const RootScopeSymbol = Symbol("nip22RootScope"); +const ParentSymbol = Symbol("nip22Parent"); +const IsTopLevelSymbol = Symbol("nip22IsTopLevel"); + +// --- Parsing helpers --- + +function findTag(event: NostrEvent, tagName: string): string[] | undefined { + return event.tags.find((t: string[]) => t[0] === tagName); +} + +/** + * Parse scope from E/e, A/a, I/i tags using applesauce helpers. + * The helpers work on the tag array structure regardless of tag name casing. + */ +function parseScopeFromTags( + event: NostrEvent, + eTagName: string, + aTagName: string, + iTagName: string, +): CommentScope | null { + // Check E/e tag — use applesauce helper for structured parsing + const eTagData = findTag(event, eTagName); + if (eTagData) { + const pointer = getEventPointerFromETag(eTagData); + if (pointer) { + return { type: "event", ...pointer }; + } + } + + // Check A/a tag — use applesauce helper for coordinate parsing + const aTagData = findTag(event, aTagName); + if (aTagData) { + const pointer = getAddressPointerFromATag(aTagData); + if (pointer) { + return { type: "address", ...pointer }; + } + } + + // Check I/i tag — external identifiers (no applesauce helper for this) + const iTagData = findTag(event, iTagName); + if (iTagData && iTagData[1]) { + return { + type: "external", + value: iTagData[1], + hint: iTagData[2] || undefined, + }; + } + + return null; +} + +/** + * Parse an author tag (P/p) using applesauce helper. + */ +function parseAuthorTag( + event: NostrEvent, + tagName: string, +): ProfilePointer | undefined { + const tag = findTag(event, tagName); + if (!tag) return undefined; + return getProfilePointerFromPTag(tag) ?? undefined; +} + +// --- Public API --- + +/** + * Get the root scope of a NIP-22 comment (uppercase E/A/I + K + P tags). + * This tells you what the comment thread is *about* (a blog post, file, URL, podcast, etc.). + */ +export function getCommentRootScope( + event: NostrEvent, +): CommentRootScope | null { + return getOrComputeCachedValue(event, RootScopeSymbol, () => { + const scope = parseScopeFromTags(event, "E", "A", "I"); + if (!scope) return null; + + const kTag = findTag(event, "K"); + if (!kTag || !kTag[1]) return null; // K is mandatory + + const author = parseAuthorTag(event, "P"); + + return { + scope, + kind: kTag[1], + author, + }; + }); +} + +/** + * Get the parent item of a NIP-22 comment (lowercase e/a/i + k + p tags). + * This tells you what this comment is directly replying to. + */ +export function getCommentParent(event: NostrEvent): CommentParent | null { + return getOrComputeCachedValue(event, ParentSymbol, () => { + const scope = parseScopeFromTags(event, "e", "a", "i"); + if (!scope) return null; + + const kTag = findTag(event, "k"); + if (!kTag || !kTag[1]) return null; // k is mandatory + + const author = parseAuthorTag(event, "p"); + + return { + scope, + kind: kTag[1], + author, + }; + }); +} + +/** + * Returns true when the comment is a top-level comment on the root item + * (not a reply to another comment). Determined by checking if the parent + * kind is not "1111". + */ +export function isTopLevelComment(event: NostrEvent): boolean { + return getOrComputeCachedValue(event, IsTopLevelSymbol, () => { + const parent = getCommentParent(event); + if (!parent) return true; + return parent.kind !== "1111"; + }); +} + +/** + * Map a NIP-73 external identifier type (K/k value) to an appropriate icon. + */ +export function getExternalIdentifierIcon(kValue: string): LucideIcon { + if (kValue === "web") return Globe; + if (kValue.startsWith("podcast")) return Podcast; + if (kValue === "isbn") return BookOpen; + if (kValue === "doi") return FileText; + if (kValue === "geo") return MapPin; + if (kValue === "iso3166") return Flag; + if (kValue === "#") return Hash; + if (kValue === "isan") return Film; + // Blockchain types: "bitcoin:tx", "ethereum:1:address", etc. + if (kValue.includes(":tx") || kValue.includes(":address")) return Coins; + return ExternalLink; +} + +/** + * Get a human-friendly label for an external identifier value. + */ +export function getExternalIdentifierLabel( + iValue: string, + kValue?: string, +): string { + // URLs - show truncated + if ( + kValue === "web" || + iValue.startsWith("http://") || + iValue.startsWith("https://") + ) { + try { + const url = new URL(iValue); + const path = url.pathname === "/" ? "" : url.pathname; + return `${url.hostname}${path}`; + } catch { + return iValue; + } + } + + // Podcast types + if (iValue.startsWith("podcast:item:guid:")) return "Podcast Episode"; + if (iValue.startsWith("podcast:publisher:guid:")) return "Podcast Publisher"; + if (iValue.startsWith("podcast:guid:")) return "Podcast Feed"; + + // ISBN + if (iValue.startsWith("isbn:")) return `ISBN ${iValue.slice(5)}`; + + // DOI + if (iValue.startsWith("doi:")) return `DOI ${iValue.slice(4)}`; + + // Geohash + if (kValue === "geo") return `Location ${iValue}`; + + // Country codes + if (kValue === "iso3166") return iValue.toUpperCase(); + + // Hashtag + if (iValue.startsWith("#")) return iValue; + + // Blockchain + if (iValue.includes(":tx:")) + return `Transaction ${iValue.split(":tx:")[1]?.slice(0, 12)}...`; + if (iValue.includes(":address:")) + return `Address ${iValue.split(":address:")[1]?.slice(0, 12)}...`; + + return iValue; +}