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