mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-05 18:21:28 +02:00
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:
347
src/components/nostr/kinds/TrustedAssertionDetailRenderer.tsx
Normal file
347
src/components/nostr/kinds/TrustedAssertionDetailRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
198
src/components/nostr/kinds/TrustedAssertionRenderer.tsx
Normal file
198
src/components/nostr/kinds/TrustedAssertionRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
72
src/components/nostr/kinds/TrustedProviderListRenderer.tsx
Normal file
72
src/components/nostr/kinds/TrustedProviderListRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
262
src/lib/nip85-helpers.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user