From dd6b30b82e3d2caa15f15cbb50f5cef4721040ff Mon Sep 17 00:00:00 2001 From: Alejandro Date: Fri, 20 Feb 2026 09:14:14 +0100 Subject: [PATCH] Add NIP-85 Trusted Assertions support with renderers (#252) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 / 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 --- CLAUDE.md | 6 + .../nostr/ExternalIdentifierDisplay.tsx | 124 +++++ src/components/nostr/UserName.tsx | 10 +- .../nostr/kinds/Kind1111Renderer.tsx | 23 +- .../kinds/TrustedAssertionDetailRenderer.tsx | 472 ++++++++++++++++++ .../nostr/kinds/TrustedAssertionRenderer.tsx | 248 +++++++++ .../TrustedProviderListDetailRenderer.tsx | 79 +++ .../kinds/TrustedProviderListRenderer.tsx | 88 ++++ src/components/nostr/kinds/index.tsx | 14 + src/components/ui/progress.tsx | 11 +- src/constants/kinds.ts | 36 ++ src/hooks/useProfile.ts | 15 +- src/lib/nip22-helpers.ts | 87 +--- src/lib/nip73-helpers.ts | 159 ++++++ src/lib/nip85-helpers.ts | 262 ++++++++++ 15 files changed, 1530 insertions(+), 104 deletions(-) create mode 100644 src/components/nostr/ExternalIdentifierDisplay.tsx create mode 100644 src/components/nostr/kinds/TrustedAssertionDetailRenderer.tsx create mode 100644 src/components/nostr/kinds/TrustedAssertionRenderer.tsx create mode 100644 src/components/nostr/kinds/TrustedProviderListDetailRenderer.tsx create mode 100644 src/components/nostr/kinds/TrustedProviderListRenderer.tsx create mode 100644 src/lib/nip73-helpers.ts create mode 100644 src/lib/nip85-helpers.ts diff --git a/CLAUDE.md b/CLAUDE.md index f5c49f8..cebb891 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -340,6 +340,12 @@ This allows `applyTheme()` to switch themes at runtime. - Example: `formatTimestamp(event.created_at, "long")` instead of manual `toLocaleDateString()` - **File Organization**: By domain (`nostr/`, `ui/`, `services/`, `hooks/`, `lib/`) - **State Logic**: All UI state mutations go through `src/core/logic.ts` pure functions +- **Shared Components** — Use these instead of rolling your own: + - **`UserName`** (`src/components/nostr/UserName.tsx`): Always use for displaying user pubkeys. Shows display name, Grimoire member badge, supporter flame. Clicking opens profile. Accepts optional `relayHints` prop for fetching profiles from specific relays. + - **`RelayLink`** (`src/components/nostr/RelayLink.tsx`): Always use for displaying relay URLs. Shows relay favicon, insecure `ws://` warnings, read/write badges, and opens relay detail window on click. Never render raw relay URL strings. + - **`Label`** (`src/components/ui/label.tsx`): Dotted-border tag/badge for metadata labels (language, kind, status, metric type). Two sizes: `sm` (default) and `md`. Use instead of ad-hoc `` tags for tag-like indicators. + - **`RichText`** (`src/components/nostr/RichText.tsx`): Universal Nostr content renderer. Parses mentions, hashtags, custom emoji, media embeds, and nostr: references. Use for any event body text — never render `event.content` as a raw string. + - **`CustomEmoji`** (`src/components/nostr/CustomEmoji.tsx`): Renders NIP-30 custom emoji images inline. Shows shortcode tooltip, handles load errors gracefully. ## Important Patterns diff --git a/src/components/nostr/ExternalIdentifierDisplay.tsx b/src/components/nostr/ExternalIdentifierDisplay.tsx new file mode 100644 index 0000000..6ce4a39 --- /dev/null +++ b/src/components/nostr/ExternalIdentifierDisplay.tsx @@ -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 = ( + <> + + {label} + + ); + + const base = cn( + "flex items-center gap-1.5 text-xs overflow-hidden min-w-0", + className, + ); + + if (href) { + return ( + + {content} + + ); + } + + return {content}; +} + +/** + * 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 = ( +
+ + + {label} + + {isLink && ( + + )} +
+ ); + + if (isLink) { + return ( + + {inner} + + ); + } + + return inner; +} diff --git a/src/components/nostr/UserName.tsx b/src/components/nostr/UserName.tsx index a3d301e..52bfc4e 100644 --- a/src/components/nostr/UserName.tsx +++ b/src/components/nostr/UserName.tsx @@ -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); diff --git a/src/components/nostr/kinds/Kind1111Renderer.tsx b/src/components/nostr/kinds/Kind1111Renderer.tsx index 27a2a3e..3fba028 100644 --- a/src/components/nostr/kinds/Kind1111Renderer.tsx +++ b/src/components/nostr/kinds/Kind1111Renderer.tsx @@ -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 ( - - - {label} - + ); } diff --git a/src/components/nostr/kinds/TrustedAssertionDetailRenderer.tsx b/src/components/nostr/kinds/TrustedAssertionDetailRenderer.tsx new file mode 100644 index 0000000..1d3c890 --- /dev/null +++ b/src/components/nostr/kinds/TrustedAssertionDetailRenderer.tsx @@ -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 ( +
+ Rank +
+ + + {rank}/100 + +
+
+ ); +} + +/** + * Metric row for detail table + */ +function MetricRow({ + label, + value, + unit, +}: { + label: string; + value: string | number; + unit?: string; +}) { + return ( +
+ {label} + + {value} + {unit && ( + + {unit} + + )} + +
+ ); +} + +/** + * Section header for metric groups + */ +function SectionHeader({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +/** + * 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 ; + } + + // Kind 30382: user pubkey + if (event.kind === 30382) { + return ( +
+ + +
+ ); + } + + // Kind 30384: addressable event (kind:pubkey:d-tag) + if (event.kind === 30384) { + const parts = subject.split(":"); + if (parts.length >= 3) { + return ( +
+ + + Kind {parts[0]} by{" "} + + {parts[2] && ( + / {parts[2]} + )} + +
+ ); + } + } + + // Kind 30383: event ID + return ( +
+ + {subject} +
+ ); +} + +/** + * 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 ( +
+ {activity.length > 0 && ( +
+ Activity + {activity.map((m) => ( + + ))} +
+ )} + + {zaps.length > 0 && ( +
+ Zaps + {zaps.map((m) => ( + + ))} +
+ )} + + {moderation.length > 0 && ( +
+ Moderation + {moderation.map((m) => ( + + ))} +
+ )} + + {data.topics && data.topics.length > 0 && ( +
+ Topics +
+ {data.topics.map((t) => ( + + + {t} + + ))} +
+
+ )} +
+ ); +} + +/** + * 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 ( +
+ Engagement + {metrics.map((m) => ( + + ))} +
+ ); +} + +/** + * 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 && ( +
+ Content Type +
+ {types.map((t) => { + const Icon = getExternalIdentifierIcon(t); + return ( + + + {getExternalTypeLabel(t)} + + ); + })} +
+
+ )} + {metrics.length > 0 && ( +
+ Engagement + {metrics.map((m) => ( + + ))} +
+ )} + + ); +} + +/** + * 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 ( +
+ Other + {unknownTags.map((t, i) => ( + + ))} +
+ ); +} + +/** + * 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 ( +
+ {/* Header */} +
+ +

{kindLabel}

+
+ + {/* Provider */} +
+ Provider: + +
+ + {/* Subject */} + {subject && } + + {/* Rank */} + {rankTag && } + + {/* Kind-specific metrics */} + {event.kind === 30382 && } + {(event.kind === 30383 || event.kind === 30384) && ( + + )} + {event.kind === 30385 && } + + {/* Raw/unknown tags */} + +
+ ); +} diff --git a/src/components/nostr/kinds/TrustedAssertionRenderer.tsx b/src/components/nostr/kinds/TrustedAssertionRenderer.tsx new file mode 100644 index 0000000..c61a8ab --- /dev/null +++ b/src/components/nostr/kinds/TrustedAssertionRenderer.tsx @@ -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 ( +
+ + + {rank} + +
+ ); +} + +/** + * Subject as the visual anchor — rendered as ClickableEventTitle + */ +function SubjectTitle({ + event, + subject, +}: { + event: BaseEventProps["event"]; + subject: string; +}) { + if (event.kind === 30382) { + return ( + + + + ); + } + + if (event.kind === 30385) { + const kTypes = getExternalAssertionTypes(event); + return ( + + + + ); + } + + if (event.kind === 30384) { + const parts = subject.split(":"); + const display = + parts.length >= 3 + ? `${parts[0]}:${parts[1].slice(0, 8)}...:${parts[2] || "*"}` + : subject; + return ( + + {display} + + ); + } + + // Event ID (30383) + return ( + + {subject.slice(0, 16)}... + + ); +} + +/** + * 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 ( +
+ {/* Rank bar */} + {rankTag && } + + {/* Metric type labels */} + {metricLabels.length > 0 && ( +
+ {metricLabels.map((l) => ( + + ))} +
+ )} + + {/* Summary metrics */} + {summaryMetrics.length > 0 && ( +
+ {summaryMetrics.map((m) => ( + + {m.value} + {m.unit && ( + {m.unit} + )}{" "} + {m.label} + + ))} +
+ )} +
+ ); +} + +/** + * 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 ( + +
+ {/* Kind label + subject as title */} +
+ + {subject && } +
+ + {/* Metrics preview */} + +
+
+ ); +} diff --git a/src/components/nostr/kinds/TrustedProviderListDetailRenderer.tsx b/src/components/nostr/kinds/TrustedProviderListDetailRenderer.tsx new file mode 100644 index 0000000..153eea4 --- /dev/null +++ b/src/components/nostr/kinds/TrustedProviderListDetailRenderer.tsx @@ -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 ( +
+ {/* Header */} +
+ +

Trusted Providers

+
+ + {/* Author */} +
+ Declared by: + +
+ + {/* Encrypted notice */} + {hasEncrypted && ( +
+ + + This list contains encrypted provider entries (NIP-44) that cannot + be displayed. + +
+ )} + + {/* Provider cards */} + {providers.length > 0 ? ( +
+ {providers.map((p, i) => ( +
+ {/* Provider name */} + + + {/* Kind tag */} + + + {/* Relay */} + +
+ ))} +
+ ) : ( +
+ No public provider entries found. +
+ )} +
+ ); +} diff --git a/src/components/nostr/kinds/TrustedProviderListRenderer.tsx b/src/components/nostr/kinds/TrustedProviderListRenderer.tsx new file mode 100644 index 0000000..6e4a754 --- /dev/null +++ b/src/components/nostr/kinds/TrustedProviderListRenderer.tsx @@ -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 ( + +
+ + + + Trusted Providers + + + + {/* Compact summary */} +
+ + {hasEncrypted && ( + + )} +
+ + {/* Provider preview */} + {previewProviders.length > 0 && ( +
+ {previewProviders.map((p, i) => ( +
+ + + on + +
+ ))} + {providers.length > 3 && ( + + +{providers.length - 3} more + + )} +
+ )} + + {/* All-encrypted fallback */} + {hasEncrypted && providers.length === 0 && ( +
+ + All provider entries are encrypted +
+ )} +
+
+ ); +} diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx index 6f966d3..2732fc4 100644 --- a/src/components/nostr/kinds/index.tsx +++ b/src/components/nostr/kinds/index.tsx @@ -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> = { 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> = { 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) diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx index 882affb..924c179 100644 --- a/src/components/ui/progress.tsx +++ b/src/components/ui/progress.tsx @@ -5,8 +5,10 @@ import { cn } from "@/lib/utils"; const Progress = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, value, ...props }, ref) => ( + React.ComponentPropsWithoutRef & { + indicatorClassName?: string; + } +>(({ className, value, indicatorClassName, ...props }, ref) => ( diff --git a/src/constants/kinds.ts b/src/constants/kinds.ts index 791ef06..2c6ee1c 100644 --- a/src/constants/kinds.ts +++ b/src/constants/kinds.ts @@ -4,6 +4,7 @@ import { ArrowRight, Award, BarChart3, + ShieldCheck, Bookmark, Calendar, CalendarClock, @@ -829,6 +830,13 @@ export const EVENT_KINDS: Record = { // 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 = { 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", diff --git a/src/hooks/useProfile.ts b/src/hooks/useProfile.ts index 27f02f3..45a7d5c 100644 --- a/src/hooks/useProfile.ts +++ b/src/hooks/useProfile.ts @@ -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(); const abortControllerRef = useRef(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; } diff --git a/src/lib/nip22-helpers.ts b/src/lib/nip22-helpers.ts index a1dacbc..7e0ad76 100644 --- a/src/lib/nip22-helpers.ts +++ b/src/lib/nip22-helpers.ts @@ -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; -} diff --git a/src/lib/nip73-helpers.ts b/src/lib/nip73-helpers.ts new file mode 100644 index 0000000..824bc08 --- /dev/null +++ b/src/lib/nip73-helpers.ts @@ -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; +} diff --git a/src/lib/nip85-helpers.ts b/src/lib/nip85-helpers.ts new file mode 100644 index 0000000..dca6626 --- /dev/null +++ b/src/lib/nip85-helpers.ts @@ -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 = { + 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 = { + 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; +}