mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-05 02:01:22 +02:00
feat: implement NIP-89 app definitions and recommendations with rich rendering (#36)
* feat: implement NIP-89 app definitions and recommendations with rich rendering
Add comprehensive support for NIP-89 Application Handlers (kind 31990) and
Handler Recommendations (kind 31989) with rich, interactive visualizations.
Core Implementation:
- nip89-helpers.ts: Utility functions for extracting NIP-89 event metadata
- App name, description, image from kind 31990 content JSON
- Supported kinds from k tags
- Platform URLs (web, ios, android) from platform tags
- Handler references from kind 31989 a tags
- URL template substitution for <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
* fix: remove unused imports and parameters in NIP-89 renderers
* fix: correct AddressPointer import and apply prettier formatting
- Change AddressPointer import from applesauce-core/helpers to nostr-tools/nip19
to match codebase conventions
- Auto-fix prettier formatting for nip89 files
* fix: add defensive type checks to prevent React error 31
- Add type guards in nip89-helpers to ensure string types
- Check metadata object structure before accessing properties
- Add fallbacks for undefined address.identifier values
- Prevents accidentally rendering objects as React children
* fix: stringify contentJson for CopyableJsonViewer and support 'about' field
- Fix React error 31: CopyableJsonViewer expects string, not object
- Add JSON.stringify() with pretty printing for metadata display
- Support both 'description' and 'about' fields in content JSON (common in kind 0)
- Add tests for 'about' field handling
* refactor: simplify NIP-89 detail renderers
Remove unnecessary metadata displays:
- Remove app image from ApplicationHandlerDetailRenderer
- Remove Event ID and Created timestamp from both detail renderers
- Remove Raw Metadata section from ApplicationHandlerDetailRenderer
- Remove Raw References section from HandlerRecommendationDetailRenderer
- Clean up unused imports (getAppImage, CopyableJsonViewer, useMemo, formatAddressPointer)
Keeps the UI focused on the essential information: app name, description,
supported kinds, and platform URLs.
* feat: add website display and filter non-platform tags
NIP-89 renderer improvements:
- Add getAppWebsite() helper to extract website from content JSON
- Display website URL in both feed and detail renderers with external link
- Filter out non-platform tags (r, t, client, alt, e, p, a) to prevent garbage display
- Remove relay hint display from HandlerRecommendationDetailRenderer
- Clean up unused relayHint parameter
Fixes the 'r r' tag appearing as a platform by properly excluding
common non-platform tags when detecting platform URLs.
* refactor: create reusable ExternalLink component for consistent styling
Create ExternalLink component following patterns from HighlightRenderer and
BookmarkRenderer with:
- Two variants: 'muted' (default, text-muted-foreground with underline)
and 'default' (text-primary with hover:underline)
- Three sizes: xs, sm, base
- Configurable icon display
- Consistent truncate behavior for long URLs
- Stop propagation on click
Apply to NIP-89 renderers:
- ApplicationHandlerRenderer: uses muted variant (feed view)
- ApplicationHandlerDetailRenderer: uses default variant (detail view)
This ensures consistent link styling across the entire application
and makes it easy to maintain a unified design language.
* refactor: consolidate JSON parsing into cached getAppMetadata helper
Performance optimization:
- Create getAppMetadata() helper that parses content JSON once and caches
the result using Symbol.for('nip89-metadata') as cache key
- All metadata helpers (getAppName, getAppDescription, getAppWebsite) now
use the cached metadata instead of parsing JSON multiple times
- Prevents redundant JSON.parse() calls when multiple helpers are used
Code cleanup - removed unused functions:
- getAppImage() - no longer used after removing image display
- getHandlersByPlatform() - filtering done in component state
- substituteTemplate() - not needed in current implementation
- hasPlaceholder() - utility never used
- formatAddressPointer() - not needed anymore
Updated tests:
- Replace getAppImage tests with getAppWebsite tests
- Remove tests for deleted utility functions
- All remaining tests pass
This consolidation improves performance by ensuring JSON.parse() is called
at most once per event, regardless of how many metadata fields are accessed.
* feat: use app name in window titles for NIP-89 app events
Add special handling for kind 31990 (Application Handler) events in
getEventDisplayTitle to use the app name from content JSON instead of
generic kind name. Falls back to identifier if app name not available.
This gives NIP-89 app handler events nice readable window titles.
---------
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
65
src/components/ExternalLink.tsx
Normal file
65
src/components/ExternalLink.tsx
Normal file
@@ -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 (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1",
|
||||
sizeClasses[size],
|
||||
variantClasses[variant],
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{showIcon && (
|
||||
<ExternalLinkIcon
|
||||
className={cn("flex-shrink-0", iconSizeClasses[size], iconClassName)}
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">{children}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
183
src/components/nostr/kinds/ApplicationHandlerDetailRenderer.tsx
Normal file
183
src/components/nostr/kinds/ApplicationHandlerDetailRenderer.tsx
Normal file
@@ -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 <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 supportedKinds = getSupportedKinds(event);
|
||||
const platformUrls = getPlatformUrls(event);
|
||||
const identifier = getHandlerIdentifier(event);
|
||||
const website = getAppWebsite(event);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
{/* Header Section */}
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* App Name */}
|
||||
<h1 className="text-3xl font-bold">{appName}</h1>
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p className="text-muted-foreground text-lg">{description}</p>
|
||||
)}
|
||||
|
||||
{/* Website */}
|
||||
{website && (
|
||||
<ExternalLink href={website} variant="default" size="base">
|
||||
{website}
|
||||
</ExternalLink>
|
||||
)}
|
||||
|
||||
{/* 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"><bech32></code>{" "}
|
||||
placeholder will be replaced with the NIP-19 encoded event
|
||||
(nevent, naddr, note, etc.)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
src/components/nostr/kinds/ApplicationHandlerRenderer.tsx
Normal file
105
src/components/nostr/kinds/ApplicationHandlerRenderer.tsx
Normal file
@@ -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 <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);
|
||||
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 (
|
||||
<BaseEventContainer event={event}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* App Name */}
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
className="text-lg font-semibold text-foreground"
|
||||
>
|
||||
{appName}
|
||||
</ClickableEventTitle>
|
||||
|
||||
{/* Website */}
|
||||
{website && <ExternalLink href={website}>{website}</ExternalLink>}
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
@@ -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 <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,
|
||||
}: {
|
||||
address: { kind: number; pubkey: string; identifier: string };
|
||||
platform?: 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 || "handler"}...
|
||||
</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]) => (
|
||||
<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 && (
|
||||
<div className="flex flex-col gap-1 pt-2 border-t border-border text-xs text-muted-foreground">
|
||||
<div>
|
||||
Recommended for:{" "}
|
||||
<Badge variant="outline" className="text-[10px] ml-1">
|
||||
{platform}
|
||||
</Badge>
|
||||
</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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
src/components/nostr/kinds/HandlerRecommendationRenderer.tsx
Normal file
130
src/components/nostr/kinds/HandlerRecommendationRenderer.tsx
Normal file
@@ -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 <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,
|
||||
}: {
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
370
src/lib/nip89-helpers.test.ts
Normal file
370
src/lib/nip89-helpers.test.ts
Normal file
@@ -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>): 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 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/<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("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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
257
src/lib/nip89-helpers.ts
Normal file
257
src/lib/nip89-helpers.ts
Normal file
@@ -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<string, any> | 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<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
|
||||
// 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("<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 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();
|
||||
}
|
||||
Reference in New Issue
Block a user