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 && (
+

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