diff --git a/src/components/nostr/kinds/ZapstoreAppDetailRenderer.tsx b/src/components/nostr/kinds/ZapstoreAppDetailRenderer.tsx
new file mode 100644
index 0000000..7b77aaa
--- /dev/null
+++ b/src/components/nostr/kinds/ZapstoreAppDetailRenderer.tsx
@@ -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 (
+
+ {/* Header Section */}
+
+ {/* App Icon */}
+ {iconUrl ? (
+

+ ) : (
+
+ )}
+
+ {/* App Title & Summary */}
+
+
{appName}
+ {summary && (
+
{summary}
+ )}
+
+
+
+ {/* Metadata Grid */}
+
+ {/* Publisher */}
+
+
Publisher
+
+
+
+ {/* Identifier */}
+ {identifier && (
+
+
Package ID
+
+ {identifier}
+
+
+ )}
+
+ {/* License */}
+ {license && (
+
+
License
+ {license}
+
+ )}
+
+ {/* Repository */}
+ {repository && (
+
+
Repository
+
+ {repository}
+
+
+ )}
+
+
+ {/* Platforms Section */}
+ {platforms.length > 0 && (
+
+
+ Platforms ({platforms.length})
+
+
+ {platforms.map((platform) => (
+
+ {platform}
+
+ ))}
+
+
+ )}
+
+ {/* Screenshots Section */}
+ {images.length > 0 && (
+
+
+ Screenshots ({images.length})
+
+
+ {images.map((imageUrl, idx) => (
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/src/components/nostr/kinds/ZapstoreAppRenderer.tsx b/src/components/nostr/kinds/ZapstoreAppRenderer.tsx
new file mode 100644
index 0000000..3d7a14d
--- /dev/null
+++ b/src/components/nostr/kinds/ZapstoreAppRenderer.tsx
@@ -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 (
+
+
+ {/* App Icon */}
+ {iconUrl ? (
+

+ ) : (
+
+ )}
+
+ {/* App Info */}
+
+ {/* App Name */}
+
+ {appName}
+
+
+ {/* Summary */}
+ {summary && (
+
+ {summary}
+
+ )}
+
+ {/* Platforms & License */}
+
+ {platforms.length > 0 && (
+ <>
+ {platforms.slice(0, 4).map((platform) => (
+
+ {platform}
+
+ ))}
+ {platforms.length > 4 && (
+
+ +{platforms.length - 4} more
+
+ )}
+ >
+ )}
+ {license && (
+
+ {license}
+
+ )}
+
+
+ {/* Repository Link */}
+ {repository && (
+
+ {repository}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/ZapstoreAppSetDetailRenderer.tsx b/src/components/nostr/kinds/ZapstoreAppSetDetailRenderer.tsx
new file mode 100644
index 0000000..866ac17
--- /dev/null
+++ b/src/components/nostr/kinds/ZapstoreAppSetDetailRenderer.tsx
@@ -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 (
+
+
+
+
+ Loading {address?.identifier || "app"}...
+
+
+
+ );
+ }
+
+ const appName = getAppName(appEvent);
+ const summary = getAppSummary(appEvent);
+ const iconUrl = getAppIcon(appEvent);
+ const platforms = getAppPlatforms(appEvent);
+
+ const handleClick = () => {
+ addWindow("open", { pointer: address });
+ };
+
+ return (
+
+ {/* App Icon */}
+ {iconUrl ? (
+

+ ) : (
+
+ )}
+
+ {/* App Info */}
+
+ {/* App Name */}
+
+
+ {/* Summary */}
+ {summary && (
+
+ {summary}
+
+ )}
+
+ {/* Platforms */}
+ {platforms.length > 0 && (
+
+ {platforms.slice(0, 6).map((platform) => (
+
+ {platform}
+
+ ))}
+ {platforms.length > 6 && (
+
+ +{platforms.length - 6} more
+
+ )}
+
+ )}
+
+
+ );
+}
+
+/**
+ * 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 (
+
+ {/* Header Section */}
+
+
{setName}
+
+ {/* Metadata */}
+
+ {/* Curator */}
+
+
Curated by
+
+
+
+ {/* Identifier */}
+ {identifier && (
+
+
Collection ID
+
+ {identifier}
+
+
+ )}
+
+
+ {/* App Count */}
+
+ {apps.length} {apps.length === 1 ? "app" : "apps"} in this collection
+
+
+
+ {/* Apps Section */}
+
+
Apps
+
+ {apps.length === 0 ? (
+
+ No apps in this collection yet.
+
+ ) : (
+
+ {apps.map((ref, idx) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/ZapstoreAppSetRenderer.tsx b/src/components/nostr/kinds/ZapstoreAppSetRenderer.tsx
new file mode 100644
index 0000000..9f939e3
--- /dev/null
+++ b/src/components/nostr/kinds/ZapstoreAppSetRenderer.tsx
@@ -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 (
+
+ );
+}
+
+/**
+ * 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 (
+
+
+ {/* Collection Name */}
+
+ {setName}
+
+
+ {/* App Count */}
+
+ {apps.length} {apps.length === 1 ? "app" : "apps"}
+
+
+ {/* App List */}
+ {displayApps.length > 0 && (
+
+ {displayApps.map((ref, idx) => (
+
+ ))}
+ {remainingCount > 0 && (
+
+ +{remainingCount} more app{remainingCount > 1 ? "s" : ""}
+
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx
index da8f34c..7f3e8b8 100644
--- a/src/components/nostr/kinds/index.tsx
+++ b/src/components/nostr/kinds/index.tsx
@@ -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> = {
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> = {
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)
};
/**
diff --git a/src/lib/zapstore-helpers.test.ts b/src/lib/zapstore-helpers.test.ts
new file mode 100644
index 0000000..e194df7
--- /dev/null
+++ b/src/lib/zapstore-helpers.test.ts
@@ -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 {
+ 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 {
+ 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,
+ });
+ });
+ });
+});
diff --git a/src/lib/zapstore-helpers.ts b/src/lib/zapstore-helpers.ts
new file mode 100644
index 0000000..ce5b1a4
--- /dev/null
+++ b/src/lib/zapstore-helpers.ts
@@ -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,
+ };
+}