diff --git a/src/components/nostr/kinds/ZapstoreAppDetailRenderer.tsx b/src/components/nostr/kinds/ZapstoreAppDetailRenderer.tsx
new file mode 100644
index 0000000..2465308
--- /dev/null
+++ b/src/components/nostr/kinds/ZapstoreAppDetailRenderer.tsx
@@ -0,0 +1,301 @@
+import { NostrEvent } from "@/types/nostr";
+import {
+ getAppName,
+ getAppSummary,
+ getAppIcon,
+ getAppImages,
+ detectPlatforms,
+ getAppRepository,
+ getAppLicense,
+ getAppIdentifier,
+ getReleaseVersion,
+ getReleaseFileEventId,
+} from "@/lib/zapstore-helpers";
+import type { Platform } from "@/lib/zapstore-helpers";
+import { UserName } from "../UserName";
+import { ExternalLink } from "@/components/ExternalLink";
+import { MediaEmbed } from "../MediaEmbed";
+import { Badge } from "@/components/ui/badge";
+import { use$ } from "applesauce-react/hooks";
+import eventStore from "@/services/event-store";
+import { useMemo } from "react";
+import { useGrimoire } from "@/core/state";
+import {
+ Package,
+ Globe,
+ Smartphone,
+ TabletSmartphone,
+ Monitor,
+ Laptop,
+ FileDown,
+} from "lucide-react";
+
+interface ZapstoreAppDetailRendererProps {
+ event: NostrEvent;
+}
+
+/**
+ * Release item component showing version and download link
+ */
+function ReleaseItem({ release }: { release: NostrEvent }) {
+ const { addWindow } = useGrimoire();
+ const version = getReleaseVersion(release);
+ const fileEventId = getReleaseFileEventId(release);
+
+ const handleClick = () => {
+ addWindow("open", {
+ pointer: {
+ kind: release.kind,
+ pubkey: release.pubkey,
+ identifier: release.tags.find((t) => t[0] === "d")?.[1] || "",
+ },
+ });
+ };
+
+ const handleDownload = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ if (fileEventId) {
+ addWindow("open", { pointer: { id: fileEventId } });
+ }
+ };
+
+ return (
+
+
+
+ {fileEventId && (
+
+ )}
+
+ );
+}
+
+/**
+ * Platform icon and label component
+ */
+function PlatformItem({ platform }: { platform: Platform }) {
+ const iconClass = "size-5";
+
+ const getPlatformName = () => {
+ switch (platform) {
+ case "android":
+ return "Android";
+ case "ios":
+ return "iOS";
+ case "web":
+ return "Web";
+ case "macos":
+ return "macOS";
+ case "windows":
+ return "Windows";
+ case "linux":
+ return "Linux";
+ default:
+ return platform;
+ }
+ };
+
+ const getIcon = () => {
+ switch (platform) {
+ case "android":
+ return ;
+ case "ios":
+ return ;
+ case "web":
+ return ;
+ case "macos":
+ return ;
+ case "windows":
+ case "linux":
+ return ;
+ default:
+ return null;
+ }
+ };
+
+ return (
+
+ {getIcon()}
+ {getPlatformName()}
+
+ );
+}
+
+/**
+ * Detail renderer for Kind 32267 - App
+ * Shows comprehensive app information including screenshots, platforms, and releases
+ */
+export function ZapstoreAppDetailRenderer({
+ event,
+}: ZapstoreAppDetailRendererProps) {
+ const appName = getAppName(event);
+ const summary = getAppSummary(event);
+ const iconUrl = getAppIcon(event);
+ const images = getAppImages(event);
+ const platforms = detectPlatforms(event);
+ const repository = getAppRepository(event);
+ const license = getAppLicense(event);
+ const identifier = getAppIdentifier(event);
+
+ // Query for releases that reference this app
+ const releasesFilter = useMemo(() => {
+ if (!identifier) {
+ // Return a filter that matches nothing when no identifier
+ return { kinds: [30063], ids: [] };
+ }
+ return {
+ kinds: [30063],
+ "#a": [`32267:${event.pubkey}:${identifier}`],
+ };
+ }, [event.pubkey, identifier]);
+
+ const releases = use$(
+ () => eventStore.timeline(releasesFilter),
+ [releasesFilter],
+ );
+
+ // Sort releases by version (newest first) or created_at
+ const sortedReleases = useMemo(() => {
+ const releasesList = releases || [];
+ return [...releasesList].sort((a, b) => {
+ const versionA = getReleaseVersion(a);
+ const versionB = getReleaseVersion(b);
+ if (versionA && versionB) {
+ return versionB.localeCompare(versionA, undefined, { numeric: true });
+ }
+ return b.created_at - a.created_at;
+ });
+ }, [releases]);
+
+ 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 && (
+
+
Available On
+
+ {platforms.map((platform) => (
+
+ ))}
+
+
+ )}
+
+ {/* Releases Section */}
+ {sortedReleases.length > 0 && (
+
+
+ Releases ({sortedReleases.length})
+
+
+ {sortedReleases.map((release) => (
+
+ ))}
+
+
+ )}
+
+ {/* 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..204bb7a
--- /dev/null
+++ b/src/components/nostr/kinds/ZapstoreAppRenderer.tsx
@@ -0,0 +1,48 @@
+import {
+ BaseEventContainer,
+ BaseEventProps,
+ ClickableEventTitle,
+} from "./BaseEventRenderer";
+import {
+ getAppName,
+ getAppSummary,
+ detectPlatforms,
+} from "@/lib/zapstore-helpers";
+import { PlatformIcon } from "./zapstore/PlatformIcon";
+
+/**
+ * Renderer for Kind 32267 - App Metadata
+ * Clean feed view with app name, summary, and platform icons
+ */
+export function ZapstoreAppRenderer({ event }: BaseEventProps) {
+ const appName = getAppName(event);
+ const summary = getAppSummary(event);
+ const platforms = detectPlatforms(event);
+
+ return (
+
+
+
+ {appName}
+
+
+ {summary && (
+
+ {summary}
+
+ )}
+
+ {platforms.length > 0 && (
+
+ {platforms.map((platform) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/ZapstoreAppSetDetailRenderer.tsx b/src/components/nostr/kinds/ZapstoreAppSetDetailRenderer.tsx
new file mode 100644
index 0000000..7d4e6e5
--- /dev/null
+++ b/src/components/nostr/kinds/ZapstoreAppSetDetailRenderer.tsx
@@ -0,0 +1,149 @@
+import { NostrEvent } from "@/types/nostr";
+import {
+ getCurationSetName,
+ getAppReferences,
+ getAppName,
+ getAppSummary,
+ getAppIcon,
+ detectPlatforms,
+ getCurationSetIdentifier,
+} from "@/lib/zapstore-helpers";
+import { useNostrEvent } from "@/hooks/useNostrEvent";
+import { useGrimoire } from "@/core/state";
+import { UserName } from "../UserName";
+import { Package } from "lucide-react";
+import { PlatformIcon } from "./zapstore/PlatformIcon";
+
+interface ZapstoreAppSetDetailRendererProps {
+ event: NostrEvent;
+}
+
+/**
+ * App card showing app details with icon, summary, and platforms
+ */
+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 = detectPlatforms(appEvent);
+
+ const handleClick = () => {
+ addWindow("open", { pointer: address });
+ };
+
+ return (
+
+ {iconUrl ? (
+

+ ) : (
+
+ )}
+
+
+
+
+ {summary && (
+
+ {summary}
+
+ )}
+
+ {platforms.length > 0 && (
+
+ {platforms.map((platform) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+/**
+ * Detail renderer for Kind 30267 - App Collection
+ * Displays all apps in the collection with comprehensive metadata
+ */
+export function ZapstoreAppSetDetailRenderer({
+ event,
+}: ZapstoreAppSetDetailRendererProps) {
+ const setName = getCurationSetName(event);
+ const apps = getAppReferences(event);
+ const identifier = getCurationSetIdentifier(event);
+
+ return (
+
+
+
{setName}
+
+
+
+
Curated by
+
+
+
+ {identifier && (
+
+
Collection ID
+
+ {identifier}
+
+
+ )}
+
+
+
+ {apps.length} {apps.length === 1 ? "app" : "apps"} in this collection
+
+
+
+
+
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..c911a7b
--- /dev/null
+++ b/src/components/nostr/kinds/ZapstoreAppSetRenderer.tsx
@@ -0,0 +1,75 @@
+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";
+
+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 - App Collection
+ * Compact feed view listing all apps similar to relay lists
+ */
+export function ZapstoreAppSetRenderer({ event }: BaseEventProps) {
+ const setName = getCurationSetName(event);
+ const apps = getAppReferences(event);
+
+ return (
+
+
+
+ {setName}
+
+
+
+ {apps.length} {apps.length === 1 ? "app" : "apps"}
+
+
+ {apps.length > 0 && (
+
+ {apps.map((ref, idx) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/ZapstoreReleaseDetailRenderer.tsx b/src/components/nostr/kinds/ZapstoreReleaseDetailRenderer.tsx
new file mode 100644
index 0000000..08adb93
--- /dev/null
+++ b/src/components/nostr/kinds/ZapstoreReleaseDetailRenderer.tsx
@@ -0,0 +1,134 @@
+import { NostrEvent } from "@/types/nostr";
+import {
+ getReleaseVersion,
+ getReleaseIdentifier,
+ getReleaseFileEventId,
+ getReleaseAppPointer,
+ getAppName,
+ getAppIcon,
+} from "@/lib/zapstore-helpers";
+import { useNostrEvent } from "@/hooks/useNostrEvent";
+import { useGrimoire } from "@/core/state";
+import { Badge } from "@/components/ui/badge";
+import { UserName } from "../UserName";
+import {
+ Package,
+ FileDown,
+ ExternalLink as ExternalLinkIcon,
+} from "lucide-react";
+import { Kind1063Renderer } from "./FileMetadataRenderer";
+
+interface ZapstoreReleaseDetailRendererProps {
+ event: NostrEvent;
+}
+
+/**
+ * Detail renderer for Kind 30063 - App Release
+ * Shows release information with embedded file metadata
+ */
+export function ZapstoreReleaseDetailRenderer({
+ event,
+}: ZapstoreReleaseDetailRendererProps) {
+ const { addWindow } = useGrimoire();
+ const version = getReleaseVersion(event);
+ const identifier = getReleaseIdentifier(event);
+ const fileEventId = getReleaseFileEventId(event);
+ const appPointer = getReleaseAppPointer(event);
+
+ const appEvent = useNostrEvent(appPointer || undefined);
+ const fileEvent = useNostrEvent(
+ fileEventId ? { id: fileEventId } : undefined,
+ event,
+ );
+
+ const appName = appEvent ? getAppName(appEvent) : appPointer?.identifier;
+ const appIcon = appEvent ? getAppIcon(appEvent) : undefined;
+
+ const handleAppClick = () => {
+ if (appPointer) {
+ addWindow("open", { pointer: appPointer });
+ }
+ };
+
+ return (
+
+
+ {appIcon ? (
+

+ ) : (
+
+ )}
+
+
+
+
{appName || "Release"}
+ {version && (
+
+ v{version}
+
+ )}
+
+
+ {appName && appPointer && (
+
+ )}
+
+
+
+
+
+
Publisher
+
+
+
+ {identifier && (
+
+
Release ID
+
+ {identifier}
+
+
+ )}
+
+
+ {fileEvent && (
+
+ )}
+
+ {fileEventId && !fileEvent && (
+
+
+ Loading file metadata...
+
+ )}
+
+ {!fileEventId && (
+
+
+ No file metadata available
+
+ )}
+
+ );
+}
diff --git a/src/components/nostr/kinds/ZapstoreReleaseRenderer.tsx b/src/components/nostr/kinds/ZapstoreReleaseRenderer.tsx
new file mode 100644
index 0000000..e788ec7
--- /dev/null
+++ b/src/components/nostr/kinds/ZapstoreReleaseRenderer.tsx
@@ -0,0 +1,81 @@
+import {
+ BaseEventContainer,
+ BaseEventProps,
+ ClickableEventTitle,
+} from "./BaseEventRenderer";
+import {
+ getReleaseVersion,
+ getReleaseFileEventId,
+ getReleaseAppPointer,
+ getAppName,
+} from "@/lib/zapstore-helpers";
+import { useNostrEvent } from "@/hooks/useNostrEvent";
+import { useGrimoire } from "@/core/state";
+import { Badge } from "@/components/ui/badge";
+import { Package, FileDown } from "lucide-react";
+
+/**
+ * Renderer for Kind 30063 - App Release
+ * Displays release version with links to app and download file
+ */
+export function ZapstoreReleaseRenderer({ event }: BaseEventProps) {
+ const { addWindow } = useGrimoire();
+ const version = getReleaseVersion(event);
+ const fileEventId = getReleaseFileEventId(event);
+ const appPointer = getReleaseAppPointer(event);
+
+ const appEvent = useNostrEvent(appPointer || undefined);
+ const appName = appEvent ? getAppName(appEvent) : appPointer?.identifier;
+
+ const handleAppClick = () => {
+ if (appPointer) {
+ addWindow("open", { pointer: appPointer });
+ }
+ };
+
+ const handleFileClick = () => {
+ if (fileEventId) {
+ addWindow("open", { pointer: { id: fileEventId } });
+ }
+ };
+
+ return (
+
+
+
+ {appName && `${appName} `}
+ {version && (
+
+ v{version}
+
+ )}
+
+
+
+ {appName && (
+
+ )}
+
+ {fileEventId && (
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx
index da8f34c..80441e4 100644
--- a/src/components/nostr/kinds/index.tsx
+++ b/src/components/nostr/kinds/index.tsx
@@ -54,6 +54,12 @@ 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 { ZapstoreReleaseRenderer } from "./ZapstoreReleaseRenderer";
+import { ZapstoreReleaseDetailRenderer } from "./ZapstoreReleaseDetailRenderer";
import { NostrEvent } from "@/types/nostr";
import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer";
@@ -94,6 +100,8 @@ const kindRenderers: Record> = {
30002: GenericRelayListRenderer, // Relay Sets (NIP-51)
30023: Kind30023Renderer, // Long-form Article
30030: EmojiSetRenderer, // Emoji Sets (NIP-30)
+ 30063: ZapstoreReleaseRenderer, // Zapstore App Release
+ 30267: ZapstoreAppSetRenderer, // Zapstore App Collection
30311: LiveActivityRenderer, // Live Streaming Event (NIP-53)
34235: Kind21Renderer, // Horizontal Video (NIP-71 legacy)
34236: Kind22Renderer, // Vertical Video (NIP-71 legacy)
@@ -105,6 +113,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, // Zapstore App
39701: Kind39701Renderer, // Web Bookmarks (NIP-B0)
};
@@ -159,6 +168,8 @@ const detailRenderers: Record<
777: SpellDetailRenderer, // Spell Detail
30023: Kind30023DetailRenderer, // Long-form Article Detail
30030: EmojiSetDetailRenderer, // Emoji Sets Detail (NIP-30)
+ 30063: ZapstoreReleaseDetailRenderer, // Zapstore App Release Detail
+ 30267: ZapstoreAppSetDetailRenderer, // Zapstore App Collection Detail
30311: LiveActivityDetailRenderer, // Live Streaming Event Detail (NIP-53)
30617: RepositoryDetailRenderer, // Repository Detail (NIP-34)
30618: RepositoryStateDetailRenderer, // Repository State Detail (NIP-34)
@@ -168,6 +179,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, // Zapstore App Detail
};
/**
diff --git a/src/components/nostr/kinds/zapstore/PlatformIcon.tsx b/src/components/nostr/kinds/zapstore/PlatformIcon.tsx
new file mode 100644
index 0000000..fcb9373
--- /dev/null
+++ b/src/components/nostr/kinds/zapstore/PlatformIcon.tsx
@@ -0,0 +1,71 @@
+import type { Platform } from "@/lib/zapstore-helpers";
+import {
+ Globe,
+ Smartphone,
+ TabletSmartphone,
+ Monitor,
+ Laptop,
+} from "lucide-react";
+
+interface PlatformIconProps {
+ platform: Platform;
+ showLabel?: boolean;
+ size?: "sm" | "md";
+}
+
+export function PlatformIcon({
+ platform,
+ showLabel = true,
+ size = "sm",
+}: PlatformIconProps) {
+ const iconClass = size === "sm" ? "size-3" : "size-4";
+
+ const getPlatformLabel = () => {
+ switch (platform) {
+ case "android":
+ return "Android";
+ case "ios":
+ return "iOS";
+ case "web":
+ return "Web";
+ case "macos":
+ return "macOS";
+ case "windows":
+ return "Windows";
+ case "linux":
+ return "Linux";
+ default:
+ return platform;
+ }
+ };
+
+ const getIcon = () => {
+ const className = `${iconClass} text-muted-foreground`;
+ switch (platform) {
+ case "android":
+ return ;
+ case "ios":
+ return ;
+ case "web":
+ return ;
+ case "macos":
+ return ;
+ case "windows":
+ case "linux":
+ return ;
+ default:
+ return null;
+ }
+ };
+
+ return (
+
+ {getIcon()}
+ {showLabel && (
+
+ {getPlatformLabel()}
+
+ )}
+
+ );
+}
diff --git a/src/constants/kinds.ts b/src/constants/kinds.ts
index eb0299a..9bc78e6 100644
--- a/src/constants/kinds.ts
+++ b/src/constants/kinds.ts
@@ -1172,8 +1172,8 @@ export const EVENT_KINDS: Record = {
// },
30063: {
kind: 30063,
- name: "Release Artifact Set",
- description: "Release artifact sets",
+ name: "App Release",
+ description: "Application release with version and files",
nip: "51",
icon: Package,
},
@@ -1193,8 +1193,8 @@ export const EVENT_KINDS: Record = {
},
30267: {
kind: 30267,
- name: "App Curation",
- description: "App curation sets",
+ name: "App Collection",
+ description: "Curated collection of applications",
nip: "51",
icon: BookHeart,
},
@@ -1345,13 +1345,13 @@ export const EVENT_KINDS: Record = {
nip: "89",
icon: Package,
},
- // 32267: {
- // kind: 32267,
- // name: "Software App",
- // description: "Software Application",
- // nip: "",
- // icon: AppWindow,
- // },
+ 32267: {
+ kind: 32267,
+ name: "App",
+ description: "Application metadata with platforms and screenshots",
+ nip: "",
+ icon: Package,
+ },
34235: {
kind: 34235,
name: "Video",
diff --git a/src/lib/zapstore-helpers.test.ts b/src/lib/zapstore-helpers.test.ts
new file mode 100644
index 0000000..b2e7ce6
--- /dev/null
+++ b/src/lib/zapstore-helpers.test.ts
@@ -0,0 +1,651 @@
+import { describe, it, expect } from "vitest";
+import {
+ getAppName,
+ getAppIdentifier,
+ getAppSummary,
+ getAppRepository,
+ getAppIcon,
+ getAppImages,
+ getAppLicense,
+ getAppPlatforms,
+ getAppReleases,
+ getCurationSetName,
+ getCurationSetIdentifier,
+ getAppReferences,
+ getReleaseIdentifier,
+ getReleaseVersion,
+ getReleaseFileEventId,
+ getReleaseAppPointer,
+ 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("Kind 30063 (Release) Helpers", () => {
+ // Helper to create a minimal kind 30063 event (Release)
+ function createReleaseEvent(overrides?: Partial): NostrEvent {
+ return {
+ id: "test-id",
+ pubkey: "test-pubkey",
+ created_at: 1234567890,
+ kind: 30063,
+ tags: [],
+ content: "",
+ sig: "test-sig",
+ ...overrides,
+ };
+ }
+
+ describe("getReleaseIdentifier", () => {
+ it("should extract release identifier from d tag", () => {
+ const event = createReleaseEvent({
+ tags: [["d", "com.wavves.app@1.0.0"]],
+ });
+ expect(getReleaseIdentifier(event)).toBe("com.wavves.app@1.0.0");
+ });
+
+ it("should return undefined if no d tag", () => {
+ const event = createReleaseEvent({
+ tags: [],
+ });
+ expect(getReleaseIdentifier(event)).toBeUndefined();
+ });
+
+ it("should return undefined for non-30063 events", () => {
+ const event = createReleaseEvent({
+ kind: 1,
+ tags: [["d", "test"]],
+ });
+ expect(getReleaseIdentifier(event)).toBeUndefined();
+ });
+ });
+
+ describe("getReleaseVersion", () => {
+ it("should extract version from identifier with @ symbol", () => {
+ const event = createReleaseEvent({
+ tags: [["d", "com.wavves.app@1.0.0"]],
+ });
+ expect(getReleaseVersion(event)).toBe("1.0.0");
+ });
+
+ it("should handle version with multiple parts", () => {
+ const event = createReleaseEvent({
+ tags: [["d", "com.example.app@2.5.1-beta"]],
+ });
+ expect(getReleaseVersion(event)).toBe("2.5.1-beta");
+ });
+
+ it("should handle identifier with multiple @ symbols (use last one)", () => {
+ const event = createReleaseEvent({
+ tags: [["d", "com.example@app@3.0.0"]],
+ });
+ expect(getReleaseVersion(event)).toBe("3.0.0");
+ });
+
+ it("should return undefined if no @ in identifier", () => {
+ const event = createReleaseEvent({
+ tags: [["d", "no-version-here"]],
+ });
+ expect(getReleaseVersion(event)).toBeUndefined();
+ });
+
+ it("should return undefined if @ is at end", () => {
+ const event = createReleaseEvent({
+ tags: [["d", "com.example.app@"]],
+ });
+ expect(getReleaseVersion(event)).toBeUndefined();
+ });
+
+ it("should return undefined if no d tag", () => {
+ const event = createReleaseEvent({
+ tags: [],
+ });
+ expect(getReleaseVersion(event)).toBeUndefined();
+ });
+
+ it("should return undefined for non-30063 events", () => {
+ const event = createReleaseEvent({
+ kind: 1,
+ tags: [["d", "test@1.0.0"]],
+ });
+ expect(getReleaseVersion(event)).toBeUndefined();
+ });
+ });
+
+ describe("getReleaseFileEventId", () => {
+ it("should extract file event ID from e tag", () => {
+ const event = createReleaseEvent({
+ tags: [
+ [
+ "e",
+ "365a0e4a1da3c13c839f0ab170fc3dfadf246368f3a5fc6df2bb18b2db9fcb7e",
+ ],
+ ],
+ });
+ expect(getReleaseFileEventId(event)).toBe(
+ "365a0e4a1da3c13c839f0ab170fc3dfadf246368f3a5fc6df2bb18b2db9fcb7e",
+ );
+ });
+
+ it("should return undefined if no e tag", () => {
+ const event = createReleaseEvent({
+ tags: [],
+ });
+ expect(getReleaseFileEventId(event)).toBeUndefined();
+ });
+
+ it("should return undefined for non-30063 events", () => {
+ const event = createReleaseEvent({
+ kind: 1,
+ tags: [["e", "test123"]],
+ });
+ expect(getReleaseFileEventId(event)).toBeUndefined();
+ });
+ });
+
+ describe("getReleaseAppPointer", () => {
+ it("should extract app metadata pointer from a tag", () => {
+ const event = createReleaseEvent({
+ tags: [
+ [
+ "a",
+ "32267:7a42d5fa97d51fb73e90406f55dc2fb05f49b54c1910496ddc4b66c92a34779e:com.wavves.app",
+ ],
+ ],
+ });
+ const pointer = getReleaseAppPointer(event);
+ expect(pointer).toEqual({
+ kind: 32267,
+ pubkey:
+ "7a42d5fa97d51fb73e90406f55dc2fb05f49b54c1910496ddc4b66c92a34779e",
+ identifier: "com.wavves.app",
+ });
+ });
+
+ it("should return null if a tag points to wrong kind", () => {
+ const event = createReleaseEvent({
+ tags: [["a", "30023:pubkey:article"]],
+ });
+ expect(getReleaseAppPointer(event)).toBeNull();
+ });
+
+ it("should return null if a tag is invalid", () => {
+ const event = createReleaseEvent({
+ tags: [["a", "invalid-format"]],
+ });
+ expect(getReleaseAppPointer(event)).toBeNull();
+ });
+
+ it("should return null if no a tag", () => {
+ const event = createReleaseEvent({
+ tags: [],
+ });
+ expect(getReleaseAppPointer(event)).toBeNull();
+ });
+
+ it("should return null for non-30063 events", () => {
+ const event = createReleaseEvent({
+ kind: 1,
+ tags: [["a", "32267:pubkey:app"]],
+ });
+ expect(getReleaseAppPointer(event)).toBeNull();
+ });
+ });
+});
+
+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..aa3f2b1
--- /dev/null
+++ b/src/lib/zapstore-helpers.ts
@@ -0,0 +1,323 @@
+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");
+}
+
+/**
+ * Platform names for display
+ */
+export type Platform =
+ | "android"
+ | "ios"
+ | "web"
+ | "linux"
+ | "windows"
+ | "macos";
+
+/**
+ * Detect unique platforms from f tags
+ * Normalizes architecture-specific tags (e.g., "android-arm64-v8a" → "android")
+ */
+export function detectPlatforms(event: NostrEvent): Platform[] {
+ if (event.kind !== 32267 && event.kind !== 1063) return [];
+
+ const fTags = getTagValues(event, "f");
+ const platformSet = new Set();
+
+ for (const tag of fTags) {
+ const lower = tag.toLowerCase();
+
+ if (lower.startsWith("android")) {
+ platformSet.add("android");
+ } else if (lower.startsWith("ios") || lower.includes("iphone")) {
+ platformSet.add("ios");
+ } else if (lower === "web" || lower.includes("web")) {
+ platformSet.add("web");
+ } else if (lower.includes("linux")) {
+ platformSet.add("linux");
+ } else if (lower.includes("windows") || lower.includes("win")) {
+ platformSet.add("windows");
+ } else if (
+ lower.includes("macos") ||
+ lower.includes("mac") ||
+ lower.includes("darwin")
+ ) {
+ platformSet.add("macos");
+ }
+ }
+
+ // Sort for consistent order
+ return Array.from(platformSet).sort();
+}
+
+/**
+ * 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;
+}
+
+// ============================================================================
+// Kind 30063 (Release) Helpers
+// ============================================================================
+
+/**
+ * Get release identifier from kind 30063 d tag
+ * Usually in format: package@version (e.g., "com.wavves.app@1.0.0")
+ */
+export function getReleaseIdentifier(event: NostrEvent): string | undefined {
+ if (event.kind !== 30063) return undefined;
+ return getTagValue(event, "d");
+}
+
+/**
+ * Get version from release identifier
+ * Extracts version from "package@version" format
+ */
+export function getReleaseVersion(event: NostrEvent): string | undefined {
+ if (event.kind !== 30063) return undefined;
+
+ const identifier = getReleaseIdentifier(event);
+ if (!identifier) return undefined;
+
+ // Try to extract version after @ symbol
+ const atIndex = identifier.lastIndexOf("@");
+ if (atIndex !== -1 && atIndex < identifier.length - 1) {
+ return identifier.substring(atIndex + 1);
+ }
+
+ return undefined;
+}
+
+/**
+ * Get file metadata event ID from kind 30063 e tag
+ * Points to kind 1063 (File Metadata) event
+ */
+export function getReleaseFileEventId(event: NostrEvent): string | undefined {
+ if (event.kind !== 30063) return undefined;
+ return getTagValue(event, "e");
+}
+
+/**
+ * Get app metadata pointer from kind 30063 a tag
+ * Points to kind 32267 (App Metadata) event
+ */
+export function getReleaseAppPointer(event: NostrEvent): AddressPointer | null {
+ if (event.kind !== 30063) return null;
+
+ const aTag = getTagValue(event, "a");
+ if (!aTag) return null;
+
+ const pointer = parseAddressPointer(aTag);
+ // Verify it points to an app metadata event
+ if (pointer && pointer.kind === 32267) {
+ return pointer;
+ }
+
+ return null;
+}
+
+// ============================================================================
+// 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,
+ };
+}