From 2adfe5bb688d05490289bbb4f15848adcd5c4413 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Jan 2026 21:19:59 +0000 Subject: [PATCH] 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 --- .../ApplicationHandlerDetailRenderer.tsx | 210 +++++++++ .../kinds/ApplicationHandlerRenderer.tsx | 99 ++++ .../HandlerRecommendationDetailRenderer.tsx | 306 ++++++++++++ .../kinds/HandlerRecommendationRenderer.tsx | 130 ++++++ src/components/nostr/kinds/index.tsx | 8 + src/lib/nip89-helpers.test.ts | 440 ++++++++++++++++++ src/lib/nip89-helpers.ts | 275 +++++++++++ 7 files changed, 1468 insertions(+) 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/nostr/kinds/ApplicationHandlerDetailRenderer.tsx b/src/components/nostr/kinds/ApplicationHandlerDetailRenderer.tsx new file mode 100644 index 0000000..9cc6ca5 --- /dev/null +++ b/src/components/nostr/kinds/ApplicationHandlerDetailRenderer.tsx @@ -0,0 +1,210 @@ +import { NostrEvent } from "@/types/nostr"; +import { + getAppName, + getAppDescription, + getAppImage, + getSupportedKinds, + getPlatformUrls, + getHandlerIdentifier, +} 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 { Copy, CopyCheck, Globe, Smartphone, TabletSmartphone } from "lucide-react"; +import { CopyableJsonViewer } from "@/components/JsonViewer"; +import { useMemo } from "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 image = getAppImage(event); + const supportedKinds = getSupportedKinds(event); + const platformUrls = getPlatformUrls(event); + const identifier = getHandlerIdentifier(event); + + // Parse content JSON if available + const contentJson = useMemo(() => { + if (!event.content) return null; + try { + return JSON.parse(event.content); + } catch { + return null; + } + }, [event.content]); + + return ( +
+ {/* Header Section */} +
+ {/* App Image */} + {image && ( + {appName} + )} + + {/* App Name */} +

{appName}

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

{description}

+ )} + + {/* 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.) +

+ )} +
+ ))} +
+
+ )} + + {/* Raw Metadata Section */} + {contentJson && Object.keys(contentJson).length > 0 && ( +
+

Metadata

+ +
+ )} + + {/* Event Info */} +
+
+ Event ID:{" "} + {event.id} +
+
+ Created:{" "} + {new Date(event.created_at * 1000).toLocaleString()} +
+
+
+ ); +} diff --git a/src/components/nostr/kinds/ApplicationHandlerRenderer.tsx b/src/components/nostr/kinds/ApplicationHandlerRenderer.tsx new file mode 100644 index 0000000..c5bf3a5 --- /dev/null +++ b/src/components/nostr/kinds/ApplicationHandlerRenderer.tsx @@ -0,0 +1,99 @@ +import { + BaseEventContainer, + BaseEventProps, + ClickableEventTitle, +} from "./BaseEventRenderer"; +import { + getAppName, + getSupportedKinds, + getAvailablePlatforms, +} from "@/lib/nip89-helpers"; +import { KindBadge } from "@/components/KindBadge"; +import { Badge } from "@/components/ui/badge"; +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); + + // 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} + + + {/* 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..b30a933 --- /dev/null +++ b/src/components/nostr/kinds/HandlerRecommendationDetailRenderer.tsx @@ -0,0 +1,306 @@ +import { NostrEvent } from "@/types/nostr"; +import { + getRecommendedKind, + getHandlerReferences, + getRecommendedPlatforms, + formatAddressPointer, + 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, + ExternalLink, +} 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, + relayHint, +}: { + address: { kind: number; pubkey: string; identifier: string }; + platform?: string; + relayHint?: string; +}) { + const { addWindow } = useGrimoire(); + const handlerEvent = useNostrEvent(address); + + if (!handlerEvent) { + return ( +
+
+ + + Loading {address.identifier}... + +
+
+ ); + } + + 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, url]) => ( + + + {plat} + + ))} +
+
+ )} + + {/* Recommendation Context */} + {(platform || relayHint) && ( +
+ {platform && ( +
+ Recommended for: {platform} +
+ )} + {relayHint && ( +
+ Relay hint: {relayHint} +
+ )} +
+ )} +
+ ); +} + +/** + * 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) => ( + + ))} +
+ )} +
+ + {/* Raw Data Section */} +
+

