Files
eranos/src/hooks/useGoalProgress.ts
T

137 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo } from 'react';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { extractZapAmount, extractZapSender } from '@/hooks/useEventInteractions';
import { getPaginationCursor } from '@/lib/feedUtils';
import type { ParsedGoal } from '@/lib/goalUtils';
const ZAP_RECEIPT_PAGE_SIZE = 500;
const MAX_ZAP_RECEIPT_PAGES = 20;
export interface GoalProgress {
/** Total zapped in millisatoshis. */
currentMsat: number;
/** Total zapped in satoshis. */
currentSats: number;
/** Target in millisatoshis. */
targetMsat: number;
/** Target in satoshis. */
targetSats: number;
/** Percentage funded (0100, capped at 100). */
percentage: number;
/** Unique contributor pubkeys. */
contributors: string[];
/** Number of individual zap receipts. */
zapCount: number;
/** True when the tally reached the safety cap before exhausting relay results. */
isPartial: boolean;
}
interface ZapReceiptItem {
msats: number;
sender: string;
createdAt: number;
}
/**
* Queries kind 9735 zap receipts targeting a goal event and tallies progress.
* Respects the goal's `relays` and `closed_at` deadline.
*
* Zap receipts are tallied at face value (same as the rest of the app).
* Full NIP-57 validation was removed because the LNURL signer resolution
* added a network request per beneficiary for a trust level that is still
* spoofable and that no other zap display in the app enforces.
*/
export function useGoalProgress(goalEvent: NostrEvent | undefined, goal: ParsedGoal) {
const { nostr } = useNostr();
const goalEventId = goalEvent?.id;
const relaysKey = goal.relays.join(',');
const query = useQuery({
queryKey: ['goal-progress', goalEventId, relaysKey, goal.closedAt ?? null],
queryFn: async (c) => {
if (!goalEventId) {
return { receipts: [] as ZapReceiptItem[], isPartial: false };
}
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]);
const relay = goal.relays.length > 0 ? nostr.group(goal.relays) : nostr;
const receipts: ZapReceiptItem[] = [];
const seen = new Set<string>();
let until = goal.closedAt;
let isPartial = false;
for (let page = 0; page < MAX_ZAP_RECEIPT_PAGES; page++) {
const events = await relay.query(
[{ kinds: [9735], '#e': [goalEventId], limit: ZAP_RECEIPT_PAGE_SIZE, ...(until ? { until } : {}) }],
{ signal },
);
const validEvents = events.filter((event) => !goal.closedAt || event.created_at <= goal.closedAt);
for (const event of validEvents) {
if (seen.has(event.id)) continue;
seen.add(event.id);
receipts.push({
msats: extractZapAmount(event),
sender: extractZapSender(event),
createdAt: event.created_at,
});
}
if (events.length < ZAP_RECEIPT_PAGE_SIZE || validEvents.length === 0) {
break;
}
until = getPaginationCursor(validEvents) - 1;
if (page === MAX_ZAP_RECEIPT_PAGES - 1) {
isPartial = true;
}
}
return { receipts, isPartial };
},
enabled: !!goalEventId,
staleTime: 30_000,
});
const progress = useMemo((): GoalProgress => {
const items = query.data?.receipts ?? [];
let totalMsat = 0;
const contributorSet = new Set<string>();
let count = 0;
for (const item of items) {
// Skip receipts after the deadline
if (goal.closedAt && item.createdAt > goal.closedAt) continue;
if (item.msats <= 0) continue;
totalMsat += item.msats;
if (item.sender) contributorSet.add(item.sender);
count++;
}
const targetMsat = goal.amountMsat;
const targetSats = Math.floor(targetMsat / 1000);
const currentSats = Math.floor(totalMsat / 1000);
return {
currentMsat: totalMsat,
currentSats,
targetMsat,
targetSats,
percentage: targetMsat > 0 ? Math.min(100, Math.round((totalMsat / targetMsat) * 100)) : 0,
contributors: Array.from(contributorSet),
zapCount: count,
isPartial: query.data?.isPartial ?? false,
};
}, [query.data, goal.amountMsat, goal.closedAt]);
return {
...progress,
isLoading: query.isLoading,
};
}