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

* feat: Add Zapstore app and app curation set renderers

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

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

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

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

* feat: Add Zapstore release renderer (kind 30063)

Add support for rendering Zapstore app release events (kind 30063):
- Kind 30063 (Release): Connects apps (32267) to file artifacts (1063)

New files:
- src/components/nostr/kinds/ZapstoreReleaseRenderer.tsx: Feed view for releases
- src/components/nostr/kinds/ZapstoreReleaseDetailRenderer.tsx: Detail view with embedded file metadata

Modified:
- src/lib/zapstore-helpers.ts: Add release helper functions
  - getReleaseIdentifier(): Extract release ID (package@version)
  - getReleaseVersion(): Parse version from identifier
  - getReleaseFileEventId(): Get file metadata event pointer
  - getReleaseAppPointer(): Get app metadata pointer
- src/lib/zapstore-helpers.test.ts: Add 18 new tests for release helpers (61 total)
- src/components/nostr/kinds/index.tsx: Register kind 30063 renderers

Complete Zapstore app ecosystem now supported:
- Kind 32267: App metadata (name, icon, description)
- Kind 30267: App curation sets (collections)
- Kind 30063: App releases (version tracking)
- Kind 1063: File metadata (downloads)

All tests pass (744 total), build succeeds.

* refactor: Simplify Zapstore app renderers with platform icons

Improve Zapstore app rendering for cleaner, more intuitive display:

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

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

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

All tests pass (744 total), build succeeds.

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

Address feedback to improve Zapstore renderer UX:

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

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

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

All tests pass (744 total), build succeeds.

* refactor: Update Zapstore app set renderers with improved UX

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

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

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

* feat: Add releases section to app detail view

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

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

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

* fix: Remove author filter from releases query

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

---------

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

View File

