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
This commit is contained in:
Claude
2026-01-20 16:26:07 +00:00
parent a75b822dcb
commit 2fd7cc8bc7
3 changed files with 189 additions and 150 deletions

View File

@@ -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<string, Contributor>();
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;

View File

@@ -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;

View File

@@ -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<string[]>([]);
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<string, Contributor>();
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,
};
}