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>