mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-08 22:47:02 +02:00
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:
@@ -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
244
src/lib/nip22-helpers.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user