From d14c2f3028c3a36b37b460c4f88a17f4c43d48a6 Mon Sep 17 00:00:00 2001 From: Alejandro Date: Mon, 5 Jan 2026 11:42:14 +0100 Subject: [PATCH] feat: implement NIP-89 app definitions and recommendations with rich rendering (#36) * feat: implement NIP-89 app definitions and recommendations with rich rendering Add comprehensive support for NIP-89 Application Handlers (kind 31990) and Handler Recommendations (kind 31989) with rich, interactive visualizations. Core Implementation: - nip89-helpers.ts: Utility functions for extracting NIP-89 event metadata - App name, description, image from kind 31990 content JSON - Supported kinds from k tags - Platform URLs (web, ios, android) from platform tags - Handler references from kind 31989 a tags - URL template substitution for placeholders Feed Renderers: - ApplicationHandlerRenderer (31990): Shows app name, supported kinds as clickable KindBadges (max 8 in feed), and platform badges - HandlerRecommendationRenderer (31989): Shows recommended kind and handler list (max 3 in feed) with platform indicators Detail Renderers: - ApplicationHandlerDetailRenderer (31990): Comprehensive view with app info, all supported kinds in grid layout (clickable), platform URLs with copy buttons, and metadata JSON viewer - HandlerRecommendationDetailRenderer (31989): Full view with platform filtering tabs, expanded handler cards showing app details, and raw reference data Features: - Clickable KindBadges throughout for quick navigation - Platform-aware filtering and display - Fetches referenced kind 31990 events reactively - Copy buttons for URL templates - Platform icons (web, ios, android) - Follows existing Grimoire patterns (SpellRenderer for kinds display, CodeSnippetDetailRenderer for metadata sections) Testing: - Comprehensive test suite for nip89-helpers (50+ test cases) - Tests cover all helper functions with edge cases - Follows existing test patterns from codebase Registry: - Added both kinds (31989, 31990) to kindRenderers and detailRenderers - Automatically expands supported kinds count in KindsViewer * fix: remove unused imports and parameters in NIP-89 renderers * fix: correct AddressPointer import and apply prettier formatting - Change AddressPointer import from applesauce-core/helpers to nostr-tools/nip19 to match codebase conventions - Auto-fix prettier formatting for nip89 files * fix: add defensive type checks to prevent React error 31 - Add type guards in nip89-helpers to ensure string types - Check metadata object structure before accessing properties - Add fallbacks for undefined address.identifier values - Prevents accidentally rendering objects as React children * fix: stringify contentJson for CopyableJsonViewer and support 'about' field - Fix React error 31: CopyableJsonViewer expects string, not object - Add JSON.stringify() with pretty printing for metadata display - Support both 'description' and 'about' fields in content JSON (common in kind 0) - Add tests for 'about' field handling * refactor: simplify NIP-89 detail renderers Remove unnecessary metadata displays: - Remove app image from ApplicationHandlerDetailRenderer - Remove Event ID and Created timestamp from both detail renderers - Remove Raw Metadata section from ApplicationHandlerDetailRenderer - Remove Raw References section from HandlerRecommendationDetailRenderer - Clean up unused imports (getAppImage, CopyableJsonViewer, useMemo, formatAddressPointer) Keeps the UI focused on the essential information: app name, description, supported kinds, and platform URLs. * feat: add website display and filter non-platform tags NIP-89 renderer improvements: - Add getAppWebsite() helper to extract website from content JSON - Display website URL in both feed and detail renderers with external link - Filter out non-platform tags (r, t, client, alt, e, p, a) to prevent garbage display - Remove relay hint display from HandlerRecommendationDetailRenderer - Clean up unused relayHint parameter Fixes the 'r r' tag appearing as a platform by properly excluding common non-platform tags when detecting platform URLs. * refactor: create reusable ExternalLink component for consistent styling Create ExternalLink component following patterns from HighlightRenderer and BookmarkRenderer with: - Two variants: 'muted' (default, text-muted-foreground with underline) and 'default' (text-primary with hover:underline) - Three sizes: xs, sm, base - Configurable icon display - Consistent truncate behavior for long URLs - Stop propagation on click Apply to NIP-89 renderers: - ApplicationHandlerRenderer: uses muted variant (feed view) - ApplicationHandlerDetailRenderer: uses default variant (detail view) This ensures consistent link styling across the entire application and makes it easy to maintain a unified design language. * refactor: consolidate JSON parsing into cached getAppMetadata helper Performance optimization: - Create getAppMetadata() helper that parses content JSON once and caches the result using Symbol.for('nip89-metadata') as cache key - All metadata helpers (getAppName, getAppDescription, getAppWebsite) now use the cached metadata instead of parsing JSON multiple times - Prevents redundant JSON.parse() calls when multiple helpers are used Code cleanup - removed unused functions: - getAppImage() - no longer used after removing image display - getHandlersByPlatform() - filtering done in component state - substituteTemplate() - not needed in current implementation - hasPlaceholder() - utility never used - formatAddressPointer() - not needed anymore Updated tests: - Replace getAppImage tests with getAppWebsite tests - Remove tests for deleted utility functions - All remaining tests pass This consolidation improves performance by ensuring JSON.parse() is called at most once per event, regardless of how many metadata fields are accessed. * feat: use app name in window titles for NIP-89 app events Add special handling for kind 31990 (Application Handler) events in getEventDisplayTitle to use the app name from content JSON instead of generic kind name. Falls back to identifier if app name not available. This gives NIP-89 app handler events nice readable window titles. --------- Co-authored-by: Claude --- src/components/ExternalLink.tsx | 65 +++ .../ApplicationHandlerDetailRenderer.tsx | 183 +++++++++ .../kinds/ApplicationHandlerRenderer.tsx | 105 +++++ .../HandlerRecommendationDetailRenderer.tsx | 263 +++++++++++++ .../kinds/HandlerRecommendationRenderer.tsx | 130 ++++++ src/components/nostr/kinds/index.tsx | 8 + src/lib/event-title.ts | 4 + src/lib/nip89-helpers.test.ts | 370 ++++++++++++++++++ src/lib/nip89-helpers.ts | 257 ++++++++++++ 9 files changed, 1385 insertions(+) create mode 100644 src/components/ExternalLink.tsx create mode 100644 src/components/nostr/kinds/ApplicationHandlerDetailRenderer.tsx create mode 100644 src/components/nostr/kinds/ApplicationHandlerRenderer.tsx create mode 100644 src/components/nostr/kinds/HandlerRecommendationDetailRenderer.tsx create mode 100644 src/components/nostr/kinds/HandlerRecommendationRenderer.tsx create mode 100644 src/lib/nip89-helpers.test.ts create mode 100644 src/lib/nip89-helpers.ts diff --git a/src/components/ExternalLink.tsx b/src/components/ExternalLink.tsx new file mode 100644 index 0000000..16336c2 --- /dev/null +++ b/src/components/ExternalLink.tsx @@ -0,0 +1,65 @@ +import { ExternalLink as ExternalLinkIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface ExternalLinkProps { + href: string; + children: React.ReactNode; + className?: string; + iconClassName?: string; + showIcon?: boolean; + variant?: "default" | "muted"; + size?: "xs" | "sm" | "base"; +} + +/** + * Reusable external link component with consistent styling across the app + * Follows patterns from HighlightRenderer and BookmarkRenderer + */ +export function ExternalLink({ + href, + children, + className, + iconClassName, + showIcon = true, + variant = "muted", + size = "xs", +}: ExternalLinkProps) { + const sizeClasses = { + xs: "text-xs", + sm: "text-sm", + base: "text-base", + }; + + const iconSizeClasses = { + xs: "size-3", + sm: "size-3", + base: "size-4", + }; + + const variantClasses = { + default: "text-primary hover:underline", + muted: "text-muted-foreground underline decoration-dotted", + }; + + return ( + e.stopPropagation()} + > + {showIcon && ( + + )} + {children} + + ); +} diff --git a/src/components/nostr/kinds/ApplicationHandlerDetailRenderer.tsx b/src/components/nostr/kinds/ApplicationHandlerDetailRenderer.tsx new file mode 100644 index 0000000..3099727 --- /dev/null +++ b/src/components/nostr/kinds/ApplicationHandlerDetailRenderer.tsx @@ -0,0 +1,183 @@ +import { NostrEvent } from "@/types/nostr"; +import { + getAppName, + getAppDescription, + getSupportedKinds, + getPlatformUrls, + getHandlerIdentifier, + getAppWebsite, +} from "@/lib/nip89-helpers"; +import { KindBadge } from "@/components/KindBadge"; +import { Badge } from "@/components/ui/badge"; +import { useCopy } from "@/hooks/useCopy"; +import { UserName } from "../UserName"; +import { ExternalLink } from "@/components/ExternalLink"; +import { + Copy, + CopyCheck, + Globe, + Smartphone, + TabletSmartphone, +} from "lucide-react"; + +interface ApplicationHandlerDetailRendererProps { + event: NostrEvent; +} + +/** + * Get icon for platform name + */ +function PlatformIcon({ platform }: { platform: string }) { + const lowerPlatform = platform.toLowerCase(); + + if (lowerPlatform === "web") { + return ; + } + if (lowerPlatform === "ios") { + return ; + } + if (lowerPlatform === "android") { + return ; + } + + // Default for other platforms + return {platform}; +} + +/** + * Copy button for URL templates + */ +function CopyUrlButton({ url }: { url: string }) { + const { copy, copied } = useCopy(); + + return ( + + ); +} + +/** + * Detail renderer for Kind 31990 - Application Handler + * Shows comprehensive metadata including all supported kinds and platform URLs + * Note: NIP-89 helpers wrap getTagValue which caches internally + */ +export function ApplicationHandlerDetailRenderer({ + event, +}: ApplicationHandlerDetailRendererProps) { + const appName = getAppName(event); + const description = getAppDescription(event); + const supportedKinds = getSupportedKinds(event); + const platformUrls = getPlatformUrls(event); + const identifier = getHandlerIdentifier(event); + const website = getAppWebsite(event); + + return ( +
+ {/* Header Section */} +
+ {/* App Name */} +

{appName}

+ + {/* Description */} + {description && ( +

{description}

+ )} + + {/* Website */} + {website && ( + + {website} + + )} + + {/* Metadata Grid */} +
+ {/* Publisher */} +
+

Publisher

+ +
+ + {/* Identifier */} + {identifier && ( +
+

Identifier

+ {identifier} +
+ )} +
+
+ + {/* Supported Kinds Section */} + {supportedKinds.length > 0 && ( +
+

+ Supported Kinds ({supportedKinds.length}) +

+
+ {supportedKinds.map((kind) => ( + + ))} +
+
+ )} + + {/* Platforms & URLs Section */} + {Object.keys(platformUrls).length > 0 && ( +
+

Platforms & URLs

+
+ {Object.entries(platformUrls).map(([platform, url]) => ( +
+ {/* Platform Name */} +
+ + + {platform} + +
+ + {/* URL Template */} +
+ + {url} + + +
+ + {/* Placeholder Help */} + {url.includes("") && ( +

+ The <bech32>{" "} + placeholder will be replaced with the NIP-19 encoded event + (nevent, naddr, note, etc.) +

+ )} +
+ ))} +
+
+ )} +
+ ); +} diff --git a/src/components/nostr/kinds/ApplicationHandlerRenderer.tsx b/src/components/nostr/kinds/ApplicationHandlerRenderer.tsx new file mode 100644 index 0000000..c786dac --- /dev/null +++ b/src/components/nostr/kinds/ApplicationHandlerRenderer.tsx @@ -0,0 +1,105 @@ +import { + BaseEventContainer, + BaseEventProps, + ClickableEventTitle, +} from "./BaseEventRenderer"; +import { + getAppName, + getSupportedKinds, + getAvailablePlatforms, + getAppWebsite, +} from "@/lib/nip89-helpers"; +import { KindBadge } from "@/components/KindBadge"; +import { Badge } from "@/components/ui/badge"; +import { ExternalLink } from "@/components/ExternalLink"; +import { Globe, Smartphone, TabletSmartphone } from "lucide-react"; + +/** + * Get icon for platform name + */ +function PlatformIcon({ platform }: { platform: string }) { + const lowerPlatform = platform.toLowerCase(); + + if (lowerPlatform === "web") { + return ; + } + if (lowerPlatform === "ios") { + return ; + } + if (lowerPlatform === "android") { + return ; + } + + // Default icon for other platforms + return {platform}; +} + +/** + * Renderer for Kind 31990 - Application Handler + * Displays app name, supported kinds, and available platforms + */ +export function ApplicationHandlerRenderer({ event }: BaseEventProps) { + const appName = getAppName(event); + const supportedKinds = getSupportedKinds(event); + const platforms = getAvailablePlatforms(event); + const website = getAppWebsite(event); + + // Show max 8 kinds in feed view + const MAX_KINDS_IN_FEED = 8; + const displayKinds = supportedKinds.slice(0, MAX_KINDS_IN_FEED); + const remainingCount = supportedKinds.length - displayKinds.length; + + return ( + +
+ {/* App Name */} + + {appName} + + + {/* Website */} + {website && {website}} + + {/* Supported Kinds */} + {displayKinds.length > 0 && ( +
+ Handles: + {displayKinds.map((kind) => ( + + ))} + {remainingCount > 0 && ( + + +{remainingCount} more + + )} +
+ )} + + {/* Platforms */} + {platforms.length > 0 && ( +
+ {platforms.map((platform) => ( + + + {platform} + + ))} +
+ )} +
+
+ ); +} diff --git a/src/components/nostr/kinds/HandlerRecommendationDetailRenderer.tsx b/src/components/nostr/kinds/HandlerRecommendationDetailRenderer.tsx new file mode 100644 index 0000000..fd6d673 --- /dev/null +++ b/src/components/nostr/kinds/HandlerRecommendationDetailRenderer.tsx @@ -0,0 +1,263 @@ +import { NostrEvent } from "@/types/nostr"; +import { + getRecommendedKind, + getHandlerReferences, + getRecommendedPlatforms, + getAppName, + getAppDescription, + getSupportedKinds, + getPlatformUrls, +} from "@/lib/nip89-helpers"; +import { KindBadge } from "@/components/KindBadge"; +import { Badge } from "@/components/ui/badge"; +import { useNostrEvent } from "@/hooks/useNostrEvent"; +import { useGrimoire } from "@/core/state"; +import { UserName } from "../UserName"; +import { Globe, Smartphone, TabletSmartphone, Package } from "lucide-react"; +import { useState } from "react"; + +interface HandlerRecommendationDetailRendererProps { + event: NostrEvent; +} + +/** + * Get icon for platform name + */ +function PlatformIcon({ platform }: { platform: string }) { + const lowerPlatform = platform.toLowerCase(); + + if (lowerPlatform === "web") { + return ; + } + if (lowerPlatform === "ios") { + return ; + } + if (lowerPlatform === "android") { + return ; + } + + return {platform}; +} + +/** + * Expanded handler card showing full app details + */ +function HandlerCard({ + address, + platform, +}: { + address: { kind: number; pubkey: string; identifier: string }; + platform?: string; +}) { + const { addWindow } = useGrimoire(); + const handlerEvent = useNostrEvent(address); + + if (!handlerEvent) { + return ( +
+
+ + + Loading {address?.identifier || "handler"}... + +
+
+ ); + } + + const appName = getAppName(handlerEvent); + const description = getAppDescription(handlerEvent); + const supportedKinds = getSupportedKinds(handlerEvent); + const platformUrls = getPlatformUrls(handlerEvent); + + const handleClick = () => { + addWindow("open", { pointer: address }); + }; + + return ( +
+ {/* App Header */} +
+ +
+ + {description && ( +

{description}

+ )} +
+
+ + {/* Supported Kinds Preview */} + {supportedKinds.length > 0 && ( +
+

+ Handles {supportedKinds.length} kind + {supportedKinds.length > 1 ? "s" : ""} +

+
+ {supportedKinds.slice(0, 10).map((kind) => ( + + ))} + {supportedKinds.length > 10 && ( + + +{supportedKinds.length - 10} + + )} +
+
+ )} + + {/* Platform URLs */} + {Object.keys(platformUrls).length > 0 && ( +
+

+ Platforms +

+
+ {Object.entries(platformUrls).map(([plat]) => ( + + + {plat} + + ))} +
+
+ )} + + {/* Recommendation Context */} + {platform && ( +
+
+ Recommended for:{" "} + + {platform} + +
+
+ )} +
+ ); +} + +/** + * Detail renderer for Kind 31989 - Handler Recommendation + * Shows comprehensive view of recommended handlers with platform filtering + */ +export function HandlerRecommendationDetailRenderer({ + event, +}: HandlerRecommendationDetailRendererProps) { + const recommendedKind = getRecommendedKind(event); + const allHandlers = getHandlerReferences(event); + const platforms = getRecommendedPlatforms(event); + + const [selectedPlatform, setSelectedPlatform] = useState(null); + + // Filter handlers by selected platform + const displayHandlers = selectedPlatform + ? allHandlers.filter((h) => h.platform === selectedPlatform) + : allHandlers; + + return ( +
+ {/* Header Section */} +
+

Handler Recommendation

+ + {/* Recommended Kind */} + {recommendedKind !== undefined && ( +
+ For: + +
+ )} + + {/* Recommender */} +
+ Recommended by: + +
+
+ + {/* Platform Filter Tabs */} + {platforms.length > 0 && ( +
+ + {platforms.map((platform) => { + const count = allHandlers.filter( + (h) => h.platform === platform, + ).length; + return ( + + ); + })} +
+ )} + + {/* Handlers Section */} +
+

+ Recommended Handlers ({displayHandlers.length}) +

+ + {displayHandlers.length === 0 ? ( +

+ No handlers found for the selected platform. +

+ ) : ( +
+ {displayHandlers.map((ref, idx) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/src/components/nostr/kinds/HandlerRecommendationRenderer.tsx b/src/components/nostr/kinds/HandlerRecommendationRenderer.tsx new file mode 100644 index 0000000..3c0a6b9 --- /dev/null +++ b/src/components/nostr/kinds/HandlerRecommendationRenderer.tsx @@ -0,0 +1,130 @@ +import { + BaseEventContainer, + BaseEventProps, + ClickableEventTitle, +} from "./BaseEventRenderer"; +import { + getRecommendedKind, + getHandlerReferences, + getAppName, +} from "@/lib/nip89-helpers"; +import { KindBadge } from "@/components/KindBadge"; +import { Badge } from "@/components/ui/badge"; +import { useNostrEvent } from "@/hooks/useNostrEvent"; +import { useGrimoire } from "@/core/state"; +import { Globe, Smartphone, TabletSmartphone, Package } from "lucide-react"; + +/** + * Get icon for platform name + */ +function PlatformIcon({ platform }: { platform: string }) { + const lowerPlatform = platform.toLowerCase(); + + if (lowerPlatform === "web") { + return ; + } + if (lowerPlatform === "ios") { + return ; + } + if (lowerPlatform === "android") { + return ; + } + + return null; +} + +/** + * Individual handler item - fetches and displays handler info + */ +function HandlerItem({ + address, + platform, +}: { + address: { kind: number; pubkey: string; identifier: string }; + platform?: string; + relayHint?: string; +}) { + const { addWindow } = useGrimoire(); + const handlerEvent = useNostrEvent(address); + const appName = handlerEvent + ? getAppName(handlerEvent) + : address?.identifier || "Unknown Handler"; + + const handleClick = () => { + addWindow("open", { pointer: address }); + }; + + return ( +
+ + + {platform && ( + + + {platform} + + )} +
+ ); +} + +/** + * Renderer for Kind 31989 - Handler Recommendation + * Displays which event kind is being recommended and the handlers + */ +export function HandlerRecommendationRenderer({ event }: BaseEventProps) { + const recommendedKind = getRecommendedKind(event); + const handlers = getHandlerReferences(event); + + // Show max 3 handlers in feed view + const MAX_HANDLERS_IN_FEED = 3; + const displayHandlers = handlers.slice(0, MAX_HANDLERS_IN_FEED); + const remainingCount = handlers.length - displayHandlers.length; + + return ( + +
+ {/* Title with recommended kind */} + + Recommends handlers for + {recommendedKind !== undefined && ( + + )} + + + {/* Handler List */} + {displayHandlers.length > 0 && ( +
+ {displayHandlers.map((ref, idx) => ( + + ))} + {remainingCount > 0 && ( + + +{remainingCount} more handler{remainingCount > 1 ? "s" : ""} + + )} +
+ )} +
+
+ ); +} diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx index 989d4de..7409372 100644 --- a/src/components/nostr/kinds/index.tsx +++ b/src/components/nostr/kinds/index.tsx @@ -44,6 +44,10 @@ import { SpellbookRenderer, SpellbookDetailRenderer, } from "./SpellbookRenderer"; +import { ApplicationHandlerRenderer } from "./ApplicationHandlerRenderer"; +import { ApplicationHandlerDetailRenderer } from "./ApplicationHandlerDetailRenderer"; +import { HandlerRecommendationRenderer } from "./HandlerRecommendationRenderer"; +import { HandlerRecommendationDetailRenderer } from "./HandlerRecommendationDetailRenderer"; import { NostrEvent } from "@/types/nostr"; import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer"; @@ -90,6 +94,8 @@ const kindRenderers: Record> = { 30618: RepositoryStateRenderer, // Repository State (NIP-34) 30777: SpellbookRenderer, // Spellbook (Grimoire) 30817: CommunityNIPRenderer, // Community NIP + 31989: HandlerRecommendationRenderer, // Handler Recommendation (NIP-89) + 31990: ApplicationHandlerRenderer, // Application Handler (NIP-89) 39701: Kind39701Renderer, // Web Bookmarks (NIP-B0) }; @@ -148,6 +154,8 @@ const detailRenderers: Record< 30618: RepositoryStateDetailRenderer, // Repository State Detail (NIP-34) 30777: SpellbookDetailRenderer, // Spellbook Detail (Grimoire) 30817: CommunityNIPDetailRenderer, // Community NIP Detail + 31989: HandlerRecommendationDetailRenderer, // Handler Recommendation Detail (NIP-89) + 31990: ApplicationHandlerDetailRenderer, // Application Handler Detail (NIP-89) }; /** diff --git a/src/lib/event-title.ts b/src/lib/event-title.ts index c8824a3..426d3fd 100644 --- a/src/lib/event-title.ts +++ b/src/lib/event-title.ts @@ -9,6 +9,7 @@ import { getPullRequestSubject, } from "@/lib/nip34-helpers"; import { getCodeName } from "@/lib/nip-c0-helpers"; +import { getAppName } from "@/lib/nip89-helpers"; import { getKindInfo } from "@/constants/kinds"; /** @@ -48,6 +49,9 @@ export function getEventDisplayTitle( case 1618: // Pull request title = getPullRequestSubject(event); break; + case 31990: // Application Handler + title = getAppName(event); + break; } if (title) return title; diff --git a/src/lib/nip89-helpers.test.ts b/src/lib/nip89-helpers.test.ts new file mode 100644 index 0000000..eb49b57 --- /dev/null +++ b/src/lib/nip89-helpers.test.ts @@ -0,0 +1,370 @@ +import { describe, it, expect } from "vitest"; +import { + getAppName, + getAppDescription, + getAppWebsite, + getSupportedKinds, + getPlatformUrls, + getAvailablePlatforms, + getHandlerIdentifier, + getRecommendedKind, + parseAddressPointer, + getHandlerReferences, + getRecommendedPlatforms, +} from "./nip89-helpers"; +import { NostrEvent } from "@/types/nostr"; + +// Helper to create a minimal kind 31990 event +function createHandlerEvent(overrides?: Partial): NostrEvent { + return { + id: "test-id", + pubkey: "test-pubkey", + created_at: 1234567890, + kind: 31990, + tags: [], + content: "", + sig: "test-sig", + ...overrides, + }; +} + +// Helper to create a minimal kind 31989 event +function createRecommendationEvent( + overrides?: Partial, +): NostrEvent { + return { + id: "test-id", + pubkey: "test-pubkey", + created_at: 1234567890, + kind: 31989, + tags: [], + content: "", + sig: "test-sig", + ...overrides, + }; +} + +describe("Kind 31990 (Application Handler) Helpers", () => { + describe("getAppName", () => { + it("should extract name from content JSON", () => { + const event = createHandlerEvent({ + content: JSON.stringify({ name: "My Nostr App" }), + tags: [["d", "my-app"]], + }); + expect(getAppName(event)).toBe("My Nostr App"); + }); + + it("should fallback to d tag if no content", () => { + const event = createHandlerEvent({ + content: "", + tags: [["d", "my-app-identifier"]], + }); + expect(getAppName(event)).toBe("my-app-identifier"); + }); + + it("should fallback to d tag if content is not valid JSON", () => { + const event = createHandlerEvent({ + content: "not json", + tags: [["d", "fallback-name"]], + }); + expect(getAppName(event)).toBe("fallback-name"); + }); + + it("should return 'Unknown App' if no name and no d tag", () => { + const event = createHandlerEvent({ + content: "", + tags: [], + }); + expect(getAppName(event)).toBe("Unknown App"); + }); + }); + + describe("getAppDescription", () => { + it("should extract description from content JSON", () => { + const event = createHandlerEvent({ + content: JSON.stringify({ description: "A great app" }), + }); + expect(getAppDescription(event)).toBe("A great app"); + }); + + it("should extract about field as fallback", () => { + const event = createHandlerEvent({ + content: JSON.stringify({ about: "An awesome app" }), + }); + expect(getAppDescription(event)).toBe("An awesome app"); + }); + + it("should prefer description over about", () => { + const event = createHandlerEvent({ + content: JSON.stringify({ + description: "Description text", + about: "About text", + }), + }); + expect(getAppDescription(event)).toBe("Description text"); + }); + + it("should return undefined if no content", () => { + const event = createHandlerEvent({ content: "" }); + expect(getAppDescription(event)).toBeUndefined(); + }); + + it("should return undefined if content is not valid JSON", () => { + const event = createHandlerEvent({ content: "not json" }); + expect(getAppDescription(event)).toBeUndefined(); + }); + }); + + describe("getAppWebsite", () => { + it("should extract website from content JSON", () => { + const event = createHandlerEvent({ + content: JSON.stringify({ website: "https://example.com" }), + }); + expect(getAppWebsite(event)).toBe("https://example.com"); + }); + + it("should return undefined if no website field", () => { + const event = createHandlerEvent({ + content: JSON.stringify({ name: "App" }), + }); + expect(getAppWebsite(event)).toBeUndefined(); + }); + + it("should return undefined if content is empty", () => { + const event = createHandlerEvent({ content: "" }); + expect(getAppWebsite(event)).toBeUndefined(); + }); + }); + + describe("getSupportedKinds", () => { + it("should extract all k tag values as numbers", () => { + const event = createHandlerEvent({ + tags: [ + ["k", "1"], + ["k", "3"], + ["k", "9802"], + ["d", "my-app"], + ], + }); + expect(getSupportedKinds(event)).toEqual([1, 3, 9802]); + }); + + it("should sort kinds numerically", () => { + const event = createHandlerEvent({ + tags: [ + ["k", "9802"], + ["k", "1"], + ["k", "30023"], + ["k", "3"], + ], + }); + expect(getSupportedKinds(event)).toEqual([1, 3, 9802, 30023]); + }); + + it("should filter out invalid kind numbers", () => { + const event = createHandlerEvent({ + tags: [ + ["k", "1"], + ["k", "not-a-number"], + ["k", "3"], + ], + }); + expect(getSupportedKinds(event)).toEqual([1, 3]); + }); + + it("should return empty array if no k tags", () => { + const event = createHandlerEvent({ + tags: [["d", "my-app"]], + }); + expect(getSupportedKinds(event)).toEqual([]); + }); + }); + + describe("getPlatformUrls", () => { + it("should extract known platform URLs", () => { + const event = createHandlerEvent({ + tags: [ + ["web", "https://app.example.com/"], + ["ios", "myapp://view/"], + ["android", "myapp://view/"], + ["d", "my-app"], + ], + }); + const urls = getPlatformUrls(event); + expect(urls.web).toBe("https://app.example.com/"); + expect(urls.ios).toBe("myapp://view/"); + expect(urls.android).toBe("myapp://view/"); + }); + + it("should return empty object if no platform tags", () => { + const event = createHandlerEvent({ + tags: [["d", "my-app"]], + }); + expect(getPlatformUrls(event)).toEqual({}); + }); + }); + + describe("getAvailablePlatforms", () => { + it("should return array of available platform names", () => { + const event = createHandlerEvent({ + tags: [ + ["web", "https://app.example.com/"], + ["ios", "myapp://view/"], + ["d", "my-app"], + ], + }); + const platforms = getAvailablePlatforms(event); + expect(platforms).toContain("web"); + expect(platforms).toContain("ios"); + expect(platforms).toHaveLength(2); + }); + }); + + describe("getHandlerIdentifier", () => { + it("should extract d tag value", () => { + const event = createHandlerEvent({ + tags: [["d", "my-unique-id"]], + }); + expect(getHandlerIdentifier(event)).toBe("my-unique-id"); + }); + + it("should return undefined if no d tag", () => { + const event = createHandlerEvent({ + tags: [], + }); + expect(getHandlerIdentifier(event)).toBeUndefined(); + }); + }); +}); + +describe("Kind 31989 (Handler Recommendation) Helpers", () => { + describe("getRecommendedKind", () => { + it("should extract kind number from d tag", () => { + const event = createRecommendationEvent({ + tags: [["d", "9802"]], + }); + expect(getRecommendedKind(event)).toBe(9802); + }); + + it("should return undefined if d tag is not a valid number", () => { + const event = createRecommendationEvent({ + tags: [["d", "not-a-number"]], + }); + expect(getRecommendedKind(event)).toBeUndefined(); + }); + + it("should return undefined if no d tag", () => { + const event = createRecommendationEvent({ + tags: [], + }); + expect(getRecommendedKind(event)).toBeUndefined(); + }); + }); + + describe("parseAddressPointer", () => { + it("should parse valid address pointer", () => { + const result = parseAddressPointer("31990:abcd1234:my-handler"); + expect(result).toEqual({ + kind: 31990, + pubkey: "abcd1234", + identifier: "my-handler", + }); + }); + + it("should return null for invalid format", () => { + expect(parseAddressPointer("invalid")).toBeNull(); + expect(parseAddressPointer("31990:abcd")).toBeNull(); + expect(parseAddressPointer("not-a-kind:pubkey:id")).toBeNull(); + }); + + it("should handle empty identifier", () => { + const result = parseAddressPointer("31990:abcd1234:"); + expect(result).toEqual({ + kind: 31990, + pubkey: "abcd1234", + identifier: "", + }); + }); + }); + + describe("getHandlerReferences", () => { + it("should extract handler references from a tags", () => { + const event = createRecommendationEvent({ + tags: [ + ["d", "9802"], + ["a", "31990:pubkey1:handler1", "wss://relay.com", "web"], + ["a", "31990:pubkey2:handler2", "", "ios"], + ], + }); + const refs = getHandlerReferences(event); + expect(refs).toHaveLength(2); + expect(refs[0].address).toEqual({ + kind: 31990, + pubkey: "pubkey1", + identifier: "handler1", + }); + expect(refs[0].relayHint).toBe("wss://relay.com"); + expect(refs[0].platform).toBe("web"); + expect(refs[1].platform).toBe("ios"); + }); + + it("should handle a tags without relay hint or platform", () => { + const event = createRecommendationEvent({ + tags: [ + ["d", "9802"], + ["a", "31990:pubkey1:handler1"], + ], + }); + const refs = getHandlerReferences(event); + expect(refs).toHaveLength(1); + expect(refs[0].relayHint).toBeUndefined(); + expect(refs[0].platform).toBeUndefined(); + }); + + it("should filter out invalid a tags", () => { + const event = createRecommendationEvent({ + tags: [ + ["d", "9802"], + ["a", "31990:pubkey1:handler1"], + ["a", "invalid-format"], + ["a", "31990:pubkey2:handler2"], + ], + }); + const refs = getHandlerReferences(event); + expect(refs).toHaveLength(2); + }); + + it("should return empty array if no a tags", () => { + const event = createRecommendationEvent({ + tags: [["d", "9802"]], + }); + expect(getHandlerReferences(event)).toEqual([]); + }); + }); + + describe("getRecommendedPlatforms", () => { + it("should return unique platforms from handler references", () => { + const event = createRecommendationEvent({ + tags: [ + ["d", "9802"], + ["a", "31990:pubkey1:handler1", "", "web"], + ["a", "31990:pubkey2:handler2", "", "ios"], + ["a", "31990:pubkey3:handler3", "", "web"], + ["a", "31990:pubkey4:handler4", "", "android"], + ], + }); + const platforms = getRecommendedPlatforms(event); + expect(platforms).toEqual(["android", "ios", "web"]); + }); + + it("should return empty array if no platforms specified", () => { + const event = createRecommendationEvent({ + tags: [ + ["d", "9802"], + ["a", "31990:pubkey1:handler1"], + ], + }); + expect(getRecommendedPlatforms(event)).toEqual([]); + }); + }); +}); diff --git a/src/lib/nip89-helpers.ts b/src/lib/nip89-helpers.ts new file mode 100644 index 0000000..79a0085 --- /dev/null +++ b/src/lib/nip89-helpers.ts @@ -0,0 +1,257 @@ +import { NostrEvent } from "@/types/nostr"; +import { getTagValue } from "applesauce-core/helpers"; +import { AddressPointer } from "nostr-tools/nip19"; + +/** + * NIP-89 Helper Functions + * For working with Application Handler (31990) and Handler Recommendation (31989) events + */ + +/** + * Get all values for a tag name (plural version of getTagValue) + * Unlike getTagValue which returns first match, this returns all matches + */ +function getTagValues(event: NostrEvent, tagName: string): string[] { + return event.tags + .filter((tag) => tag[0] === tagName) + .map((tag) => tag[1]) + .filter((val): val is string => val !== undefined); +} + +// ============================================================================ +// Kind 31990 (Application Handler) Helpers +// ============================================================================ + +/** + * Get parsed metadata from kind 31990 event content JSON + * Caches the parsed result to avoid redundant JSON.parse calls + */ +function getAppMetadata(event: NostrEvent): Record | null { + if (event.kind !== 31990 || !event.content) return null; + + // Use a symbol as cache key to avoid property name conflicts + const cacheKey = Symbol.for("nip89-metadata"); + const cached = (event as any)[cacheKey]; + if (cached !== undefined) return cached; + + try { + const metadata = JSON.parse(event.content); + if (metadata && typeof metadata === "object") { + (event as any)[cacheKey] = metadata; + return metadata; + } + } catch { + // Invalid JSON + } + + (event as any)[cacheKey] = null; + return null; +} + +/** + * Extract app name from kind 31990 event content JSON or fallback to d tag + */ +export function getAppName(event: NostrEvent): string { + if (event.kind !== 31990) return ""; + + const metadata = getAppMetadata(event); + if (metadata?.name && typeof metadata.name === "string") { + return metadata.name; + } + + // Fallback to d tag identifier + const dTag = getTagValue(event, "d"); + return dTag && typeof dTag === "string" ? dTag : "Unknown App"; +} + +/** + * Extract app description from kind 31990 event content JSON + * Checks both 'description' and 'about' fields + */ +export function getAppDescription(event: NostrEvent): string | undefined { + if (event.kind !== 31990) return undefined; + + const metadata = getAppMetadata(event); + if (metadata) { + // Check description first, then about (common in kind 0 profile format) + const desc = metadata.description || metadata.about; + if (desc && typeof desc === "string") { + return desc; + } + } + + return undefined; +} + +/** + * Extract website URL from kind 31990 event content JSON + */ +export function getAppWebsite(event: NostrEvent): string | undefined { + if (event.kind !== 31990) return undefined; + + const metadata = getAppMetadata(event); + if (metadata?.website && typeof metadata.website === "string") { + return metadata.website; + } + + return undefined; +} + +/** + * Get all supported kinds from k tags in kind 31990 event + */ +export function getSupportedKinds(event: NostrEvent): number[] { + if (event.kind !== 31990) return []; + + const kindTags = getTagValues(event, "k"); + return kindTags + .map((k) => parseInt(k, 10)) + .filter((k) => !isNaN(k)) + .sort((a, b) => a - b); // Sort numerically +} + +/** + * Get platform-specific URL templates from kind 31990 event + * Returns a map of platform name to URL template + */ +export function getPlatformUrls(event: NostrEvent): Record { + if (event.kind !== 31990) return {}; + + const platforms: Record = {}; + const knownPlatforms = ["web", "ios", "android", "macos", "windows", "linux"]; + + for (const platform of knownPlatforms) { + const url = getTagValue(event, platform); + if (url) { + platforms[platform] = url; + } + } + + // Also check for any other platform tags + // Exclude common non-platform tags: d, k, r, t, client, etc. + const excludedTags = ["d", "k", "r", "t", "client", "alt", "e", "p", "a"]; + for (const tag of event.tags) { + const tagName = tag[0]; + const tagValue = tag[1]; + if ( + tagValue && + !knownPlatforms.includes(tagName) && + !excludedTags.includes(tagName) + ) { + // Could be a custom platform tag + if (tagValue.includes("://") || tagValue.includes("")) { + platforms[tagName] = tagValue; + } + } + } + + return platforms; +} + +/** + * Get available platforms for kind 31990 event + */ +export function getAvailablePlatforms(event: NostrEvent): string[] { + return Object.keys(getPlatformUrls(event)); +} + +/** + * Get the d tag identifier from kind 31990 event + */ +export function getHandlerIdentifier(event: NostrEvent): string | undefined { + if (event.kind !== 31990) return undefined; + return getTagValue(event, "d"); +} + +// ============================================================================ +// Kind 31989 (Handler Recommendation) Helpers +// ============================================================================ + +/** + * Get the recommended event kind from kind 31989 d tag + */ +export function getRecommendedKind(event: NostrEvent): number | undefined { + if (event.kind !== 31989) return undefined; + + const dTag = getTagValue(event, "d"); + if (!dTag) return undefined; + + const kind = parseInt(dTag, 10); + return isNaN(kind) ? undefined : kind; +} + +/** + * Parse an address pointer from an a tag value + * Format: "kind:pubkey:identifier" + */ +export function parseAddressPointer(aTagValue: string): AddressPointer | null { + const parts = aTagValue.split(":"); + if (parts.length !== 3) return null; + + const kind = parseInt(parts[0], 10); + const pubkey = parts[1]; + const identifier = parts[2]; + + if (isNaN(kind) || !pubkey || identifier === undefined) return null; + + return { + kind, + pubkey, + identifier, + }; +} + +/** + * Handler reference with additional metadata from a tag + */ +export interface HandlerReference { + address: AddressPointer; + relayHint?: string; + platform?: string; +} + +/** + * Get all handler references from kind 31989 a tags + */ +export function getHandlerReferences(event: NostrEvent): HandlerReference[] { + if (event.kind !== 31989) return []; + + const references: HandlerReference[] = []; + + const aTags = event.tags.filter((tag) => tag[0] === "a"); + + for (const tag of aTags) { + const aTagValue = tag[1]; + if (!aTagValue) continue; + + const address = parseAddressPointer(aTagValue); + if (!address) continue; + + const relayHint = tag[2]; + const platform = tag[3]; + + references.push({ + address, + relayHint: relayHint || undefined, + platform: platform || undefined, + }); + } + + return references; +} + +/** + * Get unique platforms from handler references in kind 31989 + */ +export function getRecommendedPlatforms(event: NostrEvent): string[] { + const refs = getHandlerReferences(event); + const platforms = new Set(); + + for (const ref of refs) { + if (ref.platform) { + platforms.add(ref.platform); + } + } + + return Array.from(platforms).sort(); +}