feat: Add Zapstore app and app curation set renderers (#49)

* feat: Add Zapstore app and app curation set renderers

Add support for rendering Zapstore app-related Nostr events:
- Kind 32267 (App Metadata): Display app details, icon, platforms, screenshots
- Kind 30267 (App Curation Set): Display curated app collections

New files:
- src/lib/zapstore-helpers.ts: Helper functions for extracting app metadata
- src/lib/zapstore-helpers.test.ts: Comprehensive test coverage (43 tests)
- src/components/nostr/kinds/ZapstoreAppRenderer.tsx: Feed view for apps
- src/components/nostr/kinds/ZapstoreAppDetailRenderer.tsx: Detail view for apps
- src/components/nostr/kinds/ZapstoreAppSetRenderer.tsx: Feed view for collections
- src/components/nostr/kinds/ZapstoreAppSetDetailRenderer.tsx: Detail view for collections

Modified:
- src/components/nostr/kinds/index.tsx: Register new renderers in kind registry

All tests pass (726 total), build succeeds, no lint errors.

* 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.

* refactor: Simplify Zapstore app renderers with platform icons

Improve Zapstore app rendering for cleaner, more intuitive display:

Changes:
- Add detectPlatforms() helper to normalize architecture tags (e.g., "android-arm64-v8a" → "android")
- Replace verbose platform badges with clean platform icons (Android, iOS, Web, macOS, Windows, Linux)
- Remove screenshots from feed view (keep in detail view only)
- Remove repository links and license badges from feed view
- Update detail view to show "Available On" with icon+label platform items

Feed view now shows:
- App icon
- App name
- Summary (2 lines max)
- Platform icons (just icons, no text)

Detail view now shows:
- App icon, name, summary
- Publisher, Package ID, License, Repository (metadata grid)
- Available On (platforms with icons and labels)
- Screenshots gallery (unchanged)

All tests pass (744 total), build succeeds.

* fix: Polish Zapstore renderers with platform labels and clean layout

Address feedback to improve Zapstore renderer UX:

Changes:
1. App feed (ZapstoreAppRenderer):
   - Add platform text labels next to icons (e.g., "Android", "iOS", "Web")
   - Now shows icon + label for better clarity

2. Release feed (ZapstoreReleaseRenderer):
   - Remove big package icon from feed view
   - Cleaner, more compact layout with just app name, version badge, and action links

3. Registry comments:
   - Update to human-friendly names:
     * "Zapstore App" (instead of "App Metadata (Zapstore)")
     * "Zapstore App Collection" (instead of "App Curation Set (Zapstore)")
     * "Zapstore App Release" (instead of "App Release (Zapstore)")

All tests pass (744 total), build succeeds.

* refactor: Update Zapstore app set renderers with improved UX

- ZapstoreAppSetRenderer: Show ALL apps with compact spacing (gap-0.5) like relay lists, removed 5-app limit
- ZapstoreAppSetDetailRenderer: Replace raw platform tags with normalized platform icons using detectPlatforms()
- Both renderers now provide cleaner, more consistent UI following Grimoire patterns

* refactor: Add human-friendly names and simplify Zapstore renderers

- kinds.ts: Add kind 32267 (App), update 30063 to "App Release", update 30267 to "App Collection"
- Extract PlatformIcon to shared component (zapstore/PlatformIcon.tsx)
- Update all renderer comments to use human-friendly terminology
- Remove unnecessary comments throughout Zapstore renderers
- Simplify code without changing functionality

* feat: Add releases section to app detail view

- Query for all releases (kind 30063) that reference the app
- Display releases sorted by version (newest first)
- Each release shows version badge and download link
- Clicking release opens full release detail view
- Clicking download opens file metadata view

* fix: Force screenshots as images and filter releases by author

- Add type="image" to MediaEmbed for screenshots to fix "unsupported media type" errors
- Filter releases to only show those from the same author (pubkey) as the app
- Prevents releases from other apps or authors from appearing in the app detail view

* fix: Remove author filter from releases query

The a tag already uniquely identifies the app (32267:pubkey:identifier).
Releases may be published by different authors (maintainers, packagers)
than the app author, so we should show all releases that reference
the app via the a tag, regardless of who published them.

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Alejandro
2026-01-11 21:36:47 +01:00
committed by GitHub
parent 84b5ac88aa
commit 5233c57a1c
11 changed files with 1856 additions and 11 deletions

View File

@@ -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 (
<div className="flex items-center justify-between p-3 bg-muted/20 rounded-lg hover:bg-muted/30 transition-colors">
<button
onClick={handleClick}
className="flex items-center gap-2 hover:underline cursor-crosshair"
>
<Package className="size-4 text-muted-foreground" />
<span className="font-medium">
{version ? `Version ${version}` : "Release"}
</span>
{version && (
<Badge variant="secondary" className="text-xs">
v{version}
</Badge>
)}
</button>
{fileEventId && (
<button
onClick={handleDownload}
className="flex items-center gap-1.5 text-primary hover:underline text-sm"
>
<FileDown className="size-4" />
<span>Download</span>
</button>
)}
</div>
);
}
/**
* 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 <TabletSmartphone className={iconClass} />;
case "ios":
return <Smartphone className={iconClass} />;
case "web":
return <Globe className={iconClass} />;
case "macos":
return <Laptop className={iconClass} />;
case "windows":
case "linux":
return <Monitor className={iconClass} />;
default:
return null;
}
};
return (
<div className="flex items-center gap-2 px-3 py-2 bg-muted/30 rounded-lg">
{getIcon()}
<span className="text-sm font-medium">{getPlatformName()}</span>
</div>
);
}
/**
* 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 (
<div className="flex flex-col gap-6 p-6 max-w-4xl mx-auto">
{/* Header Section */}
<div className="flex gap-4">
{/* App Icon */}
{iconUrl ? (
<img
src={iconUrl}
alt={appName}
className="size-20 rounded-lg object-cover flex-shrink-0"
loading="lazy"
/>
) : (
<div className="size-20 rounded-lg bg-muted flex items-center justify-center flex-shrink-0">
<Package className="size-10 text-muted-foreground" />
</div>
)}
{/* App Title & Summary */}
<div className="flex flex-col gap-2 flex-1 min-w-0">
<h1 className="text-3xl font-bold">{appName}</h1>
{summary && (
<p className="text-muted-foreground text-base">{summary}</p>
)}
</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>
{/* Identifier */}
{identifier && (
<div className="flex flex-col gap-1">
<h3 className="text-muted-foreground">Package ID</h3>
<code className="font-mono text-sm truncate" title={identifier}>
{identifier}
</code>
</div>
)}
{/* License */}
{license && (
<div className="flex flex-col gap-1">
<h3 className="text-muted-foreground">License</h3>
<code className="font-mono text-sm">{license}</code>
</div>
)}
{/* Repository */}
{repository && (
<div className="flex flex-col gap-1">
<h3 className="text-muted-foreground">Repository</h3>
<ExternalLink href={repository} className="truncate">
{repository}
</ExternalLink>
</div>
)}
</div>
{/* Platforms Section */}
{platforms.length > 0 && (
<div className="flex flex-col gap-3">
<h2 className="text-xl font-semibold">Available On</h2>
<div className="flex flex-wrap gap-2">
{platforms.map((platform) => (
<PlatformItem key={platform} platform={platform} />
))}
</div>
</div>
)}
{/* Releases Section */}
{sortedReleases.length > 0 && (
<div className="flex flex-col gap-3">
<h2 className="text-xl font-semibold">
Releases ({sortedReleases.length})
</h2>
<div className="flex flex-col gap-2">
{sortedReleases.map((release) => (
<ReleaseItem key={release.id} release={release} />
))}
</div>
</div>
)}
{/* Screenshots Section */}
{images.length > 0 && (
<div className="flex flex-col gap-3">
<h2 className="text-xl font-semibold">
Screenshots ({images.length})
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{images.map((imageUrl, idx) => (
<MediaEmbed
key={idx}
url={imageUrl}
type="image"
preset="preview"
enableZoom
className="w-full rounded-lg overflow-hidden aspect-video"
/>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -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 (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
<ClickableEventTitle
event={event}
className="text-base font-semibold text-foreground"
>
{appName}
</ClickableEventTitle>
{summary && (
<p className="text-sm text-muted-foreground line-clamp-2">
{summary}
</p>
)}
{platforms.length > 0 && (
<div className="flex items-center gap-2">
{platforms.map((platform) => (
<PlatformIcon key={platform} platform={platform} />
))}
</div>
)}
</div>
</BaseEventContainer>
);
}

View File

@@ -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 (
<div className="p-4 bg-muted/20 rounded-lg border border-border">
<div className="flex items-center gap-2">
<Package className="size-5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
Loading {address?.identifier || "app"}...
</span>
</div>
</div>
);
}
const appName = getAppName(appEvent);
const summary = getAppSummary(appEvent);
const iconUrl = getAppIcon(appEvent);
const platforms = detectPlatforms(appEvent);
const handleClick = () => {
addWindow("open", { pointer: address });
};
return (
<div className="p-4 bg-muted/20 rounded-lg border border-border flex gap-4 hover:bg-muted/30 transition-colors">
{iconUrl ? (
<img
src={iconUrl}
alt={appName}
className="size-16 rounded-lg object-cover flex-shrink-0"
loading="lazy"
/>
) : (
<div className="size-16 rounded-lg bg-muted flex items-center justify-center flex-shrink-0">
<Package className="size-8 text-muted-foreground" />
</div>
)}
<div className="flex-1 flex flex-col gap-2 min-w-0">
<button
onClick={handleClick}
className="text-lg font-semibold hover:underline cursor-crosshair text-left"
>
{appName}
</button>
{summary && (
<p className="text-sm text-muted-foreground line-clamp-2">
{summary}
</p>
)}
{platforms.length > 0 && (
<div className="flex items-center gap-2">
{platforms.map((platform) => (
<PlatformIcon key={platform} platform={platform} />
))}
</div>
)}
</div>
</div>
);
}
/**
* 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 (
<div className="flex flex-col gap-6 p-6">
<div className="flex flex-col gap-3">
<h1 className="text-3xl font-bold">{setName}</h1>
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="flex flex-col gap-1">
<h3 className="text-muted-foreground">Curated by</h3>
<UserName pubkey={event.pubkey} />
</div>
{identifier && (
<div className="flex flex-col gap-1">
<h3 className="text-muted-foreground">Collection ID</h3>
<code className="font-mono text-sm truncate" title={identifier}>
{identifier}
</code>
</div>
)}
</div>
<p className="text-muted-foreground">
{apps.length} {apps.length === 1 ? "app" : "apps"} in this collection
</p>
</div>
<div className="flex flex-col gap-3">
<h2 className="text-xl font-semibold">Apps</h2>
{apps.length === 0 ? (
<p className="text-muted-foreground">
No apps in this collection yet.
</p>
) : (
<div className="flex flex-col gap-3">
{apps.map((ref, idx) => (
<AppCard key={idx} address={ref.address} />
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -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 (
<div className="flex items-center gap-2">
<Package className="size-3 text-muted-foreground" />
<button
onClick={handleClick}
className="text-sm hover:underline cursor-crosshair text-primary truncate"
>
{appName}
</button>
</div>
);
}
/**
* 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 (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
<ClickableEventTitle
event={event}
className="text-base font-semibold text-foreground"
>
{setName}
</ClickableEventTitle>
<p className="text-sm text-muted-foreground">
{apps.length} {apps.length === 1 ? "app" : "apps"}
</p>
{apps.length > 0 && (
<div className="flex flex-col gap-0.5">
{apps.map((ref, idx) => (
<AppItem key={idx} address={ref.address} />
))}
</div>
)}
</div>
</BaseEventContainer>
);
}

View File

@@ -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 (
<div className="flex flex-col gap-6 p-6 max-w-4xl mx-auto">
<div className="flex gap-4">
{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>
)}
<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>
{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>
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="flex flex-col gap-1">
<h3 className="text-muted-foreground">Publisher</h3>
<UserName pubkey={event.pubkey} />
</div>
{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>
{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>
)}
{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,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 (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
<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>
<div className="flex items-center gap-3 flex-wrap text-sm">
{appName && (
<button
onClick={handleAppClick}
className="flex items-center gap-1.5 text-primary hover:underline"
>
<Package className="size-3" />
<span>{appName}</span>
</button>
)}
{fileEventId && (
<button
onClick={handleFileClick}
className="flex items-center gap-1.5 text-primary hover:underline"
>
<FileDown className="size-3" />
<span>Download</span>
</button>
)}
</div>
</div>
</BaseEventContainer>
);
}

View File

@@ -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<number, React.ComponentType<BaseEventProps>> = {
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<number, React.ComponentType<BaseEventProps>> = {
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
};
/**

View File

@@ -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 <TabletSmartphone className={className} />;
case "ios":
return <Smartphone className={className} />;
case "web":
return <Globe className={className} />;
case "macos":
return <Laptop className={className} />;
case "windows":
case "linux":
return <Monitor className={className} />;
default:
return null;
}
};
return (
<div className="flex items-center gap-1.5">
{getIcon()}
{showLabel && (
<span className="text-xs text-muted-foreground">
{getPlatformLabel()}
</span>
)}
</div>
);
}

View File

@@ -1172,8 +1172,8 @@ export const EVENT_KINDS: Record<number | string, EventKind> = {
// },
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<number | string, EventKind> = {
},
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<number | string, EventKind> = {
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",

View File

@@ -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>): 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>): 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>): 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,
});
});
});
});

323
src/lib/zapstore-helpers.ts Normal file
View File

@@ -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<Platform>();
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,
};
}