mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 07:27:23 +02:00
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:
301
src/components/nostr/kinds/ZapstoreAppDetailRenderer.tsx
Normal file
301
src/components/nostr/kinds/ZapstoreAppDetailRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
src/components/nostr/kinds/ZapstoreAppRenderer.tsx
Normal file
48
src/components/nostr/kinds/ZapstoreAppRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
149
src/components/nostr/kinds/ZapstoreAppSetDetailRenderer.tsx
Normal file
149
src/components/nostr/kinds/ZapstoreAppSetDetailRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
src/components/nostr/kinds/ZapstoreAppSetRenderer.tsx
Normal file
75
src/components/nostr/kinds/ZapstoreAppSetRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
134
src/components/nostr/kinds/ZapstoreReleaseDetailRenderer.tsx
Normal file
134
src/components/nostr/kinds/ZapstoreReleaseDetailRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
src/components/nostr/kinds/ZapstoreReleaseRenderer.tsx
Normal file
81
src/components/nostr/kinds/ZapstoreReleaseRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
71
src/components/nostr/kinds/zapstore/PlatformIcon.tsx
Normal file
71
src/components/nostr/kinds/zapstore/PlatformIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
651
src/lib/zapstore-helpers.test.ts
Normal file
651
src/lib/zapstore-helpers.test.ts
Normal 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
323
src/lib/zapstore-helpers.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user