Add NIP-85 Trusted Assertions support with renderers (#252)

* feat: add NIP-85 Trusted Assertions feed & detail renderers

Add support for NIP-85 Trusted Assertion events (kinds 30382-30385) and
the Trusted Provider Declaration (kind 10040) with kind constants,
helper library, and feed + detail renderers.

- Add kind entries for 10040, 30382, 30383, 30384, 30385 to EVENT_KINDS
- Create src/lib/nip85-helpers.ts with cached helpers for parsing
  assertion data (user, event, address, external) and provider lists
- Create shared TrustedAssertionRenderer for all 4 assertion kinds with
  rank bar, subject display, and compact metrics preview
- Create TrustedAssertionDetailRenderer with full metrics table,
  rank visualization, topics, and raw tag fallback
- Create TrustedProviderListRenderer/DetailRenderer for kind 10040
  with provider table and encrypted entries indicator
- Register all renderers in kinds/index.tsx

https://claude.ai/code/session_01XjwLaShFSVPR5gNA7iUjuB

* 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

* fix: UI polish for NIP-85 renderers

Address review feedback across all NIP-85 components:

Icon: Replace BarChart3 with ShieldCheck for assertion kind constants
(30382-30385) — communicates "verified trust data" vs generic analytics.

Feed renderer: Subject is now the visual anchor (ClickableEventTitle)
with kind label as a small outline Badge above it. Rank bar widened to
w-32, color-coded green/yellow/red by score threshold. Fix "Zaps Recd"
abbreviation to "Zaps In".

Detail renderer: Metrics grouped into Activity, Zaps, Moderation
sections with uppercase section headers. Kind 30384 addresses now
show "Kind X by <UserName> / d-tag" instead of raw hex. ExternalMetrics
type badges now show NIP-73 icons + friendly labels (getExternalTypeLabel)
instead of raw k-tag values like "podcast:item:guid".

Provider list: Feed uses compact Badge for kind:tag + count summary.
Detail uses stacked cards instead of 3-column table for narrow panels.

ExternalIdentifierBlock: Linked blocks now show dotted underline on
label + ExternalLink icon for clear click affordance.

Add getExternalTypeLabel() to nip73-helpers for k-value display names.

https://claude.ai/code/session_01XjwLaShFSVPR5gNA7iUjuB

* fix: use Progress component for rank bars, consistent sats formatting

Replace custom rank bar divs with the existing Progress UI component
(with new indicatorClassName prop for color-coded fills).

Make sat amount displays consistent with zap receipt renderer pattern:
value and "sats" unit are rendered as separate elements — numeric value
in font-medium, unit in smaller muted text — matching how Kind9735
displays amounts across the app.

https://claude.ai/code/session_01XjwLaShFSVPR5gNA7iUjuB

* fix: use RelayLink for relay URLs, remove redundant kindTag, add relay hints

Provider list renderers now use RelayLink instead of raw relay URL
strings — shows favicon, insecure ws:// warning, opens relay detail
on click.

Remove kindTag display from provider cards — it's an internal
protocol detail redundant in the UI context.

Pass relay hints from provider entries to UserName so profiles can
be fetched from the relay the provider actually publishes to.

Add UserName relayHints prop (forwarded to useProfile).

Add RelayLink and UserName to Shared Components section in CLAUDE.md
so they're consistently used across the codebase.

https://claude.ai/code/session_01XjwLaShFSVPR5gNA7iUjuB

* docs: expand shared components section in CLAUDE.md

Add BaseEventContainer, ClickableEventTitle, RichText, QuotedEvent,
and CustomEmoji to the shared components reference. These are the
core building blocks used across all kind renderers — documenting
them prevents re-implementation and ensures consistent patterns.

https://claude.ai/code/session_01XjwLaShFSVPR5gNA7iUjuB

* docs: trim shared components list in CLAUDE.md

Remove BaseEventContainer and QuotedEvent — these are internal
patterns that kind renderer authors already know from context,
not general-purpose components that get misused or forgotten.

https://claude.ai/code/session_01XjwLaShFSVPR5gNA7iUjuB

* feat: show metric type labels in trusted assertion feed view

Add Label components to the assertion feed renderer so you can see at a
glance which metrics an assertion carries (Followers, Posts, Zaps, etc.)
instead of just numeric values. Also swap Badge → Label for the kind
indicator for visual consistency.

Replace hardcoded green/yellow/red rank colors with theme variables
(success/warning/destructive) in both feed and detail renderers so the
rank bar works correctly across all themes.

Add Label to CLAUDE.md shared components list (22 imports across the
codebase).

https://claude.ai/code/session_01XjwLaShFSVPR5gNA7iUjuB

* feat: show provider kind tag in trusted provider list renderers

Add Label with formatKindTag() to both feed and detail views so each
provider row shows what it provides (e.g. "User Assertion: Rank").
Also swap Badge → Label for consistency with the assertion renderers.

https://claude.ai/code/session_01XjwLaShFSVPR5gNA7iUjuB

* fix: stabilize relayHints in useProfile to prevent fetch abort loop

relayHints was used directly in the useEffect dependency array, so
callers passing a new array literal (e.g. [p.relay]) on every render
caused the effect to re-run each cycle — aborting the previous network
fetch before it could complete. The IndexedDB fast-path masked this in
the feed view (profiles already cached), but the detail view showed
raw pubkeys because profiles were never fetched from the network.

Wrap relayHints in a JSON.stringify-based useMemo (same pattern as
useStableArray) so the effect only re-runs when the actual relay
values change.

https://claude.ai/code/session_01XjwLaShFSVPR5gNA7iUjuB

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Alejandro
2026-02-20 09:14:14 +01:00
committed by GitHub
parent 6c2adc01e0
commit dd6b30b82e
15 changed files with 1530 additions and 104 deletions

View File

@@ -0,0 +1,124 @@
/**
* 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 { ExternalLink } from "lucide-react";
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 isLink = !!href;
const inner = (
<div
className={cn(
"flex items-center gap-2 p-3 rounded-md bg-muted/50",
isLink && "group",
className,
)}
>
<Icon className="size-4 text-muted-foreground flex-shrink-0" />
<span
className={cn(
"text-sm break-all flex-1",
isLink &&
"underline decoration-dotted group-hover:text-foreground transition-colors",
)}
>
{label}
</span>
{isLink && (
<ExternalLink className="size-3 text-muted-foreground flex-shrink-0" />
)}
</div>
);
if (isLink) {
return (
<a href={href} target="_blank" rel="noopener noreferrer">
{inner}
</a>
);
}
return inner;
}

View File

@@ -10,6 +10,7 @@ interface UserNameProps {
pubkey: string;
isMention?: boolean;
className?: string;
relayHints?: string[];
}
/**
@@ -25,9 +26,14 @@ interface UserNameProps {
* - Premium supporters (2.1k+ sats/month): Flame badge in their username color
* - Regular supporters: Yellow flame badge (no username color change)
*/
export function UserName({ pubkey, isMention, className }: UserNameProps) {
export function UserName({
pubkey,
isMention,
className,
relayHints,
}: UserNameProps) {
const { addWindow, state } = useGrimoire();
const profile = useProfile(pubkey);
const profile = useProfile(pubkey, relayHints);
const isGrimoire = isGrimoireMember(pubkey);
const { isSupporter, isPremiumSupporter } = useIsSupporter(pubkey);
const displayName = getDisplayName(pubkey, profile);

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

@@ -0,0 +1,472 @@
import { NostrEvent } from "@/types/nostr";
import { UserName } from "../UserName";
import { ExternalIdentifierBlock } from "../ExternalIdentifierDisplay";
import {
getAssertionSubject,
getAssertionTags,
getUserAssertionData,
getEventAssertionData,
getExternalAssertionData,
getExternalAssertionTypes,
ASSERTION_KIND_LABELS,
ASSERTION_TAG_LABELS,
} from "@/lib/nip85-helpers";
import {
getExternalIdentifierIcon,
getExternalTypeLabel,
} from "@/lib/nip73-helpers";
import { formatTimestamp } from "@/hooks/useLocale";
import { ShieldCheck, User, FileText, Hash } from "lucide-react";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";
function rankColor(rank: number) {
if (rank >= 70) return { indicator: "bg-success", text: "text-success" };
if (rank >= 40) return { indicator: "bg-warning", text: "text-warning" };
return { indicator: "bg-destructive", text: "text-destructive" };
}
/**
* Color-coded rank bar with label, using Progress component
*/
function RankBar({ rank }: { rank: number }) {
const clamped = Math.min(100, Math.max(0, rank));
const { indicator, text } = rankColor(clamped);
return (
<div className="flex flex-col gap-1.5">
<span className="text-sm text-muted-foreground">Rank</span>
<div className="flex items-center gap-3">
<Progress
value={clamped}
className="flex-1 bg-muted"
indicatorClassName={indicator}
/>
<span
className={cn(
"text-sm font-semibold tabular-nums w-12 text-right",
text,
)}
>
{rank}/100
</span>
</div>
</div>
);
}
/**
* Metric row for detail table
*/
function MetricRow({
label,
value,
unit,
}: {
label: string;
value: string | number;
unit?: string;
}) {
return (
<div className="flex justify-between items-center py-1.5 border-b border-border/30 last:border-0">
<span className="text-sm text-muted-foreground">{label}</span>
<span className="text-sm font-medium tabular-nums">
{value}
{unit && (
<span className="text-xs font-normal text-muted-foreground ml-1">
{unit}
</span>
)}
</span>
</div>
);
}
/**
* Section header for metric groups
*/
function SectionHeader({ children }: { children: React.ReactNode }) {
return (
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground pt-2 first:pt-0">
{children}
</span>
);
}
/**
* Subject header — clickable for Nostr subjects, rich display for external
*/
function SubjectHeader({
event,
subject,
}: {
event: NostrEvent;
subject: string;
}) {
// Kind 30385: NIP-73 external identifier
if (event.kind === 30385) {
const kTypes = getExternalAssertionTypes(event);
return <ExternalIdentifierBlock value={subject} kType={kTypes[0]} />;
}
// Kind 30382: user pubkey
if (event.kind === 30382) {
return (
<div className="flex items-center gap-2 p-3 rounded-md bg-muted/50">
<User className="size-4 text-muted-foreground" />
<UserName pubkey={subject} className="font-medium" />
</div>
);
}
// Kind 30384: addressable event (kind:pubkey:d-tag)
if (event.kind === 30384) {
const parts = subject.split(":");
if (parts.length >= 3) {
return (
<div className="flex items-center gap-2 p-3 rounded-md bg-muted/50">
<FileText className="size-4 text-muted-foreground" />
<span className="font-mono text-sm">
Kind {parts[0]} by{" "}
<UserName pubkey={parts[1]} className="text-sm inline" />
{parts[2] && (
<span className="text-muted-foreground"> / {parts[2]}</span>
)}
</span>
</div>
);
}
}
// Kind 30383: event ID
return (
<div className="flex items-center gap-2 p-3 rounded-md bg-muted/50">
<FileText className="size-4 text-muted-foreground" />
<span className="font-mono text-sm break-all">{subject}</span>
</div>
);
}
/**
* User assertion metrics (kind 30382) — grouped into sections
*/
function UserMetrics({ event }: { event: NostrEvent }) {
const data = getUserAssertionData(event);
type Metric = { label: string; value: string | number; unit?: string };
// Activity section
const activity: Metric[] = [];
if (data.postCount !== undefined)
activity.push({ label: "Posts", value: data.postCount.toLocaleString() });
if (data.replyCount !== undefined)
activity.push({
label: "Replies",
value: data.replyCount.toLocaleString(),
});
if (data.reactionsCount !== undefined)
activity.push({
label: "Reactions",
value: data.reactionsCount.toLocaleString(),
});
if (data.followers !== undefined)
activity.push({
label: "Followers",
value: data.followers.toLocaleString(),
});
if (data.firstCreatedAt !== undefined)
activity.push({
label: "First Post",
value: formatTimestamp(data.firstCreatedAt, "long"),
});
if (data.activeHoursStart !== undefined && data.activeHoursEnd !== undefined)
activity.push({
label: "Active Hours (UTC)",
value: `${data.activeHoursStart}:00 ${data.activeHoursEnd}:00`,
});
// Zaps section
const zaps: Metric[] = [];
if (data.zapAmountReceived !== undefined)
zaps.push({
label: "Received",
value: data.zapAmountReceived.toLocaleString(),
unit: "sats",
});
if (data.zapAmountSent !== undefined)
zaps.push({
label: "Sent",
value: data.zapAmountSent.toLocaleString(),
unit: "sats",
});
if (data.zapCountReceived !== undefined)
zaps.push({
label: "Count In",
value: data.zapCountReceived.toLocaleString(),
});
if (data.zapCountSent !== undefined)
zaps.push({
label: "Count Out",
value: data.zapCountSent.toLocaleString(),
});
if (data.zapAvgAmountDayReceived !== undefined)
zaps.push({
label: "Avg/Day In",
value: data.zapAvgAmountDayReceived.toLocaleString(),
unit: "sats",
});
if (data.zapAvgAmountDaySent !== undefined)
zaps.push({
label: "Avg/Day Out",
value: data.zapAvgAmountDaySent.toLocaleString(),
unit: "sats",
});
// Moderation section
const moderation: Metric[] = [];
if (data.reportsReceived !== undefined)
moderation.push({
label: "Reports Received",
value: data.reportsReceived.toLocaleString(),
});
if (data.reportsSent !== undefined)
moderation.push({
label: "Reports Sent",
value: data.reportsSent.toLocaleString(),
});
return (
<div className="flex flex-col gap-3">
{activity.length > 0 && (
<div className="flex flex-col">
<SectionHeader>Activity</SectionHeader>
{activity.map((m) => (
<MetricRow
key={m.label}
label={m.label}
value={m.value}
unit={m.unit}
/>
))}
</div>
)}
{zaps.length > 0 && (
<div className="flex flex-col">
<SectionHeader>Zaps</SectionHeader>
{zaps.map((m) => (
<MetricRow
key={m.label}
label={m.label}
value={m.value}
unit={m.unit}
/>
))}
</div>
)}
{moderation.length > 0 && (
<div className="flex flex-col">
<SectionHeader>Moderation</SectionHeader>
{moderation.map((m) => (
<MetricRow
key={m.label}
label={m.label}
value={m.value}
unit={m.unit}
/>
))}
</div>
)}
{data.topics && data.topics.length > 0 && (
<div className="flex flex-col gap-1.5">
<SectionHeader>Topics</SectionHeader>
<div className="flex flex-wrap gap-1.5">
{data.topics.map((t) => (
<span
key={t}
className="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-0.5 text-xs"
>
<Hash className="size-3" />
{t}
</span>
))}
</div>
</div>
)}
</div>
);
}
/**
* Event/address assertion metrics (kind 30383/30384)
*/
function EventMetrics({ event }: { event: NostrEvent }) {
const data = getEventAssertionData(event);
const metrics: { label: string; value: string | number; unit?: string }[] =
[];
if (data.commentCount !== undefined)
metrics.push({
label: "Comments",
value: data.commentCount.toLocaleString(),
});
if (data.quoteCount !== undefined)
metrics.push({ label: "Quotes", value: data.quoteCount.toLocaleString() });
if (data.repostCount !== undefined)
metrics.push({
label: "Reposts",
value: data.repostCount.toLocaleString(),
});
if (data.reactionCount !== undefined)
metrics.push({
label: "Reactions",
value: data.reactionCount.toLocaleString(),
});
if (data.zapCount !== undefined)
metrics.push({ label: "Zap Count", value: data.zapCount.toLocaleString() });
if (data.zapAmount !== undefined)
metrics.push({
label: "Zap Amount",
value: data.zapAmount.toLocaleString(),
unit: "sats",
});
if (metrics.length === 0) return null;
return (
<div className="flex flex-col">
<SectionHeader>Engagement</SectionHeader>
{metrics.map((m) => (
<MetricRow key={m.label} label={m.label} value={m.value} />
))}
</div>
);
}
/**
* External assertion metrics (kind 30385) — with friendly type labels + icons
*/
function ExternalMetrics({ event }: { event: NostrEvent }) {
const data = getExternalAssertionData(event);
const types = getExternalAssertionTypes(event);
const metrics: { label: string; value: string | number; unit?: string }[] =
[];
if (data.commentCount !== undefined)
metrics.push({
label: "Comments",
value: data.commentCount.toLocaleString(),
});
if (data.reactionCount !== undefined)
metrics.push({
label: "Reactions",
value: data.reactionCount.toLocaleString(),
});
return (
<>
{types.length > 0 && (
<div className="flex flex-col gap-1.5">
<SectionHeader>Content Type</SectionHeader>
<div className="flex flex-wrap gap-1.5">
{types.map((t) => {
const Icon = getExternalIdentifierIcon(t);
return (
<span
key={t}
className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2.5 py-0.5 text-xs font-medium"
>
<Icon className="size-3" />
{getExternalTypeLabel(t)}
</span>
);
})}
</div>
</div>
)}
{metrics.length > 0 && (
<div className="flex flex-col">
<SectionHeader>Engagement</SectionHeader>
{metrics.map((m) => (
<MetricRow
key={m.label}
label={m.label}
value={m.value}
unit={m.unit}
/>
))}
</div>
)}
</>
);
}
/**
* Fallback: show any unrecognized tags as raw rows
*/
function RawAssertionTags({ event }: { event: NostrEvent }) {
const tags = getAssertionTags(event);
const knownTags = new Set(Object.keys(ASSERTION_TAG_LABELS));
const unknownTags = tags.filter(
(t) => !knownTags.has(t.name) && t.name !== "t",
);
if (unknownTags.length === 0) return null;
return (
<div className="flex flex-col">
<SectionHeader>Other</SectionHeader>
{unknownTags.map((t, i) => (
<MetricRow key={`${t.name}-${i}`} label={t.name} value={t.value} />
))}
</div>
);
}
/**
* Trusted Assertion Detail Renderer (Kinds 30382-30385)
*/
export function TrustedAssertionDetailRenderer({
event,
}: {
event: NostrEvent;
}) {
const subject = getAssertionSubject(event);
const kindLabel = ASSERTION_KIND_LABELS[event.kind] || "Assertion";
const tags = getAssertionTags(event);
const rankTag = tags.find((t) => t.name === "rank");
return (
<div className="flex flex-col gap-5 p-6 max-w-2xl mx-auto">
{/* Header */}
<div className="flex items-center gap-2">
<ShieldCheck className="size-5 text-muted-foreground" />
<h2 className="text-lg font-semibold">{kindLabel}</h2>
</div>
{/* Provider */}
<div className="flex items-center gap-1.5 text-sm">
<span className="text-muted-foreground">Provider:</span>
<UserName pubkey={event.pubkey} className="font-medium" />
</div>
{/* Subject */}
{subject && <SubjectHeader event={event} subject={subject} />}
{/* Rank */}
{rankTag && <RankBar rank={parseInt(rankTag.value, 10)} />}
{/* Kind-specific metrics */}
{event.kind === 30382 && <UserMetrics event={event} />}
{(event.kind === 30383 || event.kind === 30384) && (
<EventMetrics event={event} />
)}
{event.kind === 30385 && <ExternalMetrics event={event} />}
{/* Raw/unknown tags */}
<RawAssertionTags event={event} />
</div>
);
}

View File

@@ -0,0 +1,248 @@
import {
BaseEventProps,
BaseEventContainer,
ClickableEventTitle,
} from "./BaseEventRenderer";
import { Label } from "@/components/ui/label";
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";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";
function rankColor(rank: number) {
if (rank >= 70) return { indicator: "bg-success", text: "text-success" };
if (rank >= 40) return { indicator: "bg-warning", text: "text-warning" };
return { indicator: "bg-destructive", text: "text-destructive" };
}
/**
* Color-coded rank bar using Progress component
*/
function RankBar({ rank }: { rank: number }) {
const clamped = Math.min(100, Math.max(0, rank));
const { indicator, text } = rankColor(clamped);
return (
<div className="flex items-center gap-1.5">
<Progress
value={clamped}
className="w-32 bg-muted"
indicatorClassName={indicator}
/>
<span className={cn("text-xs font-semibold tabular-nums", text)}>
{rank}
</span>
</div>
);
}
/**
* Subject as the visual anchor — rendered as ClickableEventTitle
*/
function SubjectTitle({
event,
subject,
}: {
event: BaseEventProps["event"];
subject: string;
}) {
if (event.kind === 30382) {
return (
<ClickableEventTitle
event={event}
className="text-base font-semibold text-foreground"
>
<UserName pubkey={subject} className="text-base font-semibold" />
</ClickableEventTitle>
);
}
if (event.kind === 30385) {
const kTypes = getExternalAssertionTypes(event);
return (
<ClickableEventTitle
event={event}
className="text-base font-semibold text-foreground"
>
<ExternalIdentifierInline
value={subject}
kType={kTypes[0]}
className="text-sm font-medium"
/>
</ClickableEventTitle>
);
}
if (event.kind === 30384) {
const parts = subject.split(":");
const display =
parts.length >= 3
? `${parts[0]}:${parts[1].slice(0, 8)}...:${parts[2] || "*"}`
: subject;
return (
<ClickableEventTitle
event={event}
className="text-sm font-semibold font-mono text-foreground"
>
{display}
</ClickableEventTitle>
);
}
// Event ID (30383)
return (
<ClickableEventTitle
event={event}
className="text-sm font-semibold font-mono text-foreground"
>
{subject.slice(0, 16)}...
</ClickableEventTitle>
);
}
/**
* Compact metrics preview — shows rank + top metrics
*/
function MetricsPreview({
event,
}: {
event: { kind: number } & BaseEventProps["event"];
}) {
const tags = getAssertionTags(event);
const rankTag = tags.find((t) => t.name === "rank");
// Collect metric type labels for the tag row
const metricLabels = tags
.filter((t) => t.name !== "rank" && t.name !== "t")
.map((t) => ASSERTION_TAG_LABELS[t.name] || t.name);
let summaryMetrics: { label: string; value: string; unit?: string }[] = [];
if (event.kind === 30382) {
const data = getUserAssertionData(event);
if (data.followers !== undefined)
summaryMetrics.push({
label: "Followers",
value: data.followers.toLocaleString(),
});
if (data.postCount !== undefined)
summaryMetrics.push({
label: "Posts",
value: data.postCount.toLocaleString(),
});
if (data.zapAmountReceived !== undefined)
summaryMetrics.push({
label: "Zaps In",
value: data.zapAmountReceived.toLocaleString(),
unit: "sats",
});
} else if (event.kind === 30383 || event.kind === 30384) {
const data = getEventAssertionData(event);
if (data.reactionCount !== undefined)
summaryMetrics.push({
label: "Reactions",
value: data.reactionCount.toLocaleString(),
});
if (data.commentCount !== undefined)
summaryMetrics.push({
label: "Comments",
value: data.commentCount.toLocaleString(),
});
if (data.zapAmount !== undefined)
summaryMetrics.push({
label: "Zaps",
value: data.zapAmount.toLocaleString(),
unit: "sats",
});
} else if (event.kind === 30385) {
const data = getExternalAssertionData(event);
if (data.reactionCount !== undefined)
summaryMetrics.push({
label: "Reactions",
value: data.reactionCount.toLocaleString(),
});
if (data.commentCount !== undefined)
summaryMetrics.push({
label: "Comments",
value: data.commentCount.toLocaleString(),
});
}
// Fall back to raw tags if no structured data
if (summaryMetrics.length === 0) {
summaryMetrics = tags
.filter((t) => t.name !== "rank" && t.name !== "t")
.slice(0, 3)
.map((t) => ({
label: ASSERTION_TAG_LABELS[t.name] || t.name,
value: t.value,
}));
} else {
summaryMetrics = summaryMetrics.slice(0, 3);
}
return (
<div className="flex flex-col gap-1.5">
{/* Rank bar */}
{rankTag && <RankBar rank={parseInt(rankTag.value, 10)} />}
{/* Metric type labels */}
{metricLabels.length > 0 && (
<div className="flex flex-wrap gap-1">
{metricLabels.map((l) => (
<Label key={l}>{l}</Label>
))}
</div>
)}
{/* Summary metrics */}
{summaryMetrics.length > 0 && (
<div className="flex flex-wrap gap-x-4 gap-y-1">
{summaryMetrics.map((m) => (
<span key={m.label} className="text-xs text-muted-foreground">
<span className="font-medium text-foreground">{m.value}</span>
{m.unit && (
<span className="text-muted-foreground"> {m.unit}</span>
)}{" "}
{m.label}
</span>
))}
</div>
)}
</div>
);
}
/**
* Trusted Assertion Renderer — Feed View (Kinds 30382-30385)
* Shared renderer for all four NIP-85 assertion event kinds
*/
export function TrustedAssertionRenderer({ event }: BaseEventProps) {
const subject = getAssertionSubject(event);
const kindLabel = ASSERTION_KIND_LABELS[event.kind] || "Assertion";
return (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
{/* Kind label + subject as title */}
<div className="flex flex-col gap-1">
<Label className="w-fit">{kindLabel}</Label>
{subject && <SubjectTitle event={event} subject={subject} />}
</div>
{/* Metrics preview */}
<MetricsPreview event={event} />
</div>
</BaseEventContainer>
);
}

View File

@@ -0,0 +1,79 @@
import { NostrEvent } from "@/types/nostr";
import { UserName } from "../UserName";
import { RelayLink } from "../RelayLink";
import {
getTrustedProviders,
hasEncryptedProviders,
formatKindTag,
} from "@/lib/nip85-helpers";
import { Label } from "@/components/ui/label";
import { Shield, Lock } from "lucide-react";
/**
* Trusted Provider List Detail Renderer (Kind 10040)
* Stacked card layout for each provider entry — works at any panel width
*/
export function TrustedProviderListDetailRenderer({
event,
}: {
event: NostrEvent;
}) {
const providers = getTrustedProviders(event);
const hasEncrypted = hasEncryptedProviders(event);
return (
<div className="flex flex-col gap-5 p-6 max-w-2xl mx-auto">
{/* Header */}
<div className="flex items-center gap-2">
<Shield className="size-5 text-muted-foreground" />
<h2 className="text-lg font-semibold">Trusted Providers</h2>
</div>
{/* Author */}
<div className="flex items-center gap-1.5 text-sm">
<span className="text-muted-foreground">Declared by:</span>
<UserName pubkey={event.pubkey} className="font-medium" />
</div>
{/* Encrypted notice */}
{hasEncrypted && (
<div className="flex items-center gap-2 p-3 rounded-md bg-muted/50 text-sm">
<Lock className="size-4 text-muted-foreground" />
<span className="text-muted-foreground">
This list contains encrypted provider entries (NIP-44) that cannot
be displayed.
</span>
</div>
)}
{/* Provider cards */}
{providers.length > 0 ? (
<div className="flex flex-col gap-2">
{providers.map((p, i) => (
<div
key={`${p.servicePubkey}-${i}`}
className="flex flex-col gap-2 p-3 rounded-md border border-border/50 bg-muted/30"
>
{/* Provider name */}
<UserName
pubkey={p.servicePubkey}
relayHints={[p.relay]}
className="text-sm font-medium"
/>
{/* Kind tag */}
<Label className="w-fit">{formatKindTag(p.kindTag)}</Label>
{/* Relay */}
<RelayLink url={p.relay} showInboxOutbox={false} />
</div>
))}
</div>
) : (
<div className="text-sm text-muted-foreground italic">
No public provider entries found.
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,88 @@
import {
BaseEventProps,
BaseEventContainer,
ClickableEventTitle,
} from "./BaseEventRenderer";
import { Label } from "@/components/ui/label";
import { UserName } from "../UserName";
import { RelayLink } from "../RelayLink";
import {
getTrustedProviders,
hasEncryptedProviders,
formatKindTag,
} from "@/lib/nip85-helpers";
import { Shield, Lock } from "lucide-react";
/**
* Trusted Provider List Renderer — Feed View (Kind 10040)
* Shows the user's declared trusted assertion providers
*/
export function TrustedProviderListRenderer({ event }: BaseEventProps) {
const providers = getTrustedProviders(event);
const hasEncrypted = hasEncryptedProviders(event);
const previewProviders = providers.slice(0, 3);
return (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
<ClickableEventTitle event={event} className="text-base font-semibold">
<span className="flex items-center gap-1.5">
<Shield className="size-4 text-muted-foreground" />
Trusted Providers
</span>
</ClickableEventTitle>
{/* Compact summary */}
<div className="flex flex-wrap items-center gap-1.5">
<Label>
{providers.length} provider{providers.length !== 1 ? "s" : ""}
</Label>
{hasEncrypted && (
<Label className="flex items-center gap-1">
<Lock className="size-3" />
Encrypted
</Label>
)}
</div>
{/* Provider preview */}
{previewProviders.length > 0 && (
<div className="flex flex-col gap-1.5">
{previewProviders.map((p, i) => (
<div
key={`${p.servicePubkey}-${i}`}
className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground"
>
<UserName
pubkey={p.servicePubkey}
relayHints={[p.relay]}
className="text-xs"
/>
<Label>{formatKindTag(p.kindTag)}</Label>
<span className="text-muted-foreground/50">on</span>
<RelayLink
url={p.relay}
showInboxOutbox={false}
className="inline-flex"
/>
</div>
))}
{providers.length > 3 && (
<span className="text-xs text-muted-foreground">
+{providers.length - 3} more
</span>
)}
</div>
)}
{/* All-encrypted fallback */}
{hasEncrypted && providers.length === 0 && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Lock className="size-3" />
<span>All provider entries are encrypted</span>
</div>
)}
</div>
</BaseEventContainer>
);
}

View File

@@ -161,6 +161,10 @@ import { PollDetailRenderer } from "./PollDetailRenderer";
import { PollResponseRenderer } from "./PollResponseRenderer";
import { ReportRenderer, ReportDetailRenderer } from "./ReportRenderer";
import { ThreadRenderer } from "./ThreadRenderer";
import { TrustedAssertionRenderer } from "./TrustedAssertionRenderer";
import { TrustedAssertionDetailRenderer } from "./TrustedAssertionDetailRenderer";
import { TrustedProviderListRenderer } from "./TrustedProviderListRenderer";
import { TrustedProviderListDetailRenderer } from "./TrustedProviderListDetailRenderer";
/**
* Registry of kind-specific renderers
@@ -215,6 +219,7 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
10015: InterestListRenderer, // Interest List (NIP-51)
10020: MediaFollowListRenderer, // Media Follow List (NIP-51)
10030: EmojiListRenderer, // User Emoji List (NIP-51)
10040: TrustedProviderListRenderer, // Trusted Provider List (NIP-85)
10050: GenericRelayListRenderer, // DM Relay List (NIP-51)
10063: BlossomServerListRenderer, // Blossom User Server List (BUD-03)
10101: WikiAuthorsRenderer, // Good Wiki Authors (NIP-51)
@@ -238,6 +243,10 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
30166: RelayDiscoveryRenderer, // Relay Discovery (NIP-66)
30267: ZapstoreAppSetRenderer, // Zapstore App Collection
30311: LiveActivityRenderer, // Live Streaming Event (NIP-53)
30382: TrustedAssertionRenderer, // User Assertion (NIP-85)
30383: TrustedAssertionRenderer, // Event Assertion (NIP-85)
30384: TrustedAssertionRenderer, // Address Assertion (NIP-85)
30385: TrustedAssertionRenderer, // External Assertion (NIP-85)
34235: Kind21Renderer, // Horizontal Video (NIP-71 legacy)
34236: Kind22Renderer, // Vertical Video (NIP-71 legacy)
30617: RepositoryRenderer, // Repository (NIP-34)
@@ -321,6 +330,7 @@ const detailRenderers: Record<
10004: CommunityListDetailRenderer, // Community List Detail (NIP-51)
10005: ChannelListDetailRenderer, // Channel List Detail (NIP-51)
10015: InterestListDetailRenderer, // Interest List Detail (NIP-51)
10040: TrustedProviderListDetailRenderer, // Trusted Provider List Detail (NIP-85)
10020: MediaFollowListDetailRenderer, // Media Follow List Detail (NIP-51)
10030: EmojiListDetailRenderer, // User Emoji List Detail (NIP-51)
10063: BlossomServerListDetailRenderer, // Blossom User Server List Detail (BUD-03)
@@ -344,6 +354,10 @@ const detailRenderers: Record<
30166: RelayDiscoveryDetailRenderer, // Relay Discovery Detail (NIP-66)
30267: ZapstoreAppSetDetailRenderer, // Zapstore App Collection Detail
30311: LiveActivityDetailRenderer, // Live Streaming Event Detail (NIP-53)
30382: TrustedAssertionDetailRenderer, // User Assertion Detail (NIP-85)
30383: TrustedAssertionDetailRenderer, // Event Assertion Detail (NIP-85)
30384: TrustedAssertionDetailRenderer, // Address Assertion Detail (NIP-85)
30385: TrustedAssertionDetailRenderer, // External Assertion Detail (NIP-85)
30617: RepositoryDetailRenderer, // Repository Detail (NIP-34)
30618: RepositoryStateDetailRenderer, // Repository State Detail (NIP-34)
30777: SpellbookDetailRenderer, // Spellbook Detail (Grimoire)

View File

@@ -5,8 +5,10 @@ import { cn } from "@/lib/utils";
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> & {
indicatorClassName?: string;
}
>(({ className, value, indicatorClassName, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
@@ -16,7 +18,10 @@ const Progress = React.forwardRef<
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
className={cn(
"h-full w-full flex-1 bg-primary transition-all",
indicatorClassName,
)}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>

View File

@@ -4,6 +4,7 @@ import {
ArrowRight,
Award,
BarChart3,
ShieldCheck,
Bookmark,
Calendar,
CalendarClock,
@@ -829,6 +830,13 @@ export const EVENT_KINDS: Record<number | string, EventKind> = {
// nip: "Marmot",
// icon: Key,
// },
10040: {
kind: 10040,
name: "Trusted Providers",
description: "Trusted Assertion Provider List",
nip: "85",
icon: Shield,
},
10063: {
kind: 10063,
name: "Blossom Server List",
@@ -1220,6 +1228,34 @@ export const EVENT_KINDS: Record<number | string, EventKind> = {
nip: "53",
icon: Video,
},
30382: {
kind: 30382,
name: "User Assertion",
description: "Trusted Assertion: User",
nip: "85",
icon: ShieldCheck,
},
30383: {
kind: 30383,
name: "Event Assertion",
description: "Trusted Assertion: Event",
nip: "85",
icon: ShieldCheck,
},
30384: {
kind: 30384,
name: "Address Assertion",
description: "Trusted Assertion: Addressable Event",
nip: "85",
icon: ShieldCheck,
},
30385: {
kind: 30385,
name: "External Assertion",
description: "Trusted Assertion: External Identifier",
nip: "85",
icon: ShieldCheck,
},
30312: {
kind: 30312,
name: "Interactive Room",

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useRef, useMemo } from "react";
import { profileLoader } from "@/services/loaders";
import { ProfileContent, getProfileContent } from "applesauce-core/helpers";
import { kinds } from "nostr-tools";
@@ -22,6 +22,14 @@ export function useProfile(
const [profile, setProfile] = useState<ProfileContent | undefined>();
const abortControllerRef = useRef<AbortController | null>(null);
// Stabilize relayHints so callers can pass [p.relay] without causing
// the effect to re-run (and abort in-flight fetches) every render.
const stableRelayHints = useMemo(
() => relayHints,
// eslint-disable-next-line react-hooks/exhaustive-deps
[JSON.stringify(relayHints)],
);
useEffect(() => {
if (!pubkey) {
setProfile(undefined);
@@ -45,7 +53,8 @@ export function useProfile(
const sub = profileLoader({
kind: kinds.Metadata,
pubkey,
...(relayHints && relayHints.length > 0 && { relays: relayHints }),
...(stableRelayHints &&
stableRelayHints.length > 0 && { relays: stableRelayHints }),
}).subscribe({
next: async (fetchedEvent) => {
if (controller.signal.aborted) return;
@@ -85,7 +94,7 @@ export function useProfile(
controller.abort();
sub.unsubscribe();
};
}, [pubkey, relayHints]);
}, [pubkey, stableRelayHints]);
return profile;
}

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

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

@@ -0,0 +1,159 @@
/**
* 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;
}
/**
* Get a human-friendly type label for a NIP-73 k-tag value.
* Maps protocol-level k-values to user-facing names.
*/
export function getExternalTypeLabel(kValue: string): string {
if (kValue === "web") return "Website";
if (kValue === "podcast:item:guid") return "Podcast Episode";
if (kValue === "podcast:publisher:guid") return "Podcast Publisher";
if (kValue === "podcast:guid" || kValue.startsWith("podcast"))
return "Podcast";
if (kValue === "isbn") return "Book";
if (kValue === "doi") return "Paper";
if (kValue === "geo") return "Location";
if (kValue === "iso3166") return "Country";
if (kValue === "#") return "Hashtag";
if (kValue === "isan") return "Film";
if (kValue.includes(":tx")) return "Transaction";
if (kValue.includes(":address")) return "Address";
return kValue;
}

262
src/lib/nip85-helpers.ts Normal file
View File

@@ -0,0 +1,262 @@
import type { NostrEvent } from "@/types/nostr";
import { getOrComputeCachedValue, getTagValue } from "applesauce-core/helpers";
/**
* NIP-85 Helper Functions
* Utility functions for parsing NIP-85 Trusted Assertion events
*
* Kind 30382 - User Assertions (subject: pubkey)
* Kind 30383 - Event Assertions (subject: event_id)
* Kind 30384 - Addressable Event Assertions (subject: event_address)
* Kind 30385 - External Identifier Assertions (subject: NIP-73 i-tag)
* Kind 10040 - Trusted Provider List
*/
// ============================================================================
// Types
// ============================================================================
export interface AssertionTag {
name: string;
value: string;
}
export interface UserAssertionData {
rank?: number;
followers?: number;
firstCreatedAt?: number;
postCount?: number;
replyCount?: number;
reactionsCount?: number;
zapAmountReceived?: number;
zapAmountSent?: number;
zapCountReceived?: number;
zapCountSent?: number;
zapAvgAmountDayReceived?: number;
zapAvgAmountDaySent?: number;
reportsReceived?: number;
reportsSent?: number;
topics?: string[];
activeHoursStart?: number;
activeHoursEnd?: number;
}
export interface EventAssertionData {
rank?: number;
commentCount?: number;
quoteCount?: number;
repostCount?: number;
reactionCount?: number;
zapCount?: number;
zapAmount?: number;
}
export interface ExternalAssertionData {
rank?: number;
commentCount?: number;
reactionCount?: number;
}
export interface TrustedProviderEntry {
kindTag: string;
servicePubkey: string;
relay: string;
}
// ============================================================================
// Human-readable labels for assertion tags
// ============================================================================
export const ASSERTION_TAG_LABELS: Record<string, string> = {
rank: "Rank",
followers: "Followers",
first_created_at: "First Post",
post_cnt: "Posts",
reply_cnt: "Replies",
reactions_cnt: "Reactions",
zap_amt_recd: "Zaps Received (sats)",
zap_amt_sent: "Zaps Sent (sats)",
zap_cnt_recd: "Zaps Received",
zap_cnt_sent: "Zaps Sent",
zap_avg_amt_day_recd: "Avg Zap/Day Received",
zap_avg_amt_day_sent: "Avg Zap/Day Sent",
reports_cnt_recd: "Reports Received",
reports_cnt_sent: "Reports Sent",
active_hours_start: "Active Start (UTC)",
active_hours_end: "Active End (UTC)",
comment_cnt: "Comments",
quote_cnt: "Quotes",
repost_cnt: "Reposts",
reaction_cnt: "Reactions",
zap_cnt: "Zaps",
zap_amount: "Zap Amount (sats)",
};
/** Kind-specific subject type labels */
export const ASSERTION_KIND_LABELS: Record<number, string> = {
30382: "User Assertion",
30383: "Event Assertion",
30384: "Address Assertion",
30385: "External Assertion",
};
// ============================================================================
// Cache symbols
// ============================================================================
const AssertionTagsSymbol = Symbol("assertionTags");
const UserAssertionDataSymbol = Symbol("userAssertionData");
const EventAssertionDataSymbol = Symbol("eventAssertionData");
const ExternalAssertionDataSymbol = Symbol("externalAssertionData");
const TrustedProvidersSymbol = Symbol("trustedProviders");
// Tags that are structural, not result data
const STRUCTURAL_TAGS = new Set(["d", "p", "e", "a", "k"]);
// ============================================================================
// Shared Helpers
// ============================================================================
/**
* Get the subject of the assertion (d tag value)
* - Kind 30382: pubkey
* - Kind 30383: event_id
* - Kind 30384: event_address (kind:pubkey:d-tag)
* - Kind 30385: NIP-73 identifier
*/
export function getAssertionSubject(event: NostrEvent): string | undefined {
return getTagValue(event, "d");
}
/**
* Get all result tags (non-structural tags) as AssertionTag[]
*/
export function getAssertionTags(event: NostrEvent): AssertionTag[] {
return getOrComputeCachedValue(event, AssertionTagsSymbol, () =>
event.tags
.filter((t) => !STRUCTURAL_TAGS.has(t[0]) && t[1] !== undefined)
.map((t) => ({ name: t[0], value: t[1] })),
);
}
// ============================================================================
// Kind 30382: User Assertion Helpers
// ============================================================================
function parseIntTag(event: NostrEvent, tag: string): number | undefined {
const val = getTagValue(event, tag);
if (val === undefined) return undefined;
const n = parseInt(val, 10);
return isNaN(n) ? undefined : n;
}
/**
* Get full parsed user assertion data (cached)
*/
export function getUserAssertionData(event: NostrEvent): UserAssertionData {
return getOrComputeCachedValue(event, UserAssertionDataSymbol, () => {
const topics = event.tags
.filter((t) => t[0] === "t" && t[1])
.map((t) => t[1]);
return {
rank: parseIntTag(event, "rank"),
followers: parseIntTag(event, "followers"),
firstCreatedAt: parseIntTag(event, "first_created_at"),
postCount: parseIntTag(event, "post_cnt"),
replyCount: parseIntTag(event, "reply_cnt"),
reactionsCount: parseIntTag(event, "reactions_cnt"),
zapAmountReceived: parseIntTag(event, "zap_amt_recd"),
zapAmountSent: parseIntTag(event, "zap_amt_sent"),
zapCountReceived: parseIntTag(event, "zap_cnt_recd"),
zapCountSent: parseIntTag(event, "zap_cnt_sent"),
zapAvgAmountDayReceived: parseIntTag(event, "zap_avg_amt_day_recd"),
zapAvgAmountDaySent: parseIntTag(event, "zap_avg_amt_day_sent"),
reportsReceived: parseIntTag(event, "reports_cnt_recd"),
reportsSent: parseIntTag(event, "reports_cnt_sent"),
topics: topics.length > 0 ? topics : undefined,
activeHoursStart: parseIntTag(event, "active_hours_start"),
activeHoursEnd: parseIntTag(event, "active_hours_end"),
};
});
}
// ============================================================================
// Kind 30383 / 30384: Event & Address Assertion Helpers
// ============================================================================
/**
* Get full parsed event/address assertion data (cached)
*/
export function getEventAssertionData(event: NostrEvent): EventAssertionData {
return getOrComputeCachedValue(event, EventAssertionDataSymbol, () => ({
rank: parseIntTag(event, "rank"),
commentCount: parseIntTag(event, "comment_cnt"),
quoteCount: parseIntTag(event, "quote_cnt"),
repostCount: parseIntTag(event, "repost_cnt"),
reactionCount: parseIntTag(event, "reaction_cnt"),
zapCount: parseIntTag(event, "zap_cnt"),
zapAmount: parseIntTag(event, "zap_amount"),
}));
}
// ============================================================================
// Kind 30385: External Assertion Helpers
// ============================================================================
/**
* Get full parsed external assertion data (cached)
*/
export function getExternalAssertionData(
event: NostrEvent,
): ExternalAssertionData {
return getOrComputeCachedValue(event, ExternalAssertionDataSymbol, () => ({
rank: parseIntTag(event, "rank"),
commentCount: parseIntTag(event, "comment_cnt"),
reactionCount: parseIntTag(event, "reaction_cnt"),
}));
}
/**
* Get NIP-73 k tags (type identifiers for external subjects)
*/
export function getExternalAssertionTypes(event: NostrEvent): string[] {
return event.tags.filter((t) => t[0] === "k" && t[1]).map((t) => t[1]);
}
// ============================================================================
// Kind 10040: Trusted Provider List Helpers
// ============================================================================
/**
* Get public trusted provider entries from tags (cached)
*/
export function getTrustedProviders(event: NostrEvent): TrustedProviderEntry[] {
return getOrComputeCachedValue(event, TrustedProvidersSymbol, () =>
event.tags
.filter((t) => t[0].includes(":") && t[1] && t[2])
.map((t) => ({
kindTag: t[0],
servicePubkey: t[1],
relay: t[2],
})),
);
}
/**
* Check if the event has encrypted provider entries
*/
export function hasEncryptedProviders(event: NostrEvent): boolean {
return event.content !== undefined && event.content.trim().length > 0;
}
/**
* Format a kind:tag string for display (e.g., "30382:rank" → "User: Rank")
*/
export function formatKindTag(kindTag: string): string {
const [kindStr, tag] = kindTag.split(":");
const kind = parseInt(kindStr, 10);
const kindLabel = ASSERTION_KIND_LABELS[kind] || `Kind ${kind}`;
const tagLabel = tag ? ASSERTION_TAG_LABELS[tag] || tag : "";
return tagLabel ? `${kindLabel}: ${tagLabel}` : kindLabel;
}