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 && (
)}
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;
+}