Add download button to Zapstore app renderers (#108)

* Add download button to Zapstore app renderers

Add a download button for the latest release to both the feed and detail
renderers for kind 32267 (Zapstore App Metadata). The feed renderer shows
a compact version button, while the detail renderer shows a prominent
download button in the header. Both fetch the latest release and link to
its file metadata event (kind 1063) for download.

* Add proper relay hints for fetching Zapstore releases

Use useLiveTimeline instead of eventStore.timeline() to actually fetch
release events from relays. Relay selection includes:
- Seen relays (where the app event was received from)
- Publisher's outbox relays (NIP-65)
- Aggregator relays as fallback

This ensures releases are properly fetched rather than just read from
the local event store cache.

* Add relay hints when opening file events for download

Pass relay hints from the release event's seen relays when opening
file metadata events (kind 1063) for download. This ensures the
event loader knows where to fetch the file event from.

Also adds relay hints to the ReleaseItem component for both opening
the release detail and the download file.

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Alejandro
2026-01-15 16:30:19 +01:00
committed by GitHub
parent 7a293bb41b
commit d172d67584
2 changed files with 200 additions and 14 deletions

View File

@@ -16,8 +16,6 @@ import { UserName } from "../UserName";
import { ExternalLink } from "@/components/ExternalLink";
import { MediaEmbed } from "../MediaEmbed";
import { Badge } from "@/components/ui/badge";
import { use$ } from "applesauce-react/hooks";
import eventStore from "@/services/event-store";
import { useMemo } from "react";
import { useGrimoire } from "@/core/state";
import {
@@ -29,6 +27,10 @@ import {
Laptop,
FileDown,
} from "lucide-react";
import { getSeenRelays } from "applesauce-core/helpers/relays";
import { relayListCache } from "@/services/relay-list-cache";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
import { useLiveTimeline } from "@/hooks/useLiveTimeline";
interface ZapstoreAppDetailRendererProps {
event: NostrEvent;
@@ -42,12 +44,19 @@ function ReleaseItem({ release }: { release: NostrEvent }) {
const version = getReleaseVersion(release);
const fileEventId = getReleaseFileEventId(release);
// Get relay hints from the release event
const releaseSeenRelays = getSeenRelays(release);
const relayHints = releaseSeenRelays
? Array.from(releaseSeenRelays).slice(0, 3)
: [];
const handleClick = () => {
addWindow("open", {
pointer: {
kind: release.kind,
pubkey: release.pubkey,
identifier: release.tags.find((t) => t[0] === "d")?.[1] || "",
relays: relayHints,
},
});
};
@@ -55,7 +64,9 @@ function ReleaseItem({ release }: { release: NostrEvent }) {
const handleDownload = (e: React.MouseEvent) => {
e.stopPropagation();
if (fileEventId) {
addWindow("open", { pointer: { id: fileEventId } });
addWindow("open", {
pointer: { id: fileEventId, relays: relayHints },
});
}
};
@@ -147,6 +158,7 @@ function PlatformItem({ platform }: { platform: Platform }) {
export function ZapstoreAppDetailRenderer({
event,
}: ZapstoreAppDetailRendererProps) {
const { addWindow } = useGrimoire();
const appName = getAppName(event);
const summary = getAppSummary(event);
const iconUrl = getAppIcon(event);
@@ -156,6 +168,37 @@ export function ZapstoreAppDetailRenderer({
const license = getAppLicense(event);
const identifier = getAppIdentifier(event);
// Build relay list for fetching releases:
// 1. Seen relays (where we received this app event)
// 2. Publisher's outbox relays (NIP-65)
// 3. Aggregator relays (fallback)
const relays = useMemo(() => {
const relaySet = new Set<string>();
// Add seen relays from the app event
const seenRelays = getSeenRelays(event);
if (seenRelays) {
for (const relay of seenRelays) {
relaySet.add(relay);
}
}
// Add publisher's outbox relays
const outboxRelays = relayListCache.getOutboxRelaysSync(event.pubkey);
if (outboxRelays) {
for (const relay of outboxRelays.slice(0, 3)) {
relaySet.add(relay);
}
}
// Add aggregator relays
for (const relay of AGGREGATOR_RELAYS) {
relaySet.add(relay);
}
return Array.from(relaySet);
}, [event]);
// Query for releases that reference this app
const releasesFilter = useMemo(() => {
if (!identifier) {
@@ -168,9 +211,12 @@ export function ZapstoreAppDetailRenderer({
};
}, [event.pubkey, identifier]);
const releases = use$(
() => eventStore.timeline(releasesFilter),
[releasesFilter],
// Use useLiveTimeline to fetch releases from relays with proper hints
const { events: releases } = useLiveTimeline(
`zapstore-releases-detail-${event.id}`,
releasesFilter,
relays,
{ limit: 50 },
);
// Sort releases by version (newest first) or created_at
@@ -186,6 +232,27 @@ export function ZapstoreAppDetailRenderer({
});
}, [releases]);
// Get the latest release for the header download button
const latestRelease = sortedReleases[0] || null;
const latestFileEventId = latestRelease
? getReleaseFileEventId(latestRelease)
: null;
const latestVersion = latestRelease ? getReleaseVersion(latestRelease) : null;
const handleDownloadLatest = () => {
if (latestFileEventId && latestRelease) {
// Get relay hints from the release event (where we found it)
const releaseSeenRelays = getSeenRelays(latestRelease);
const relayHints = releaseSeenRelays
? Array.from(releaseSeenRelays).slice(0, 3)
: relays.slice(0, 3);
addWindow("open", {
pointer: { id: latestFileEventId, relays: relayHints },
});
}
};
return (
<div className="flex flex-col gap-6 p-6 max-w-4xl mx-auto">
{/* Header Section */}
@@ -206,7 +273,18 @@ export function ZapstoreAppDetailRenderer({
{/* App Title & Summary */}
<div className="flex flex-col gap-2 flex-1 min-w-0">
<h1 className="text-3xl font-bold">{appName}</h1>
<div className="flex items-start justify-between gap-4">
<h1 className="text-3xl font-bold">{appName}</h1>
{latestFileEventId && (
<button
onClick={handleDownloadLatest}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-primary-foreground bg-primary rounded-lg hover:bg-primary/90 transition-colors flex-shrink-0"
>
<FileDown className="size-4" />
{latestVersion ? `Download v${latestVersion}` : "Download"}
</button>
)}
</div>
{summary && (
<p className="text-muted-foreground text-base">{summary}</p>
)}

View File

@@ -6,28 +6,136 @@ import {
import {
getAppName,
getAppSummary,
getAppIdentifier,
detectPlatforms,
getReleaseVersion,
getReleaseFileEventId,
} from "@/lib/zapstore-helpers";
import { PlatformIcon } from "./zapstore/PlatformIcon";
import { useMemo } from "react";
import { useGrimoire } from "@/core/state";
import { FileDown } from "lucide-react";
import { getSeenRelays } from "applesauce-core/helpers/relays";
import { relayListCache } from "@/services/relay-list-cache";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
import { useLiveTimeline } from "@/hooks/useLiveTimeline";
/**
* Renderer for Kind 32267 - App Metadata
* Clean feed view with app name, summary, and platform icons
* Clean feed view with app name, summary, platform icons, and download button
*/
export function ZapstoreAppRenderer({ event }: BaseEventProps) {
const { addWindow } = useGrimoire();
const appName = getAppName(event);
const summary = getAppSummary(event);
const identifier = getAppIdentifier(event);
const platforms = detectPlatforms(event);
// Build relay list for fetching releases:
// 1. Seen relays (where we received this app event)
// 2. Publisher's outbox relays (NIP-65)
// 3. Aggregator relays (fallback)
const relays = useMemo(() => {
const relaySet = new Set<string>();
// Add seen relays from the app event
const seenRelays = getSeenRelays(event);
if (seenRelays) {
for (const relay of seenRelays) {
relaySet.add(relay);
}
}
// Add publisher's outbox relays
const outboxRelays = relayListCache.getOutboxRelaysSync(event.pubkey);
if (outboxRelays) {
for (const relay of outboxRelays.slice(0, 3)) {
relaySet.add(relay);
}
}
// Add aggregator relays
for (const relay of AGGREGATOR_RELAYS) {
relaySet.add(relay);
}
return Array.from(relaySet);
}, [event]);
// Query for releases that reference this app
const releasesFilter = useMemo(() => {
if (!identifier) {
return { kinds: [30063], ids: [] };
}
return {
kinds: [30063],
"#a": [`32267:${event.pubkey}:${identifier}`],
};
}, [event.pubkey, identifier]);
// Use useLiveTimeline to actually fetch releases from relays
const { events: releases } = useLiveTimeline(
`zapstore-releases-${event.id}`,
releasesFilter,
relays,
{ limit: 10 },
);
// Get the latest release (by version or created_at)
const latestRelease = useMemo(() => {
if (!releases || releases.length === 0) return null;
return [...releases].sort((a, b) => {
const versionA = getReleaseVersion(a);
const versionB = getReleaseVersion(b);
if (versionA && versionB) {
return versionB.localeCompare(versionA, undefined, { numeric: true });
}
return b.created_at - a.created_at;
})[0];
}, [releases]);
const latestFileEventId = latestRelease
? getReleaseFileEventId(latestRelease)
: null;
const latestVersion = latestRelease ? getReleaseVersion(latestRelease) : null;
const handleDownload = (e: React.MouseEvent) => {
e.stopPropagation();
if (latestFileEventId && latestRelease) {
// Get relay hints from the release event (where we found it)
const releaseSeenRelays = getSeenRelays(latestRelease);
const relayHints = releaseSeenRelays
? Array.from(releaseSeenRelays).slice(0, 3)
: relays.slice(0, 3);
addWindow("open", {
pointer: { id: latestFileEventId, relays: relayHints },
});
}
};
return (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
<ClickableEventTitle
event={event}
className="text-base font-semibold text-foreground"
>
{appName}
</ClickableEventTitle>
<div className="flex items-center justify-between gap-2">
<ClickableEventTitle
event={event}
className="text-base font-semibold text-foreground"
>
{appName}
</ClickableEventTitle>
{latestFileEventId && (
<button
onClick={handleDownload}
className="flex items-center gap-1.5 px-2 py-1 text-xs font-medium text-primary border border-primary/20 rounded hover:bg-primary/10 transition-colors flex-shrink-0"
title={latestVersion ? `Download v${latestVersion}` : "Download"}
>
<FileDown className="size-3" />
{latestVersion ? `v${latestVersion}` : "Download"}
</button>
)}
</div>
{summary && (
<p className="text-sm text-muted-foreground line-clamp-2">