From 62ce435043b6676656a20cf8d750f92fbb3de119 Mon Sep 17 00:00:00 2001 From: Alejandro Date: Thu, 12 Feb 2026 22:43:09 +0100 Subject: [PATCH] feat(kind-1111): show root scope and parent context per NIP-22 (#245) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * fix(kind-1111): unify scope row styling, fix redundant icons and link style - Replace separate NostrEventCard/ExternalScopeCard with children-based ScopeRow so root and reply rows look identical - Remove redundant icons: root events show only KindBadge, replies show only Reply arrow — no double icons - External URLs now use the standard link style (text-accent + dotted underline on hover) matching UrlList, with ExternalLink icon - Children are direct flex items for proper gap spacing https://claude.ai/code/session_01Dxedi6VWdZG8nFpba211oR * fix(kind-1111): match icon sizes and make external URLs clickable - Set KindBadge iconClassname to size-3 so it matches Reply and ExternalLink icons (was size-4 by default) - Use the I-tag value itself as href when it's a URL and no hint is provided — most web I-tags only have the URL in value, not in hint https://claude.ai/code/session_01Dxedi6VWdZG8nFpba211oR * fix(kind-1111): use muted style for external link rows Match the muted-foreground + dotted underline style used by other scope rows instead of accent color. https://claude.ai/code/session_01Dxedi6VWdZG8nFpba211oR --------- Co-authored-by: Claude --- .../nostr/kinds/Kind1111Renderer.tsx | 229 +++++++++++----- src/lib/nip22-helpers.ts | 244 ++++++++++++++++++ 2 files changed, 408 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..27a2a3e 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,22 @@ import { } from "applesauce-common/helpers/comment"; import { useNostrEvent } from "@/hooks/useNostrEvent"; import { UserName } from "../UserName"; -import { Reply } from "lucide-react"; +import { ExternalLink, Reply } from "lucide-react"; import { useGrimoire } from "@/core/state"; import { InlineReplySkeleton } from "@/components/ui/skeleton"; import { KindBadge } from "@/components/KindBadge"; import { getEventDisplayTitle } from "@/lib/event-title"; import type { NostrEvent } from "@/types/nostr"; import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; + getCommentRootScope, + isTopLevelComment, + 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 +51,159 @@ function convertCommentPointer( } /** - * Parent event card component - compact single line + * Convert a CommentScope to a useNostrEvent-compatible pointer. */ -function ParentEventCard({ - parentEvent, - icon: Icon, - tooltipText, - onClickHandler, -}: { - parentEvent: NostrEvent; - icon: typeof Reply; - tooltipText: string; - onClickHandler: () => void; -}) { - // Don't show kind badge for kind 1 (most common, adds clutter) - const showKindBadge = parentEvent.kind !== 1; +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; +} +/** + * Inline scope row — children are direct flex items. + * Renders as a plain div, clickable div, or anchor depending on props. + */ +function ScopeRow({ + children, + onClick, + href, +}: { + children: ReactNode; + onClick?: () => void; + href?: string; +}) { + const base = "flex items-center gap-1.5 text-xs overflow-hidden min-w-0"; + + if (href) { + return ( + + {children} + + ); + } + + if (onClick) { + return ( +
+ {children} +
+ ); + } + + return
{children}
; +} + +/** + * Inline content for a loaded Nostr event: KindBadge + UserName + title preview. + */ +function NostrEventContent({ nostrEvent }: { nostrEvent: NostrEvent }) { + const title = getEventDisplayTitle(nostrEvent, false); return ( -
- - - - - -

{tooltipText}

-
-
- {showKindBadge && } + <> + -
- {getEventDisplayTitle(parentEvent, false) || ( - - )} -
-
+ + {title || nostrEvent.content.slice(0, 80)} + + ); } /** - * Renderer for Kind 1111 - Post (NIP-22) - * Shows immediate parent (reply) only for cleaner display + * 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) — render as a link + if (root.scope.type === "external") { + const { value, hint } = root.scope; + const label = getExternalIdentifierLabel(value, root.kind); + // Use hint if available, otherwise use value directly when it's a URL + const href = + hint || + (value.startsWith("http://") || value.startsWith("https://") + ? value + : undefined); + return ( + + + {label} + + ); + } + + if (!pointer) return null; + + // Loading + if (!rootEvent) { + return ( + + } + /> + ); + } + + return ( + 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 +212,27 @@ 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 && ( - } /> + )} + + {!topLevel && replyPointer && replyEvent && ( + + + - )} - + + {getEventDisplayTitle(replyEvent, false) || + replyEvent.content.slice(0, 80)} + + + )} 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; +}