diff --git a/src/components/nostr/kinds/ZapstoreAppDetailRenderer.tsx b/src/components/nostr/kinds/ZapstoreAppDetailRenderer.tsx new file mode 100644 index 0000000..7b77aaa --- /dev/null +++ b/src/components/nostr/kinds/ZapstoreAppDetailRenderer.tsx @@ -0,0 +1,144 @@ +import { NostrEvent } from "@/types/nostr"; +import { + getAppName, + getAppSummary, + getAppIcon, + getAppImages, + getAppPlatforms, + getAppRepository, + getAppLicense, + getAppIdentifier, +} from "@/lib/zapstore-helpers"; +import { Badge } from "@/components/ui/badge"; +import { UserName } from "../UserName"; +import { ExternalLink } from "@/components/ExternalLink"; +import { MediaEmbed } from "../MediaEmbed"; +import { Package } from "lucide-react"; + +interface ZapstoreAppDetailRendererProps { + event: NostrEvent; +} + +/** + * Detail renderer for Kind 32267 - Zapstore App Metadata + * Shows comprehensive app information including screenshots + * Note: Zapstore helpers wrap getTagValue which caches internally + */ +export function ZapstoreAppDetailRenderer({ + event, +}: ZapstoreAppDetailRendererProps) { + const appName = getAppName(event); + const summary = getAppSummary(event); + const iconUrl = getAppIcon(event); + const images = getAppImages(event); + const platforms = getAppPlatforms(event); + const repository = getAppRepository(event); + const license = getAppLicense(event); + const identifier = getAppIdentifier(event); + + 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 && ( +
+

+ Platforms ({platforms.length}) +

+
+ {platforms.map((platform) => ( + + {platform} + + ))} +
+
+ )} + + {/* 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..3d7a14d --- /dev/null +++ b/src/components/nostr/kinds/ZapstoreAppRenderer.tsx @@ -0,0 +1,104 @@ +import { + BaseEventContainer, + BaseEventProps, + ClickableEventTitle, +} from "./BaseEventRenderer"; +import { + getAppName, + getAppSummary, + getAppIcon, + getAppPlatforms, + getAppRepository, + getAppLicense, +} from "@/lib/zapstore-helpers"; +import { Badge } from "@/components/ui/badge"; +import { ExternalLink } from "@/components/ExternalLink"; +import { Package } from "lucide-react"; + +/** + * Renderer for Kind 32267 - Zapstore App Metadata + * Displays app name, icon, summary, and platforms in feed + */ +export function ZapstoreAppRenderer({ event }: BaseEventProps) { + const appName = getAppName(event); + const summary = getAppSummary(event); + const iconUrl = getAppIcon(event); + const platforms = getAppPlatforms(event); + const repository = getAppRepository(event); + const license = getAppLicense(event); + + return ( + +
+ {/* App Icon */} + {iconUrl ? ( + {appName} + ) : ( +
+ +
+ )} + + {/* App Info */} +
+ {/* App Name */} + + {appName} + + + {/* Summary */} + {summary && ( +

+ {summary} +

+ )} + + {/* Platforms & License */} +
+ {platforms.length > 0 && ( + <> + {platforms.slice(0, 4).map((platform) => ( + + {platform} + + ))} + {platforms.length > 4 && ( + + +{platforms.length - 4} more + + )} + + )} + {license && ( + + {license} + + )} +
+ + {/* Repository Link */} + {repository && ( + + {repository} + + )} +
+
+
+ ); +} diff --git a/src/components/nostr/kinds/ZapstoreAppSetDetailRenderer.tsx b/src/components/nostr/kinds/ZapstoreAppSetDetailRenderer.tsx new file mode 100644 index 0000000..866ac17 --- /dev/null +++ b/src/components/nostr/kinds/ZapstoreAppSetDetailRenderer.tsx @@ -0,0 +1,171 @@ +import { NostrEvent } from "@/types/nostr"; +import { + getCurationSetName, + getAppReferences, + getAppName, + getAppSummary, + getAppIcon, + getAppPlatforms, + getCurationSetIdentifier, +} from "@/lib/zapstore-helpers"; +import { Badge } from "@/components/ui/badge"; +import { useNostrEvent } from "@/hooks/useNostrEvent"; +import { useGrimoire } from "@/core/state"; +import { UserName } from "../UserName"; +import { Package } from "lucide-react"; + +interface ZapstoreAppSetDetailRendererProps { + event: NostrEvent; +} + +/** + * Expanded app card showing full app details + */ +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 = getAppPlatforms(appEvent); + + const handleClick = () => { + addWindow("open", { pointer: address }); + }; + + return ( +
+ {/* App Icon */} + {iconUrl ? ( + {appName} + ) : ( +
+ +
+ )} + + {/* App Info */} +
+ {/* App Name */} + + + {/* Summary */} + {summary && ( +

+ {summary} +

+ )} + + {/* Platforms */} + {platforms.length > 0 && ( +
+ {platforms.slice(0, 6).map((platform) => ( + + {platform} + + ))} + {platforms.length > 6 && ( + + +{platforms.length - 6} more + + )} +
+ )} +
+
+ ); +} + +/** + * Detail renderer for Kind 30267 - Zapstore App Curation Set + * Shows comprehensive view of all apps in the collection + */ +export function ZapstoreAppSetDetailRenderer({ + event, +}: ZapstoreAppSetDetailRendererProps) { + const setName = getCurationSetName(event); + const apps = getAppReferences(event); + const identifier = getCurationSetIdentifier(event); + + return ( +
+ {/* Header Section */} +
+

{setName}

+ + {/* Metadata */} +
+ {/* Curator */} +
+

Curated by

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

Collection ID

+ + {identifier} + +
+ )} +
+ + {/* App Count */} +

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

