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 <bech32> 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
This commit is contained in:
Claude
2026-01-04 21:19:59 +00:00
parent a5040b8ab6
commit 2adfe5bb68
7 changed files with 1468 additions and 0 deletions

View File

@@ -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 <Globe className="size-4" />;
}
if (lowerPlatform === "ios") {
return <Smartphone className="size-4" />;
}
if (lowerPlatform === "android") {
return <TabletSmartphone className="size-4" />;
}
// Default for other platforms
return <span className="text-sm font-mono">{platform}</span>;
}
/**
* Copy button for URL templates
*/
function CopyUrlButton({ url }: { url: string }) {
const { copy, copied } = useCopy();
return (
<button
onClick={() => copy(url)}
className="p-1 hover:bg-muted rounded transition-colors"
title="Copy URL template"
>
{copied ? (
<CopyCheck className="size-4 text-green-500" />
) : (
<Copy className="size-4 text-muted-foreground" />
)}
</button>
);
}
/**
* 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 (
<div className="flex flex-col gap-6 p-6">
{/* Header Section */}
<div className="flex flex-col gap-3">
{/* App Image */}
{image && (
<img
src={image}
alt={appName}
className="w-20 h-20 rounded-lg object-cover border border-border"
/>
)}
{/* App Name */}
<h1 className="text-3xl font-bold">{appName}</h1>
{/* Description */}
{description && (
<p className="text-muted-foreground text-lg">{description}</p>
)}
{/* Metadata Grid */}
<div className="grid grid-cols-2 gap-4 text-sm">
{/* Publisher */}
<div className="flex flex-col gap-1">
<h3 className="text-muted-foreground">Publisher</h3>
<UserName pubkey={event.pubkey} />
</div>
{/* Identifier */}
{identifier && (
<div className="flex flex-col gap-1">
<h3 className="text-muted-foreground">Identifier</h3>
<code className="font-mono text-sm">{identifier}</code>
</div>
)}
</div>
</div>
{/* Supported Kinds Section */}
{supportedKinds.length > 0 && (
<div className="flex flex-col gap-3">
<h2 className="text-xl font-semibold">
Supported Kinds ({supportedKinds.length})
</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2">
{supportedKinds.map((kind) => (
<KindBadge
key={kind}
kind={kind}
variant="default"
showIcon
showName
clickable
className="text-xs justify-start"
/>
))}
</div>
</div>
)}
{/* Platforms & URLs Section */}
{Object.keys(platformUrls).length > 0 && (
<div className="flex flex-col gap-3">
<h2 className="text-xl font-semibold">Platforms & URLs</h2>
<div className="flex flex-col gap-3">
{Object.entries(platformUrls).map(([platform, url]) => (
<div
key={platform}
className="flex flex-col gap-2 p-3 bg-muted/30 rounded-lg border border-border"
>
{/* Platform Name */}
<div className="flex items-center gap-2">
<PlatformIcon platform={platform} />
<Badge variant="secondary" className="capitalize">
{platform}
</Badge>
</div>
{/* URL Template */}
<div className="flex items-center gap-2">
<code className="flex-1 text-xs font-mono bg-muted p-2 rounded overflow-x-auto">
{url}
</code>
<CopyUrlButton url={url} />
</div>
{/* Placeholder Help */}
{url.includes("<bech32>") && (
<p className="text-xs text-muted-foreground">
The <code className="bg-muted px-1">&lt;bech32&gt;</code>{" "}
placeholder will be replaced with the NIP-19 encoded event
(nevent, naddr, note, etc.)
</p>
)}
</div>
))}
</div>
</div>
)}
{/* Raw Metadata Section */}
{contentJson && Object.keys(contentJson).length > 0 && (
<div className="flex flex-col gap-3">
<h2 className="text-xl font-semibold">Metadata</h2>
<CopyableJsonViewer json={contentJson} />
</div>
)}
{/* Event Info */}
<div className="flex flex-col gap-2 pt-4 border-t border-border text-sm text-muted-foreground">
<div>
Event ID:{" "}
<code className="font-mono text-xs bg-muted px-1">{event.id}</code>
</div>
<div>
Created:{" "}
{new Date(event.created_at * 1000).toLocaleString()}
</div>
</div>
</div>
);
}

