diff --git a/src/components/nostr/kinds/ZapstoreReleaseDetailRenderer.tsx b/src/components/nostr/kinds/ZapstoreReleaseDetailRenderer.tsx
new file mode 100644
index 0000000..5b56e7f
--- /dev/null
+++ b/src/components/nostr/kinds/ZapstoreReleaseDetailRenderer.tsx
@@ -0,0 +1,143 @@
+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 - Zapstore Release
+ * Shows comprehensive release information including 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);
+
+ // Fetch related events
+ const appEvent = useNostrEvent(appPointer || undefined);
+ const fileEvent = useNostrEvent(
+ fileEventId ? { id: fileEventId } : undefined,
+ );
+
+ const appName = appEvent ? getAppName(appEvent) : appPointer?.identifier;
+ const appIcon = appEvent ? getAppIcon(appEvent) : undefined;
+
+ const handleAppClick = () => {
+ if (appPointer) {
+ addWindow("open", { pointer: appPointer });
+ }
+ };
+
+ return (
+
+ {/* Header Section */}
+
+ {/* App Icon or Package Icon */}
+ {appIcon ? (
+

+ ) : (
+
+ )}
+
+ {/* Release Title */}
+
+
+
{appName || "Release"}
+ {version && (
+
+ v{version}
+
+ )}
+
+
+ {/* App Link */}
+ {appName && appPointer && (
+
+ )}
+
+
+
+ {/* Metadata Grid */}
+
+ {/* Publisher */}
+
+
Publisher
+
+
+
+ {/* Release Identifier */}
+ {identifier && (
+
+
Release ID
+
+ {identifier}
+
+
+ )}
+
+
+ {/* File Metadata Section */}
+ {fileEvent && (
+
+ )}
+
+ {/* Loading/Missing States */}
+ {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..c3cdaf8
--- /dev/null
+++ b/src/components/nostr/kinds/ZapstoreReleaseRenderer.tsx
@@ -0,0 +1,94 @@
+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 - Zapstore Release
+ * Displays release version and links to app and file metadata
+ */
+export function ZapstoreReleaseRenderer({ event }: BaseEventProps) {
+ const { addWindow } = useGrimoire();
+ const version = getReleaseVersion(event);
+ const fileEventId = getReleaseFileEventId(event);
+ const appPointer = getReleaseAppPointer(event);
+
+ // Fetch app metadata to show app name
+ 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 (
+
+
+ {/* Icon */}
+
+
+ {/* Release Info */}
+
+ {/* Title */}
+
+ {appName && `${appName} `}
+ {version && (
+
+ v{version}
+
+ )}
+
+
+ {/* Links */}
+
+ {/* App Link */}
+ {appName && (
+
+ )}
+
+ {/* File Link */}
+ {fileEventId && (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx
index 7f3e8b8..afaf6a4 100644
--- a/src/components/nostr/kinds/index.tsx
+++ b/src/components/nostr/kinds/index.tsx
@@ -58,6 +58,8 @@ 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";
@@ -98,6 +100,7 @@ const kindRenderers: Record> = {
30002: GenericRelayListRenderer, // Relay Sets (NIP-51)
30023: Kind30023Renderer, // Long-form Article
30030: EmojiSetRenderer, // Emoji Sets (NIP-30)
+ 30063: ZapstoreReleaseRenderer, // App Release (Zapstore)
30267: ZapstoreAppSetRenderer, // App Curation Set (Zapstore)
30311: LiveActivityRenderer, // Live Streaming Event (NIP-53)
34235: Kind21Renderer, // Horizontal Video (NIP-71 legacy)
@@ -165,6 +168,7 @@ const detailRenderers: Record<
777: SpellDetailRenderer, // Spell Detail
30023: Kind30023DetailRenderer, // Long-form Article Detail
30030: EmojiSetDetailRenderer, // Emoji Sets Detail (NIP-30)
+ 30063: ZapstoreReleaseDetailRenderer, // App Release Detail (Zapstore)
30267: ZapstoreAppSetDetailRenderer, // App Curation Set Detail (Zapstore)
30311: LiveActivityDetailRenderer, // Live Streaming Event Detail (NIP-53)
30617: RepositoryDetailRenderer, // Repository Detail (NIP-34)
diff --git a/src/lib/zapstore-helpers.test.ts b/src/lib/zapstore-helpers.test.ts
index e194df7..b2e7ce6 100644
--- a/src/lib/zapstore-helpers.test.ts
+++ b/src/lib/zapstore-helpers.test.ts
@@ -12,6 +12,10 @@ import {
getCurationSetName,
getCurationSetIdentifier,
getAppReferences,
+ getReleaseIdentifier,
+ getReleaseVersion,
+ getReleaseFileEventId,
+ getReleaseAppPointer,
parseAddressPointer,
} from "./zapstore-helpers";
import { NostrEvent } from "@/types/nostr";
@@ -434,6 +438,178 @@ describe("Kind 30267 (App Curation Set) Helpers", () => {
});
});
+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", () => {
diff --git a/src/lib/zapstore-helpers.ts b/src/lib/zapstore-helpers.ts
index ce5b1a4..6c5398b 100644
--- a/src/lib/zapstore-helpers.ts
+++ b/src/lib/zapstore-helpers.ts
@@ -190,6 +190,66 @@ export function getAppReferences(event: NostrEvent): AppReference[] {
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
// ============================================================================