From c223245e363ee5651634148c06d1010848bca412 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Jan 2026 18:36:40 +0000 Subject: [PATCH] feat: Add Zapstore release renderer (kind 30063) Add support for rendering Zapstore app release events (kind 30063): - Kind 30063 (Release): Connects apps (32267) to file artifacts (1063) New files: - src/components/nostr/kinds/ZapstoreReleaseRenderer.tsx: Feed view for releases - src/components/nostr/kinds/ZapstoreReleaseDetailRenderer.tsx: Detail view with embedded file metadata Modified: - src/lib/zapstore-helpers.ts: Add release helper functions - getReleaseIdentifier(): Extract release ID (package@version) - getReleaseVersion(): Parse version from identifier - getReleaseFileEventId(): Get file metadata event pointer - getReleaseAppPointer(): Get app metadata pointer - src/lib/zapstore-helpers.test.ts: Add 18 new tests for release helpers (61 total) - src/components/nostr/kinds/index.tsx: Register kind 30063 renderers Complete Zapstore app ecosystem now supported: - Kind 32267: App metadata (name, icon, description) - Kind 30267: App curation sets (collections) - Kind 30063: App releases (version tracking) - Kind 1063: File metadata (downloads) All tests pass (744 total), build succeeds. --- .../kinds/ZapstoreReleaseDetailRenderer.tsx | 143 ++++++++++++++ .../nostr/kinds/ZapstoreReleaseRenderer.tsx | 94 ++++++++++ src/components/nostr/kinds/index.tsx | 4 + src/lib/zapstore-helpers.test.ts | 176 ++++++++++++++++++ src/lib/zapstore-helpers.ts | 60 ++++++ 5 files changed, 477 insertions(+) create mode 100644 src/components/nostr/kinds/ZapstoreReleaseDetailRenderer.tsx create mode 100644 src/components/nostr/kinds/ZapstoreReleaseRenderer.tsx 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 ? ( + {appName + ) : ( +
+ +
+ )} + + {/* 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 && ( +
+

+ + Download +

+
+ +
+
+ )} + + {/* 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 // ============================================================================