Add rendering for Zapstore release (kind 30063) and asset (kind 3063) events
- New ZapstoreReleaseContent component: shows app icon/name fetched from the linked kind 32267, version badge, channel badge, release notes, and a downloads section that fetches and renders each linked kind 3063 asset - New ZapstoreAssetContent component: shows MIME-type icon, platform/arch badges, file size, SHA-256 hash, commit hash, supported NIPs, and APK certificate hashes - Register both kinds in NoteCard, PostDetailPage, extraKinds, CommentContext, ExternalContentHeader, and NotificationsPage label/icon maps - Route kind 3063 to the Zapstore relay in NostrProvider and useEvent - Kind 3063 is excluded from feeds (display-only on direct navigation)
This commit is contained in:
@@ -108,6 +108,7 @@ const KIND_LABELS: Record<number, string> = {
|
||||
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<Record<number, React.ComponentType<{ className?: strin
|
||||
30030: SmilePlus,
|
||||
30054: Podcast,
|
||||
30055: Podcast,
|
||||
3063: Package,
|
||||
30063: Package,
|
||||
30311: Radio,
|
||||
30617: GitBranch,
|
||||
@@ -216,6 +218,7 @@ const KIND_SUFFIXES: Partial<Record<number, string>> = {
|
||||
const KIND_POSTFIXES: Partial<Record<number, string>> = {
|
||||
32267: 'on Zapstore',
|
||||
30063: 'release',
|
||||
3063: 'asset',
|
||||
};
|
||||
|
||||
/** Get a display name for an event based on its kind and tags. */
|
||||
|
||||
@@ -1085,6 +1085,7 @@ const WELL_KNOWN_KIND_LABELS: Record<number, string> = {
|
||||
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]);
|
||||
|
||||
@@ -109,8 +109,8 @@ const NostrProvider: React.FC<NostrProviderProps> = (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]));
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
<NsiteCard event={event} />
|
||||
) : isZapstoreApp ? (
|
||||
<ZapstoreAppContent event={event} compact />
|
||||
) : isZapstoreRelease ? (
|
||||
<ZapstoreReleaseContent event={event} compact />
|
||||
) : isZapstoreAsset ? (
|
||||
<ZapstoreAssetContent event={event} compact />
|
||||
) : isAppHandler ? (
|
||||
<AppHandlerContent event={event} compact />
|
||||
) : isEncryptedDM ? (
|
||||
@@ -2007,6 +2016,14 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
|
||||
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",
|
||||
|
||||
@@ -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<string, string> = {
|
||||
'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 <Smartphone className={className} />;
|
||||
}
|
||||
if (mime.includes('apple') || mime.includes('mach') || mime.includes('msi') || mime.includes('portable-executable')) {
|
||||
return <Monitor className={className} />;
|
||||
}
|
||||
if (mime.includes('appimage') || mime.includes('flatpak') || mime.includes('executable')) {
|
||||
return <Monitor className={className} />;
|
||||
}
|
||||
if (mime.includes('wasm') || mime.includes('webbundle') || mime.includes('chrome') || mime.includes('xpinstall') || mime.includes('vsix')) {
|
||||
return <Globe className={className} />;
|
||||
}
|
||||
return <Package className={className} />;
|
||||
}
|
||||
|
||||
/** 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<string, string> = {
|
||||
'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<string, string> = {
|
||||
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 (
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${colorClass}`}>
|
||||
{channel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Hook to fetch asset events (kind 3063) for a release. */
|
||||
function useReleaseAssets(assetIds: string[]) {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
return useQuery<NostrEvent[]>({
|
||||
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<NostrEvent | null>({
|
||||
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 (
|
||||
<div className="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-muted/50 transition-colors group">
|
||||
{/* Platform icon */}
|
||||
<div className="size-8 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<PlatformIcon mime={mime} className="size-4 text-primary" />
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium truncate">{label}</span>
|
||||
{variant && (
|
||||
<Badge variant="outline" className="text-xs px-1.5 py-0">
|
||||
{variant}
|
||||
</Badge>
|
||||
)}
|
||||
{platformLabels.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{platformLabels.join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||
{version && (
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Tag className="size-3" />
|
||||
{version}
|
||||
</span>
|
||||
)}
|
||||
{size && (
|
||||
<span className="text-xs text-muted-foreground">{size}</span>
|
||||
)}
|
||||
{commit && (
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<GitCommit className="size-3" />
|
||||
<code className="font-mono">{commit.slice(0, 7)}</code>
|
||||
</span>
|
||||
)}
|
||||
{hash && (
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Hash className="size-3" />
|
||||
<code className="font-mono">{hash.slice(0, 8)}</code>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Download button */}
|
||||
{url && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="gap-1.5 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => { e.stopPropagation(); handleDownload(); }}
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
Download
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="mt-2 space-y-2.5">
|
||||
{/* Header: icon + app name + version */}
|
||||
<div className="flex items-start gap-3">
|
||||
{appIcon ? (
|
||||
<img
|
||||
src={appIcon}
|
||||
alt={appName ?? ''}
|
||||
className="size-10 rounded-xl object-cover shrink-0 shadow-sm"
|
||||
loading="lazy"
|
||||
onError={(e) => { (e.currentTarget as HTMLElement).style.display = 'none'; }}
|
||||
/>
|
||||
) : (
|
||||
<div className="size-10 rounded-xl bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<Package className="size-5 text-primary/50" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{appName && (
|
||||
appNaddr ? (
|
||||
<Link
|
||||
to={`/${appNaddr}`}
|
||||
className="font-semibold text-[15px] leading-snug hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{appName}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="font-semibold text-[15px] leading-snug">{appName}</span>
|
||||
)
|
||||
)}
|
||||
{version && (
|
||||
<Badge variant="outline" className="text-xs px-2 py-0">
|
||||
v{version}
|
||||
</Badge>
|
||||
)}
|
||||
<ChannelBadge channel={channel} />
|
||||
</div>
|
||||
{/* Asset count summary */}
|
||||
{assetIds.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{assetIds.length} {assetIds.length === 1 ? 'asset' : 'assets'} available
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Release notes (truncated) */}
|
||||
{releaseNotes && (
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap break-words line-clamp-4">
|
||||
{releaseNotes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Full detail view
|
||||
return (
|
||||
<div className="mt-3 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-4">
|
||||
{appIcon ? (
|
||||
<img
|
||||
src={appIcon}
|
||||
alt={appName ?? ''}
|
||||
className="size-14 rounded-2xl object-cover shrink-0 shadow-md"
|
||||
loading="lazy"
|
||||
onError={(e) => { (e.currentTarget as HTMLElement).style.display = 'none'; }}
|
||||
/>
|
||||
) : (
|
||||
<div className="size-14 rounded-2xl bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<Package className="size-7 text-primary/50" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{appName && (
|
||||
appNaddr ? (
|
||||
<Link
|
||||
to={`/${appNaddr}`}
|
||||
className="text-lg font-bold leading-snug hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{appName}
|
||||
</Link>
|
||||
) : (
|
||||
<h2 className="text-lg font-bold leading-snug">{appName}</h2>
|
||||
)
|
||||
)}
|
||||
<div className="flex items-center gap-2 flex-wrap mt-1.5">
|
||||
{version && (
|
||||
<Badge variant="secondary" className="text-xs px-2 py-0">
|
||||
v{version}
|
||||
</Badge>
|
||||
)}
|
||||
<ChannelBadge channel={channel} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action row */}
|
||||
{appId && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" className="gap-1.5" asChild>
|
||||
<a
|
||||
href={`https://zapstore.dev/apps/${encodeURIComponent(appId)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
View on Zapstore
|
||||
</a>
|
||||
</Button>
|
||||
{appNaddr && (
|
||||
<Button size="sm" variant="ghost" className="gap-1.5" asChild>
|
||||
<Link to={`/${appNaddr}`} onClick={(e) => e.stopPropagation()}>
|
||||
<Package className="size-3.5" />
|
||||
App details
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Release notes */}
|
||||
{releaseNotes && (
|
||||
<div className="rounded-xl border border-border bg-muted/30 p-4 space-y-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
|
||||
Release Notes
|
||||
</p>
|
||||
<p className="text-sm leading-relaxed whitespace-pre-wrap break-words">
|
||||
{releaseNotes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assets */}
|
||||
{assetIds.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground px-1">
|
||||
Downloads
|
||||
</p>
|
||||
<div className="rounded-xl border border-border overflow-hidden divide-y divide-border">
|
||||
{assetsLoading
|
||||
? Array.from({ length: Math.min(assetIds.length, 3) }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-3 py-2.5">
|
||||
<Skeleton className="size-8 rounded-lg shrink-0" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-3.5 w-32" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
: assets.length > 0
|
||||
? assets.map((asset) => (
|
||||
<AssetRow key={asset.id} event={asset} />
|
||||
))
|
||||
: assetIds.map((id) => (
|
||||
<div key={id} className="flex items-center gap-3 px-3 py-2.5">
|
||||
<div className="size-8 rounded-lg bg-muted flex items-center justify-center shrink-0">
|
||||
<Package className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-muted-foreground font-mono truncate">{id.slice(0, 16)}…</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Skeleton loading state for ZapstoreReleaseContent. */
|
||||
export function ZapstoreReleaseSkeleton() {
|
||||
return (
|
||||
<div className="mt-3 space-y-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<Skeleton className="size-14 rounded-2xl shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-5 w-28" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-5 w-12 rounded-full" />
|
||||
<Skeleton className="h-5 w-10 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-8 w-36 rounded-md" />
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-4/5" />
|
||||
<Skeleton className="h-4 w-3/5" />
|
||||
</div>
|
||||
<div className="rounded-xl border border-border overflow-hidden divide-y divide-border">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-3 py-2.5">
|
||||
<Skeleton className="size-8 rounded-lg shrink-0" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-3.5 w-32" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 (
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-10 rounded-xl bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<PlatformIcon mime={mime} className="size-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-[15px] leading-snug">{label}</span>
|
||||
{variant && (
|
||||
<Badge variant="outline" className="text-xs px-1.5 py-0">{variant}</Badge>
|
||||
)}
|
||||
{version && (
|
||||
<span className="text-xs text-muted-foreground">v{version}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5 text-xs text-muted-foreground flex-wrap">
|
||||
{appIdentifier && <span>{appIdentifier}</span>}
|
||||
{platformLabels.length > 0 && <span>{platformLabels.join(', ')}</span>}
|
||||
{size && <span>{size}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="size-14 rounded-2xl bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<PlatformIcon mime={mime} className="size-7 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-xl font-bold leading-snug">{label}</h2>
|
||||
{appIdentifier && (
|
||||
<p className="text-sm text-muted-foreground mt-0.5">{appIdentifier}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 flex-wrap mt-2">
|
||||
{version && (
|
||||
<Badge variant="secondary" className="text-xs px-2 py-0">
|
||||
v{version}
|
||||
</Badge>
|
||||
)}
|
||||
{variant && (
|
||||
<Badge variant="outline" className="text-xs px-2 py-0">{variant}</Badge>
|
||||
)}
|
||||
{platformLabels.length > 0 && (
|
||||
platformLabels.map((p) => (
|
||||
<Badge key={p} variant="outline" className="text-xs px-2 py-0">{p}</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Download button */}
|
||||
{url && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={(e) => { e.stopPropagation(); handleDownload(); }}
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
Download
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Metadata grid */}
|
||||
<div className="rounded-xl border border-border divide-y divide-border">
|
||||
{size && (
|
||||
<MetaRow label="File Size" value={size} />
|
||||
)}
|
||||
{mime && (
|
||||
<MetaRow label="MIME Type" value={<code className="text-xs font-mono">{mime}</code>} />
|
||||
)}
|
||||
{hash && (
|
||||
<MetaRow label="SHA-256" value={<code className="text-xs font-mono break-all">{hash}</code>} />
|
||||
)}
|
||||
{commit && (
|
||||
<MetaRow
|
||||
label="Commit"
|
||||
value={
|
||||
<span className="flex items-center gap-1">
|
||||
<GitCommit className="size-3 shrink-0" />
|
||||
<code className="text-xs font-mono">{commit}</code>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{minPlatformVersion && (
|
||||
<MetaRow label="Min Platform Version" value={minPlatformVersion} />
|
||||
)}
|
||||
{supportedNips.length > 0 && (
|
||||
<MetaRow
|
||||
label="Supported NIPs"
|
||||
value={
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{supportedNips.map((nip) => (
|
||||
<Badge key={nip} variant="secondary" className="text-xs px-1.5 py-0">
|
||||
NIP-{nip}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Certificate hashes (Android) */}
|
||||
{getAllTags(event.tags, 'apk_certificate_hash').length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
APK Certificate
|
||||
</p>
|
||||
{getAllTags(event.tags, 'apk_certificate_hash').map((hash) => (
|
||||
<div key={hash} className="flex items-center gap-2">
|
||||
<Shield className="size-3.5 text-green-600 shrink-0" />
|
||||
<code className="text-xs font-mono text-muted-foreground break-all">{hash}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A single metadata row inside the asset details grid. */
|
||||
function MetaRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start gap-4 px-3 py-2">
|
||||
<span className="text-xs text-muted-foreground w-36 shrink-0 pt-0.5">{label}</span>
|
||||
<span className="text-sm flex-1 min-w-0">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Skeleton for ZapstoreAssetContent. */
|
||||
export function ZapstoreAssetSkeleton() {
|
||||
return (
|
||||
<div className="mt-3 space-y-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<Skeleton className="size-14 rounded-2xl shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-6 w-40" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-5 w-14 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-8 w-28 rounded-md" />
|
||||
<div className="rounded-xl border border-border divide-y divide-border">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-start gap-4 px-3 py-2">
|
||||
<Skeleton className="h-3 w-28 mt-0.5 shrink-0" />
|
||||
<Skeleton className="h-3 w-48 flex-1" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Re-export Separator so it's available if needed
|
||||
export { Separator };
|
||||
@@ -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.
|
||||
|
||||
@@ -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<number, string> = {
|
||||
32267: 'app',
|
||||
31990: 'app',
|
||||
30063: 'release',
|
||||
3063: 'asset',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -78,6 +78,7 @@ const NOTIFICATION_KIND_NOUNS: Record<number, string> = {
|
||||
30030: 'emoji pack',
|
||||
30054: 'podcast episode',
|
||||
30055: 'podcast trailer',
|
||||
3063: 'asset',
|
||||
30063: 'release',
|
||||
30311: 'stream',
|
||||
30315: 'status',
|
||||
|
||||
@@ -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 }) {
|
||||
</div>
|
||||
) : isZapstoreApp ? (
|
||||
<ZapstoreAppContent event={event} />
|
||||
) : isZapstoreRelease ? (
|
||||
<Suspense fallback={<ZapstoreReleaseSkeleton />}>
|
||||
<ZapstoreReleaseContent event={event} />
|
||||
</Suspense>
|
||||
) : isZapstoreAsset ? (
|
||||
<Suspense fallback={<ZapstoreAssetSkeleton />}>
|
||||
<ZapstoreAssetContent event={event} />
|
||||
</Suspense>
|
||||
) : isAppHandler ? (
|
||||
<AppHandlerContent event={event} />
|
||||
) : isEncryptedDM ? (
|
||||
|
||||
Reference in New Issue
Block a user