feat: Add Zapstore app and app curation set renderers

Add support for rendering Zapstore app-related Nostr events:
- Kind 32267 (App Metadata): Display app details, icon, platforms, screenshots
- Kind 30267 (App Curation Set): Display curated app collections

New files:
- src/lib/zapstore-helpers.ts: Helper functions for extracting app metadata
- src/lib/zapstore-helpers.test.ts: Comprehensive test coverage (43 tests)
- src/components/nostr/kinds/ZapstoreAppRenderer.tsx: Feed view for apps
- src/components/nostr/kinds/ZapstoreAppDetailRenderer.tsx: Detail view for apps
- src/components/nostr/kinds/ZapstoreAppSetRenderer.tsx: Feed view for collections
- src/components/nostr/kinds/ZapstoreAppSetDetailRenderer.tsx: Detail view for collections

Modified:
- src/components/nostr/kinds/index.tsx: Register new renderers in kind registry

All tests pass (726 total), build succeeds, no lint errors.
This commit is contained in:
Claude
2026-01-11 18:30:49 +00:00
parent 84b5ac88aa
commit 4a8797ad8b
7 changed files with 1209 additions and 0 deletions

View File

@@ -0,0 +1,144 @@
import { NostrEvent } from "@/types/nostr";
import {
getAppName,
getAppSummary,
getAppIcon,
getAppImages,
getAppPlatforms,
getAppRepository,
getAppLicense,
getAppIdentifier,
} from "@/lib/zapstore-helpers";
import { Badge } from "@/components/ui/badge";
import { UserName } from "../UserName";
import { ExternalLink } from "@/components/ExternalLink";
import { MediaEmbed } from "../MediaEmbed";
import { Package } from "lucide-react";
interface ZapstoreAppDetailRendererProps {
event: NostrEvent;
}
/**
* Detail renderer for Kind 32267 - Zapstore App Metadata
* Shows comprehensive app information including screenshots
* Note: Zapstore helpers wrap getTagValue which caches internally
*/
export function ZapstoreAppDetailRenderer({
event,
}: ZapstoreAppDetailRendererProps) {
const appName = getAppName(event);
const summary = getAppSummary(event);
const iconUrl = getAppIcon(event);
const images = getAppImages(event);
const platforms = getAppPlatforms(event);
const repository = getAppRepository(event);
const license = getAppLicense(event);
const identifier = getAppIdentifier(event);
return (
<div className="flex flex-col gap-6 p-6 max-w-4xl mx-auto">
{/* Header Section */}
<div className="flex gap-4">
{/* App Icon */}
{iconUrl ? (
<img
src={iconUrl}
alt={appName}
className="size-20 rounded-lg object-cover flex-shrink-0"
loading="lazy"
/>
) : (
<div className="size-20 rounded-lg bg-muted flex items-center justify-center flex-shrink-0">
<Package className="size-10 text-muted-foreground" />
</div>
)}
{/* App Title & Summary */}
<div className="flex flex-col gap-2 flex-1 min-w-0">
<h1 className="text-3xl font-bold">{appName}</h1>
{summary && (
<p className="text-muted-foreground text-base">{summary}</p>
)}
</div>
</div>
{/* 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">Package ID</h3>
<code className="font-mono text-sm truncate" title={identifier}>
{identifier}
</code>
</div>
)}
{/* License */}
{license && (
<div className="flex flex-col gap-1">
<h3 className="text-muted-foreground">License</h3>
<code className="font-mono text-sm">{license}</code>
</div>
)}
{/* Repository */}
{repository && (
<div className="flex flex-col gap-1">
<h3 className="text-muted-foreground">Repository</h3>
<ExternalLink href={repository} className="truncate">
{repository}
</ExternalLink>
</div>
)}
</div>
{/* Platforms Section */}
{platforms.length > 0 && (
<div className="flex flex-col gap-3">
<h2 className="text-xl font-semibold">
Platforms ({platforms.length})
</h2>
<div className="flex flex-wrap gap-2">
{platforms.map((platform) => (
<Badge
key={platform}
variant="secondary"
className="text-sm px-3 py-1"
>
{platform}
</Badge>
))}
</div>
</div>
)}
{/* Screenshots Section */}
{images.length > 0 && (
<div className="flex flex-col gap-3">
<h2 className="text-xl font-semibold">
Screenshots ({images.length})
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{images.map((imageUrl, idx) => (
<MediaEmbed
key={idx}
url={imageUrl}
preset="preview"
enableZoom
className="w-full rounded-lg overflow-hidden aspect-video"
/>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,104 @@
import {
BaseEventContainer,
BaseEventProps,
ClickableEventTitle,
} from "./BaseEventRenderer";
import {
getAppName,
getAppSummary,
getAppIcon,
getAppPlatforms,
getAppRepository,
getAppLicense,
} from "@/lib/zapstore-helpers";
import { Badge } from "@/components/ui/badge";
import { ExternalLink } from "@/components/ExternalLink";
import { Package } from "lucide-react";
/**
* Renderer for Kind 32267 - Zapstore App Metadata
* Displays app name, icon, summary, and platforms in feed
*/
export function ZapstoreAppRenderer({ event }: BaseEventProps) {
const appName = getAppName(event);
const summary = getAppSummary(event);
const iconUrl = getAppIcon(event);
const platforms = getAppPlatforms(event);
const repository = getAppRepository(event);
const license = getAppLicense(event);
return (
<BaseEventContainer event={event}>
<div className="flex gap-3">
{/* App Icon */}
{iconUrl ? (
<img
src={iconUrl}
alt={appName}
className="size-12 rounded-lg object-cover flex-shrink-0"
loading="lazy"
/>
) : (
<div className="size-12 rounded-lg bg-muted flex items-center justify-center flex-shrink-0">
<Package className="size-6 text-muted-foreground" />
</div>
)}
{/* App Info */}
<div className="flex flex-col gap-2 flex-1 min-w-0">
{/* App Name */}
<ClickableEventTitle
event={event}
className="text-base font-semibold text-foreground"
>
{appName}
</ClickableEventTitle>
{/* Summary */}
{summary && (
<p className="text-sm text-muted-foreground line-clamp-2">
{summary}
</p>
)}
{/* Platforms & License */}
<div className="flex items-center gap-2 flex-wrap">
{platforms.length > 0 && (
<>
{platforms.slice(0, 4).map((platform) => (
<Badge
key={platform}
variant="secondary"
className="text-[10px] px-2 py-0.5"
>
{platform}
</Badge>
))}
{platforms.length > 4 && (
<Badge variant="outline" className="text-[10px] px-2 py-0">
+{platforms.length - 4} more
</Badge>
)}
</>
)}
{license && (
<Badge variant="outline" className="text-[10px] px-2 py-0.5">
{license}
</Badge>
)}
</div>
{/* Repository Link */}
{repository && (
<ExternalLink
href={repository}
className="text-xs truncate max-w-full"
>
{repository}
</ExternalLink>
)}
</div>
</div>
</BaseEventContainer>
);
}

View File

@@ -0,0 +1,171 @@
import { NostrEvent } from "@/types/nostr";
import {
getCurationSetName,
getAppReferences,
getAppName,
getAppSummary,
getAppIcon,
getAppPlatforms,
getCurationSetIdentifier,
} from "@/lib/zapstore-helpers";
import { Badge } from "@/components/ui/badge";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { useGrimoire } from "@/core/state";
import { UserName } from "../UserName";
import { Package } from "lucide-react";
interface ZapstoreAppSetDetailRendererProps {
event: NostrEvent;
}
/**
* Expanded app card showing full app details
*/
function AppCard({
address,
}: {
address: { kind: number; pubkey: string; identifier: string };
}) {
const { addWindow } = useGrimoire();
const appEvent = useNostrEvent(address);
if (!appEvent) {
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 || "app"}...
</span>
</div>
</div>
);
}
const appName = getAppName(appEvent);
const summary = getAppSummary(appEvent);
const iconUrl = getAppIcon(appEvent);
const platforms = getAppPlatforms(appEvent);
const handleClick = () => {
addWindow("open", { pointer: address });
};
return (
<div className="p-4 bg-muted/20 rounded-lg border border-border flex gap-4 hover:bg-muted/30 transition-colors">
{/* App Icon */}
{iconUrl ? (
<img
src={iconUrl}
alt={appName}
className="size-16 rounded-lg object-cover flex-shrink-0"
loading="lazy"
/>
) : (
<div className="size-16 rounded-lg bg-muted flex items-center justify-center flex-shrink-0">
<Package className="size-8 text-muted-foreground" />
</div>
)}
{/* App Info */}
<div className="flex-1 flex flex-col gap-2 min-w-0">
{/* App Name */}
<button
onClick={handleClick}
className="text-lg font-semibold hover:underline cursor-crosshair text-left"
>
{appName}
</button>
{/* Summary */}
{summary && (
<p className="text-sm text-muted-foreground line-clamp-2">
{summary}
</p>
)}
{/* Platforms */}
{platforms.length > 0 && (
<div className="flex flex-wrap gap-2">
{platforms.slice(0, 6).map((platform) => (
<Badge
key={platform}
variant="secondary"
className="text-[10px] px-2 py-0.5"
>
{platform}
</Badge>
))}
{platforms.length > 6 && (
<Badge variant="outline" className="text-[10px] px-2 py-0">
+{platforms.length - 6} more
</Badge>
)}
</div>
)}
</div>
</div>
);
}
/**
* Detail renderer for Kind 30267 - Zapstore App Curation Set
* Shows comprehensive view of all apps in the collection
*/
export function ZapstoreAppSetDetailRenderer({
event,
}: ZapstoreAppSetDetailRendererProps) {
const setName = getCurationSetName(event);
const apps = getAppReferences(event);
const identifier = getCurationSetIdentifier(event);
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">{setName}</h1>
{/* Metadata */}
<div className="grid grid-cols-2 gap-4 text-sm">
{/* Curator */}
<div className="flex flex-col gap-1">
<h3 className="text-muted-foreground">Curated by</h3>
<UserName pubkey={event.pubkey} />
</div>
{/* Identifier */}
{identifier && (
<div className="flex flex-col gap-1">
<h3 className="text-muted-foreground">Collection ID</h3>
<code className="font-mono text-sm truncate" title={identifier}>
{identifier}
</code>
</div>
)}
</div>
{/* App Count */}
<p className="text-muted-foreground">
{apps.length} {apps.length === 1 ? "app" : "apps"} in this collection
</p>
</div>
{/* Apps Section */}
<div className="flex flex-col gap-3">
<h2 className="text-xl font-semibold">Apps</h2>
{apps.length === 0 ? (
<p className="text-muted-foreground">
No apps in this collection yet.
</p>
) : (
<div className="flex flex-col gap-3">
{apps.map((ref, idx) => (
<AppCard key={idx} address={ref.address} />
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,91 @@
import {
BaseEventContainer,
BaseEventProps,
ClickableEventTitle,
} from "./BaseEventRenderer";
import {
getCurationSetName,
getAppReferences,
getAppName,
} from "@/lib/zapstore-helpers";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { useGrimoire } from "@/core/state";
import { Package } from "lucide-react";
/**
* Individual app item - fetches and displays app info
*/
function AppItem({
address,
}: {
address: { kind: number; pubkey: string; identifier: string };
}) {
const { addWindow } = useGrimoire();
const appEvent = useNostrEvent(address);
const appName = appEvent
? getAppName(appEvent)
: address?.identifier || "Unknown App";
const handleClick = () => {
addWindow("open", { pointer: address });
};
return (
<div className="flex items-center gap-2">
<Package className="size-3 text-muted-foreground" />
<button
onClick={handleClick}
className="text-sm hover:underline cursor-crosshair text-primary truncate"
>
{appName}
</button>
</div>
);
}
/**
* Renderer for Kind 30267 - Zapstore App Curation Set
* Displays collection name and list of apps
*/
export function ZapstoreAppSetRenderer({ event }: BaseEventProps) {
const setName = getCurationSetName(event);
const apps = getAppReferences(event);
// Show max 5 apps in feed view
const MAX_APPS_IN_FEED = 5;
const displayApps = apps.slice(0, MAX_APPS_IN_FEED);
const remainingCount = apps.length - displayApps.length;
return (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
{/* Collection Name */}
<ClickableEventTitle
event={event}
className="text-base font-semibold text-foreground"
>
{setName}
</ClickableEventTitle>
{/* App Count */}
<p className="text-sm text-muted-foreground">
{apps.length} {apps.length === 1 ? "app" : "apps"}
</p>
{/* App List */}
{displayApps.length > 0 && (
<div className="flex flex-col gap-1.5 pl-4 border-l-2 border-muted">
{displayApps.map((ref, idx) => (
<AppItem key={idx} address={ref.address} />
))}
{remainingCount > 0 && (
<span className="text-xs text-muted-foreground">
+{remainingCount} more app{remainingCount > 1 ? "s" : ""}
</span>
)}
</div>
)}
</div>
</BaseEventContainer>
);
}

View File

@@ -54,6 +54,10 @@ import { CalendarTimeEventRenderer } from "./CalendarTimeEventRenderer";
import { CalendarTimeEventDetailRenderer } from "./CalendarTimeEventDetailRenderer";
import { EmojiSetRenderer } from "./EmojiSetRenderer";
import { EmojiSetDetailRenderer } from "./EmojiSetDetailRenderer";
import { ZapstoreAppRenderer } from "./ZapstoreAppRenderer";
import { ZapstoreAppDetailRenderer } from "./ZapstoreAppDetailRenderer";
import { ZapstoreAppSetRenderer } from "./ZapstoreAppSetRenderer";
import { ZapstoreAppSetDetailRenderer } from "./ZapstoreAppSetDetailRenderer";
import { NostrEvent } from "@/types/nostr";
import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer";
@@ -94,6 +98,7 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
30002: GenericRelayListRenderer, // Relay Sets (NIP-51)
30023: Kind30023Renderer, // Long-form Article
30030: EmojiSetRenderer, // Emoji Sets (NIP-30)
30267: ZapstoreAppSetRenderer, // App Curation Set (Zapstore)
30311: LiveActivityRenderer, // Live Streaming Event (NIP-53)
34235: Kind21Renderer, // Horizontal Video (NIP-71 legacy)
34236: Kind22Renderer, // Vertical Video (NIP-71 legacy)
@@ -105,6 +110,7 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
31923: CalendarTimeEventRenderer, // Time-Based Calendar Event (NIP-52)
31989: HandlerRecommendationRenderer, // Handler Recommendation (NIP-89)
31990: ApplicationHandlerRenderer, // Application Handler (NIP-89)
32267: ZapstoreAppRenderer, // App Metadata (Zapstore)
39701: Kind39701Renderer, // Web Bookmarks (NIP-B0)
};
@@ -159,6 +165,7 @@ const detailRenderers: Record<
777: SpellDetailRenderer, // Spell Detail
30023: Kind30023DetailRenderer, // Long-form Article Detail
30030: EmojiSetDetailRenderer, // Emoji Sets Detail (NIP-30)
30267: ZapstoreAppSetDetailRenderer, // App Curation Set Detail (Zapstore)
30311: LiveActivityDetailRenderer, // Live Streaming Event Detail (NIP-53)
30617: RepositoryDetailRenderer, // Repository Detail (NIP-34)
30618: RepositoryStateDetailRenderer, // Repository State Detail (NIP-34)
@@ -168,6 +175,7 @@ const detailRenderers: Record<
31923: CalendarTimeEventDetailRenderer, // Time-Based Calendar Event Detail (NIP-52)
31989: HandlerRecommendationDetailRenderer, // Handler Recommendation Detail (NIP-89)
31990: ApplicationHandlerDetailRenderer, // Application Handler Detail (NIP-89)
32267: ZapstoreAppDetailRenderer, // App Metadata Detail (Zapstore)
};
/**

View File

@@ -0,0 +1,475 @@
import { describe, it, expect } from "vitest";
import {
getAppName,
getAppIdentifier,
getAppSummary,
getAppRepository,
getAppIcon,
getAppImages,
getAppLicense,
getAppPlatforms,
getAppReleases,
getCurationSetName,
getCurationSetIdentifier,
getAppReferences,
parseAddressPointer,
} from "./zapstore-helpers";
import { NostrEvent } from "@/types/nostr";
// Helper to create a minimal kind 32267 event (App Metadata)
function createAppEvent(overrides?: Partial<NostrEvent>): NostrEvent {
return {
id: "test-id",
pubkey: "test-pubkey",
created_at: 1234567890,
kind: 32267,
tags: [],
content: "",
sig: "test-sig",
...overrides,
};
}
// Helper to create a minimal kind 30267 event (App Curation Set)
function createCurationSetEvent(overrides?: Partial<NostrEvent>): NostrEvent {
return {
id: "test-id",
pubkey: "test-pubkey",
created_at: 1234567890,
kind: 30267,
tags: [],
content: "",
sig: "test-sig",
...overrides,
};
}
describe("Kind 32267 (App Metadata) Helpers", () => {
describe("getAppName", () => {
it("should extract name from name tag", () => {
const event = createAppEvent({
tags: [
["name", "0xchat"],
["d", "com.oxchat.nostr"],
],
});
expect(getAppName(event)).toBe("0xchat");
});
it("should fallback to d tag if no name tag", () => {
const event = createAppEvent({
tags: [["d", "com.example.app"]],
});
expect(getAppName(event)).toBe("com.example.app");
});
it("should return 'Unknown App' if no name and no d tag", () => {
const event = createAppEvent({
tags: [],
});
expect(getAppName(event)).toBe("Unknown App");
});
it("should return empty string for non-32267 events", () => {
const event = createAppEvent({
kind: 1,
tags: [["name", "Test"]],
});
expect(getAppName(event)).toBe("");
});
});
describe("getAppIdentifier", () => {
it("should extract d tag value", () => {
const event = createAppEvent({
tags: [["d", "com.oxchat.nostr"]],
});
expect(getAppIdentifier(event)).toBe("com.oxchat.nostr");
});
it("should return undefined if no d tag", () => {
const event = createAppEvent({
tags: [],
});
expect(getAppIdentifier(event)).toBeUndefined();
});
it("should return undefined for non-32267 events", () => {
const event = createAppEvent({
kind: 1,
tags: [["d", "test"]],
});
expect(getAppIdentifier(event)).toBeUndefined();
});
});
describe("getAppSummary", () => {
it("should extract summary from summary tag", () => {
const event = createAppEvent({
tags: [["summary", "A secure chat app built on Nostr"]],
});
expect(getAppSummary(event)).toBe("A secure chat app built on Nostr");
});
it("should fallback to content if no summary tag", () => {
const event = createAppEvent({
content: "Fallback description from content",
tags: [],
});
expect(getAppSummary(event)).toBe("Fallback description from content");
});
it("should return undefined if no summary and empty content", () => {
const event = createAppEvent({
content: "",
tags: [],
});
expect(getAppSummary(event)).toBeUndefined();
});
it("should prefer summary tag over content", () => {
const event = createAppEvent({
content: "Content description",
tags: [["summary", "Summary description"]],
});
expect(getAppSummary(event)).toBe("Summary description");
});
});
describe("getAppRepository", () => {
it("should extract repository URL", () => {
const event = createAppEvent({
tags: [["repository", "https://github.com/0xchat-app/0xchat-app-main"]],
});
expect(getAppRepository(event)).toBe(
"https://github.com/0xchat-app/0xchat-app-main",
);
});
it("should return undefined if no repository tag", () => {
const event = createAppEvent({
tags: [],
});
expect(getAppRepository(event)).toBeUndefined();
});
});
describe("getAppIcon", () => {
it("should extract icon URL", () => {
const event = createAppEvent({
tags: [["icon", "https://cdn.zapstore.dev/icon.png"]],
});
expect(getAppIcon(event)).toBe("https://cdn.zapstore.dev/icon.png");
});
it("should return undefined if no icon tag", () => {
const event = createAppEvent({
tags: [],
});
expect(getAppIcon(event)).toBeUndefined();
});
});
describe("getAppImages", () => {
it("should extract all image URLs", () => {
const event = createAppEvent({
tags: [
["image", "https://cdn.zapstore.dev/image1.png"],
["image", "https://cdn.zapstore.dev/image2.png"],
["image", "https://cdn.zapstore.dev/image3.png"],
["name", "App"],
],
});
expect(getAppImages(event)).toEqual([
"https://cdn.zapstore.dev/image1.png",
"https://cdn.zapstore.dev/image2.png",
"https://cdn.zapstore.dev/image3.png",
]);
});
it("should return empty array if no image tags", () => {
const event = createAppEvent({
tags: [["name", "App"]],
});
expect(getAppImages(event)).toEqual([]);
});
it("should return empty array for non-32267 events", () => {
const event = createAppEvent({
kind: 1,
tags: [["image", "test.png"]],
});
expect(getAppImages(event)).toEqual([]);
});
});
describe("getAppLicense", () => {
it("should extract license", () => {
const event = createAppEvent({
tags: [["license", "MIT"]],
});
expect(getAppLicense(event)).toBe("MIT");
});
it("should return undefined if no license tag", () => {
const event = createAppEvent({
tags: [],
});
expect(getAppLicense(event)).toBeUndefined();
});
});
describe("getAppPlatforms", () => {
it("should extract all platform/architecture values from f tags", () => {
const event = createAppEvent({
tags: [
["f", "android-arm64-v8a"],
["f", "android-armeabi-v7a"],
["name", "App"],
],
});
expect(getAppPlatforms(event)).toEqual([
"android-arm64-v8a",
"android-armeabi-v7a",
]);
});
it("should return empty array if no f tags", () => {
const event = createAppEvent({
tags: [["name", "App"]],
});
expect(getAppPlatforms(event)).toEqual([]);
});
it("should return empty array for non-32267 events", () => {
const event = createAppEvent({
kind: 1,
tags: [["f", "test"]],
});
expect(getAppPlatforms(event)).toEqual([]);
});
});
describe("getAppReleases", () => {
it("should extract release references from a tags", () => {
const event = createAppEvent({
tags: [
[
"a",
"30063:5eca50a04afaefe55659fb74810b42654e2268c1acca6e53801b9862db74a83a:com.oxchat.nostr@v1.5.1-release",
],
],
});
const releases = getAppReleases(event);
expect(releases).toHaveLength(1);
expect(releases[0]).toEqual({
kind: 30063,
pubkey:
"5eca50a04afaefe55659fb74810b42654e2268c1acca6e53801b9862db74a83a",
identifier: "com.oxchat.nostr@v1.5.1-release",
});
});
it("should handle multiple release references", () => {
const event = createAppEvent({
tags: [
["a", "30063:pubkey1:release1"],
["a", "30063:pubkey2:release2"],
],
});
const releases = getAppReleases(event);
expect(releases).toHaveLength(2);
});
it("should filter out invalid a tags", () => {
const event = createAppEvent({
tags: [
["a", "30063:pubkey1:release1"],
["a", "invalid"],
["a", "30063:pubkey2:release2"],
],
});
const releases = getAppReleases(event);
expect(releases).toHaveLength(2);
});
it("should return empty array if no a tags", () => {
const event = createAppEvent({
tags: [["name", "App"]],
});
expect(getAppReleases(event)).toEqual([]);
});
});
});
describe("Kind 30267 (App Curation Set) Helpers", () => {
describe("getCurationSetName", () => {
it("should extract name from name tag", () => {
const event = createCurationSetEvent({
tags: [
["name", "Nostr Social"],
["d", "nostr-social"],
],
});
expect(getCurationSetName(event)).toBe("Nostr Social");
});
it("should fallback to d tag if no name tag", () => {
const event = createCurationSetEvent({
tags: [["d", "my-collection"]],
});
expect(getCurationSetName(event)).toBe("my-collection");
});
it("should return 'Unnamed Collection' if no name and no d tag", () => {
const event = createCurationSetEvent({
tags: [],
});
expect(getCurationSetName(event)).toBe("Unnamed Collection");
});
it("should return empty string for non-30267 events", () => {
const event = createCurationSetEvent({
kind: 1,
tags: [["name", "Test"]],
});
expect(getCurationSetName(event)).toBe("");
});
});
describe("getCurationSetIdentifier", () => {
it("should extract d tag value", () => {
const event = createCurationSetEvent({
tags: [["d", "nostr-social"]],
});
expect(getCurationSetIdentifier(event)).toBe("nostr-social");
});
it("should return undefined if no d tag", () => {
const event = createCurationSetEvent({
tags: [],
});
expect(getCurationSetIdentifier(event)).toBeUndefined();
});
it("should return undefined for non-30267 events", () => {
const event = createCurationSetEvent({
kind: 1,
tags: [["d", "test"]],
});
expect(getCurationSetIdentifier(event)).toBeUndefined();
});
});
describe("getAppReferences", () => {
it("should extract app references from a tags", () => {
const event = createCurationSetEvent({
tags: [
["d", "nostr-social"],
[
"a",
"32267:4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0:to.iris",
"wss://relay.com",
],
[
"a",
"32267:b090908101cc6498893cc7f14d745dcea0b2ab6842cc4b512515643d272a375c:net.primal.android",
],
],
});
const refs = getAppReferences(event);
expect(refs).toHaveLength(2);
expect(refs[0].address).toEqual({
kind: 32267,
pubkey:
"4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0",
identifier: "to.iris",
});
expect(refs[0].relayHint).toBe("wss://relay.com");
expect(refs[1].relayHint).toBeUndefined();
});
it("should only include kind 32267 references", () => {
const event = createCurationSetEvent({
tags: [
["d", "collection"],
["a", "32267:pubkey1:app1"],
["a", "30023:pubkey2:article1"],
["a", "32267:pubkey3:app2"],
],
});
const refs = getAppReferences(event);
expect(refs).toHaveLength(2);
expect(refs[0].address.kind).toBe(32267);
expect(refs[1].address.kind).toBe(32267);
});
it("should filter out invalid a tags", () => {
const event = createCurationSetEvent({
tags: [
["d", "collection"],
["a", "32267:pubkey1:app1"],
["a", "invalid-format"],
["a", "32267:pubkey2:app2"],
],
});
const refs = getAppReferences(event);
expect(refs).toHaveLength(2);
});
it("should return empty array if no a tags", () => {
const event = createCurationSetEvent({
tags: [["d", "collection"]],
});
expect(getAppReferences(event)).toEqual([]);
});
it("should return empty array for non-30267 events", () => {
const event = createCurationSetEvent({
kind: 1,
tags: [["a", "32267:pubkey:app"]],
});
expect(getAppReferences(event)).toEqual([]);
});
});
});
describe("Shared Helpers", () => {
describe("parseAddressPointer", () => {
it("should parse valid address pointer", () => {
const result = parseAddressPointer("32267:abcd1234:com.example.app");
expect(result).toEqual({
kind: 32267,
pubkey: "abcd1234",
identifier: "com.example.app",
});
});
it("should handle empty identifier", () => {
const result = parseAddressPointer("30267:abcd1234:");
expect(result).toEqual({
kind: 30267,
pubkey: "abcd1234",
identifier: "",
});
});
it("should return null for invalid format", () => {
expect(parseAddressPointer("invalid")).toBeNull();
expect(parseAddressPointer("32267:abcd")).toBeNull();
expect(parseAddressPointer("not-a-kind:pubkey:id")).toBeNull();
});
it("should handle long pubkeys and identifiers", () => {
const longPubkey =
"5eca50a04afaefe55659fb74810b42654e2268c1acca6e53801b9862db74a83a";
const longId = "com.oxchat.nostr@v1.5.1-release";
const result = parseAddressPointer(`30063:${longPubkey}:${longId}`);
expect(result).toEqual({
kind: 30063,
pubkey: longPubkey,
identifier: longId,
});
});
});
});

216
src/lib/zapstore-helpers.ts Normal file
View File

@@ -0,0 +1,216 @@
import { NostrEvent } from "@/types/nostr";
import { getTagValue } from "applesauce-core/helpers";
import { AddressPointer } from "nostr-tools/nip19";
/**
* Zapstore Helper Functions
* For working with App Metadata (32267) and App Curation Set (30267) 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 32267 (App Metadata) Helpers
// ============================================================================
/**
* Get app name from kind 32267 name tag
*/
export function getAppName(event: NostrEvent): string {
if (event.kind !== 32267) return "";
const name = getTagValue(event, "name");
if (name && typeof name === "string") {
return name;
}
// Fallback to d tag identifier
const dTag = getTagValue(event, "d");
return dTag && typeof dTag === "string" ? dTag : "Unknown App";
}
/**
* Get app identifier from kind 32267 d tag (like package name)
*/
export function getAppIdentifier(event: NostrEvent): string | undefined {
if (event.kind !== 32267) return undefined;
return getTagValue(event, "d");
}
/**
* Get app summary/description from kind 32267 summary tag
*/
export function getAppSummary(event: NostrEvent): string | undefined {
if (event.kind !== 32267) return undefined;
const summary = getTagValue(event, "summary");
if (summary && typeof summary === "string") {
return summary;
}
// Fallback to content if no summary tag
return event.content || undefined;
}
/**
* Get repository URL from kind 32267 repository tag
*/
export function getAppRepository(event: NostrEvent): string | undefined {
if (event.kind !== 32267) return undefined;
return getTagValue(event, "repository");
}
/**
* Get app icon URL from kind 32267 icon tag
*/
export function getAppIcon(event: NostrEvent): string | undefined {
if (event.kind !== 32267) return undefined;
return getTagValue(event, "icon");
}
/**
* Get app screenshot URLs from kind 32267 image tags (multiple)
*/
export function getAppImages(event: NostrEvent): string[] {
if (event.kind !== 32267) return [];
return getTagValues(event, "image");
}
/**
* Get app license from kind 32267 license tag
*/
export function getAppLicense(event: NostrEvent): string | undefined {
if (event.kind !== 32267) return undefined;
return getTagValue(event, "license");
}
/**
* Get supported platforms/architectures from kind 32267 f tags
*/
export function getAppPlatforms(event: NostrEvent): string[] {
if (event.kind !== 32267) return [];
return getTagValues(event, "f");
}
/**
* Get release artifact references from kind 32267 a tags (usually kind 30063)
*/
export function getAppReleases(event: NostrEvent): AddressPointer[] {
if (event.kind !== 32267) return [];
const aTags = event.tags.filter((tag) => tag[0] === "a");
const releases: AddressPointer[] = [];
for (const tag of aTags) {
const aTagValue = tag[1];
if (!aTagValue) continue;
const address = parseAddressPointer(aTagValue);
if (address) {
releases.push(address);
}
}
return releases;
}
// ============================================================================
// Kind 30267 (App Curation Set) Helpers
// ============================================================================
/**
* Get curation set name from kind 30267 name tag
*/
export function getCurationSetName(event: NostrEvent): string {
if (event.kind !== 30267) return "";
const name = getTagValue(event, "name");
if (name && typeof name === "string") {
return name;
}
// Fallback to d tag identifier
const dTag = getTagValue(event, "d");
return dTag && typeof dTag === "string" ? dTag : "Unnamed Collection";
}
/**
* Get curation set identifier from kind 30267 d tag
*/
export function getCurationSetIdentifier(
event: NostrEvent,
): string | undefined {
if (event.kind !== 30267) return undefined;
return getTagValue(event, "d");
}
/**
* App reference with relay hint from a tag
*/
export interface AppReference {
address: AddressPointer;
relayHint?: string;
}
/**
* Get all app references from kind 30267 a tags
*/
export function getAppReferences(event: NostrEvent): AppReference[] {
if (event.kind !== 30267) return [];
const references: AppReference[] = [];
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;
// Kind 32267 apps are expected in curation sets
if (address.kind === 32267) {
const relayHint = tag[2];
references.push({
address,
relayHint: relayHint || undefined,
});
}
}
return references;
}
// ============================================================================
// Shared Helpers
// ============================================================================
/**
* 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,
};
}