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; +}