mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-11 07:56:50 +02:00
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:
143
src/components/nostr/kinds/ZapstoreReleaseDetailRenderer.tsx
Normal file
143
src/components/nostr/kinds/ZapstoreReleaseDetailRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
src/components/nostr/kinds/ZapstoreReleaseRenderer.tsx
Normal file
94
src/components/nostr/kinds/ZapstoreReleaseRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user