@@ -0,0 +1,651 @@
import { describe, it, expect } from "vitest";
import {
getAppName,
getAppIdentifier,
getAppSummary,
getAppRepository,
getAppIcon,
getAppImages,
getAppLicense,
getAppPlatforms,
getAppReleases,
getCurationSetName,
getCurationSetIdentifier,
getAppReferences,
getReleaseIdentifier,
getReleaseVersion,
getReleaseFileEventId,
getReleaseAppPointer,
parseAddressPointer,
} from "./zapstore-helpers";
import { NostrEvent } from "@/types/nostr";
// Helper to create a minimal kind 32267 event (App Metadata)
function createAppEvent(overrides?: Partial<NostrEvent>): NostrEvent {
return {
id: "test-id",
pubkey: "test-pubkey",
created_at: 1234567890,
kind: 32267,
tags: [],
content: "",
sig: "test-sig",
...overrides,
};
}
// Helper to create a minimal kind 30267 event (App Curation Set)
function createCurationSetEvent(overrides?: Partial<NostrEvent>): NostrEvent {
return {
id: "test-id",
pubkey: "test-pubkey",
created_at: 1234567890,
kind: 30267,
tags: [],
content: "",
sig: "test-sig",
...overrides,
};
}
describe("Kind 32267 (App Metadata) Helpers", () => {
describe("getAppName", () => {
it("should extract name from name tag", () => {
const event = createAppEvent({
tags: [
["name", "0xchat"],
["d", "com.oxchat.nostr"],
],
});
expect(getAppName(event)).toBe("0xchat");
});
it("should fallback to d tag if no name tag", () => {
const event = createAppEvent({
tags: [["d", "com.example.app"]],
});
expect(getAppName(event)).toBe("com.example.app");
});
it("should return 'Unknown App' if no name and no d tag", () => {
const event = createAppEvent({
tags: [],
});
expect(getAppName(event)).toBe("Unknown App");
});
it("should return empty string for non-32267 events", () => {
const event = createAppEvent({
kind: 1,
tags: [["name", "Test"]],
});
expect(getAppName(event)).toBe("");
});
});
describe("getAppIdentifier", () => {
it("should extract d tag value", () => {
const event = createAppEvent({
tags: [["d", "com.oxchat.nostr"]],
});
expect(getAppIdentifier(event)).toBe("com.oxchat.nostr");
});
it("should return undefined if no d tag", () => {
const event = createAppEvent({
tags: [],
});
expect(getAppIdentifier(event)).toBeUndefined();
});
it("should return undefined for non-32267 events", () => {
const event = createAppEvent({
kind: 1,
tags: [["d", "test"]],
});
expect(getAppIdentifier(event)).toBeUndefined();
});
});
describe("getAppSummary", () => {
it("should extract summary from summary tag", () => {
const event = createAppEvent({
tags: [["summary", "A secure chat app built on Nostr"]],
});
expect(getAppSummary(event)).toBe("A secure chat app built on Nostr");
});
it("should fallback to content if no summary tag", () => {
const event = createAppEvent({
content: "Fallback description from content",
tags: [],
});
expect(getAppSummary(event)).toBe("Fallback description from content");
});
it("should return undefined if no summary and empty content", () => {
const event = createAppEvent({
content: "",
tags: [],
});
expect(getAppSummary(event)).toBeUndefined();
});
it("should prefer summary tag over content", () => {
const event = createAppEvent({
content: "Content description",
tags: [["summary", "Summary description"]],
});
expect(getAppSummary(event)).toBe("Summary description");
});
});
describe("getAppRepository", () => {
it("should extract repository URL", () => {
const event = createAppEvent({
tags: [["repository", "https://github.com/0xchat-app/0xchat-app-main"]],
});
expect(getAppRepository(event)).toBe(
"https://github.com/0xchat-app/0xchat-app-main",
);
});
it("should return undefined if no repository tag", () => {
const event = createAppEvent({
tags: [],
});
expect(getAppRepository(event)).toBeUndefined();
});
});
describe("getAppIcon", () => {
it("should extract icon URL", () => {
const event = createAppEvent({
tags: [["icon", "https://cdn.zapstore.dev/icon.png"]],
});
expect(getAppIcon(event)).toBe("https://cdn.zapstore.dev/icon.png");
});
it("should return undefined if no icon tag", () => {
const event = createAppEvent({
tags: [],
});
expect(getAppIcon(event)).toBeUndefined();
});
});
describe("getAppImages", () => {
it("should extract all image URLs", () => {
const event = createAppEvent({
tags: [
["image", "https://cdn.zapstore.dev/image1.png"],
["image", "https://cdn.zapstore.dev/image2.png"],
["image", "https://cdn.zapstore.dev/image3.png"],
["name", "App"],
],
});
expect(getAppImages(event)).toEqual([
"https://cdn.zapstore.dev/image1.png",
"https://cdn.zapstore.dev/image2.png",
"https://cdn.zapstore.dev/image3.png",
]);
});
it("should return empty array if no image tags", () => {
const event = createAppEvent({
tags: [["name", "App"]],
});
expect(getAppImages(event)).toEqual([]);
});
it("should return empty array for non-32267 events", () => {
const event = createAppEvent({
kind: 1,
tags: [["image", "test.png"]],
});
expect(getAppImages(event)).toEqual([]);
});
});
describe("getAppLicense", () => {
it("should extract license", () => {
const event = createAppEvent({
tags: [["license", "MIT"]],
});
expect(getAppLicense(event)).toBe("MIT");
});
it("should return undefined if no license tag", () => {
const event = createAppEvent({
tags: [],
});
expect(getAppLicense(event)).toBeUndefined();
});
});
describe("getAppPlatforms", () => {
it("should extract all platform/architecture values from f tags", () => {
const event = createAppEvent({
tags: [
["f", "android-arm64-v8a"],
["f", "android-armeabi-v7a"],
["name", "App"],
],
});
expect(getAppPlatforms(event)).toEqual([
"android-arm64-v8a",
"android-armeabi-v7a",
]);
});
it("should return empty array if no f tags", () => {
const event = createAppEvent({
tags: [["name", "App"]],
});
expect(getAppPlatforms(event)).toEqual([]);
});
it("should return empty array for non-32267 events", () => {
const event = createAppEvent({
kind: 1,
tags: [["f", "test"]],
});
expect(getAppPlatforms(event)).toEqual([]);
});
});
describe("getAppReleases", () => {
it("should extract release references from a tags", () => {
const event = createAppEvent({
tags: [
[
"a",
"30063:5eca50a04afaefe55659fb74810b42654e2268c1acca6e53801b9862db74a83a:com.oxchat.nostr@v1.5.1-release",
],
],
});
const releases = getAppReleases(event);
expect(releases).toHaveLength(1);
expect(releases[0]).toEqual({
kind: 30063,
pubkey:
"5eca50a04afaefe55659fb74810b42654e2268c1acca6e53801b9862db74a83a",
identifier: "com.oxchat.nostr@v1.5.1-release",
});
});
it("should handle multiple release references", () => {
const event = createAppEvent({
tags: [
["a", "30063:pubkey1:release1"],
["a", "30063:pubkey2:release2"],
],
});
const releases = getAppReleases(event);
expect(releases).toHaveLength(2);
});
it("should filter out invalid a tags", () => {
const event = createAppEvent({
tags: [
["a", "30063:pubkey1:release1"],
["a", "invalid"],
["a", "30063:pubkey2:release2"],
],
});
const releases = getAppReleases(event);
expect(releases).toHaveLength(2);
});
it("should return empty array if no a tags", () => {
const event = createAppEvent({
tags: [["name", "App"]],
});
expect(getAppReleases(event)).toEqual([]);
});
});
});
describe("Kind 30267 (App Curation Set) Helpers", () => {
describe("getCurationSetName", () => {
it("should extract name from name tag", () => {
const event = createCurationSetEvent({
tags: [
["name", "Nostr Social"],
["d", "nostr-social"],
],
});
expect(getCurationSetName(event)).toBe("Nostr Social");
});
it("should fallback to d tag if no name tag", () => {
const event = createCurationSetEvent({
tags: [["d", "my-collection"]],
});
expect(getCurationSetName(event)).toBe("my-collection");
});
it("should return 'Unnamed Collection' if no name and no d tag", () => {
const event = createCurationSetEvent({
tags: [],
});
expect(getCurationSetName(event)).toBe("Unnamed Collection");
});
it("should return empty string for non-30267 events", () => {
const event = createCurationSetEvent({
kind: 1,
tags: [["name", "Test"]],
});
expect(getCurationSetName(event)).toBe("");
});
});
describe("getCurationSetIdentifier", () => {
it("should extract d tag value", () => {
const event = createCurationSetEvent({
tags: [["d", "nostr-social"]],
});
expect(getCurationSetIdentifier(event)).toBe("nostr-social");
});
it("should return undefined if no d tag", () => {
const event = createCurationSetEvent({
tags: [],
});
expect(getCurationSetIdentifier(event)).toBeUndefined();
});
it("should return undefined for non-30267 events", () => {
const event = createCurationSetEvent({
kind: 1,
tags: [["d", "test"]],
});
expect(getCurationSetIdentifier(event)).toBeUndefined();
});
});
describe("getAppReferences", () => {
it("should extract app references from a tags", () => {
const event = createCurationSetEvent({
tags: [
["d", "nostr-social"],
[
"a",
"32267:4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0:to.iris",
"wss://relay.com",
],
[
"a",
"32267:b090908101cc6498893cc7f14d745dcea0b2ab6842cc4b512515643d272a375c:net.primal.android",
],
],
});
const refs = getAppReferences(event);
expect(refs).toHaveLength(2);
expect(refs[0].address).toEqual({
kind: 32267,
pubkey:
"4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0",
identifier: "to.iris",
});
expect(refs[0].relayHint).toBe("wss://relay.com");
expect(refs[1].relayHint).toBeUndefined();
});
it("should only include kind 32267 references", () => {
const event = createCurationSetEvent({
tags: [
["d", "collection"],
["a", "32267:pubkey1:app1"],
["a", "30023:pubkey2:article1"],
["a", "32267:pubkey3:app2"],
],
});
const refs = getAppReferences(event);
expect(refs).toHaveLength(2);
expect(refs[0].address.kind).toBe(32267);
expect(refs[1].address.kind).toBe(32267);
});
it("should filter out invalid a tags", () => {
const event = createCurationSetEvent({
tags: [
["d", "collection"],
["a", "32267:pubkey1:app1"],
["a", "invalid-format"],
["a", "32267:pubkey2:app2"],
],
});
const refs = getAppReferences(event);
expect(refs).toHaveLength(2);
});
it("should return empty array if no a tags", () => {
const event = createCurationSetEvent({
tags: [["d", "collection"]],
});
expect(getAppReferences(event)).toEqual([]);
});
it("should return empty array for non-30267 events", () => {
const event = createCurationSetEvent({
kind: 1,
tags: [["a", "32267:pubkey:app"]],
});
expect(getAppReferences(event)).toEqual([]);
});
});
});
describe("Kind 30063 (Release) Helpers", () => {
// Helper to create a minimal kind 30063 event (Release)
function createReleaseEvent(overrides?: Partial<NostrEvent>): NostrEvent {
return {
id: "test-id",
pubkey: "test-pubkey",
created_at: 1234567890,
kind: 30063,
tags: [],
content: "",
sig: "test-sig",
...overrides,
};
}
describe("getReleaseIdentifier", () => {
it("should extract release identifier from d tag", () => {
const event = createReleaseEvent({
tags: [["d", "com.wavves.app@1.0.0"]],
});
expect(getReleaseIdentifier(event)).toBe("com.wavves.app@1.0.0");
});
it("should return undefined if no d tag", () => {
const event = createReleaseEvent({
tags: [],
});
expect(getReleaseIdentifier(event)).toBeUndefined();
});
it("should return undefined for non-30063 events", () => {
const event = createReleaseEvent({
kind: 1,
tags: [["d", "test"]],
});
expect(getReleaseIdentifier(event)).toBeUndefined();
});
});
describe("getReleaseVersion", () => {
it("should extract version from identifier with @ symbol", () => {
const event = createReleaseEvent({
tags: [["d", "com.wavves.app@1.0.0"]],
});
expect(getReleaseVersion(event)).toBe("1.0.0");
});
it("should handle version with multiple parts", () => {
const event = createReleaseEvent({
tags: [["d", "com.example.app@2.5.1-beta"]],
});
expect(getReleaseVersion(event)).toBe("2.5.1-beta");
});
it("should handle identifier with multiple @ symbols (use last one)", () => {
const event = createReleaseEvent({
tags: [["d", "com.example@app@3.0.0"]],
});
expect(getReleaseVersion(event)).toBe("3.0.0");
});
it("should return undefined if no @ in identifier", () => {
const event = createReleaseEvent({
tags: [["d", "no-version-here"]],
});
expect(getReleaseVersion(event)).toBeUndefined();
});
it("should return undefined if @ is at end", () => {
const event = createReleaseEvent({
tags: [["d", "com.example.app@"]],
});
expect(getReleaseVersion(event)).toBeUndefined();
});
it("should return undefined if no d tag", () => {
const event = createReleaseEvent({
tags: [],
});
expect(getReleaseVersion(event)).toBeUndefined();
});
it("should return undefined for non-30063 events", () => {
const event = createReleaseEvent({
kind: 1,
tags: [["d", "test@1.0.0"]],
});
expect(getReleaseVersion(event)).toBeUndefined();
});
});
describe("getReleaseFileEventId", () => {
it("should extract file event ID from e tag", () => {
const event = createReleaseEvent({
tags: [
[
"e",
"365a0e4a1da3c13c839f0ab170fc3dfadf246368f3a5fc6df2bb18b2db9fcb7e",
],
],
});
expect(getReleaseFileEventId(event)).toBe(
"365a0e4a1da3c13c839f0ab170fc3dfadf246368f3a5fc6df2bb18b2db9fcb7e",
);
});
it("should return undefined if no e tag", () => {
const event = createReleaseEvent({
tags: [],
});
expect(getReleaseFileEventId(event)).toBeUndefined();
});
it("should return undefined for non-30063 events", () => {
const event = createReleaseEvent({
kind: 1,
tags: [["e", "test123"]],
});
expect(getReleaseFileEventId(event)).toBeUndefined();
});
});
describe("getReleaseAppPointer", () => {
it("should extract app metadata pointer from a tag", () => {
const event = createReleaseEvent({
tags: [
[
"a",
"32267:7a42d5fa97d51fb73e90406f55dc2fb05f49b54c1910496ddc4b66c92a34779e:com.wavves.app",
],
],
});
const pointer = getReleaseAppPointer(event);
expect(pointer).toEqual({
kind: 32267,
pubkey:
"7a42d5fa97d51fb73e90406f55dc2fb05f49b54c1910496ddc4b66c92a34779e",
identifier: "com.wavves.app",
});
});
it("should return null if a tag points to wrong kind", () => {
const event = createReleaseEvent({
tags: [["a", "30023:pubkey:article"]],
});
expect(getReleaseAppPointer(event)).toBeNull();
});
it("should return null if a tag is invalid", () => {
const event = createReleaseEvent({
tags: [["a", "invalid-format"]],
});
expect(getReleaseAppPointer(event)).toBeNull();
});
it("should return null if no a tag", () => {
const event = createReleaseEvent({
tags: [],
});
expect(getReleaseAppPointer(event)).toBeNull();
});
it("should return null for non-30063 events", () => {
const event = createReleaseEvent({
kind: 1,
tags: [["a", "32267:pubkey:app"]],
});
expect(getReleaseAppPointer(event)).toBeNull();
});
});
});
describe("Shared Helpers", () => {
describe("parseAddressPointer", () => {
it("should parse valid address pointer", () => {
const result = parseAddressPointer("32267:abcd1234:com.example.app");
expect(result).toEqual({
kind: 32267,
pubkey: "abcd1234",
identifier: "com.example.app",
});
});
it("should handle empty identifier", () => {
const result = parseAddressPointer("30267:abcd1234:");
expect(result).toEqual({
kind: 30267,
pubkey: "abcd1234",
identifier: "",
});
});
it("should return null for invalid format", () => {
expect(parseAddressPointer("invalid")).toBeNull();
expect(parseAddressPointer("32267:abcd")).toBeNull();
expect(parseAddressPointer("not-a-kind:pubkey:id")).toBeNull();
});
it("should handle long pubkeys and identifiers", () => {
const longPubkey =
"5eca50a04afaefe55659fb74810b42654e2268c1acca6e53801b9862db74a83a";
const longId = "com.oxchat.nostr@v1.5.1-release";
const result = parseAddressPointer(`30063:${longPubkey}:${longId}`);
expect(result).toEqual({
kind: 30063,
pubkey: longPubkey,
identifier: longId,
});
});
});
});

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

