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
This commit is contained in:
Claude
2026-02-19 08:19:28 +00:00
parent 27a1a7ad62
commit 4349e437fa
6 changed files with 305 additions and 110 deletions

View File

@@ -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 = (
<>
<Icon className="size-3 flex-shrink-0" />
<span className="truncate">{label}</span>
</>
);
const base = cn(
"flex items-center gap-1.5 text-xs overflow-hidden min-w-0",
className,
);
if (href) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={cn(
base,
"text-muted-foreground underline decoration-dotted hover:text-foreground transition-colors",
)}
>
{content}
</a>
);
}
return <span className={cn(base, "text-muted-foreground")}>{content}</span>;
}
/**
* 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 = (
<div
className={cn(
"flex items-center gap-2 p-3 rounded-md bg-muted/50",
className,
)}
>
<Icon className="size-4 text-muted-foreground flex-shrink-0" />
<span className="text-sm break-all">{label}</span>
</div>
);
if (href) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="hover:opacity-80 transition-opacity"
>
{inner}
</a>
);
}
return inner;
}

View File

@@ -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 (
<ScopeRow href={href}>
<ExternalLink className="size-3 flex-shrink-0" />
<span className="truncate">{label}</span>
</ScopeRow>
<ExternalIdentifierInline
value={root.scope.value}
kType={root.kind}
hint={root.scope.hint}
/>
);
}

View File

@@ -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 <ExternalIdentifierBlock value={subject} kType={kTypes[0]} />;
}
const icon =
kind === 30382 ? (
event.kind === 30382 ? (
<User className="size-4" />
) : kind === 30385 ? (
<Link className="size-4" />
) : (
<FileText className="size-4" />
);
@@ -67,7 +78,7 @@ function SubjectHeader({ kind, subject }: { kind: number; subject: string }) {
return (
<div className="flex items-center gap-2 p-3 rounded-md bg-muted/50">
<span className="text-muted-foreground">{icon}</span>
{kind === 30382 ? (
{event.kind === 30382 ? (
<UserName pubkey={subject} className="font-medium" />
) : (
<span className="font-mono text-sm break-all">{subject}</span>
@@ -323,7 +334,7 @@ export function TrustedAssertionDetailRenderer({
</div>
{/* Subject */}
{subject && <SubjectHeader kind={event.kind} subject={subject} />}
{subject && <SubjectHeader event={event} subject={subject} />}
{/* Rank */}
{rankTag && (

View File

@@ -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 <UserName pubkey={subject} className="text-sm font-medium" />;
}
if (kind === 30384) {
if (event.kind === 30385) {
// NIP-73 external identifier: use shared component with proper icon
const kTypes = getExternalAssertionTypes(event);
return (
<ExternalIdentifierInline
value={subject}
kType={kTypes[0]}
className="text-sm"
/>
);
}
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 (
<span className="text-sm font-mono text-muted-foreground truncate">
{kind === 30383 ? `${subject.slice(0, 16)}...` : subject}
{subject.slice(0, 16)}...
</span>
);
}
@@ -186,7 +200,7 @@ export function TrustedAssertionRenderer({ event }: BaseEventProps) {
{subject && (
<div className="flex items-center gap-1.5 text-sm">
<span className="text-muted-foreground">Subject:</span>
<AssertionSubject kind={event.kind} subject={subject} />
<AssertionSubject event={event} subject={subject} />
</div>
)}

View File

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

138
src/lib/nip73-helpers.ts Normal file
View File

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