diff --git a/src/components/nostr/kinds/GoalDetailRenderer.tsx b/src/components/nostr/kinds/GoalDetailRenderer.tsx index dfac0ad..7728cc4 100644 --- a/src/components/nostr/kinds/GoalDetailRenderer.tsx +++ b/src/components/nostr/kinds/GoalDetailRenderer.tsx @@ -1,30 +1,12 @@ -import { useMemo } from "react"; import { NostrEvent } from "@/types/nostr"; import { Zap } from "lucide-react"; -import { useTimeline } from "@/hooks/useTimeline"; -import { - getGoalAmount, - getGoalRelays, - getGoalClosedAt, - getGoalTitle, - getGoalSummary, - isGoalClosed, - getGoalBeneficiaries, -} from "@/lib/nip75-helpers"; -import { getZapAmount, getZapSender } from "applesauce-common/helpers/zap"; +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"; -import { AGGREGATOR_RELAYS } from "@/services/loaders"; - -interface Contributor { - pubkey: string; - totalAmount: number; - zapCount: number; -} /** * Detail renderer for Kind 9041 - Zap Goals (NIP-75) @@ -32,80 +14,19 @@ interface Contributor { */ export function GoalDetailRenderer({ event }: { event: NostrEvent }) { const { locale, addWindow } = useGrimoire(); + const { + title, + summary, + closedAt, + closed, + beneficiaries, + targetSats, + raisedSats, + progress, + contributors, + loading, + } = useGoalProgress(event); - // Get goal metadata - const targetAmount = getGoalAmount(event); - const goalRelays = getGoalRelays(event); - const closedAt = getGoalClosedAt(event); - const title = getGoalTitle(event); - const summary = getGoalSummary(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, - ); - - // 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; diff --git a/src/components/nostr/kinds/GoalRenderer.tsx b/src/components/nostr/kinds/GoalRenderer.tsx index a892596..508d1fe 100644 --- a/src/components/nostr/kinds/GoalRenderer.tsx +++ b/src/components/nostr/kinds/GoalRenderer.tsx @@ -1,25 +1,14 @@ -import { useMemo } from "react"; import { BaseEventProps, BaseEventContainer, ClickableEventTitle, } from "./BaseEventRenderer"; import { Zap } from "lucide-react"; -import { useTimeline } from "@/hooks/useTimeline"; -import { - getGoalAmount, - getGoalRelays, - getGoalClosedAt, - getGoalTitle, - getGoalSummary, - isGoalClosed, -} from "@/lib/nip75-helpers"; -import { getZapAmount } from "applesauce-common/helpers/zap"; +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 { AGGREGATOR_RELAYS } from "@/services/loaders"; /** * Renderer for Kind 9041 - Zap Goals (NIP-75) @@ -27,53 +16,18 @@ import { AGGREGATOR_RELAYS } from "@/services/loaders"; */ export function GoalRenderer({ event }: BaseEventProps) { const { locale, addWindow } = useGrimoire(); + const { + title, + summary, + closedAt, + closed, + targetSats, + raisedSats, + progress, + loading, + zaps, + } = useGoalProgress(event); - // Get goal metadata - const targetAmount = getGoalAmount(event); - const goalRelays = getGoalRelays(event); - const closedAt = getGoalClosedAt(event); - const title = getGoalTitle(event); - const summary = getGoalSummary(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, - ); - - // 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; 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, + }; +}