From 4349e437fa9486c2579ae56b61409559f6da5502 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 08:19:28 +0000 Subject: [PATCH] refactor: extract NIP-73 helpers and shared ExternalIdentifierDisplay Move getExternalIdentifierIcon() and getExternalIdentifierLabel() from nip22-helpers.ts into a new nip73-helpers.ts since they are NIP-73 utilities, not NIP-22 specific. Add inferExternalIdentifierType() and getExternalIdentifierHref() helpers. Create shared ExternalIdentifierDisplay components (inline + block variants) that use proper NIP-73 type-specific icons (Globe for web, BookOpen for ISBN, Podcast for podcasts, Film for ISAN, etc.) instead of a generic ExternalLink icon. - Kind 1111 renderer now uses ExternalIdentifierInline for root scope - Kind 30385 assertion renderer uses ExternalIdentifierInline (feed) and ExternalIdentifierBlock (detail) for NIP-73 subjects - nip22-helpers.ts re-exports from nip73-helpers for compatibility https://claude.ai/code/session_01XjwLaShFSVPR5gNA7iUjuB --- .../nostr/ExternalIdentifierDisplay.tsx | 114 +++++++++++++++ .../nostr/kinds/Kind1111Renderer.tsx | 23 +-- .../kinds/TrustedAssertionDetailRenderer.tsx | 25 +++- .../nostr/kinds/TrustedAssertionRenderer.tsx | 28 +++- src/lib/nip22-helpers.ts | 87 +---------- src/lib/nip73-helpers.ts | 138 ++++++++++++++++++ 6 files changed, 305 insertions(+), 110 deletions(-) create mode 100644 src/components/nostr/ExternalIdentifierDisplay.tsx create mode 100644 src/lib/nip73-helpers.ts diff --git a/src/components/nostr/ExternalIdentifierDisplay.tsx b/src/components/nostr/ExternalIdentifierDisplay.tsx new file mode 100644 index 0000000..a7e0125 --- /dev/null +++ b/src/components/nostr/ExternalIdentifierDisplay.tsx @@ -0,0 +1,114 @@ +/** + * Shared UI components for displaying NIP-73 external content identifiers. + * + * Used by: + * - Kind 1111 (NIP-22 Comment) — root scope display + * - Kind 30385 (NIP-85 Trusted Assertion) — external subject display + */ + +import { + getExternalIdentifierIcon, + getExternalIdentifierLabel, + getExternalIdentifierHref, + inferExternalIdentifierType, +} from "@/lib/nip73-helpers"; +import { cn } from "@/lib/utils"; + +/** + * Inline external identifier — icon + label, optionally linked. + * Compact version for feed renderers. + */ +export function ExternalIdentifierInline({ + value, + kType, + hint, + className, +}: { + value: string; + kType?: string; + hint?: string; + className?: string; +}) { + const type = kType || inferExternalIdentifierType(value); + const Icon = getExternalIdentifierIcon(type); + const label = getExternalIdentifierLabel(value, type); + const href = getExternalIdentifierHref(value, hint); + + const content = ( + <> + + {label} + + ); + + const base = cn( + "flex items-center gap-1.5 text-xs overflow-hidden min-w-0", + className, + ); + + if (href) { + return ( + + {content} + + ); + } + + return {content}; +} + +/** + * Block-level external identifier display — icon + label in a card-like container. + * Used in detail renderers. + */ +export function ExternalIdentifierBlock({ + value, + kType, + hint, + className, +}: { + value: string; + kType?: string; + hint?: string; + className?: string; +}) { + const type = kType || inferExternalIdentifierType(value); + const Icon = getExternalIdentifierIcon(type); + const label = getExternalIdentifierLabel(value, type); + const href = getExternalIdentifierHref(value, hint); + + const inner = ( +
+ + {label} +
+ ); + + if (href) { + return ( + + {inner} + + ); + } + + return inner; +} diff --git a/src/components/nostr/kinds/Kind1111Renderer.tsx b/src/components/nostr/kinds/Kind1111Renderer.tsx index 27a2a3e..3fba028 100644 --- a/src/components/nostr/kinds/Kind1111Renderer.tsx +++ b/src/components/nostr/kinds/Kind1111Renderer.tsx @@ -9,7 +9,7 @@ import { } from "applesauce-common/helpers/comment"; import { useNostrEvent } from "@/hooks/useNostrEvent"; import { UserName } from "../UserName"; -import { ExternalLink, Reply } from "lucide-react"; +import { Reply } from "lucide-react"; import { useGrimoire } from "@/core/state"; import { InlineReplySkeleton } from "@/components/ui/skeleton"; import { KindBadge } from "@/components/KindBadge"; @@ -18,10 +18,10 @@ import type { NostrEvent } from "@/types/nostr"; import { getCommentRootScope, isTopLevelComment, - getExternalIdentifierLabel, type CommentRootScope, type CommentScope, } from "@/lib/nip22-helpers"; +import { ExternalIdentifierInline } from "../ExternalIdentifierDisplay"; /** * Convert CommentPointer to pointer format for useNostrEvent @@ -149,21 +149,14 @@ function RootScopeDisplay({ const pointer = scopeToPointer(root.scope); const rootEvent = useNostrEvent(pointer, event); - // External identifier (I-tag) — render as a link + // External identifier (I-tag) — render using shared NIP-73 component 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} - + ); } diff --git a/src/components/nostr/kinds/TrustedAssertionDetailRenderer.tsx b/src/components/nostr/kinds/TrustedAssertionDetailRenderer.tsx index 490afb1..aa4dc77 100644 --- a/src/components/nostr/kinds/TrustedAssertionDetailRenderer.tsx +++ b/src/components/nostr/kinds/TrustedAssertionDetailRenderer.tsx @@ -1,5 +1,6 @@ import { NostrEvent } from "@/types/nostr"; import { UserName } from "../UserName"; +import { ExternalIdentifierBlock } from "../ExternalIdentifierDisplay"; import { getAssertionSubject, getAssertionTags, @@ -11,7 +12,7 @@ import { ASSERTION_TAG_LABELS, } from "@/lib/nip85-helpers"; import { formatTimestamp } from "@/hooks/useLocale"; -import { BarChart3, User, FileText, Link, Hash } from "lucide-react"; +import { BarChart3, User, FileText, Hash } from "lucide-react"; /** * Rank visualization bar @@ -54,12 +55,22 @@ function MetricRow({ /** * Subject header section based on kind */ -function SubjectHeader({ kind, subject }: { kind: number; subject: string }) { +function SubjectHeader({ + event, + subject, +}: { + event: NostrEvent; + subject: string; +}) { + // Kind 30385: NIP-73 external identifier — use shared block component + if (event.kind === 30385) { + const kTypes = getExternalAssertionTypes(event); + return ; + } + const icon = - kind === 30382 ? ( + event.kind === 30382 ? ( - ) : kind === 30385 ? ( - ) : ( ); @@ -67,7 +78,7 @@ function SubjectHeader({ kind, subject }: { kind: number; subject: string }) { return (
{icon} - {kind === 30382 ? ( + {event.kind === 30382 ? ( ) : ( {subject} @@ -323,7 +334,7 @@ export function TrustedAssertionDetailRenderer({
{/* Subject */} - {subject && } + {subject && } {/* Rank */} {rankTag && ( diff --git a/src/components/nostr/kinds/TrustedAssertionRenderer.tsx b/src/components/nostr/kinds/TrustedAssertionRenderer.tsx index d62aa9e..7e07b94 100644 --- a/src/components/nostr/kinds/TrustedAssertionRenderer.tsx +++ b/src/components/nostr/kinds/TrustedAssertionRenderer.tsx @@ -4,12 +4,14 @@ import { ClickableEventTitle, } from "./BaseEventRenderer"; import { UserName } from "../UserName"; +import { ExternalIdentifierInline } from "../ExternalIdentifierDisplay"; import { getAssertionSubject, getAssertionTags, getUserAssertionData, getEventAssertionData, getExternalAssertionData, + getExternalAssertionTypes, ASSERTION_KIND_LABELS, ASSERTION_TAG_LABELS, } from "@/lib/nip85-helpers"; @@ -19,18 +21,30 @@ import { BarChart3 } from "lucide-react"; * Subject display based on assertion kind */ function AssertionSubject({ - kind, + event, subject, }: { - kind: number; + event: BaseEventProps["event"]; subject: string; }) { - if (kind === 30382) { + if (event.kind === 30382) { // User: show as UserName return ; } - if (kind === 30384) { + if (event.kind === 30385) { + // NIP-73 external identifier: use shared component with proper icon + const kTypes = getExternalAssertionTypes(event); + return ( + + ); + } + + if (event.kind === 30384) { // Addressable event: kind:pubkey:d-tag const parts = subject.split(":"); if (parts.length >= 3) { @@ -42,10 +56,10 @@ function AssertionSubject({ } } - // Event ID (30383) or NIP-73 identifier (30385) + // Event ID (30383) or fallback return ( - {kind === 30383 ? `${subject.slice(0, 16)}...` : subject} + {subject.slice(0, 16)}... ); } @@ -186,7 +200,7 @@ export function TrustedAssertionRenderer({ event }: BaseEventProps) { {subject && (
Subject: - +
)} diff --git a/src/lib/nip22-helpers.ts b/src/lib/nip22-helpers.ts index a1dacbc..7e0ad76 100644 --- a/src/lib/nip22-helpers.ts +++ b/src/lib/nip22-helpers.ts @@ -10,19 +10,12 @@ import type { 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"; + +// Re-export NIP-73 helpers for backwards compatibility +export { + getExternalIdentifierIcon, + getExternalIdentifierLabel, +} from "./nip73-helpers"; // --- Types --- @@ -174,71 +167,3 @@ export function isTopLevelComment(event: NostrEvent): boolean { 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; -} diff --git a/src/lib/nip73-helpers.ts b/src/lib/nip73-helpers.ts new file mode 100644 index 0000000..90ded41 --- /dev/null +++ b/src/lib/nip73-helpers.ts @@ -0,0 +1,138 @@ +/** + * NIP-73 External Content IDs + * Utility functions for parsing and displaying NIP-73 external identifiers + * + * External identifiers (i-tags) reference content outside Nostr: + * URLs, books (ISBN), podcasts, movies (ISAN), papers (DOI), geohashes, + * countries (ISO-3166), hashtags, and blockchain transactions/addresses. + * + * Used by: + * - NIP-22 comments (kind 1111) referencing external content + * - NIP-85 trusted assertions (kind 30385) rating external content + */ + +import type { LucideIcon } from "lucide-react"; +import { + Globe, + Podcast, + BookOpen, + FileText, + MapPin, + Hash, + Coins, + Film, + Flag, + ExternalLink, +} from "lucide-react"; + +/** + * 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; +} + +/** + * Infer a NIP-73 k-tag value from an i-tag value when no k-tag is present. + * Useful for contexts where only the identifier is available. + */ +export function inferExternalIdentifierType(iValue: string): string { + if (iValue.startsWith("http://") || iValue.startsWith("https://")) + return "web"; + if (iValue.startsWith("podcast:")) { + if (iValue.startsWith("podcast:item:guid:")) return "podcast:item:guid"; + if (iValue.startsWith("podcast:publisher:guid:")) + return "podcast:publisher:guid"; + return "podcast:guid"; + } + if (iValue.startsWith("isbn:")) return "isbn"; + if (iValue.startsWith("doi:")) return "doi"; + if (iValue.startsWith("geo:")) return "geo"; + if (iValue.startsWith("iso3166:")) return "iso3166"; + if (iValue.startsWith("#")) return "#"; + if (iValue.startsWith("isan:")) return "isan"; + if (iValue.includes(":tx:")) { + const chain = iValue.split(":")[0]; + return `${chain}:tx`; + } + if (iValue.includes(":address:")) { + const chain = iValue.split(":")[0]; + return `${chain}:address`; + } + return "web"; +} + +/** + * Resolve the best href for an external identifier. + * Uses the hint if available, otherwise the raw value if it's a URL. + */ +export function getExternalIdentifierHref( + iValue: string, + hint?: string, +): string | undefined { + if (hint) return hint; + if (iValue.startsWith("http://") || iValue.startsWith("https://")) + return iValue; + return undefined; +}