diff --git a/src/components/nostr/kinds/ZapstoreAppDetailRenderer.tsx b/src/components/nostr/kinds/ZapstoreAppDetailRenderer.tsx new file mode 100644 index 0000000..2465308 --- /dev/null +++ b/src/components/nostr/kinds/ZapstoreAppDetailRenderer.tsx @@ -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 ( +
+ + + {fileEventId && ( + + )} +
+ ); +} + +/** + * 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 ; + case "ios": + return ; + case "web": + return ; + case "macos": + return ; + case "windows": + case "linux": + return ; + default: + return null; + } + }; + + return ( +
+ {getIcon()} + {getPlatformName()} +
+ ); +} + +/** + * 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 ( +
+ {/* Header Section */} +
+ {/* App Icon */} + {iconUrl ? ( + {appName} + ) : ( +
+ +
+ )} + + {/* App Title & Summary */} +
+

{appName}

+ {summary && ( +

{summary}

+ )} +
+
+ + {/* Metadata Grid */} +
+ {/* Publisher */} +
+

Publisher

+ +
+ + {/* Identifier */} + {identifier && ( +
+

Package ID

+ + {identifier} + +
+ )} + + {/* License */} + {license && ( +
+

License

+ {license} +
+ )} + + {/* Repository */} + {repository && ( +
+

Repository

+ + {repository} + +
+ )} +
+ + {/* Platforms Section */} + {platforms.length > 0 && ( +
+

Available On

+
+ {platforms.map((platform) => ( + + ))} +
+
+ )} + + {/* Releases Section */} + {sortedReleases.length > 0 && ( +
+

+ Releases ({sortedReleases.length}) +

+
+ {sortedReleases.map((release) => ( + + ))} +
+
+ )} + + {/* Screenshots Section */} + {images.length > 0 && ( +
+

+ Screenshots ({images.length}) +

+
+ {images.map((imageUrl, idx) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/src/components/nostr/kinds/ZapstoreAppRenderer.tsx b/src/components/nostr/kinds/ZapstoreAppRenderer.tsx new file mode 100644 index 0000000..204bb7a --- /dev/null +++ b/src/components/nostr/kinds/ZapstoreAppRenderer.tsx @@ -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 ( + +
+ + {appName} + + + {summary && ( +

+ {summary} +

+ )} + + {platforms.length > 0 && ( +
+ {platforms.map((platform) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/src/components/nostr/kinds/ZapstoreAppSetDetailRenderer.tsx b/src/components/nostr/kinds/ZapstoreAppSetDetailRenderer.tsx new file mode 100644 index 0000000..7d4e6e5 --- /dev/null +++ b/src/components/nostr/kinds/ZapstoreAppSetDetailRenderer.tsx @@ -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 ( +
+
+ + + Loading {address?.identifier || "app"}... + +
+
+ ); + } + + const appName = getAppName(appEvent); + const summary = getAppSummary(appEvent); + const iconUrl = getAppIcon(appEvent); + const platforms = detectPlatforms(appEvent); + + const handleClick = () => { + addWindow("open", { pointer: address }); + }; + + return ( +
+ {iconUrl ? ( + {appName} + ) : ( +
+ +
+ )} + +
+ + + {summary && ( +

+ {summary} +

+ )} + + {platforms.length > 0 && ( +
+ {platforms.map((platform) => ( + + ))} +
+ )} +
+
+ ); +} + +/** + * 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 ( +
+
+

{setName}

+ +
+
+

Curated by

+ +
+ + {identifier && ( +
+

Collection ID

+ + {identifier} + +
+ )} +
+ +

+ {apps.length} {apps.length === 1 ? "app" : "apps"} in this collection +

+
+ +
+

Apps

+ + {apps.length === 0 ? ( +

+ No apps in this collection yet. +

+ ) : ( +
+ {apps.map((ref, idx) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/src/components/nostr/kinds/ZapstoreAppSetRenderer.tsx b/src/components/nostr/kinds/ZapstoreAppSetRenderer.tsx new file mode 100644 index 0000000..c911a7b --- /dev/null +++ b/src/components/nostr/kinds/ZapstoreAppSetRenderer.tsx @@ -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 ( +
+ + +
+ ); +} + +/** + * 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 ( + +
+ + {setName} + + +

+ {apps.length} {apps.length === 1 ? "app" : "apps"} +

+ + {apps.length > 0 && ( +
+ {apps.map((ref, idx) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/src/components/nostr/kinds/ZapstoreReleaseDetailRenderer.tsx b/src/components/nostr/kinds/ZapstoreReleaseDetailRenderer.tsx new file mode 100644 index 0000000..08adb93 --- /dev/null +++ b/src/components/nostr/kinds/ZapstoreReleaseDetailRenderer.tsx @@ -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 ( +
+
+ {appIcon ? ( + {appName + ) : ( +
+ +
+ )} + +
+
+

{appName || "Release"}

+ {version && ( + + v{version} + + )} +
+ + {appName && appPointer && ( + + )} +
+
+ +
+
+

Publisher

+ +
+ + {identifier && ( +
+

Release ID

+ + {identifier} + +
+ )} +
+ + {fileEvent && ( +
+

+ + Download +

+
+ +
+
+ )} + + {fileEventId && !fileEvent && ( +
+ + Loading file metadata... +
+ )} + + {!fileEventId && ( +
+ + No file metadata available +
+ )} +
+ ); +} diff --git a/src/components/nostr/kinds/ZapstoreReleaseRenderer.tsx b/src/components/nostr/kinds/ZapstoreReleaseRenderer.tsx new file mode 100644 index 0000000..e788ec7 --- /dev/null +++ b/src/components/nostr/kinds/ZapstoreReleaseRenderer.tsx @@ -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 ( + +
+ + {appName && `${appName} `} + {version && ( + + v{version} + + )} + + +
+ {appName && ( + + )} + + {fileEventId && ( + + )} +
+
+
+ ); +} diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx index da8f34c..80441e4 100644 --- a/src/components/nostr/kinds/index.tsx +++ b/src/components/nostr/kinds/index.tsx @@ -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> = { 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> = { 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 }; /** diff --git a/src/components/nostr/kinds/zapstore/PlatformIcon.tsx b/src/components/nostr/kinds/zapstore/PlatformIcon.tsx new file mode 100644 index 0000000..fcb9373 --- /dev/null +++ b/src/components/nostr/kinds/zapstore/PlatformIcon.tsx @@ -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 ; + case "ios": + return ; + case "web": + return ; + case "macos": + return ; + case "windows": + case "linux": + return ; + default: + return null; + } + }; + + return ( +
+ {getIcon()} + {showLabel && ( + + {getPlatformLabel()} + + )} +
+ ); +} diff --git a/src/constants/kinds.ts b/src/constants/kinds.ts index eb0299a..9bc78e6 100644 --- a/src/constants/kinds.ts +++ b/src/constants/kinds.ts @@ -1172,8 +1172,8 @@ export const EVENT_KINDS: Record = { // }, 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 = { }, 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 = { 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", diff --git a/src/lib/zapstore-helpers.test.ts b/src/lib/zapstore-helpers.test.ts new file mode 100644 index 0000000..b2e7ce6 --- /dev/null +++ b/src/lib/zapstore-helpers.test.ts @@ -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 { + 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 { + 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 { + 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, + }); + }); + }); +}); diff --git a/src/lib/zapstore-helpers.ts b/src/lib/zapstore-helpers.ts new file mode 100644 index 0000000..aa3f2b1 --- /dev/null +++ b/src/lib/zapstore-helpers.ts @@ -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(); + + 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, + }; +}