feat(kind-1111): show root scope and parent context per NIP-22 (#245)

* 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 <noreply@anthropic.com>
This commit is contained in:
Alejandro
2026-02-12 22:43:09 +01:00
committed by GitHub
parent 7167d64d9c
commit 62ce435043
2 changed files with 408 additions and 65 deletions

View File

@@ -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 (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={`${base} text-muted-foreground underline decoration-dotted hover:text-foreground transition-colors`}
>
{children}
</a>
);
}
if (onClick) {
return (
<div
className={`${base} text-muted-foreground cursor-crosshair hover:text-foreground transition-colors`}
onClick={onClick}
>
{children}
</div>
);
}
return <div className={`${base} text-muted-foreground`}>{children}</div>;
}
/**
* Inline content for a loaded Nostr event: KindBadge + UserName + title preview.
*/
function NostrEventContent({ nostrEvent }: { nostrEvent: NostrEvent }) {
const title = getEventDisplayTitle(nostrEvent, false);
return (
<div
onClick={onClickHandler}
className="flex items-center gap-2 p-1 bg-muted/20 text-xs hover:bg-muted/30 cursor-crosshair rounded transition-colors"
>
<Tooltip>
<TooltipTrigger asChild>
<Icon className="size-3 flex-shrink-0" />
</TooltipTrigger>
<TooltipContent>
<p>{tooltipText}</p>
</TooltipContent>
</Tooltip>
{showKindBadge && <KindBadge kind={parentEvent.kind} variant="compact" />}
<>
<KindBadge
kind={nostrEvent.kind}
variant="compact"
iconClassname="size-3"
/>
<UserName
pubkey={parentEvent.pubkey}
pubkey={nostrEvent.pubkey}
className="text-accent font-semibold flex-shrink-0"
/>
<div className="text-muted-foreground truncate line-clamp-1 min-w-0 flex-1">
{getEventDisplayTitle(parentEvent, false) || (
<RichText
event={parentEvent}
options={{ showMedia: false, showEventEmbeds: false }}
/>
)}
</div>
</div>
<span className="truncate min-w-0">
{title || nostrEvent.content.slice(0, 80)}
</span>
</>
);
}
/**
* 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 (
<ScopeRow href={href}>
<ExternalLink className="size-3 flex-shrink-0" />
<span className="truncate">{label}</span>
</ScopeRow>
);
}
if (!pointer) return null;
// Loading
if (!rootEvent) {
return (
<InlineReplySkeleton
icon={
<KindBadge
kind={parseInt(root.kind, 10) || 0}
variant="compact"
iconClassname="size-3"
/>
}
/>
);
}
return (
<ScopeRow onClick={() => addWindow("open", { pointer })}>
<NostrEventContent nostrEvent={rootEvent} />
</ScopeRow>
);
}
/**
* 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 (
<BaseEventContainer event={event}>
<TooltipProvider>
{/* Show reply event (immediate parent) */}
{replyPointer && !replyEvent && (
<InlineReplySkeleton icon={<Reply className="size-3" />} />
)}
{/* Root scope — what this comment thread is about */}
{root && <RootScopeDisplay root={root} event={event} />}
{replyPointer && replyEvent && (
<ParentEventCard
parentEvent={replyEvent}
icon={Reply}
tooltipText="Replying to"
onClickHandler={handleReplyClick}
{/* Parent reply — only shown for nested comments */}
{!topLevel && replyPointer && !replyEvent && (
<InlineReplySkeleton icon={<Reply className="size-3" />} />
)}
{!topLevel && replyPointer && replyEvent && (
<ScopeRow onClick={handleReplyClick}>
<Reply className="size-3 flex-shrink-0" />
<UserName
pubkey={replyEvent.pubkey}
className="text-accent font-semibold flex-shrink-0"
/>
)}
</TooltipProvider>
<span className="truncate min-w-0">
{getEventDisplayTitle(replyEvent, false) ||
replyEvent.content.slice(0, 80)}
</span>
</ScopeRow>
)}
<RichText event={event} className="text-sm" depth={depth} />
</BaseEventContainer>

244
src/lib/nip22-helpers.ts Normal file
View File

@@ -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;
}