View File

@@ -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 <Globe className="size-3" />;
}
if (lowerPlatform === "ios") {
return <Smartphone className="size-3" />;
}
if (lowerPlatform === "android") {
return <TabletSmartphone className="size-3" />;
}
// Default icon for other platforms
return <span className="text-[10px] font-mono">{platform}</span>;
}
/**
* 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 (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
{/* App Name */}
<ClickableEventTitle
event={event}
className="text-lg font-semibold text-foreground"
>
{appName}
</ClickableEventTitle>
{/* Supported Kinds */}
{displayKinds.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground">Handles:</span>
{displayKinds.map((kind) => (
<KindBadge
key={kind}
kind={kind}
className="text-[10px]"
showName
clickable
/>
))}
{remainingCount > 0 && (
<Badge variant="outline" className="text-[10px] px-2 py-0">
+{remainingCount} more
</Badge>
)}
</div>
)}
{/* Platforms */}
{platforms.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
{platforms.map((platform) => (
<Badge
key={platform}
variant="secondary"
className="text-[10px] gap-1 px-2 py-0.5"
>
<PlatformIcon platform={platform} />
{platform}
</Badge>
))}
</div>
)}
</div>
</BaseEventContainer>
);
}

View File

@@ -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 <Globe className="size-4" />;
}
if (lowerPlatform === "ios") {
return <Smartphone className="size-4" />;
}
if (lowerPlatform === "android") {
return <TabletSmartphone className="size-4" />;
}
return <span className="text-sm font-mono">{platform}</span>;
}
/**
* 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 (
<div className="p-4 bg-muted/20 rounded-lg border border-border">
<div className="flex items-center gap-2">
<Package className="size-5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
Loading {address.identifier}...
</span>
</div>
</div>
);
}
const appName = getAppName(handlerEvent);
const description = getAppDescription(handlerEvent);
const supportedKinds = getSupportedKinds(handlerEvent);
const platformUrls = getPlatformUrls(handlerEvent);
const handleClick = () => {
addWindow("open", { pointer: address });
};
return (
<div className="p-4 bg-muted/20 rounded-lg border border-border flex flex-col gap-3">
{/* App Header */}
<div className="flex items-start gap-3">
<Package className="size-6 text-primary mt-1" />
<div className="flex-1 flex flex-col gap-1">
<button
onClick={handleClick}
className="text-lg font-semibold hover:underline cursor-crosshair text-left"
>
{appName}
</button>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
</div>
{/* Supported Kinds Preview */}
{supportedKinds.length > 0 && (
<div className="flex flex-col gap-2">
<h4 className="text-xs font-semibold text-muted-foreground uppercase">
Handles {supportedKinds.length} kind{supportedKinds.length > 1 ? "s" : ""}
</h4>
<div className="flex flex-wrap gap-1">
{supportedKinds.slice(0, 10).map((kind) => (
<KindBadge
key={kind}
kind={kind}
variant="compact"
clickable
className="text-[10px]"
/>
))}
{supportedKinds.length > 10 && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
+{supportedKinds.length - 10}
</Badge>
)}
</div>
</div>
)}
{/* Platform URLs */}
{Object.keys(platformUrls).length > 0 && (
<div className="flex flex-col gap-2">
<h4 className="text-xs font-semibold text-muted-foreground uppercase">
Platforms
</h4>
<div className="flex flex-wrap gap-2">
{Object.entries(platformUrls).map(([plat, url]) => (
<Badge
key={plat}
variant="secondary"
className="text-[10px] gap-1 px-2 py-0.5 capitalize"
>
<PlatformIcon platform={plat} />
{plat}
</Badge>
))}
</div>
</div>
)}
{/* Recommendation Context */}
{(platform || relayHint) && (
<div className="flex flex-col gap-1 pt-2 border-t border-border text-xs text-muted-foreground">
{platform && (
<div>
Recommended for: <Badge variant="outline" className="text-[10px] ml-1">{platform}</Badge>
</div>
)}
{relayHint && (
<div className="font-mono">
Relay hint: {relayHint}
</div>
)}
</div>
)}
</div>
);
}
/**
* 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<string | null>(null);
// Filter handlers by selected platform
const displayHandlers = selectedPlatform
? allHandlers.filter((h) => h.platform === selectedPlatform)
: allHandlers;
return (
<div className="flex flex-col gap-6 p-6">
{/* Header Section */}
<div className="flex flex-col gap-3">
<h1 className="text-3xl font-bold">Handler Recommendation</h1>
{/* Recommended Kind */}
{recommendedKind !== undefined && (
<div className="flex items-center gap-3 flex-wrap">
<span className="text-lg text-muted-foreground">For:</span>
<KindBadge
kind={recommendedKind}
variant="full"
showIcon
showName
showKindNumber
clickable
className="text-lg"
/>
</div>
)}
{/* Recommender */}
<div className="flex flex-col gap-1 text-sm">
<span className="text-muted-foreground">Recommended by:</span>
<UserName pubkey={event.pubkey} className="text-base" />
</div>
</div>
{/* Platform Filter Tabs */}
{platforms.length > 0 && (
<div className="flex flex-wrap gap-2">
<button
onClick={() => setSelectedPlatform(null)}
className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
selectedPlatform === null
? "bg-primary text-primary-foreground"
: "bg-muted hover:bg-muted/80"
}`}
>
All Platforms ({allHandlers.length})
</button>
{platforms.map((platform) => {
const count = allHandlers.filter(
(h) => h.platform === platform
).length;
return (
<button
key={platform}
onClick={() => setSelectedPlatform(platform)}
className={`px-3 py-1.5 rounded-lg text-sm transition-colors capitalize flex items-center gap-1.5 ${
selectedPlatform === platform
? "bg-primary text-primary-foreground"
: "bg-muted hover:bg-muted/80"
}`}
>
<PlatformIcon platform={platform} />
{platform} ({count})
</button>
);
})}
</div>
)}
{/* Handlers Section */}
<div className="flex flex-col gap-3">
<h2 className="text-xl font-semibold">
Recommended Handlers ({displayHandlers.length})
</h2>
{displayHandlers.length === 0 ? (
<p className="text-muted-foreground">
No handlers found for the selected platform.
</p>
) : (
<div className="flex flex-col gap-3">
{displayHandlers.map((ref, idx) => (
<HandlerCard
key={idx}
address={ref.address}
platform={ref.platform}
relayHint={ref.relayHint}
/>
))}
</div>
)}
</div>
{/* Raw Data Section */}
<div className="flex flex-col gap-3">
<h2 className="text-xl font-semibold">Raw References</h2>
<div className="bg-muted/30 p-4 rounded-lg border border-border">
<div className="flex flex-col gap-2 text-sm font-mono">
{allHandlers.map((ref, idx) => (
<div key={idx} className="flex flex-col gap-1">
<div className="text-xs text-muted-foreground">
Reference {idx + 1}:
</div>
<div className="pl-2 border-l-2 border-muted flex flex-col gap-0.5 text-xs">
<div>Address: {formatAddressPointer(ref.address)}</div>
{ref.platform && <div>Platform: {ref.platform}</div>}
{ref.relayHint && <div>Relay: {ref.relayHint}</div>}
</div>
</div>
))}
</div>
</div>
</div>
{/* Event Info */}
<div className="flex flex-col gap-2 pt-4 border-t border-border text-sm text-muted-foreground">
<div>
Event ID:{" "}
<code className="font-mono text-xs bg-muted px-1">{event.id}</code>
</div>
<div>Created: {new Date(event.created_at * 1000).toLocaleString()}</div>
</div>
</div>
);
}

View File

@@ -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 <Globe className="size-3" />;
}
if (lowerPlatform === "ios") {
return <Smartphone className="size-3" />;
}
if (lowerPlatform === "android") {
return <TabletSmartphone className="size-3" />;
}
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 (
<div className="flex items-center gap-2 flex-wrap">
<Package className="size-3 text-muted-foreground" />
<button
onClick={handleClick}
className="text-sm hover:underline cursor-crosshair text-primary"
>
{appName}
</button>
{platform && (
<Badge variant="secondary" className="text-[10px] gap-1 px-1.5 py-0">
<PlatformIcon platform={platform} />
{platform}
</Badge>
)}
</div>
);
}
/**
* 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 (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
{/* Title with recommended kind */}
<ClickableEventTitle
event={event}
className="text-base font-semibold text-foreground flex items-center gap-2 flex-wrap"
>
<span>Recommends handlers for</span>
{recommendedKind !== undefined && (
<KindBadge
kind={recommendedKind}
showIcon
showName
clickable
className="text-sm"
/>
)}
</ClickableEventTitle>
{/* Handler List */}
{displayHandlers.length > 0 && (
<div className="flex flex-col gap-1.5 pl-4 border-l-2 border-muted">
{displayHandlers.map((ref, idx) => (
<HandlerItem
key={idx}
address={ref.address}
platform={ref.platform}
relayHint={ref.relayHint}
/>
))}
{remainingCount > 0 && (
<span className="text-xs text-muted-foreground">
+{remainingCount} more handler{remainingCount > 1 ? "s" : ""}
</span>
)}
</div>
)}
</div>
</BaseEventContainer>
);
}

View File

@@ -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<number, React.ComponentType<BaseEventProps>> = {
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)
};
/**

View File

@@ -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>
): 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>
): 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/<bech32>"],
["ios", "myapp://view/<bech32>"],
["android", "myapp://view/<bech32>"],
["d", "my-app"],
],
});
const urls = getPlatformUrls(event);
expect(urls.web).toBe("https://app.example.com/<bech32>");
expect(urls.ios).toBe("myapp://view/<bech32>");
expect(urls.android).toBe("myapp://view/<bech32>");
});
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/<bech32>"],
["ios", "myapp://view/<bech32>"],
["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 <bech32> placeholder with entity", () => {
const template = "https://app.com/view/<bech32>";
const result = substituteTemplate(template, "nevent1abc123");
expect(result).toBe("https://app.com/view/nevent1abc123");
});
it("should replace multiple occurrences", () => {
const template = "https://app.com/<bech32>/view/<bech32>";
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 <bech32>", () => {
expect(hasPlaceholder("https://app.com/<bech32>")).toBe(true);
});
it("should return false if template does not contain <bech32>", () => {
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");
});
});
});

275
src/lib/nip89-helpers.ts Normal file
View File

@@ -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<string, string> {
if (event.kind !== 31990) return {};
const platforms: Record<string, string> = {};
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("<bech32>")) {
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<string>();
for (const ref of refs) {
if (ref.platform) {
platforms.add(ref.platform);
}
}
return Array.from(platforms).sort();
}
// ============================================================================
// URL Template Utilities
// ============================================================================
/**
* Substitute <bech32> placeholder in URL template with actual bech32 entity
*/
export function substituteTemplate(
template: string,
bech32Entity: string
): string {
return template.replace(/<bech32>/g, bech32Entity);
}
/**
* Check if a string contains the <bech32> placeholder
*/
export function hasPlaceholder(template: string): boolean {
return template.includes("<bech32>");
}
/**
* 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}`;
}