diff --git a/src/components/CommentContext.tsx b/src/components/CommentContext.tsx index d2d5cd04..db636ef1 100644 --- a/src/components/CommentContext.tsx +++ b/src/components/CommentContext.tsx @@ -108,6 +108,7 @@ const KIND_LABELS: Record = { 30030: 'an emoji pack', 30054: 'a podcast episode', 30055: 'a podcast trailer', + 3063: 'an asset', 30063: 'a release', 30311: 'a stream', 30315: 'a status', @@ -155,6 +156,7 @@ const KIND_ICONS: Partial> = { const KIND_POSTFIXES: Partial> = { 32267: 'on Zapstore', 30063: 'release', + 3063: 'asset', }; /** Get a display name for an event based on its kind and tags. */ diff --git a/src/components/ExternalContentHeader.tsx b/src/components/ExternalContentHeader.tsx index e02ee4fe..ea77f36f 100644 --- a/src/components/ExternalContentHeader.tsx +++ b/src/components/ExternalContentHeader.tsx @@ -1085,6 +1085,7 @@ const WELL_KNOWN_KIND_LABELS: Record = { 31990: 'App', 32267: 'App', 30063: 'Release', + 3063: 'Asset', 15128: 'Nsite', 35128: 'Nsite', 31124: 'Blobbi', @@ -1110,7 +1111,7 @@ export function AddressableEventPreview({ addr }: { addr: { kind: number; pubkey const KindIcon = useMemo(() => { if (kindDef?.id) return CONTENT_KIND_ICONS[kindDef.id] ?? FileText; // Fallback icons for well-known kinds not in EXTRA_KINDS - if (addr.kind === 31990 || addr.kind === 32267 || addr.kind === 30063) return Package; + if (addr.kind === 31990 || addr.kind === 32267 || addr.kind === 30063 || addr.kind === 3063) return Package; if (addr.kind === 15128 || addr.kind === 35128) return Globe; return FileText; }, [kindDef, addr.kind]); diff --git a/src/components/NostrProvider.tsx b/src/components/NostrProvider.tsx index da28626e..ee421669 100644 --- a/src/components/NostrProvider.tsx +++ b/src/components/NostrProvider.tsx @@ -109,8 +109,8 @@ const NostrProvider: React.FC = (props) => { .filter(r => r.read) .map(r => r.url); - // Include zapstore relay for kind 32267 (apps) and 30063 (releases) - const ZAPSTORE_KINDS = [32267, 30063]; + // Include zapstore relay for kind 32267 (apps), 30063 (releases), and 3063 (assets) + const ZAPSTORE_KINDS = [32267, 30063, 3063]; if (filters.every((f) => f?.kinds?.every((k) => ZAPSTORE_KINDS.includes(k)))) { return new Map([ZAPSTORE_RELAY, ...readRelays].map(url => [url, filters])); } diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx index 3a35f479..f6336d6b 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -74,6 +74,7 @@ import { EncryptedMessageContent } from "@/components/EncryptedMessageContent"; import { EncryptedLetterContent } from "@/components/EncryptedLetterContent"; import { VanishCardCompact } from "@/components/VanishEventContent"; import { ZapstoreAppContent } from "@/components/ZapstoreAppContent"; +import { ZapstoreReleaseContent, ZapstoreAssetContent } from "@/components/ZapstoreReleaseContent"; import { AppHandlerContent } from "@/components/AppHandlerContent"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { getAvatarShape } from "@/lib/avatarShape"; @@ -307,6 +308,8 @@ export const NoteCard = memo(function NoteCard({ const isCustomNip = event.kind === 30817; const isNsite = event.kind === 15128 || event.kind === 35128; const isZapstoreApp = event.kind === 32267; + const isZapstoreRelease = event.kind === 30063; + const isZapstoreAsset = event.kind === 3063; const isAppHandler = event.kind === 31990; const isEncryptedDM = event.kind === 4; const isLetter = event.kind === 8211; @@ -338,6 +341,8 @@ export const NoteCard = memo(function NoteCard({ !isAudioKind && !isDevKind && !isZapstoreApp && + !isZapstoreRelease && + !isZapstoreAsset && !isAppHandler && !isEncryptedDM && !isLetter && @@ -535,6 +540,10 @@ export const NoteCard = memo(function NoteCard({ ) : isZapstoreApp ? ( + ) : isZapstoreRelease ? ( + + ) : isZapstoreAsset ? ( + ) : isAppHandler ? ( ) : isEncryptedDM ? ( @@ -2007,6 +2016,14 @@ const KIND_HEADER_MAP: Record = { icon: Package, action: "published an app", }, + 30063: { + icon: Package, + action: "published a release", + }, + 3063: { + icon: Package, + action: "published an asset", + }, 31990: { icon: Package, action: "published an app", diff --git a/src/components/ZapstoreReleaseContent.tsx b/src/components/ZapstoreReleaseContent.tsx new file mode 100644 index 00000000..3557817f --- /dev/null +++ b/src/components/ZapstoreReleaseContent.tsx @@ -0,0 +1,722 @@ +import type { NostrEvent } from '@nostrify/nostrify'; +import { useNostr } from '@nostrify/react'; +import { useQuery } from '@tanstack/react-query'; +import { + Package, + Download, + Tag, + Hash, + Smartphone, + Monitor, + Globe, + Shield, + ExternalLink, + GitCommit, +} from 'lucide-react'; +import { nip19 } from 'nostr-tools'; +import { useMemo } from 'react'; +import { Link } from 'react-router-dom'; + +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Separator } from '@/components/ui/separator'; +import { Skeleton } from '@/components/ui/skeleton'; +import { ZAPSTORE_RELAY } from '@/lib/appRelays'; +import { openUrl } from '@/lib/downloadFile'; + +/** Get a tag value by name. */ +function getTag(tags: string[][], name: string): string | undefined { + return tags.find(([n]) => n === name)?.[1]; +} + +/** Get all tag entries for a tag name. */ +function getAllTagEntries(tags: string[][], name: string): string[][] { + return tags.filter(([n]) => n === name); +} + +/** Get all values for a tag name. */ +function getAllTags(tags: string[][], name: string): string[] { + return tags.filter(([n]) => n === name).map(([, v]) => v); +} + +/** Map a MIME type to a human-readable platform label. */ +function mimeToLabel(mime: string): string { + const map: Record = { + 'application/vnd.android.package-archive': 'Android APK', + 'application/vnd.apple.ipa': 'iOS IPA', + 'application/x-apple-diskimage': 'macOS DMG', + 'application/vnd.apple.installer+xml': 'macOS PKG', + 'application/x-msi': 'Windows MSI', + 'application/vnd.appimage': 'Linux AppImage', + 'application/vnd.flatpak': 'Linux Flatpak', + 'application/x-executable': 'Linux Binary', + 'application/x-mach-binary': 'macOS Binary', + 'application/vnd.microsoft.portable-executable': 'Windows EXE', + 'application/vsix': 'VS Code Extension', + 'application/x-chrome-extension': 'Chrome Extension', + 'application/x-xpinstall': 'Firefox Extension', + 'application/wasm': 'WebAssembly', + 'application/webbundle': 'Web Bundle', + 'application/vnd.oci.image.manifest.v1+json': 'OCI Image', + }; + return map[mime] ?? mime; +} + +/** Return a platform icon component for a MIME type. */ +function PlatformIcon({ mime, className }: { mime: string; className?: string }) { + if (mime.includes('android') || mime.includes('apple.ipa')) { + return ; + } + if (mime.includes('apple') || mime.includes('mach') || mime.includes('msi') || mime.includes('portable-executable')) { + return ; + } + if (mime.includes('appimage') || mime.includes('flatpak') || mime.includes('executable')) { + return ; + } + if (mime.includes('wasm') || mime.includes('webbundle') || mime.includes('chrome') || mime.includes('xpinstall') || mime.includes('vsix')) { + return ; + } + return ; +} + +/** Format file size for display. */ +function formatSize(bytes: string | undefined): string | undefined { + if (!bytes) return undefined; + const n = parseInt(bytes, 10); + if (isNaN(n)) return bytes; + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} MB`; + if (n >= 1_000) return `${(n / 1_000).toFixed(0)} KB`; + return `${n} B`; +} + +/** Map platform identifier to OS label. */ +function platformLabel(f: string): string { + const map: Record = { + 'android-arm64-v8a': 'ARM64', + 'android-armeabi-v7a': 'ARMv7', + 'android-x86': 'x86', + 'android-x86_64': 'x64', + 'darwin-arm64': 'Apple Silicon', + 'darwin-x86_64': 'Intel', + 'linux-aarch64': 'ARM64', + 'linux-x86_64': 'x64', + 'linux-armv7l': 'ARMv7', + 'linux-riscv64': 'RISC-V', + 'windows-aarch64': 'ARM64', + 'windows-x86_64': 'x64', + 'ios-arm64': 'ARM64', + 'wasm32': 'WASM32', + 'wasm64': 'WASM64', + 'wasi-wasm32': 'WASI', + 'wasi-wasm64': 'WASI64', + }; + return map[f] ?? f; +} + +/** Channel label with color. */ +function ChannelBadge({ channel }: { channel: string }) { + const variants: Record = { + main: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', + beta: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400', + nightly: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400', + dev: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400', + }; + const colorClass = variants[channel] ?? 'bg-muted text-muted-foreground'; + return ( + + {channel} + + ); +} + +/** Hook to fetch asset events (kind 3063) for a release. */ +function useReleaseAssets(assetIds: string[]) { + const { nostr } = useNostr(); + + return useQuery({ + queryKey: ['zapstore-assets', ...assetIds.sort()], + queryFn: async ({ signal }) => { + if (assetIds.length === 0) return []; + try { + const querySignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]); + // Try the Zapstore relay first + const events = await nostr.relay(ZAPSTORE_RELAY).query( + [{ kinds: [3063], ids: assetIds }], + { signal: querySignal }, + ); + if (events.length > 0) return events; + // Fallback to the default pool + const fallbackSignal = AbortSignal.any([signal, AbortSignal.timeout(5000)]); + const fallback = await nostr.query( + [{ kinds: [3063], ids: assetIds }], + { signal: fallbackSignal }, + ); + return fallback; + } catch { + return []; + } + }, + enabled: assetIds.length > 0, + staleTime: 10 * 60 * 1000, + }); +} + +/** Hook to fetch the linked app event (kind 32267) for a release. */ +function useReleaseApp(appIdentifier: string | undefined, releasePubkey: string) { + const { nostr } = useNostr(); + + return useQuery({ + queryKey: ['zapstore-app-for-release', appIdentifier, releasePubkey], + queryFn: async ({ signal }) => { + if (!appIdentifier) return null; + try { + const querySignal = AbortSignal.any([signal, AbortSignal.timeout(5000)]); + const events = await nostr.relay(ZAPSTORE_RELAY).query( + [{ kinds: [32267], authors: [releasePubkey], '#d': [appIdentifier], limit: 1 }], + { signal: querySignal }, + ); + if (events.length > 0) return events[0]; + const fallbackSignal = AbortSignal.any([signal, AbortSignal.timeout(5000)]); + const fallback = await nostr.query( + [{ kinds: [32267], authors: [releasePubkey], '#d': [appIdentifier], limit: 1 }], + { signal: fallbackSignal }, + ); + return fallback.length > 0 ? fallback[0] : null; + } catch { + return null; + } + }, + enabled: !!appIdentifier, + staleTime: 5 * 60 * 1000, + }); +} + +/** Single asset download row. */ +function AssetRow({ event }: { event: NostrEvent }) { + const mime = getTag(event.tags, 'm') ?? ''; + const url = getTag(event.tags, 'url'); + const version = getTag(event.tags, 'version'); + const size = formatSize(getTag(event.tags, 'size')); + const platforms = getAllTags(event.tags, 'f'); + const variant = getTag(event.tags, 'variant'); + const commit = getTag(event.tags, 'commit'); + const hash = getTag(event.tags, 'x'); + + const label = mimeToLabel(mime); + const platformLabels = platforms.map(platformLabel); + + const handleDownload = async () => { + if (url) { + await openUrl(url); + } + }; + + return ( +
+ {/* Platform icon */} +
+ +
+ + {/* Info */} +
+
+ {label} + {variant && ( + + {variant} + + )} + {platformLabels.length > 0 && ( + + {platformLabels.join(', ')} + + )} +
+
+ {version && ( + + + {version} + + )} + {size && ( + {size} + )} + {commit && ( + + + {commit.slice(0, 7)} + + )} + {hash && ( + + + {hash.slice(0, 8)} + + )} +
+
+ + {/* Download button */} + {url && ( + + )} +
+ ); +} + +interface ZapstoreReleaseContentProps { + event: NostrEvent; + /** If true, show compact preview (used in NoteCard feed). */ + compact?: boolean; +} + +/** Renders a kind 30063 Zapstore release event. */ +export function ZapstoreReleaseContent({ event, compact }: ZapstoreReleaseContentProps) { + const version = getTag(event.tags, 'version'); + const channel = getTag(event.tags, 'c') ?? 'main'; + const appIdentifier = getTag(event.tags, 'i'); + + // Collect asset event IDs from `e` tags + const assetEntries = useMemo(() => getAllTagEntries(event.tags, 'e'), [event.tags]); + const assetIds = useMemo(() => assetEntries.map(([, id]) => id).filter(Boolean), [assetEntries]); + + const { data: assets = [], isLoading: assetsLoading } = useReleaseAssets(assetIds); + const { data: appEvent } = useReleaseApp(appIdentifier, event.pubkey); + + const appName = appEvent + ? (getTag(appEvent.tags, 'name') || getTag(appEvent.tags, 'd') || appIdentifier) + : appIdentifier; + const appIcon = appEvent ? getTag(appEvent.tags, 'icon') : undefined; + const appId = appEvent ? getTag(appEvent.tags, 'd') : appIdentifier; + + // Build naddr link to the app event if we have it + const appNaddr = appEvent + ? nip19.naddrEncode({ kind: 32267, pubkey: appEvent.pubkey, identifier: getTag(appEvent.tags, 'd') ?? '' }) + : undefined; + + const releaseNotes = event.content; + + if (compact) { + return ( +
+ {/* Header: icon + app name + version */} +
+ {appIcon ? ( + {appName { (e.currentTarget as HTMLElement).style.display = 'none'; }} + /> + ) : ( +
+ +
+ )} +
+
+ {appName && ( + appNaddr ? ( + e.stopPropagation()} + > + {appName} + + ) : ( + {appName} + ) + )} + {version && ( + + v{version} + + )} + +
+ {/* Asset count summary */} + {assetIds.length > 0 && ( +

+ {assetIds.length} {assetIds.length === 1 ? 'asset' : 'assets'} available +

+ )} +
+
+ + {/* Release notes (truncated) */} + {releaseNotes && ( +

+ {releaseNotes} +

+ )} +
+ ); + } + + // Full detail view + return ( +
+ {/* Header */} +
+ {appIcon ? ( + {appName { (e.currentTarget as HTMLElement).style.display = 'none'; }} + /> + ) : ( +
+ +
+ )} + +
+ {appName && ( + appNaddr ? ( + e.stopPropagation()} + > + {appName} + + ) : ( +

{appName}

+ ) + )} +
+ {version && ( + + v{version} + + )} + +
+
+
+ + {/* Action row */} + {appId && ( +
+ + {appNaddr && ( + + )} +
+ )} + + {/* Release notes */} + {releaseNotes && ( +
+

+ Release Notes +

+

+ {releaseNotes} +

+
+ )} + + {/* Assets */} + {assetIds.length > 0 && ( +
+

+ Downloads +

+
+ {assetsLoading + ? Array.from({ length: Math.min(assetIds.length, 3) }).map((_, i) => ( +
+ +
+ + +
+
+ )) + : assets.length > 0 + ? assets.map((asset) => ( + + )) + : assetIds.map((id) => ( +
+
+ +
+
+

{id.slice(0, 16)}…

+
+
+ )) + } +
+
+ )} +
+ ); +} + +/** Skeleton loading state for ZapstoreReleaseContent. */ +export function ZapstoreReleaseSkeleton() { + return ( +
+
+ +
+ +
+ + +
+
+
+ +
+ + + +
+
+ {[1, 2].map((i) => ( +
+ +
+ + +
+
+ ))} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// kind 3063 — Software Asset card +// --------------------------------------------------------------------------- + +interface ZapstoreAssetContentProps { + event: NostrEvent; + compact?: boolean; +} + +/** Renders a kind 3063 Zapstore software asset event. */ +export function ZapstoreAssetContent({ event, compact }: ZapstoreAssetContentProps) { + const mime = getTag(event.tags, 'm') ?? ''; + const url = getTag(event.tags, 'url'); + const version = getTag(event.tags, 'version'); + const size = formatSize(getTag(event.tags, 'size')); + const appIdentifier = getTag(event.tags, 'i'); + const platforms = getAllTags(event.tags, 'f'); + const variant = getTag(event.tags, 'variant'); + const commit = getTag(event.tags, 'commit'); + const hash = getTag(event.tags, 'x'); + const supportedNips = getAllTags(event.tags, 'supported_nip'); + const minPlatformVersion = getTag(event.tags, 'min_platform_version'); + + const label = mimeToLabel(mime); + const platformLabels = platforms.map(platformLabel); + + const handleDownload = async () => { + if (url) { + await openUrl(url); + } + }; + + if (compact) { + return ( +
+
+
+ +
+
+
+ {label} + {variant && ( + {variant} + )} + {version && ( + v{version} + )} +
+
+ {appIdentifier && {appIdentifier}} + {platformLabels.length > 0 && {platformLabels.join(', ')}} + {size && {size}} +
+
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ +
+
+

{label}

+ {appIdentifier && ( +

{appIdentifier}

+ )} +
+ {version && ( + + v{version} + + )} + {variant && ( + {variant} + )} + {platformLabels.length > 0 && ( + platformLabels.map((p) => ( + {p} + )) + )} +
+
+
+ + {/* Download button */} + {url && ( + + )} + + {/* Metadata grid */} +
+ {size && ( + + )} + {mime && ( + {mime}} /> + )} + {hash && ( + {hash}} /> + )} + {commit && ( + + + {commit} + + } + /> + )} + {minPlatformVersion && ( + + )} + {supportedNips.length > 0 && ( + + {supportedNips.map((nip) => ( + + NIP-{nip} + + ))} +
+ } + /> + )} +
+ + {/* Certificate hashes (Android) */} + {getAllTags(event.tags, 'apk_certificate_hash').length > 0 && ( +
+

+ APK Certificate +

+ {getAllTags(event.tags, 'apk_certificate_hash').map((hash) => ( +
+ + {hash} +
+ ))} +
+ )} + + ); +} + +/** A single metadata row inside the asset details grid. */ +function MetaRow({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+ {label} + {value} +
+ ); +} + +/** Skeleton for ZapstoreAssetContent. */ +export function ZapstoreAssetSkeleton() { + return ( +
+
+ +
+ + +
+ +
+
+
+ +
+ {[1, 2, 3].map((i) => ( +
+ + +
+ ))} +
+
+ ); +} + +// Re-export Separator so it's available if needed +export { Separator }; diff --git a/src/hooks/useEvent.ts b/src/hooks/useEvent.ts index d8978bb7..43a5cdd8 100644 --- a/src/hooks/useEvent.ts +++ b/src/hooks/useEvent.ts @@ -4,7 +4,7 @@ import type { NostrEvent, NostrFilter } from '@nostrify/nostrify'; import { ZAPSTORE_RELAY } from '@/lib/appRelays'; /** Kinds whose canonical home is the Zapstore relay. */ -const ZAPSTORE_KINDS = [32267, 30063]; +const ZAPSTORE_KINDS = [32267, 30063, 3063]; /** * Extract write relay URLs from a NIP-65 (kind 10002) relay list event. diff --git a/src/lib/extraKinds.ts b/src/lib/extraKinds.ts index 669fd1c3..2ace7787 100644 --- a/src/lib/extraKinds.ts +++ b/src/lib/extraKinds.ts @@ -478,7 +478,7 @@ export const EXTRA_KINDS: ExtraKindDef[] = [ id: 'development', showKey: 'showDevelopment', feedKey: 'feedIncludeDevelopment', - extraFeedKinds: [1617, 1618, 30817, 15128, 35128, 32267, 31990], + extraFeedKinds: [1617, 1618, 30817, 15128, 35128, 32267, 30063, 31990], label: 'Development', description: 'Git repos, patches, PRs, nsites, apps, and custom NIPs', route: 'development', @@ -549,6 +549,7 @@ const KIND_SPECIFIC_LABELS: Record = { 32267: 'app', 31990: 'app', 30063: 'release', + 3063: 'asset', }; /** diff --git a/src/pages/NotificationsPage.tsx b/src/pages/NotificationsPage.tsx index 2fe7b28f..573c17e6 100644 --- a/src/pages/NotificationsPage.tsx +++ b/src/pages/NotificationsPage.tsx @@ -78,6 +78,7 @@ const NOTIFICATION_KIND_NOUNS: Record = { 30030: 'emoji pack', 30054: 'podcast episode', 30055: 'podcast trailer', + 3063: 'asset', 30063: 'release', 30311: 'stream', 30315: 'status', diff --git a/src/pages/PostDetailPage.tsx b/src/pages/PostDetailPage.tsx index 0d99edfc..e331a6fe 100644 --- a/src/pages/PostDetailPage.tsx +++ b/src/pages/PostDetailPage.tsx @@ -85,6 +85,7 @@ import { VoiceMessagePlayer } from "@/components/VoiceMessagePlayer"; import { WebxdcEmbed } from "@/components/WebxdcEmbed"; import { ProfileCard } from "@/components/ProfileCard"; import { ZapstoreAppContent } from "@/components/ZapstoreAppContent"; +import { ZapstoreReleaseContent, ZapstoreReleaseSkeleton, ZapstoreAssetContent, ZapstoreAssetSkeleton } from "@/components/ZapstoreReleaseContent"; import { AppHandlerContent } from "@/components/AppHandlerContent"; import { useAppContext } from "@/hooks/useAppContext"; import { type AddrCoords, useAddrEvent, useEvent } from "@/hooks/useEvent"; @@ -136,6 +137,8 @@ function shellTitleForKind(kind?: number): string { if (kind === BADGE_PROFILE_KIND_NEW || kind === BADGE_PROFILE_KIND_LEGACY) return "Badge Collection"; if (kind === BOOK_REVIEW_KIND) return "Book Review"; if (kind === 32267) return "App Details"; + if (kind === 30063) return "Release"; + if (kind === 3063) return "Asset"; if (kind === 31990) return "App"; if (kind === 15128 || kind === 35128) return "Nsite"; if (kind === VANISH_KIND) return "Request to Vanish"; @@ -1002,6 +1005,8 @@ function PostDetailContent({ event }: { event: NostrEvent }) { const isCustomNip = event.kind === 30817; const isNsite = event.kind === 15128 || event.kind === 35128; const isZapstoreApp = event.kind === 32267; + const isZapstoreRelease = event.kind === 30063; + const isZapstoreAsset = event.kind === 3063; const isAppHandler = event.kind === 31990; const isEncryptedDM = event.kind === 4; const isLetter = event.kind === 8211; @@ -1030,6 +1035,8 @@ function PostDetailContent({ event }: { event: NostrEvent }) { !isCommunity && !isDevKind && !isZapstoreApp && + !isZapstoreRelease && + !isZapstoreAsset && !isAppHandler && !isEncryptedDM && !isLetter && @@ -2058,6 +2065,14 @@ function PostDetailContent({ event }: { event: NostrEvent }) { ) : isZapstoreApp ? ( + ) : isZapstoreRelease ? ( + }> + + + ) : isZapstoreAsset ? ( + }> + + ) : isAppHandler ? ( ) : isEncryptedDM ? (