mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-06 10:41:21 +02:00
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:
114
src/components/nostr/ExternalIdentifierDisplay.tsx
Normal file
114
src/components/nostr/ExternalIdentifierDisplay.tsx
Normal 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;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
138
src/lib/nip73-helpers.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user