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