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
This commit is contained in:
Claude
2026-02-18 19:50:13 +00:00
parent c8fb1b005b
commit 27a1a7ad62
7 changed files with 1009 additions and 0 deletions

View File

@@ -0,0 +1,347 @@
import { NostrEvent } from "@/types/nostr";
import { UserName } from "../UserName";
import {
getAssertionSubject,
getAssertionTags,
getUserAssertionData,
getEventAssertionData,
getExternalAssertionData,
getExternalAssertionTypes,
ASSERTION_KIND_LABELS,
ASSERTION_TAG_LABELS,
} from "@/lib/nip85-helpers";
import { formatTimestamp } from "@/hooks/useLocale";
import { BarChart3, User, FileText, Link, Hash } from "lucide-react";
/**
* Rank visualization bar
*/
function RankBar({ rank }: { rank: number }) {
const clamped = Math.min(100, Math.max(0, rank));
return (
<div className="flex items-center gap-3">
<div className="h-2.5 flex-1 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${clamped}%` }}
/>
</div>
<span className="text-sm font-semibold tabular-nums w-12 text-right">
{rank}/100
</span>
</div>
);
}
/**
* Metric row for detail table
*/
function MetricRow({
label,
value,
}: {
label: string;
value: string | number;
}) {
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}</span>
</div>
);
}
/**
* Subject header section based on kind
*/
function SubjectHeader({ kind, subject }: { kind: number; subject: string }) {
const icon =
kind === 30382 ? (
<User className="size-4" />
) : kind === 30385 ? (
<Link className="size-4" />
) : (
<FileText className="size-4" />
);
return (
<div className="flex items-center gap-2 p-3 rounded-md bg-muted/50">
<span className="text-muted-foreground">{icon}</span>
{kind === 30382 ? (
<UserName pubkey={subject} className="font-medium" />
) : (
<span className="font-mono text-sm break-all">{subject}</span>
)}
</div>
);
}
/**
* User assertion metrics (kind 30382)
*/
function UserMetrics({ event }: { event: NostrEvent }) {
const data = getUserAssertionData(event);
const metrics: { label: string; value: string | number }[] = [];
if (data.followers !== undefined)
metrics.push({
label: "Followers",
value: data.followers.toLocaleString(),
});
if (data.postCount !== undefined)
metrics.push({ label: "Posts", value: data.postCount.toLocaleString() });
if (data.replyCount !== undefined)
metrics.push({ label: "Replies", value: data.replyCount.toLocaleString() });
if (data.reactionsCount !== undefined)
metrics.push({
label: "Reactions",
value: data.reactionsCount.toLocaleString(),
});
if (data.zapAmountReceived !== undefined)
metrics.push({
label: "Zaps Received",
value: `${data.zapAmountReceived.toLocaleString()} sats`,
});
if (data.zapAmountSent !== undefined)
metrics.push({
label: "Zaps Sent",
value: `${data.zapAmountSent.toLocaleString()} sats`,
});
if (data.zapCountReceived !== undefined)
metrics.push({
label: "Zap Count Received",
value: data.zapCountReceived.toLocaleString(),
});
if (data.zapCountSent !== undefined)
metrics.push({
label: "Zap Count Sent",
value: data.zapCountSent.toLocaleString(),
});
if (data.zapAvgAmountDayReceived !== undefined)
metrics.push({
label: "Avg Zap/Day Received",
value: `${data.zapAvgAmountDayReceived.toLocaleString()} sats`,
});
if (data.zapAvgAmountDaySent !== undefined)
metrics.push({
label: "Avg Zap/Day Sent",
value: `${data.zapAvgAmountDaySent.toLocaleString()} sats`,
});
if (data.reportsReceived !== undefined)
metrics.push({
label: "Reports Received",
value: data.reportsReceived.toLocaleString(),
});
if (data.reportsSent !== undefined)
metrics.push({
label: "Reports Sent",
value: data.reportsSent.toLocaleString(),
});
if (data.firstCreatedAt !== undefined)
metrics.push({
label: "First Post",
value: formatTimestamp(data.firstCreatedAt, "long"),
});
if (data.activeHoursStart !== undefined && data.activeHoursEnd !== undefined)
metrics.push({
label: "Active Hours (UTC)",
value: `${data.activeHoursStart}:00 - ${data.activeHoursEnd}:00`,
});
return (
<>
{metrics.length > 0 && (
<div className="flex flex-col">
{metrics.map((m) => (
<MetricRow key={m.label} label={m.label} value={m.value} />
))}
</div>
)}
{data.topics && data.topics.length > 0 && (
<div className="flex flex-col gap-1.5">
<span className="text-sm text-muted-foreground">Topics</span>
<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>
)}
</>
);
}
/**
* Event/address assertion metrics (kind 30383/30384)
*/
function EventMetrics({ event }: { event: NostrEvent }) {
const data = getEventAssertionData(event);
const metrics: { label: string; value: string | number }[] = [];
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()} sats`,
});
if (metrics.length === 0) return null;
return (
<div className="flex flex-col">
{metrics.map((m) => (
<MetricRow key={m.label} label={m.label} value={m.value} />
))}
</div>
);
}
/**
* External assertion metrics (kind 30385)
*/
function ExternalMetrics({ event }: { event: NostrEvent }) {
const data = getExternalAssertionData(event);
const types = getExternalAssertionTypes(event);
const metrics: { label: string; value: string | number }[] = [];
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">
<span className="text-sm text-muted-foreground">Type</span>
<div className="flex flex-wrap gap-1.5">
{types.map((t) => (
<span
key={t}
className="inline-flex items-center rounded-full bg-muted px-2.5 py-0.5 text-xs font-medium"
>
{t}
</span>
))}
</div>
</div>
)}
{metrics.length > 0 && (
<div className="flex flex-col">
{metrics.map((m) => (
<MetricRow key={m.label} label={m.label} value={m.value} />
))}
</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));
// Also filter topics since they're shown separately
const unknownTags = tags.filter(
(t) => !knownTags.has(t.name) && t.name !== "t",
);
if (unknownTags.length === 0) return null;
return (
<div className="flex flex-col gap-1.5">
<span className="text-sm text-muted-foreground">Other Tags</span>
<div className="flex flex-col">
{unknownTags.map((t, i) => (
<MetricRow key={`${t.name}-${i}`} label={t.name} value={t.value} />
))}
</div>
</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">
<BarChart3 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 kind={event.kind} subject={subject} />}
{/* Rank */}
{rankTag && (
<div className="flex flex-col gap-1.5">
<span className="text-sm text-muted-foreground">Rank</span>
<RankBar rank={parseInt(rankTag.value, 10)} />
</div>
)}
{/* 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,198 @@
import {
BaseEventProps,
BaseEventContainer,
ClickableEventTitle,
} from "./BaseEventRenderer";
import { UserName } from "../UserName";
import {
getAssertionSubject,
getAssertionTags,
getUserAssertionData,
getEventAssertionData,
getExternalAssertionData,
ASSERTION_KIND_LABELS,
ASSERTION_TAG_LABELS,
} from "@/lib/nip85-helpers";
import { BarChart3 } from "lucide-react";
/**
* Subject display based on assertion kind
*/
function AssertionSubject({
kind,
subject,
}: {
kind: number;
subject: string;
}) {
if (kind === 30382) {
// User: show as UserName
return <UserName pubkey={subject} className="text-sm font-medium" />;
}
if (kind === 30384) {
// Addressable event: kind:pubkey:d-tag
const parts = subject.split(":");
if (parts.length >= 3) {
return (
<span className="text-sm font-mono text-muted-foreground">
{parts[0]}:{parts[1].slice(0, 8)}...:{parts[2] || "*"}
</span>
);
}
}
// Event ID (30383) or NIP-73 identifier (30385)
return (
<span className="text-sm font-mono text-muted-foreground truncate">
{kind === 30383 ? `${subject.slice(0, 16)}...` : subject}
</span>
);
}
/**
* 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");
// Get kind-specific summary metrics
let summaryMetrics: { label: string; value: 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 Recd",
value: `${data.zapAmountReceived.toLocaleString()} 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()} 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 badge */}
{rankTag && (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<div className="h-1.5 w-20 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all"
style={{
width: `${Math.min(100, Math.max(0, parseInt(rankTag.value, 10)))}%`,
}}
/>
</div>
<span className="text-xs font-medium">{rankTag.value}/100</span>
</div>
</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.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">
<div className="flex items-center gap-2">
<ClickableEventTitle
event={event}
className="text-base font-semibold"
>
<span className="flex items-center gap-1.5">
<BarChart3 className="size-4 text-muted-foreground" />
{kindLabel}
</span>
</ClickableEventTitle>
</div>
{/* Subject */}
{subject && (
<div className="flex items-center gap-1.5 text-sm">
<span className="text-muted-foreground">Subject:</span>
<AssertionSubject kind={event.kind} subject={subject} />
</div>
)}
{/* Metrics preview */}
<MetricsPreview event={event} />
</div>
</BaseEventContainer>
);
}

