From 13fec0345fa05c381ec0950d0d39afc8483ee8b5 Mon Sep 17 00:00:00 2001 From: Alejandro Date: Tue, 20 Jan 2026 17:28:57 +0100 Subject: [PATCH] feat: zap goals nip-75 (#174) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add NIP-75 Zap Goal rendering Add feed and detail view rendering for kind 9041 (Zap Goals): - GoalRenderer: Shows clickable title, description, and progress bar with target/raised amounts - GoalDetailRenderer: Adds sorted contributor breakdown with individual contribution totals - nip75-helpers: Helper functions for extracting goal metadata (amount, relays, deadline, beneficiaries) Both views fetch and tally zaps from the goal's specified relays. * fix: improve NIP-75 goal rendering - Remove icons from goal renderers - Remove "sats" suffixes from amounts - Use muted text instead of destructive color for closed goals - Content is the title, summary tag is the description - Only show description if summary tag exists * feat: add zap button to goal renderers Show a 'Zap this Goal' button in both feed and detail views when the goal is still open (not past its closed_at deadline). * style: unify progress indicator styles - Update user menu and welcome page progress indicators to match goal renderer style: bar on top, progress/total below with percentage - Remove "sats" suffix from progress displays - Make goal zap button primary variant and full-width * fix: polish goal renderers for release - Remove limit parameter from useTimeline (use whatever relays send) - Add more spacing between "Support Grimoire" header and progress bar * refactor: extract useGoalProgress hook for NIP-75 goals - Create useGoalProgress hook with shared goal logic - Handle relay selection: goal relays → user inbox → aggregators - Calculate progress, contributors, and all metadata in one place - Simplify both GoalRenderer and GoalDetailRenderer --------- Co-authored-by: Claude --- src/components/GrimoireWelcome.tsx | 19 +- .../nostr/kinds/GoalDetailRenderer.tsx | 159 +++++++++++++++++ src/components/nostr/kinds/GoalRenderer.tsx | 106 +++++++++++ src/components/nostr/kinds/index.tsx | 4 + src/components/nostr/user-menu.tsx | 19 +- src/hooks/useGoalProgress.ts | 164 ++++++++++++++++++ src/lib/nip75-helpers.ts | 116 +++++++++++++ 7 files changed, 573 insertions(+), 14 deletions(-) create mode 100644 src/components/nostr/kinds/GoalDetailRenderer.tsx create mode 100644 src/components/nostr/kinds/GoalRenderer.tsx create mode 100644 src/hooks/useGoalProgress.ts create mode 100644 src/lib/nip75-helpers.ts diff --git a/src/components/GrimoireWelcome.tsx b/src/components/GrimoireWelcome.tsx index 6791e47..c9cf291 100644 --- a/src/components/GrimoireWelcome.tsx +++ b/src/components/GrimoireWelcome.tsx @@ -136,15 +136,20 @@ export function GrimoireWelcome({ {description} {showProgress && ( -
-
- Monthly goal - - {formatSats(monthlyDonations)} /{" "} - {formatSats(MONTHLY_GOAL_SATS)} sats +
+ +
+ + + {formatSats(monthlyDonations)} + + {" / "} + {formatSats(MONTHLY_GOAL_SATS)} + + + {goalProgress.toFixed(0)}%
-
)} diff --git a/src/components/nostr/kinds/GoalDetailRenderer.tsx b/src/components/nostr/kinds/GoalDetailRenderer.tsx new file mode 100644 index 0000000..7728cc4 --- /dev/null +++ b/src/components/nostr/kinds/GoalDetailRenderer.tsx @@ -0,0 +1,159 @@ +import { NostrEvent } from "@/types/nostr"; +import { Zap } from "lucide-react"; +import { useGoalProgress } from "@/hooks/useGoalProgress"; +import { formatTimestamp } from "@/hooks/useLocale"; +import { useGrimoire } from "@/core/state"; +import { Progress } from "@/components/ui/progress"; +import { Button } from "@/components/ui/button"; +import { UserName } from "../UserName"; +import { Skeleton } from "@/components/ui/skeleton"; + +/** + * Detail renderer for Kind 9041 - Zap Goals (NIP-75) + * Shows full goal info with sorted contributor breakdown + */ +export function GoalDetailRenderer({ event }: { event: NostrEvent }) { + const { locale, addWindow } = useGrimoire(); + const { + title, + summary, + closedAt, + closed, + beneficiaries, + targetSats, + raisedSats, + progress, + contributors, + loading, + } = useGoalProgress(event); + + const deadlineText = closedAt + ? formatTimestamp(closedAt, "absolute", locale.locale) + : null; + + const handleZap = () => { + addWindow("zap", { + recipientPubkey: event.pubkey, + eventPointer: { id: event.id }, + }); + }; + + return ( +
+ {/* Header */} +
+

{title}

+ + {/* Description (summary tag only) */} + {summary && ( +

{summary}

+ )} + + {/* Deadline */} + {closedAt && ( +
+ {closed ? ( + Closed on {deadlineText} + ) : ( + Ends {deadlineText} + )} +
+ )} +
+ + {/* Progress Section */} + {targetSats > 0 && ( +
+
+ + {raisedSats.toLocaleString()} + + + of {targetSats.toLocaleString()} + +
+ +
+ {contributors.length} contributors + + {progress.toFixed(1)}% + +
+
+ )} + + {/* Zap Button */} + {!closed && ( + + )} + + {/* Beneficiaries */} + {beneficiaries.length > 0 && ( +
+

+ Beneficiaries +

+
+ {beneficiaries.map((pubkey) => ( +
+ +
+ ))} +
+
+ )} + + {/* Contributors */} +
+

+ Contributors +

+ + {loading && contributors.length === 0 ? ( +
+ {[...Array(5)].map((_, i) => ( +
+ + +
+ ))} +
+ ) : contributors.length === 0 ? ( +

+ No contributions yet. Be the first to contribute! +

+ ) : ( +
+ {contributors.map((contributor, index) => { + const amountSats = Math.floor(contributor.totalAmount / 1000); + return ( +
+
+ + #{index + 1} + + + {contributor.zapCount > 1 && ( + + ({contributor.zapCount} zaps) + + )} +
+ + {amountSats.toLocaleString()} + +
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/src/components/nostr/kinds/GoalRenderer.tsx b/src/components/nostr/kinds/GoalRenderer.tsx new file mode 100644 index 0000000..508d1fe --- /dev/null +++ b/src/components/nostr/kinds/GoalRenderer.tsx @@ -0,0 +1,106 @@ +import { + BaseEventProps, + BaseEventContainer, + ClickableEventTitle, +} from "./BaseEventRenderer"; +import { Zap } from "lucide-react"; +import { useGoalProgress } from "@/hooks/useGoalProgress"; +import { formatTimestamp } from "@/hooks/useLocale"; +import { useGrimoire } from "@/core/state"; +import { Progress } from "@/components/ui/progress"; +import { Button } from "@/components/ui/button"; + +/** + * Renderer for Kind 9041 - Zap Goals (NIP-75) + * Shows goal title, description, and funding progress + */ +export function GoalRenderer({ event }: BaseEventProps) { + const { locale, addWindow } = useGrimoire(); + const { + title, + summary, + closedAt, + closed, + targetSats, + raisedSats, + progress, + loading, + zaps, + } = useGoalProgress(event); + + const deadlineText = closedAt + ? formatTimestamp(closedAt, "absolute", locale.locale) + : null; + + const handleZap = () => { + addWindow("zap", { + recipientPubkey: event.pubkey, + eventPointer: { id: event.id }, + }); + }; + + return ( + +
+ {/* Title */} + + {title} + + + {/* Description (summary tag only) */} + {summary && ( +

+ {summary} +

+ )} + + {/* Progress */} + {targetSats > 0 && ( +
+ +
+ + {loading && zaps.length === 0 ? ( + "Loading..." + ) : ( + <> + + {raisedSats.toLocaleString()} + + {" / "} + {targetSats.toLocaleString()} + + )} + + + {progress.toFixed(0)}% + +
+
+ )} + + {/* Deadline */} + {closedAt && ( +
+ {closed ? ( + Closed on {deadlineText} + ) : ( + Ends {deadlineText} + )} +
+ )} + + {/* Zap Button */} + {!closed && ( + + )} +
+
+ ); +} diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx index d14e79b..1ec856d 100644 --- a/src/components/nostr/kinds/index.tsx +++ b/src/components/nostr/kinds/index.tsx @@ -148,6 +148,8 @@ import { BadgeAwardRenderer } from "./BadgeAwardRenderer"; import { BadgeAwardDetailRenderer } from "./BadgeAwardDetailRenderer"; import { ProfileBadgesRenderer } from "./ProfileBadgesRenderer"; import { ProfileBadgesDetailRenderer } from "./ProfileBadgesDetailRenderer"; +import { GoalRenderer } from "./GoalRenderer"; +import { GoalDetailRenderer } from "./GoalDetailRenderer"; /** * Registry of kind-specific renderers @@ -176,6 +178,7 @@ const kindRenderers: Record> = { 1617: PatchRenderer, // Patch (NIP-34) 1618: PullRequestRenderer, // Pull Request (NIP-34) 1621: IssueRenderer, // Issue (NIP-34) + 9041: GoalRenderer, // Zap Goal (NIP-75) 9735: Kind9735Renderer, // Zap Receipt 9802: Kind9802Renderer, // Highlight 8000: AddUserRenderer, // Add User (NIP-43) @@ -281,6 +284,7 @@ const detailRenderers: Record< 1617: PatchDetailRenderer, // Patch Detail (NIP-34) 1618: PullRequestDetailRenderer, // Pull Request Detail (NIP-34) 1621: IssueDetailRenderer, // Issue Detail (NIP-34) + 9041: GoalDetailRenderer, // Zap Goal Detail (NIP-75) 9802: Kind9802DetailRenderer, // Highlight Detail 8000: AddUserDetailRenderer, // Add User Detail (NIP-43) 8001: RemoveUserDetailRenderer, // Remove User Detail (NIP-43) diff --git a/src/components/nostr/user-menu.tsx b/src/components/nostr/user-menu.tsx index 7bd0fec..65b694d 100644 --- a/src/components/nostr/user-menu.tsx +++ b/src/components/nostr/user-menu.tsx @@ -430,18 +430,23 @@ export default function UserMenu() { className="px-2 py-2 cursor-crosshair hover:bg-accent/50 transition-colors" onClick={openDonate} > -
+
Support Grimoire
-
- Monthly goal - - {formatSats(monthlyDonations)} /{" "} - {formatSats(MONTHLY_GOAL_SATS)} sats + +
+ + + {formatSats(monthlyDonations)} + + {" / "} + {formatSats(MONTHLY_GOAL_SATS)} + + + {goalProgress.toFixed(0)}%
-
diff --git a/src/hooks/useGoalProgress.ts b/src/hooks/useGoalProgress.ts new file mode 100644 index 0000000..4266edd --- /dev/null +++ b/src/hooks/useGoalProgress.ts @@ -0,0 +1,164 @@ +import { useMemo, useState, useEffect } from "react"; +import type { NostrEvent } from "@/types/nostr"; +import { useTimeline } from "@/hooks/useTimeline"; +import { useAccount } from "@/hooks/useAccount"; +import { + getGoalAmount, + getGoalRelays, + getGoalClosedAt, + getGoalTitle, + getGoalSummary, + isGoalClosed, + getGoalBeneficiaries, +} from "@/lib/nip75-helpers"; +import { getZapAmount, getZapSender } from "applesauce-common/helpers/zap"; +import { AGGREGATOR_RELAYS } from "@/services/loaders"; +import { relayListCache } from "@/services/relay-list-cache"; + +export interface Contributor { + pubkey: string; + totalAmount: number; + zapCount: number; +} + +export interface GoalProgressResult { + // Metadata + title: string; + summary: string | undefined; + targetAmount: number | undefined; + closedAt: number | undefined; + closed: boolean; + beneficiaries: string[]; + + // Progress + targetSats: number; + raisedSats: number; + progress: number; + contributors: Contributor[]; + + // Loading state + loading: boolean; + zaps: NostrEvent[]; +} + +/** + * Hook for fetching and calculating NIP-75 Zap Goal progress + * + * Handles: + * - Parsing goal metadata from event + * - Selecting relays (goal relays → user inbox relays → aggregators) + * - Fetching zap receipts + * - Calculating total raised and contributor breakdown + */ +export function useGoalProgress(event: NostrEvent): GoalProgressResult { + const { pubkey: userPubkey } = useAccount(); + + // Parse goal metadata + const title = getGoalTitle(event); + const summary = getGoalSummary(event); + const targetAmount = getGoalAmount(event); + const closedAt = getGoalClosedAt(event); + const closed = isGoalClosed(event); + const beneficiaries = getGoalBeneficiaries(event); + const goalRelays = getGoalRelays(event); + + // Get user's inbox relays as fallback + const [userInboxRelays, setUserInboxRelays] = useState([]); + + useEffect(() => { + if (!userPubkey) { + setUserInboxRelays([]); + return; + } + + relayListCache.getInboxRelays(userPubkey).then((relays) => { + setUserInboxRelays(relays || []); + }); + }, [userPubkey]); + + // Determine which relays to use: goal relays → user inbox → aggregators + const relays = useMemo(() => { + if (goalRelays.length > 0) { + return [...goalRelays, ...AGGREGATOR_RELAYS]; + } + if (userInboxRelays.length > 0) { + return [...userInboxRelays, ...AGGREGATOR_RELAYS]; + } + return AGGREGATOR_RELAYS; + }, [goalRelays, userInboxRelays]); + + // Fetch zaps for this goal + const zapFilter = useMemo( + () => ({ + kinds: [9735], + "#e": [event.id], + }), + [event.id], + ); + + const { events: zaps, loading } = useTimeline( + `goal-zaps-${event.id}`, + zapFilter, + relays, + ); + + // Calculate total raised and contributor breakdown + const { totalRaised, contributors } = useMemo(() => { + const contributorMap = new Map(); + let total = 0; + + for (const zap of zaps) { + const amount = getZapAmount(zap) || 0; + const sender = getZapSender(zap); + + total += amount; + + if (sender) { + const existing = contributorMap.get(sender); + if (existing) { + existing.totalAmount += amount; + existing.zapCount += 1; + } else { + contributorMap.set(sender, { + pubkey: sender, + totalAmount: amount, + zapCount: 1, + }); + } + } + } + + // Sort by amount descending + const sortedContributors = Array.from(contributorMap.values()).sort( + (a, b) => b.totalAmount - a.totalAmount, + ); + + return { totalRaised: total, contributors: sortedContributors }; + }, [zaps]); + + // Convert to sats for display + const targetSats = targetAmount ? Math.floor(targetAmount / 1000) : 0; + const raisedSats = Math.floor(totalRaised / 1000); + const progress = + targetSats > 0 ? Math.min((raisedSats / targetSats) * 100, 100) : 0; + + return { + // Metadata + title, + summary, + targetAmount, + closedAt, + closed, + beneficiaries, + + // Progress + targetSats, + raisedSats, + progress, + contributors, + + // Loading state + loading, + zaps, + }; +} diff --git a/src/lib/nip75-helpers.ts b/src/lib/nip75-helpers.ts new file mode 100644 index 0000000..bd999d6 --- /dev/null +++ b/src/lib/nip75-helpers.ts @@ -0,0 +1,116 @@ +import type { NostrEvent } from "@/types/nostr"; +import { getTagValue } from "applesauce-core/helpers"; + +/** + * NIP-75 Helper Functions + * Utility functions for parsing NIP-75 Zap Goal events (kind 9041) + */ + +/** + * Get the target amount for a goal in millisatoshis + * @param event Goal event (kind 9041) + * @returns Target amount in millisats or undefined + */ +export function getGoalAmount(event: NostrEvent): number | undefined { + const amount = getTagValue(event, "amount"); + if (!amount) return undefined; + const parsed = parseInt(amount, 10); + return isNaN(parsed) ? undefined : parsed; +} + +/** + * Get the relays where zaps should be sent and tallied + * @param event Goal event (kind 9041) + * @returns Array of relay URLs + */ +export function getGoalRelays(event: NostrEvent): string[] { + const relaysTag = event.tags.find((t) => t[0] === "relays"); + if (!relaysTag) return []; + const [, ...relays] = relaysTag; + return relays.filter(Boolean); +} + +/** + * Get the deadline timestamp after which zaps should not be counted + * @param event Goal event (kind 9041) + * @returns Unix timestamp in seconds or undefined + */ +export function getGoalClosedAt(event: NostrEvent): number | undefined { + const closedAt = getTagValue(event, "closed_at"); + if (!closedAt) return undefined; + const parsed = parseInt(closedAt, 10); + return isNaN(parsed) ? undefined : parsed; +} + +/** + * Get the summary/brief description of the goal + * @param event Goal event (kind 9041) + * @returns Summary string or undefined + */ +export function getGoalSummary(event: NostrEvent): string | undefined { + return getTagValue(event, "summary"); +} + +/** + * Get the image URL for the goal + * @param event Goal event (kind 9041) + * @returns Image URL or undefined + */ +export function getGoalImage(event: NostrEvent): string | undefined { + return getTagValue(event, "image"); +} + +/** + * Get the external URL linked to the goal + * @param event Goal event (kind 9041) + * @returns URL string or undefined + */ +export function getGoalUrl(event: NostrEvent): string | undefined { + return getTagValue(event, "r"); +} + +/** + * Get the addressable event pointer linked to the goal + * @param event Goal event (kind 9041) + * @returns Address pointer string (kind:pubkey:identifier) or undefined + */ +export function getGoalLinkedAddress(event: NostrEvent): string | undefined { + return getTagValue(event, "a"); +} + +/** + * Get all beneficiary pubkeys from zap tags + * @param event Goal event (kind 9041) + * @returns Array of beneficiary pubkeys + */ +export function getGoalBeneficiaries(event: NostrEvent): string[] { + return event.tags + .filter((t) => t[0] === "zap") + .map((t) => t[1]) + .filter(Boolean); +} + +/** + * Check if a goal has closed (deadline passed) + * @param event Goal event (kind 9041) + * @returns true if goal is closed, false otherwise + */ +export function isGoalClosed(event: NostrEvent): boolean { + const closedAt = getGoalClosedAt(event); + if (!closedAt) return false; + return Date.now() / 1000 > closedAt; +} + +/** + * Get a display title for the goal + * Content is the goal title per NIP-75 + * @param event Goal event (kind 9041) + * @returns Display title string + */ +export function getGoalTitle(event: NostrEvent): string { + const content = event.content?.trim(); + if (content) { + return content; + } + return "Untitled Goal"; +}