Raw References

+
+
+ {allHandlers.map((ref, idx) => ( +
+
+ Reference {idx + 1}: +
+
+
Address: {formatAddressPointer(ref.address)}
+ {ref.platform &&
Platform: {ref.platform}
} + {ref.relayHint &&
Relay: {ref.relayHint}
} +
+
+ ))} +
+
+
+ + {/* Event Info */} +
+
+ Event ID:{" "} + {event.id} +
+
Created: {new Date(event.created_at * 1000).toLocaleString()}
+
+
+ ); +} diff --git a/src/components/nostr/kinds/HandlerRecommendationRenderer.tsx b/src/components/nostr/kinds/HandlerRecommendationRenderer.tsx new file mode 100644 index 0000000..6e727be --- /dev/null +++ b/src/components/nostr/kinds/HandlerRecommendationRenderer.tsx @@ -0,0 +1,130 @@ +import { + BaseEventContainer, + BaseEventProps, + ClickableEventTitle, +} from "./BaseEventRenderer"; +import { + getRecommendedKind, + getHandlerReferences, + formatAddressPointer, + 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, + relayHint, +}: { + 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; + + 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/nip89-helpers.test.ts b/src/lib/nip89-helpers.test.ts new file mode 100644 index 0000000..38057fd --- /dev/null +++ b/src/lib/nip89-helpers.test.ts @@ -0,0 +1,440 @@ +import { describe, it, expect } from "vitest"; +import { + getAppName, + getAppDescription, + getAppImage, + getSupportedKinds, + getPlatformUrls, + getAvailablePlatforms, + getHandlerIdentifier, + getRecommendedKind, + parseAddressPointer, + getHandlerReferences, + getHandlersByPlatform, + getRecommendedPlatforms, + substituteTemplate, + hasPlaceholder, + formatAddressPointer, +} 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 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("getAppImage", () => { + it("should extract image from content JSON", () => { + const event = createHandlerEvent({ + content: JSON.stringify({ image: "https://example.com/logo.png" }), + }); + expect(getAppImage(event)).toBe("https://example.com/logo.png"); + }); + + it("should extract picture field as fallback", () => { + const event = createHandlerEvent({ + content: JSON.stringify({ picture: "https://example.com/pic.png" }), + }); + expect(getAppImage(event)).toBe("https://example.com/pic.png"); + }); + + it("should prefer image over picture", () => { + const event = createHandlerEvent({ + content: JSON.stringify({ + image: "https://example.com/logo.png", + picture: "https://example.com/pic.png", + }), + }); + expect(getAppImage(event)).toBe("https://example.com/logo.png"); + }); + }); + + 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("getHandlersByPlatform", () => { + it("should filter handlers by platform", () => { + const event = createRecommendationEvent({ + tags: [ + ["d", "9802"], + ["a", "31990:pubkey1:handler1", "", "web"], + ["a", "31990:pubkey2:handler2", "", "ios"], + ["a", "31990:pubkey3:handler3", "", "web"], + ], + }); + const webHandlers = getHandlersByPlatform(event, "web"); + expect(webHandlers).toHaveLength(2); + expect(webHandlers[0].platform).toBe("web"); + expect(webHandlers[1].platform).toBe("web"); + + const iosHandlers = getHandlersByPlatform(event, "ios"); + expect(iosHandlers).toHaveLength(1); + expect(iosHandlers[0].platform).toBe("ios"); + }); + + it("should return all handlers if no platform specified", () => { + const event = createRecommendationEvent({ + tags: [ + ["d", "9802"], + ["a", "31990:pubkey1:handler1", "", "web"], + ["a", "31990:pubkey2:handler2", "", "ios"], + ], + }); + const allHandlers = getHandlersByPlatform(event); + expect(allHandlers).toHaveLength(2); + }); + }); + + 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([]); + }); + }); +}); + +describe("URL Template Utilities", () => { + describe("substituteTemplate", () => { + it("should replace placeholder with entity", () => { + const template = "https://app.com/view/"; + const result = substituteTemplate(template, "nevent1abc123"); + expect(result).toBe("https://app.com/view/nevent1abc123"); + }); + + it("should replace multiple occurrences", () => { + const template = "https://app.com//view/"; + const result = substituteTemplate(template, "note1xyz"); + expect(result).toBe("https://app.com/note1xyz/view/note1xyz"); + }); + + it("should return unchanged if no placeholder", () => { + const template = "https://app.com/view"; + const result = substituteTemplate(template, "nevent1abc"); + expect(result).toBe("https://app.com/view"); + }); + }); + + describe("hasPlaceholder", () => { + it("should return true if template contains ", () => { + expect(hasPlaceholder("https://app.com/")).toBe(true); + }); + + it("should return false if template does not contain ", () => { + expect(hasPlaceholder("https://app.com/view")).toBe(false); + }); + }); + + describe("formatAddressPointer", () => { + it("should format address pointer as string", () => { + const pointer = { + kind: 31990, + pubkey: "abcd1234", + identifier: "my-handler", + }; + expect(formatAddressPointer(pointer)).toBe("31990:abcd1234:my-handler"); + }); + }); +}); diff --git a/src/lib/nip89-helpers.ts b/src/lib/nip89-helpers.ts new file mode 100644 index 0000000..874b767 --- /dev/null +++ b/src/lib/nip89-helpers.ts @@ -0,0 +1,275 @@ +import { NostrEvent } from "@/types/nostr"; +import { getTagValue } from "applesauce-core/helpers"; +import { AddressPointer } from "applesauce-core/helpers"; + +/** + * 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 +// ============================================================================ + +/** + * 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 ""; + + // Try to parse content as JSON + if (event.content) { + try { + const metadata = JSON.parse(event.content); + if (metadata.name) return metadata.name; + } catch { + // Not valid JSON, continue to fallback + } + } + + // Fallback to d tag identifier + const dTag = getTagValue(event, "d"); + return dTag || "Unknown App"; +} + +/** + * Extract app description from kind 31990 event content JSON + */ +export function getAppDescription(event: NostrEvent): string | undefined { + if (event.kind !== 31990 || !event.content) return undefined; + + try { + const metadata = JSON.parse(event.content); + return metadata.description; + } catch { + return undefined; + } +} + +/** + * Extract app image URL from kind 31990 event content JSON + */ +export function getAppImage(event: NostrEvent): string | undefined { + if (event.kind !== 31990 || !event.content) return undefined; + + try { + const metadata = JSON.parse(event.content); + return metadata.image || metadata.picture; + } catch { + 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 + for (const tag of event.tags) { + const tagName = tag[0]; + const tagValue = tag[1]; + if ( + tagValue && + !knownPlatforms.includes(tagName) && + tagName !== "d" && + tagName !== "k" + ) { + // 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 handler references filtered by platform + */ +export function getHandlersByPlatform( + event: NostrEvent, + platform?: string +): HandlerReference[] { + const allRefs = getHandlerReferences(event); + + if (!platform) return allRefs; + + return allRefs.filter((ref) => ref.platform === platform); +} + +/** + * 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(); +} + +// ============================================================================ +// URL Template Utilities +// ============================================================================ + +/** + * Substitute placeholder in URL template with actual bech32 entity + */ +export function substituteTemplate( + template: string, + bech32Entity: string +): string { + return template.replace(//g, bech32Entity); +} + +/** + * Check if a string contains the placeholder + */ +export function hasPlaceholder(template: string): boolean { + return template.includes(""); +} + +/** + * Format an address pointer as a string for display + * Format: "kind:pubkey:identifier" + */ +export function formatAddressPointer(pointer: AddressPointer): string { + return `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`; +}