View File

@@ -0,0 +1,81 @@
import { NostrEvent } from "@/types/nostr";
import { UserName } from "../UserName";
import {
getTrustedProviders,
hasEncryptedProviders,
formatKindTag,
} from "@/lib/nip85-helpers";
import { Shield, Lock, Radio } from "lucide-react";
/**
* Trusted Provider List Detail Renderer (Kind 10040)
* Full table of all public provider entries
*/
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 table */}
{providers.length > 0 ? (
<div className="flex flex-col gap-0.5">
{/* Table header */}
<div className="grid grid-cols-[1fr_1fr_auto] gap-3 pb-2 border-b border-border text-xs text-muted-foreground font-medium">
<span>Metric</span>
<span>Provider</span>
<span>Relay</span>
</div>
{/* Rows */}
{providers.map((p, i) => (
<div
key={`${p.kindTag}-${i}`}
className="grid grid-cols-[1fr_1fr_auto] gap-3 py-2 border-b border-border/30 last:border-0 items-center"
>
<span className="text-sm font-mono">
{formatKindTag(p.kindTag)}
</span>
<UserName pubkey={p.servicePubkey} className="text-sm" />
<span className="flex items-center gap-1 text-xs text-muted-foreground font-mono truncate max-w-48">
<Radio className="size-3 shrink-0" />
{p.relay}
</span>
</div>
))}
</div>
) : (
<div className="text-sm text-muted-foreground italic">
No public provider entries found.
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,72 @@
import {
BaseEventProps,
BaseEventContainer,
ClickableEventTitle,
} from "./BaseEventRenderer";
import { UserName } from "../UserName";
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, 4);
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>
{/* Provider count */}
<span className="text-xs text-muted-foreground">
{providers.length} public provider{providers.length !== 1 ? "s" : ""}
{hasEncrypted && " + encrypted entries"}
</span>
{/* Preview of provider entries */}
{previewProviders.length > 0 && (
<div className="flex flex-col gap-1">
{previewProviders.map((p, i) => (
<div
key={`${p.kindTag}-${i}`}
className="flex items-center gap-2 text-xs"
>
<span className="text-muted-foreground font-mono shrink-0">
{formatKindTag(p.kindTag)}
</span>
<span className="text-muted-foreground">-</span>
<UserName pubkey={p.servicePubkey} className="text-xs" />
</div>
))}
{providers.length > 4 && (
<span className="text-xs text-muted-foreground">
+{providers.length - 4} more...
</span>
)}
</div>
)}
{/* Encrypted notice */}
{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

@@ -829,6 +829,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 +1227,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: BarChart3,
},
30383: {
kind: 30383,
name: "Event Assertion",
description: "Trusted Assertion: Event",
nip: "85",
icon: BarChart3,
},
30384: {
kind: 30384,
name: "Address Assertion",
description: "Trusted Assertion: Addressable Event",
nip: "85",
icon: BarChart3,
},
30385: {
kind: 30385,
name: "External Assertion",
description: "Trusted Assertion: External Identifier",
nip: "85",
icon: BarChart3,
},
30312: {
kind: 30312,
name: "Interactive Room",

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