@@ -0,0 +1,323 @@
import { NostrEvent } from "@/types/nostr";
import { getTagValue } from "applesauce-core/helpers";
import { AddressPointer } from "nostr-tools/nip19";
/**
* Zapstore Helper Functions
* For working with App Metadata (32267) and App Curation Set (30267) events
*/
/**
* Get all values for a tag name (plural version of getTagValue)
* Unlike getTagValue which returns first match, this returns all matches
*/
function getTagValues(event: NostrEvent, tagName: string): string[] {
return event.tags
.filter((tag) => tag[0] === tagName)
.map((tag) => tag[1])
.filter((val): val is string => val !== undefined);
}
// ============================================================================
// Kind 32267 (App Metadata) Helpers
// ============================================================================
/**
* Get app name from kind 32267 name tag
*/
export function getAppName(event: NostrEvent): string {
if (event.kind !== 32267) return "";
const name = getTagValue(event, "name");
if (name && typeof name === "string") {
return name;
}
// Fallback to d tag identifier
const dTag = getTagValue(event, "d");
return dTag && typeof dTag === "string" ? dTag : "Unknown App";
}
/**
* Get app identifier from kind 32267 d tag (like package name)
*/
export function getAppIdentifier(event: NostrEvent): string | undefined {
if (event.kind !== 32267) return undefined;
return getTagValue(event, "d");
}
/**
* Get app summary/description from kind 32267 summary tag
*/
export function getAppSummary(event: NostrEvent): string | undefined {
if (event.kind !== 32267) return undefined;
const summary = getTagValue(event, "summary");
if (summary && typeof summary === "string") {
return summary;
}
// Fallback to content if no summary tag
return event.content || undefined;
}
/**
* Get repository URL from kind 32267 repository tag
*/
export function getAppRepository(event: NostrEvent): string | undefined {
if (event.kind !== 32267) return undefined;
return getTagValue(event, "repository");
}
/**
* Get app icon URL from kind 32267 icon tag
*/
export function getAppIcon(event: NostrEvent): string | undefined {
if (event.kind !== 32267) return undefined;
return getTagValue(event, "icon");
}
/**
* Get app screenshot URLs from kind 32267 image tags (multiple)
*/
export function getAppImages(event: NostrEvent): string[] {
if (event.kind !== 32267) return [];
return getTagValues(event, "image");
}
/**
* Get app license from kind 32267 license tag
*/
export function getAppLicense(event: NostrEvent): string | undefined {
if (event.kind !== 32267) return undefined;
return getTagValue(event, "license");
}
/**
* Get supported platforms/architectures from kind 32267 f tags
*/
export function getAppPlatforms(event: NostrEvent): string[] {
if (event.kind !== 32267) return [];
return getTagValues(event, "f");
}
/**
* Platform names for display
*/
export type Platform =
| "android"
| "ios"
| "web"
| "linux"
| "windows"
| "macos";
/**
* Detect unique platforms from f tags
* Normalizes architecture-specific tags (e.g., "android-arm64-v8a" → "android")
*/
export function detectPlatforms(event: NostrEvent): Platform[] {
if (event.kind !== 32267 && event.kind !== 1063) return [];
const fTags = getTagValues(event, "f");
const platformSet = new Set<Platform>();
for (const tag of fTags) {
const lower = tag.toLowerCase();
if (lower.startsWith("android")) {
platformSet.add("android");
} else if (lower.startsWith("ios") || lower.includes("iphone")) {
platformSet.add("ios");
} else if (lower === "web" || lower.includes("web")) {
platformSet.add("web");
} else if (lower.includes("linux")) {
platformSet.add("linux");
} else if (lower.includes("windows") || lower.includes("win")) {
platformSet.add("windows");
} else if (
lower.includes("macos") ||
lower.includes("mac") ||
lower.includes("darwin")
) {
platformSet.add("macos");
}
}
// Sort for consistent order
return Array.from(platformSet).sort();
}
/**
* Get release artifact references from kind 32267 a tags (usually kind 30063)
*/
export function getAppReleases(event: NostrEvent): AddressPointer[] {
if (event.kind !== 32267) return [];
const aTags = event.tags.filter((tag) => tag[0] === "a");
const releases: AddressPointer[] = [];
for (const tag of aTags) {
const aTagValue = tag[1];
if (!aTagValue) continue;
const address = parseAddressPointer(aTagValue);
if (address) {
releases.push(address);
}
}
return releases;
}
// ============================================================================
// Kind 30267 (App Curation Set) Helpers
// ============================================================================
/**
* Get curation set name from kind 30267 name tag
*/
export function getCurationSetName(event: NostrEvent): string {
if (event.kind !== 30267) return "";
const name = getTagValue(event, "name");
if (name && typeof name === "string") {
return name;
}
// Fallback to d tag identifier
const dTag = getTagValue(event, "d");
return dTag && typeof dTag === "string" ? dTag : "Unnamed Collection";
}
/**
* Get curation set identifier from kind 30267 d tag
*/
export function getCurationSetIdentifier(
event: NostrEvent,
): string | undefined {
if (event.kind !== 30267) return undefined;
return getTagValue(event, "d");
}
/**
* App reference with relay hint from a tag
*/
export interface AppReference {
address: AddressPointer;
relayHint?: string;
}
/**
* Get all app references from kind 30267 a tags
*/
export function getAppReferences(event: NostrEvent): AppReference[] {
if (event.kind !== 30267) return [];
const references: AppReference[] = [];
const aTags = event.tags.filter((tag) => tag[0] === "a");
for (const tag of aTags) {
const aTagValue = tag[1];
if (!aTagValue) continue;
const address = parseAddressPointer(aTagValue);
if (!address) continue;
// Kind 32267 apps are expected in curation sets
if (address.kind === 32267) {
const relayHint = tag[2];
references.push({
address,
relayHint: relayHint || undefined,
});
}
}
return references;
}
// ============================================================================
// Kind 30063 (Release) Helpers
// ============================================================================
/**
* Get release identifier from kind 30063 d tag
* Usually in format: package@version (e.g., "com.wavves.app@1.0.0")
*/
export function getReleaseIdentifier(event: NostrEvent): string | undefined {
if (event.kind !== 30063) return undefined;
return getTagValue(event, "d");
}
/**
* Get version from release identifier
* Extracts version from "package@version" format
*/
export function getReleaseVersion(event: NostrEvent): string | undefined {
if (event.kind !== 30063) return undefined;
const identifier = getReleaseIdentifier(event);
if (!identifier) return undefined;
// Try to extract version after @ symbol
const atIndex = identifier.lastIndexOf("@");
if (atIndex !== -1 && atIndex < identifier.length - 1) {
return identifier.substring(atIndex + 1);
}
return undefined;
}
/**
* Get file metadata event ID from kind 30063 e tag
* Points to kind 1063 (File Metadata) event
*/
export function getReleaseFileEventId(event: NostrEvent): string | undefined {
if (event.kind !== 30063) return undefined;
return getTagValue(event, "e");
}
/**
* Get app metadata pointer from kind 30063 a tag
* Points to kind 32267 (App Metadata) event
*/
export function getReleaseAppPointer(event: NostrEvent): AddressPointer | null {
if (event.kind !== 30063) return null;
const aTag = getTagValue(event, "a");
if (!aTag) return null;
const pointer = parseAddressPointer(aTag);
// Verify it points to an app metadata event
if (pointer && pointer.kind === 32267) {
return pointer;
}
return null;
}
// ============================================================================
// Shared Helpers
// ============================================================================
/**
* Parse an address pointer from an a tag value
* Format: "kind:pubkey:identifier"
*/
export function parseAddressPointer(aTagValue: string): AddressPointer | null {
const parts = aTagValue.split(":");
if (parts.length !== 3) return null;
const kind = parseInt(parts[0], 10);
const pubkey = parts[1];
const identifier = parts[2];
if (isNaN(kind) || !pubkey || identifier === undefined) return null;
return {
kind,
pubkey,
identifier,
};
}