diff --git a/src/components/nostr/kinds/GoalDetailRenderer.tsx b/src/components/nostr/kinds/GoalDetailRenderer.tsx new file mode 100644 index 0000000..123e1b2 --- /dev/null +++ b/src/components/nostr/kinds/GoalDetailRenderer.tsx @@ -0,0 +1,232 @@ +import { useMemo } from "react"; +import { NostrEvent } from "@/types/nostr"; +import { Target, Clock, Users } from "lucide-react"; +import { useTimeline } from "@/hooks/useTimeline"; +import { + getGoalAmount, + getGoalRelays, + getGoalClosedAt, + getGoalTitle, + isGoalClosed, + getGoalBeneficiaries, +} from "@/lib/nip75-helpers"; +import { getZapAmount, getZapSender } from "applesauce-common/helpers/zap"; +import { formatTimestamp } from "@/hooks/useLocale"; +import { useGrimoire } from "@/core/state"; +import { Progress } from "@/components/ui/progress"; +import { UserName } from "../UserName"; +import { Skeleton } from "@/components/ui/skeleton"; +import { AGGREGATOR_RELAYS } from "@/services/loaders"; + +interface Contributor { + pubkey: string; + totalAmount: number; + zapCount: number; +} + +/** + * 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 } = useGrimoire(); + + // Get goal metadata + const targetAmount = getGoalAmount(event); + const goalRelays = getGoalRelays(event); + const closedAt = getGoalClosedAt(event); + const title = getGoalTitle(event); + const closed = isGoalClosed(event); + const beneficiaries = getGoalBeneficiaries(event); + + // Fetch zaps for this goal from specified relays + const zapFilter = useMemo( + () => ({ + kinds: [9735], + "#e": [event.id], + }), + [event.id], + ); + + const relays = useMemo( + () => + goalRelays.length > 0 + ? [...goalRelays, ...AGGREGATOR_RELAYS] + : AGGREGATOR_RELAYS, + [goalRelays], + ); + + const { events: zaps, loading } = useTimeline( + `goal-zaps-detail-${event.id}`, + zapFilter, + relays, + { limit: 1000 }, + ); + + // Calculate total raised and build contributor list + 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; + + // Format deadline + const deadlineText = closedAt + ? formatTimestamp(closedAt, "absolute", locale.locale) + : null; + + return ( +
+ {/* Header */} +
+
+ +

{title}

+
+ + {/* Description */} + {event.content && ( +

+ {event.content} +

+ )} + + {/* Deadline */} + {closedAt && ( +
+ + {closed ? ( + + Goal closed on {deadlineText} + + ) : ( + Ends {deadlineText} + )} +
+ )} +
+ + {/* Progress Section */} + {targetSats > 0 && ( +
+
+ + {raisedSats.toLocaleString()} + + + of {targetSats.toLocaleString()} sats + +
+ +
+ {contributors.length} contributors + + {progress.toFixed(1)}% + +
+
+ )} + + {/* 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()} sats + +
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/src/components/nostr/kinds/GoalRenderer.tsx b/src/components/nostr/kinds/GoalRenderer.tsx new file mode 100644 index 0000000..1c66475 --- /dev/null +++ b/src/components/nostr/kinds/GoalRenderer.tsx @@ -0,0 +1,139 @@ +import { useMemo } from "react"; +import { + BaseEventProps, + BaseEventContainer, + ClickableEventTitle, +} from "./BaseEventRenderer"; +import { Target, Clock } from "lucide-react"; +import { useTimeline } from "@/hooks/useTimeline"; +import { + getGoalAmount, + getGoalRelays, + getGoalClosedAt, + getGoalTitle, + isGoalClosed, +} from "@/lib/nip75-helpers"; +import { getZapAmount } from "applesauce-common/helpers/zap"; +import { formatTimestamp } from "@/hooks/useLocale"; +import { useGrimoire } from "@/core/state"; +import { Progress } from "@/components/ui/progress"; +import { AGGREGATOR_RELAYS } from "@/services/loaders"; + +/** + * Renderer for Kind 9041 - Zap Goals (NIP-75) + * Shows goal title, description, and funding progress + */ +export function GoalRenderer({ event }: BaseEventProps) { + const { locale } = useGrimoire(); + + // Get goal metadata + const targetAmount = getGoalAmount(event); + const goalRelays = getGoalRelays(event); + const closedAt = getGoalClosedAt(event); + const title = getGoalTitle(event); + const closed = isGoalClosed(event); + + // Fetch zaps for this goal from specified relays + const zapFilter = useMemo( + () => ({ + kinds: [9735], + "#e": [event.id], + }), + [event.id], + ); + + const relays = useMemo( + () => + goalRelays.length > 0 + ? [...goalRelays, ...AGGREGATOR_RELAYS] + : AGGREGATOR_RELAYS, + [goalRelays], + ); + + const { events: zaps, loading } = useTimeline( + `goal-zaps-${event.id}`, + zapFilter, + relays, + { limit: 1000 }, + ); + + // Calculate total raised + const totalRaised = useMemo(() => { + return zaps.reduce((sum, zap) => { + const amount = getZapAmount(zap); + return sum + (amount || 0); + }, 0); + }, [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; + + // Format deadline + const deadlineText = closedAt + ? formatTimestamp(closedAt, "absolute", locale.locale) + : null; + + return ( + +
+ {/* Title */} +
+ + + {title} + +
+ + {/* Description (full content if different from title) */} + {event.content && event.content.trim() !== title && ( +

+ {event.content} +

+ )} + + {/* Progress */} + {targetSats > 0 && ( +
+ +
+ + {loading && zaps.length === 0 ? ( + "Loading..." + ) : ( + <> + + {raisedSats.toLocaleString()} + + {" / "} + {targetSats.toLocaleString()} sats + + )} + + + {progress.toFixed(0)}% + +
+
+ )} + + {/* Deadline */} + {closedAt && ( +
+ + {closed ? ( + Closed + ) : ( + Ends {deadlineText} + )} +
+ )} +
+
+ ); +} 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/lib/nip75-helpers.ts b/src/lib/nip75-helpers.ts new file mode 100644 index 0000000..a83b226 --- /dev/null +++ b/src/lib/nip75-helpers.ts @@ -0,0 +1,126 @@ +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 + * Falls back to summary, then content truncated, then "Untitled Goal" + * @param event Goal event (kind 9041) + * @returns Display title string + */ +export function getGoalTitle(event: NostrEvent): string { + // First try summary tag + const summary = getGoalSummary(event); + if (summary) return summary; + + // Fall back to content (first line, truncated) + const content = event.content?.trim(); + if (content) { + const firstLine = content.split("\n")[0]; + if (firstLine.length > 80) { + return firstLine.slice(0, 77) + "..."; + } + return firstLine; + } + + return "Untitled Goal"; +}