mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-09 15:07:10 +02:00
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:
124
src/components/nostr/ExternalIdentifierDisplay.tsx
Normal file
124
src/components/nostr/ExternalIdentifierDisplay.tsx
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
472
src/components/nostr/kinds/TrustedAssertionDetailRenderer.tsx
Normal file
472
src/components/nostr/kinds/TrustedAssertionDetailRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
248
src/components/nostr/kinds/TrustedAssertionRenderer.tsx
Normal file
248
src/components/nostr/kinds/TrustedAssertionRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
88
src/components/nostr/kinds/TrustedProviderListRenderer.tsx
Normal file
88
src/components/nostr/kinds/TrustedProviderListRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ArrowRight,
|
||||
Award,
|
||||
BarChart3,
|
||||
ShieldCheck,
|
||||
Bookmark,
|
||||
Calendar,
|
||||
CalendarClock,
|
||||
@@ -829,6 +830,13 @@ export const EVENT_KINDS: Record<number | string, EventKind> = {
|
||||
// nip: "Marmot",
|
||||
// icon: Key,
|
||||
// },
|
||||
10040: {
|
||||
kind: 10040,
|
||||
name: "Trusted Providers",
|
||||
description: "Trusted Assertion Provider List",
|
||||
nip: "85",
|
||||
icon: Shield,
|
||||
},
|
||||
10063: {
|
||||
kind: 10063,
|
||||
name: "Blossom Server List",
|
||||
@@ -1220,6 +1228,34 @@ export const EVENT_KINDS: Record<number | string, EventKind> = {
|
||||
nip: "53",
|
||||
icon: Video,
|
||||
},
|
||||
30382: {
|
||||
kind: 30382,
|
||||
name: "User Assertion",
|
||||
description: "Trusted Assertion: User",
|
||||
nip: "85",
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
30383: {
|
||||
kind: 30383,
|
||||
name: "Event Assertion",
|
||||
description: "Trusted Assertion: Event",
|
||||
nip: "85",
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
30384: {
|
||||
kind: 30384,
|
||||
name: "Address Assertion",
|
||||
description: "Trusted Assertion: Addressable Event",
|
||||
nip: "85",
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
30385: {
|
||||
kind: 30385,
|
||||
name: "External Assertion",
|
||||
description: "Trusted Assertion: External Identifier",
|
||||
nip: "85",
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
30312: {
|
||||
kind: 30312,
|
||||
name: "Interactive Room",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { profileLoader } from "@/services/loaders";
|
||||
import { ProfileContent, getProfileContent } from "applesauce-core/helpers";
|
||||
import { kinds } from "nostr-tools";
|
||||
@@ -22,6 +22,14 @@ export function useProfile(
|
||||
const [profile, setProfile] = useState<ProfileContent | undefined>();
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Stabilize relayHints so callers can pass [p.relay] without causing
|
||||
// the effect to re-run (and abort in-flight fetches) every render.
|
||||
const stableRelayHints = useMemo(
|
||||
() => relayHints,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[JSON.stringify(relayHints)],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pubkey) {
|
||||
setProfile(undefined);
|
||||
@@ -45,7 +53,8 @@ export function useProfile(
|
||||
const sub = profileLoader({
|
||||
kind: kinds.Metadata,
|
||||
pubkey,
|
||||
...(relayHints && relayHints.length > 0 && { relays: relayHints }),
|
||||
...(stableRelayHints &&
|
||||
stableRelayHints.length > 0 && { relays: stableRelayHints }),
|
||||
}).subscribe({
|
||||
next: async (fetchedEvent) => {
|
||||
if (controller.signal.aborted) return;
|
||||
@@ -85,7 +94,7 @@ export function useProfile(
|
||||
controller.abort();
|
||||
sub.unsubscribe();
|
||||
};
|
||||
}, [pubkey, relayHints]);
|
||||
}, [pubkey, stableRelayHints]);
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
@@ -10,19 +10,12 @@ import type {
|
||||
AddressPointer,
|
||||
ProfilePointer,
|
||||
} from "nostr-tools/nip19";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
Globe,
|
||||
Podcast,
|
||||
BookOpen,
|
||||
FileText,
|
||||
MapPin,
|
||||
Hash,
|
||||
Coins,
|
||||
Film,
|
||||
Flag,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
|
||||
// Re-export NIP-73 helpers for backwards compatibility
|
||||
export {
|
||||
getExternalIdentifierIcon,
|
||||
getExternalIdentifierLabel,
|
||||
} from "./nip73-helpers";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
@@ -174,71 +167,3 @@ export function isTopLevelComment(event: NostrEvent): boolean {
|
||||
return parent.kind !== "1111";
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a NIP-73 external identifier type (K/k value) to an appropriate icon.
|
||||
*/
|
||||
export function getExternalIdentifierIcon(kValue: string): LucideIcon {
|
||||
if (kValue === "web") return Globe;
|
||||
if (kValue.startsWith("podcast")) return Podcast;
|
||||
if (kValue === "isbn") return BookOpen;
|
||||
if (kValue === "doi") return FileText;
|
||||
if (kValue === "geo") return MapPin;
|
||||
if (kValue === "iso3166") return Flag;
|
||||
if (kValue === "#") return Hash;
|
||||
if (kValue === "isan") return Film;
|
||||
// Blockchain types: "bitcoin:tx", "ethereum:1:address", etc.
|
||||
if (kValue.includes(":tx") || kValue.includes(":address")) return Coins;
|
||||
return ExternalLink;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-friendly label for an external identifier value.
|
||||
*/
|
||||
export function getExternalIdentifierLabel(
|
||||
iValue: string,
|
||||
kValue?: string,
|
||||
): string {
|
||||
// URLs - show truncated
|
||||
if (
|
||||
kValue === "web" ||
|
||||
iValue.startsWith("http://") ||
|
||||
iValue.startsWith("https://")
|
||||
) {
|
||||
try {
|
||||
const url = new URL(iValue);
|
||||
const path = url.pathname === "/" ? "" : url.pathname;
|
||||
return `${url.hostname}${path}`;
|
||||
} catch {
|
||||
return iValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Podcast types
|
||||
if (iValue.startsWith("podcast:item:guid:")) return "Podcast Episode";
|
||||
if (iValue.startsWith("podcast:publisher:guid:")) return "Podcast Publisher";
|
||||
if (iValue.startsWith("podcast:guid:")) return "Podcast Feed";
|
||||
|
||||
// ISBN
|
||||
if (iValue.startsWith("isbn:")) return `ISBN ${iValue.slice(5)}`;
|
||||
|
||||
// DOI
|
||||
if (iValue.startsWith("doi:")) return `DOI ${iValue.slice(4)}`;
|
||||
|
||||
// Geohash
|
||||
if (kValue === "geo") return `Location ${iValue}`;
|
||||
|
||||
// Country codes
|
||||
if (kValue === "iso3166") return iValue.toUpperCase();
|
||||
|
||||
// Hashtag
|
||||
if (iValue.startsWith("#")) return iValue;
|
||||
|
||||
// Blockchain
|
||||
if (iValue.includes(":tx:"))
|
||||
return `Transaction ${iValue.split(":tx:")[1]?.slice(0, 12)}...`;
|
||||
if (iValue.includes(":address:"))
|
||||
return `Address ${iValue.split(":address:")[1]?.slice(0, 12)}...`;
|
||||
|
||||
return iValue;
|
||||
}
|
||||
|
||||
159
src/lib/nip73-helpers.ts
Normal file
159
src/lib/nip73-helpers.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* NIP-73 External Content IDs
|
||||
* Utility functions for parsing and displaying NIP-73 external identifiers
|
||||
*
|
||||
* External identifiers (i-tags) reference content outside Nostr:
|
||||
* URLs, books (ISBN), podcasts, movies (ISAN), papers (DOI), geohashes,
|
||||
* countries (ISO-3166), hashtags, and blockchain transactions/addresses.
|
||||
*
|
||||
* Used by:
|
||||
* - NIP-22 comments (kind 1111) referencing external content
|
||||
* - NIP-85 trusted assertions (kind 30385) rating external content
|
||||
*/
|
||||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
Globe,
|
||||
Podcast,
|
||||
BookOpen,
|
||||
FileText,
|
||||
MapPin,
|
||||
Hash,
|
||||
Coins,
|
||||
Film,
|
||||
Flag,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
|
||||
/**
|
||||
* Map a NIP-73 external identifier type (K/k value) to an appropriate icon.
|
||||
*/
|
||||
export function getExternalIdentifierIcon(kValue: string): LucideIcon {
|
||||
if (kValue === "web") return Globe;
|
||||
if (kValue.startsWith("podcast")) return Podcast;
|
||||
if (kValue === "isbn") return BookOpen;
|
||||
if (kValue === "doi") return FileText;
|
||||
if (kValue === "geo") return MapPin;
|
||||
if (kValue === "iso3166") return Flag;
|
||||
if (kValue === "#") return Hash;
|
||||
if (kValue === "isan") return Film;
|
||||
// Blockchain types: "bitcoin:tx", "ethereum:1:address", etc.
|
||||
if (kValue.includes(":tx") || kValue.includes(":address")) return Coins;
|
||||
return ExternalLink;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-friendly label for an external identifier value.
|
||||
*/
|
||||
export function getExternalIdentifierLabel(
|
||||
iValue: string,
|
||||
kValue?: string,
|
||||
): string {
|
||||
// URLs - show truncated
|
||||
if (
|
||||
kValue === "web" ||
|
||||
iValue.startsWith("http://") ||
|
||||
iValue.startsWith("https://")
|
||||
) {
|
||||
try {
|
||||
const url = new URL(iValue);
|
||||
const path = url.pathname === "/" ? "" : url.pathname;
|
||||
return `${url.hostname}${path}`;
|
||||
} catch {
|
||||
return iValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Podcast types
|
||||
if (iValue.startsWith("podcast:item:guid:")) return "Podcast Episode";
|
||||
if (iValue.startsWith("podcast:publisher:guid:")) return "Podcast Publisher";
|
||||
if (iValue.startsWith("podcast:guid:")) return "Podcast Feed";
|
||||
|
||||
// ISBN
|
||||
if (iValue.startsWith("isbn:")) return `ISBN ${iValue.slice(5)}`;
|
||||
|
||||
// DOI
|
||||
if (iValue.startsWith("doi:")) return `DOI ${iValue.slice(4)}`;
|
||||
|
||||
// Geohash
|
||||
if (kValue === "geo") return `Location ${iValue}`;
|
||||
|
||||
// Country codes
|
||||
if (kValue === "iso3166") return iValue.toUpperCase();
|
||||
|
||||
// Hashtag
|
||||
if (iValue.startsWith("#")) return iValue;
|
||||
|
||||
// Blockchain
|
||||
if (iValue.includes(":tx:"))
|
||||
return `Transaction ${iValue.split(":tx:")[1]?.slice(0, 12)}...`;
|
||||
if (iValue.includes(":address:"))
|
||||
return `Address ${iValue.split(":address:")[1]?.slice(0, 12)}...`;
|
||||
|
||||
return iValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer a NIP-73 k-tag value from an i-tag value when no k-tag is present.
|
||||
* Useful for contexts where only the identifier is available.
|
||||
*/
|
||||
export function inferExternalIdentifierType(iValue: string): string {
|
||||
if (iValue.startsWith("http://") || iValue.startsWith("https://"))
|
||||
return "web";
|
||||
if (iValue.startsWith("podcast:")) {
|
||||
if (iValue.startsWith("podcast:item:guid:")) return "podcast:item:guid";
|
||||
if (iValue.startsWith("podcast:publisher:guid:"))
|
||||
return "podcast:publisher:guid";
|
||||
return "podcast:guid";
|
||||
}
|
||||
if (iValue.startsWith("isbn:")) return "isbn";
|
||||
if (iValue.startsWith("doi:")) return "doi";
|
||||
if (iValue.startsWith("geo:")) return "geo";
|
||||
if (iValue.startsWith("iso3166:")) return "iso3166";
|
||||
if (iValue.startsWith("#")) return "#";
|
||||
if (iValue.startsWith("isan:")) return "isan";
|
||||
if (iValue.includes(":tx:")) {
|
||||
const chain = iValue.split(":")[0];
|
||||
return `${chain}:tx`;
|
||||
}
|
||||
if (iValue.includes(":address:")) {
|
||||
const chain = iValue.split(":")[0];
|
||||
return `${chain}:address`;
|
||||
}
|
||||
return "web";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the best href for an external identifier.
|
||||
* Uses the hint if available, otherwise the raw value if it's a URL.
|
||||
*/
|
||||
export function getExternalIdentifierHref(
|
||||
iValue: string,
|
||||
hint?: string,
|
||||
): string | undefined {
|
||||
if (hint) return hint;
|
||||
if (iValue.startsWith("http://") || iValue.startsWith("https://"))
|
||||
return iValue;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-friendly type label for a NIP-73 k-tag value.
|
||||
* Maps protocol-level k-values to user-facing names.
|
||||
*/
|
||||
export function getExternalTypeLabel(kValue: string): string {
|
||||
if (kValue === "web") return "Website";
|
||||
if (kValue === "podcast:item:guid") return "Podcast Episode";
|
||||
if (kValue === "podcast:publisher:guid") return "Podcast Publisher";
|
||||
if (kValue === "podcast:guid" || kValue.startsWith("podcast"))
|
||||
return "Podcast";
|
||||
if (kValue === "isbn") return "Book";
|
||||
if (kValue === "doi") return "Paper";
|
||||
if (kValue === "geo") return "Location";
|
||||
if (kValue === "iso3166") return "Country";
|
||||
if (kValue === "#") return "Hashtag";
|
||||
if (kValue === "isan") return "Film";
|
||||
if (kValue.includes(":tx")) return "Transaction";
|
||||
if (kValue.includes(":address")) return "Address";
|
||||
return kValue;
|
||||
}
|
||||
262
src/lib/nip85-helpers.ts
Normal file
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