diff --git a/src/components/nostr/kinds/TrustedAssertionDetailRenderer.tsx b/src/components/nostr/kinds/TrustedAssertionDetailRenderer.tsx new file mode 100644 index 0000000..490afb1 --- /dev/null +++ b/src/components/nostr/kinds/TrustedAssertionDetailRenderer.tsx @@ -0,0 +1,347 @@ +import { NostrEvent } from "@/types/nostr"; +import { UserName } from "../UserName"; +import { + getAssertionSubject, + getAssertionTags, + getUserAssertionData, + getEventAssertionData, + getExternalAssertionData, + getExternalAssertionTypes, + ASSERTION_KIND_LABELS, + ASSERTION_TAG_LABELS, +} from "@/lib/nip85-helpers"; +import { formatTimestamp } from "@/hooks/useLocale"; +import { BarChart3, User, FileText, Link, Hash } from "lucide-react"; + +/** + * Rank visualization bar + */ +function RankBar({ rank }: { rank: number }) { + const clamped = Math.min(100, Math.max(0, rank)); + return ( +
+
+
+
+ + {rank}/100 + +
+ ); +} + +/** + * Metric row for detail table + */ +function MetricRow({ + label, + value, +}: { + label: string; + value: string | number; +}) { + return ( +
+ {label} + {value} +
+ ); +} + +/** + * Subject header section based on kind + */ +function SubjectHeader({ kind, subject }: { kind: number; subject: string }) { + const icon = + kind === 30382 ? ( + + ) : kind === 30385 ? ( + + ) : ( + + ); + + return ( +
+ {icon} + {kind === 30382 ? ( + + ) : ( + {subject} + )} +
+ ); +} + +/** + * User assertion metrics (kind 30382) + */ +function UserMetrics({ event }: { event: NostrEvent }) { + const data = getUserAssertionData(event); + + const metrics: { label: string; value: string | number }[] = []; + + if (data.followers !== undefined) + metrics.push({ + label: "Followers", + value: data.followers.toLocaleString(), + }); + if (data.postCount !== undefined) + metrics.push({ label: "Posts", value: data.postCount.toLocaleString() }); + if (data.replyCount !== undefined) + metrics.push({ label: "Replies", value: data.replyCount.toLocaleString() }); + if (data.reactionsCount !== undefined) + metrics.push({ + label: "Reactions", + value: data.reactionsCount.toLocaleString(), + }); + if (data.zapAmountReceived !== undefined) + metrics.push({ + label: "Zaps Received", + value: `${data.zapAmountReceived.toLocaleString()} sats`, + }); + if (data.zapAmountSent !== undefined) + metrics.push({ + label: "Zaps Sent", + value: `${data.zapAmountSent.toLocaleString()} sats`, + }); + if (data.zapCountReceived !== undefined) + metrics.push({ + label: "Zap Count Received", + value: data.zapCountReceived.toLocaleString(), + }); + if (data.zapCountSent !== undefined) + metrics.push({ + label: "Zap Count Sent", + value: data.zapCountSent.toLocaleString(), + }); + if (data.zapAvgAmountDayReceived !== undefined) + metrics.push({ + label: "Avg Zap/Day Received", + value: `${data.zapAvgAmountDayReceived.toLocaleString()} sats`, + }); + if (data.zapAvgAmountDaySent !== undefined) + metrics.push({ + label: "Avg Zap/Day Sent", + value: `${data.zapAvgAmountDaySent.toLocaleString()} sats`, + }); + if (data.reportsReceived !== undefined) + metrics.push({ + label: "Reports Received", + value: data.reportsReceived.toLocaleString(), + }); + if (data.reportsSent !== undefined) + metrics.push({ + label: "Reports Sent", + value: data.reportsSent.toLocaleString(), + }); + if (data.firstCreatedAt !== undefined) + metrics.push({ + label: "First Post", + value: formatTimestamp(data.firstCreatedAt, "long"), + }); + if (data.activeHoursStart !== undefined && data.activeHoursEnd !== undefined) + metrics.push({ + label: "Active Hours (UTC)", + value: `${data.activeHoursStart}:00 - ${data.activeHoursEnd}:00`, + }); + + return ( + <> + {metrics.length > 0 && ( +
+ {metrics.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 }[] = []; + + if (data.commentCount !== undefined) + metrics.push({ + label: "Comments", + value: data.commentCount.toLocaleString(), + }); + if (data.quoteCount !== undefined) + metrics.push({ label: "Quotes", value: data.quoteCount.toLocaleString() }); + if (data.repostCount !== undefined) + metrics.push({ + label: "Reposts", + value: data.repostCount.toLocaleString(), + }); + if (data.reactionCount !== undefined) + metrics.push({ + label: "Reactions", + value: data.reactionCount.toLocaleString(), + }); + if (data.zapCount !== undefined) + metrics.push({ label: "Zap Count", value: data.zapCount.toLocaleString() }); + if (data.zapAmount !== undefined) + metrics.push({ + label: "Zap Amount", + value: `${data.zapAmount.toLocaleString()} sats`, + }); + + if (metrics.length === 0) return null; + + return ( +
+ {metrics.map((m) => ( + + ))} +
+ ); +} + +/** + * External assertion metrics (kind 30385) + */ +function ExternalMetrics({ event }: { event: NostrEvent }) { + const data = getExternalAssertionData(event); + const types = getExternalAssertionTypes(event); + + const metrics: { label: string; value: string | number }[] = []; + + if (data.commentCount !== undefined) + metrics.push({ + label: "Comments", + value: data.commentCount.toLocaleString(), + }); + if (data.reactionCount !== undefined) + metrics.push({ + label: "Reactions", + value: data.reactionCount.toLocaleString(), + }); + + return ( + <> + {types.length > 0 && ( +
+ Type +
+ {types.map((t) => ( + + {t} + + ))} +
+
+ )} + {metrics.length > 0 && ( +
+ {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)); + // Also filter topics since they're shown separately + const unknownTags = tags.filter( + (t) => !knownTags.has(t.name) && t.name !== "t", + ); + + if (unknownTags.length === 0) return null; + + return ( +
+ Other Tags +
+ {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 && ( +
+ Rank + +
+ )} + + {/* 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..d62aa9e --- /dev/null +++ b/src/components/nostr/kinds/TrustedAssertionRenderer.tsx @@ -0,0 +1,198 @@ +import { + BaseEventProps, + BaseEventContainer, + ClickableEventTitle, +} from "./BaseEventRenderer"; +import { UserName } from "../UserName"; +import { + getAssertionSubject, + getAssertionTags, + getUserAssertionData, + getEventAssertionData, + getExternalAssertionData, + ASSERTION_KIND_LABELS, + ASSERTION_TAG_LABELS, +} from "@/lib/nip85-helpers"; +import { BarChart3 } from "lucide-react"; + +/** + * Subject display based on assertion kind + */ +function AssertionSubject({ + kind, + subject, +}: { + kind: number; + subject: string; +}) { + if (kind === 30382) { + // User: show as UserName + return ; + } + + if (kind === 30384) { + // Addressable event: kind:pubkey:d-tag + const parts = subject.split(":"); + if (parts.length >= 3) { + return ( + + {parts[0]}:{parts[1].slice(0, 8)}...:{parts[2] || "*"} + + ); + } + } + + // Event ID (30383) or NIP-73 identifier (30385) + return ( + + {kind === 30383 ? `${subject.slice(0, 16)}...` : subject} + + ); +} + +/** + * Compact metrics preview — shows rank + top metrics + */ +function MetricsPreview({ + event, +}: { + event: { kind: number } & BaseEventProps["event"]; +}) { + const tags = getAssertionTags(event); + const rankTag = tags.find((t) => t.name === "rank"); + + // Get kind-specific summary metrics + let summaryMetrics: { label: string; value: string }[] = []; + + if (event.kind === 30382) { + const data = getUserAssertionData(event); + if (data.followers !== undefined) + summaryMetrics.push({ + label: "Followers", + value: data.followers.toLocaleString(), + }); + if (data.postCount !== undefined) + summaryMetrics.push({ + label: "Posts", + value: data.postCount.toLocaleString(), + }); + if (data.zapAmountReceived !== undefined) + summaryMetrics.push({ + label: "Zaps Recd", + value: `${data.zapAmountReceived.toLocaleString()} sats`, + }); + } else if (event.kind === 30383 || event.kind === 30384) { + const data = getEventAssertionData(event); + if (data.reactionCount !== undefined) + summaryMetrics.push({ + label: "Reactions", + value: data.reactionCount.toLocaleString(), + }); + if (data.commentCount !== undefined) + summaryMetrics.push({ + label: "Comments", + value: data.commentCount.toLocaleString(), + }); + if (data.zapAmount !== undefined) + summaryMetrics.push({ + label: "Zaps", + value: `${data.zapAmount.toLocaleString()} sats`, + }); + } else if (event.kind === 30385) { + const data = getExternalAssertionData(event); + if (data.reactionCount !== undefined) + summaryMetrics.push({ + label: "Reactions", + value: data.reactionCount.toLocaleString(), + }); + if (data.commentCount !== undefined) + summaryMetrics.push({ + label: "Comments", + value: data.commentCount.toLocaleString(), + }); + } + + // Fall back to raw tags if no structured data + if (summaryMetrics.length === 0) { + summaryMetrics = tags + .filter((t) => t.name !== "rank" && t.name !== "t") + .slice(0, 3) + .map((t) => ({ + label: ASSERTION_TAG_LABELS[t.name] || t.name, + value: t.value, + })); + } else { + summaryMetrics = summaryMetrics.slice(0, 3); + } + + return ( +
+ {/* Rank badge */} + {rankTag && ( +
+
+
+
+
+ {rankTag.value}/100 +
+
+ )} + + {/* Summary metrics */} + {summaryMetrics.length > 0 && ( +
+ {summaryMetrics.map((m) => ( + + {m.value}{" "} + {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 ( + +
+
+ + + + {kindLabel} + + +
+ + {/* Subject */} + {subject && ( +
+ 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..8fe1c39 --- /dev/null +++ b/src/components/nostr/kinds/TrustedProviderListDetailRenderer.tsx @@ -0,0 +1,81 @@ +import { NostrEvent } from "@/types/nostr"; +import { UserName } from "../UserName"; +import { + getTrustedProviders, + hasEncryptedProviders, + formatKindTag, +} from "@/lib/nip85-helpers"; +import { Shield, Lock, Radio } from "lucide-react"; + +/** + * Trusted Provider List Detail Renderer (Kind 10040) + * Full table of all public provider entries + */ +export function TrustedProviderListDetailRenderer({ + event, +}: { + event: NostrEvent; +}) { + const providers = getTrustedProviders(event); + const hasEncrypted = hasEncryptedProviders(event); + + return ( +
+ {/* Header */} +
+ +

Trusted Providers

+
+ + {/* Author */} +
+ Declared by: + +
+ + {/* Encrypted notice */} + {hasEncrypted && ( +
+ + + This list contains encrypted provider entries (NIP-44) that cannot + be displayed. + +
+ )} + + {/* Provider table */} + {providers.length > 0 ? ( +
+ {/* Table header */} +
+ Metric + Provider + Relay +
+ + {/* Rows */} + {providers.map((p, i) => ( +
+ + {formatKindTag(p.kindTag)} + + + + + {p.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..a45c7f3 --- /dev/null +++ b/src/components/nostr/kinds/TrustedProviderListRenderer.tsx @@ -0,0 +1,72 @@ +import { + BaseEventProps, + BaseEventContainer, + ClickableEventTitle, +} from "./BaseEventRenderer"; +import { UserName } from "../UserName"; +import { + getTrustedProviders, + hasEncryptedProviders, + formatKindTag, +} from "@/lib/nip85-helpers"; +import { Shield, Lock } from "lucide-react"; + +/** + * Trusted Provider List Renderer — Feed View (Kind 10040) + * Shows the user's declared trusted assertion providers + */ +export function TrustedProviderListRenderer({ event }: BaseEventProps) { + const providers = getTrustedProviders(event); + const hasEncrypted = hasEncryptedProviders(event); + const previewProviders = providers.slice(0, 4); + + return ( + +
+ + + + Trusted Providers + + + + {/* Provider count */} + + {providers.length} public provider{providers.length !== 1 ? "s" : ""} + {hasEncrypted && " + encrypted entries"} + + + {/* Preview of provider entries */} + {previewProviders.length > 0 && ( +
+ {previewProviders.map((p, i) => ( +
+ + {formatKindTag(p.kindTag)} + + - + +
+ ))} + {providers.length > 4 && ( + + +{providers.length - 4} more... + + )} +
+ )} + + {/* Encrypted notice */} + {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/constants/kinds.ts b/src/constants/kinds.ts index 791ef06..e880aab 100644 --- a/src/constants/kinds.ts +++ b/src/constants/kinds.ts @@ -829,6 +829,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 +1227,34 @@ export const EVENT_KINDS: Record = { nip: "53", icon: Video, }, + 30382: { + kind: 30382, + name: "User Assertion", + description: "Trusted Assertion: User", + nip: "85", + icon: BarChart3, + }, + 30383: { + kind: 30383, + name: "Event Assertion", + description: "Trusted Assertion: Event", + nip: "85", + icon: BarChart3, + }, + 30384: { + kind: 30384, + name: "Address Assertion", + description: "Trusted Assertion: Addressable Event", + nip: "85", + icon: BarChart3, + }, + 30385: { + kind: 30385, + name: "External Assertion", + description: "Trusted Assertion: External Identifier", + nip: "85", + icon: BarChart3, + }, 30312: { kind: 30312, name: "Interactive Room", 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; +}