+
+ + {/* Apps Section */} +
+

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..9f939e3 --- /dev/null +++ b/src/components/nostr/kinds/ZapstoreAppSetRenderer.tsx @@ -0,0 +1,91 @@ +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"; + +/** + * Individual app item - fetches and displays app info + */ +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 - Zapstore App Curation Set + * Displays collection name and list of apps + */ +export function ZapstoreAppSetRenderer({ event }: BaseEventProps) { + const setName = getCurationSetName(event); + const apps = getAppReferences(event); + + // Show max 5 apps in feed view + const MAX_APPS_IN_FEED = 5; + const displayApps = apps.slice(0, MAX_APPS_IN_FEED); + const remainingCount = apps.length - displayApps.length; + + return ( + +
+ {/* Collection Name */} + + {setName} + + + {/* App Count */} +

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

+ + {/* App List */} + {displayApps.length > 0 && ( +
+ {displayApps.map((ref, idx) => ( + + ))} + {remainingCount > 0 && ( + + +{remainingCount} more app{remainingCount > 1 ? "s" : ""} + + )} +
+ )} +
+
+ ); +} diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx index da8f34c..7f3e8b8 100644 --- a/src/components/nostr/kinds/index.tsx +++ b/src/components/nostr/kinds/index.tsx @@ -54,6 +54,10 @@ 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 { NostrEvent } from "@/types/nostr"; import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer"; @@ -94,6 +98,7 @@ const kindRenderers: Record> = { 30002: GenericRelayListRenderer, // Relay Sets (NIP-51) 30023: Kind30023Renderer, // Long-form Article 30030: EmojiSetRenderer, // Emoji Sets (NIP-30) + 30267: ZapstoreAppSetRenderer, // App Curation Set (Zapstore) 30311: LiveActivityRenderer, // Live Streaming Event (NIP-53) 34235: Kind21Renderer, // Horizontal Video (NIP-71 legacy) 34236: Kind22Renderer, // Vertical Video (NIP-71 legacy) @@ -105,6 +110,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, // App Metadata (Zapstore) 39701: Kind39701Renderer, // Web Bookmarks (NIP-B0) }; @@ -159,6 +165,7 @@ const detailRenderers: Record< 777: SpellDetailRenderer, // Spell Detail 30023: Kind30023DetailRenderer, // Long-form Article Detail 30030: EmojiSetDetailRenderer, // Emoji Sets Detail (NIP-30) + 30267: ZapstoreAppSetDetailRenderer, // App Curation Set Detail (Zapstore) 30311: LiveActivityDetailRenderer, // Live Streaming Event Detail (NIP-53) 30617: RepositoryDetailRenderer, // Repository Detail (NIP-34) 30618: RepositoryStateDetailRenderer, // Repository State Detail (NIP-34) @@ -168,6 +175,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, // App Metadata Detail (Zapstore) }; /** diff --git a/src/lib/zapstore-helpers.test.ts b/src/lib/zapstore-helpers.test.ts new file mode 100644 index 0000000..e194df7 --- /dev/null +++ b/src/lib/zapstore-helpers.test.ts @@ -0,0 +1,475 @@ +import { describe, it, expect } from "vitest"; +import { + getAppName, + getAppIdentifier, + getAppSummary, + getAppRepository, + getAppIcon, + getAppImages, + getAppLicense, + getAppPlatforms, + getAppReleases, + getCurationSetName, + getCurationSetIdentifier, + getAppReferences, + 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("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..ce5b1a4 --- /dev/null +++ b/src/lib/zapstore-helpers.ts @@ -0,0 +1,216 @@ +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"); +} + +/** + * 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; +} + +// ============================================================================ +// 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, + }; +}