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.
This commit is contained in:
Claude
2026-01-11 18:36:40 +00:00
parent 4a8797ad8b
commit c223245e36
5 changed files with 477 additions and 0 deletions

View File

@@ -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 (
<div className="flex flex-col gap-6 p-6 max-w-4xl mx-auto">
{/* Header Section */}
<div className="flex gap-4">
{/* App Icon or Package Icon */}
{appIcon ? (
<img
src={appIcon}
alt={appName || "App"}
className="size-20 rounded-lg object-cover flex-shrink-0"
loading="lazy"
/>
) : (
<div className="size-20 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<Package className="size-10 text-primary" />
</div>
)}
{/* Release Title */}
<div className="flex flex-col gap-2 flex-1 min-w-0">
<div className="flex items-baseline gap-2 flex-wrap">
<h1 className="text-3xl font-bold">{appName || "Release"}</h1>
{version && (
<Badge variant="default" className="text-base px-3 py-1">
v{version}
</Badge>
)}
</div>
{/* App Link */}
{appName && appPointer && (
<button
onClick={handleAppClick}
className="flex items-center gap-2 text-primary hover:underline text-left"
>
<ExternalLinkIcon className="size-4" />
<span>View App Details</span>
</button>
)}
</div>
</div>
{/* Metadata Grid */}
<div className="grid grid-cols-2 gap-4 text-sm">
{/* Publisher */}
<div className="flex flex-col gap-1">
<h3 className="text-muted-foreground">Publisher</h3>
<UserName pubkey={event.pubkey} />
</div>
{/* Release Identifier */}
{identifier && (
<div className="flex flex-col gap-1">
<h3 className="text-muted-foreground">Release ID</h3>
<code className="font-mono text-sm truncate" title={identifier}>
{identifier}
</code>
</div>
)}
</div>
{/* File Metadata Section */}
{fileEvent && (
<div className="flex flex-col gap-3">
<h2 className="text-xl font-semibold flex items-center gap-2">
<FileDown className="size-5" />
Download
</h2>
<div className="border border-border rounded-lg overflow-hidden">
<Kind1063Renderer event={fileEvent} depth={0} />
</div>
</div>
)}
{/* Loading/Missing States */}
{fileEventId && !fileEvent && (
<div className="flex items-center gap-2 p-4 bg-muted/20 rounded-lg text-muted-foreground">
<FileDown className="size-5" />
<span>Loading file metadata...</span>
</div>
)}
{!fileEventId && (
<div className="flex items-center gap-2 p-4 bg-muted/20 rounded-lg text-muted-foreground">
<FileDown className="size-5" />
<span>No file metadata available</span>
</div>
)}
</div>
);
}

View File

@@ -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 (
<BaseEventContainer event={event}>
<div className="flex gap-3">
{/* Icon */}
<div className="size-12 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<Package className="size-6 text-primary" />
</div>
{/* Release Info */}
<div className="flex flex-col gap-2 flex-1 min-w-0">
{/* Title */}
<ClickableEventTitle
event={event}
className="text-base font-semibold text-foreground"
>
{appName && `${appName} `}
{version && (
<Badge variant="secondary" className="text-xs ml-1">
v{version}
</Badge>
)}
</ClickableEventTitle>
{/* Links */}
<div className="flex items-center gap-3 flex-wrap text-sm">
{/* App Link */}
{appName && (
<button
onClick={handleAppClick}
className="flex items-center gap-1.5 text-primary hover:underline"
>
<Package className="size-3" />
<span>View App</span>
</button>
)}
{/* File Link */}
{fileEventId && (
<button
onClick={handleFileClick}
className="flex items-center gap-1.5 text-primary hover:underline"
>
<FileDown className="size-3" />
<span>Download File</span>
</button>
)}
</div>
</div>
</div>
</BaseEventContainer>
);
}

View File

@@ -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<number, React.ComponentType<BaseEventProps>> = {
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)

View File

@@ -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>): 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", () => {

View File

@@ -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
// ============================================================================