2025-07-14 07:23:33 +00:00
|
|
|
import { useState, useMemo, useEffect, useCallback } from 'react';
|
2025-07-12 19:19:41 +00:00
|
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
|
|
import { useAuthor } from '@/hooks/useAuthor';
|
|
|
|
import { useAppContext } from '@/hooks/useAppContext';
|
|
|
|
import { useToast } from '@/hooks/useToast';
|
2025-07-13 17:53:30 +00:00
|
|
|
import { useNWC } from '@/hooks/useNWCContext';
|
2025-07-13 06:47:45 +00:00
|
|
|
import type { NWCConnection } from '@/hooks/useNWC';
|
2025-07-13 19:52:58 +00:00
|
|
|
import { nip57 } from 'nostr-tools';
|
2025-07-13 05:59:56 +00:00
|
|
|
import type { Event } from 'nostr-tools';
|
2025-07-12 19:19:41 +00:00
|
|
|
import type { WebLNProvider } from 'webln';
|
2025-07-14 01:03:04 +00:00
|
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
2025-07-12 19:19:41 +00:00
|
|
|
import { useNostr } from '@nostrify/react';
|
2025-07-14 01:03:04 +00:00
|
|
|
import type { NostrEvent } from '@nostrify/nostrify';
|
2025-07-12 19:19:41 +00:00
|
|
|
|
2025-07-13 19:52:58 +00:00
|
|
|
export function useZaps(
|
2025-07-14 01:03:04 +00:00
|
|
|
target: Event | Event[],
|
2025-07-13 19:52:58 +00:00
|
|
|
webln: WebLNProvider | null,
|
|
|
|
_nwcConnection: NWCConnection | null,
|
|
|
|
onZapSuccess?: () => void
|
|
|
|
) {
|
2025-07-12 19:19:41 +00:00
|
|
|
const { nostr } = useNostr();
|
|
|
|
const { toast } = useToast();
|
|
|
|
const { user } = useCurrentUser();
|
|
|
|
const { config } = useAppContext();
|
2025-07-14 01:03:04 +00:00
|
|
|
const queryClient = useQueryClient();
|
|
|
|
|
|
|
|
// Handle the case where an empty array is passed (from ZapButton when external data is provided)
|
|
|
|
const actualTarget = Array.isArray(target) ? (target.length > 0 ? target[0] : null) : target;
|
|
|
|
|
|
|
|
const author = useAuthor(actualTarget?.pubkey);
|
|
|
|
const { sendPayment, getActiveConnection } = useNWC();
|
2025-07-12 19:19:41 +00:00
|
|
|
const [isZapping, setIsZapping] = useState(false);
|
|
|
|
const [invoice, setInvoice] = useState<string | null>(null);
|
|
|
|
|
2025-07-14 06:27:29 +00:00
|
|
|
// Cleanup state when component unmounts
|
|
|
|
useEffect(() => {
|
|
|
|
return () => {
|
|
|
|
setIsZapping(false);
|
|
|
|
setInvoice(null);
|
|
|
|
};
|
|
|
|
}, []);
|
|
|
|
|
2025-07-13 19:52:58 +00:00
|
|
|
const { data: zapEvents, ...query } = useQuery<NostrEvent[], Error>({
|
2025-07-14 01:03:04 +00:00
|
|
|
queryKey: ['zaps', actualTarget?.id],
|
|
|
|
staleTime: 30000, // 30 seconds
|
2025-07-14 06:27:29 +00:00
|
|
|
refetchInterval: (query) => {
|
|
|
|
// Only refetch if the query is currently being observed (component is mounted)
|
|
|
|
return query.getObserversCount() > 0 ? 60000 : false;
|
|
|
|
},
|
2025-07-12 19:19:41 +00:00
|
|
|
queryFn: async (c) => {
|
2025-07-14 01:03:04 +00:00
|
|
|
if (!actualTarget) return [];
|
|
|
|
|
|
|
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]);
|
|
|
|
|
|
|
|
// Query for zap receipts for this specific event
|
|
|
|
if (actualTarget.kind >= 30000 && actualTarget.kind < 40000) {
|
|
|
|
// Addressable event
|
|
|
|
const identifier = actualTarget.tags.find((t) => t[0] === 'd')?.[1] || '';
|
|
|
|
const events = await nostr.query([{
|
|
|
|
kinds: [9735],
|
|
|
|
'#a': [`${actualTarget.kind}:${actualTarget.pubkey}:${identifier}`],
|
|
|
|
}], { signal });
|
|
|
|
return events;
|
2025-07-12 19:19:41 +00:00
|
|
|
} else {
|
2025-07-14 01:03:04 +00:00
|
|
|
// Regular event
|
|
|
|
const events = await nostr.query([{
|
|
|
|
kinds: [9735],
|
|
|
|
'#e': [actualTarget.id],
|
|
|
|
}], { signal });
|
|
|
|
return events;
|
2025-07-12 19:19:41 +00:00
|
|
|
}
|
|
|
|
},
|
2025-07-14 01:03:04 +00:00
|
|
|
enabled: !!actualTarget?.id,
|
2025-07-12 19:19:41 +00:00
|
|
|
});
|
|
|
|
|
2025-07-14 01:03:04 +00:00
|
|
|
// Process zap events into simple counts and totals
|
|
|
|
const { zapCount, totalSats, zaps } = useMemo(() => {
|
2025-07-14 06:27:29 +00:00
|
|
|
if (!zapEvents || !Array.isArray(zapEvents) || !actualTarget) {
|
2025-07-14 01:03:04 +00:00
|
|
|
return { zapCount: 0, totalSats: 0, zaps: [] };
|
|
|
|
}
|
2025-07-13 19:52:58 +00:00
|
|
|
|
2025-07-14 01:03:04 +00:00
|
|
|
let count = 0;
|
|
|
|
let sats = 0;
|
2025-07-13 19:52:58 +00:00
|
|
|
|
|
|
|
zapEvents.forEach(zap => {
|
2025-07-14 01:03:04 +00:00
|
|
|
count++;
|
2025-07-13 19:52:58 +00:00
|
|
|
|
2025-07-14 01:03:04 +00:00
|
|
|
// Try multiple methods to extract the amount:
|
2025-07-13 19:52:58 +00:00
|
|
|
|
2025-07-14 01:03:04 +00:00
|
|
|
// Method 1: amount tag (from zap request, sometimes copied to receipt)
|
|
|
|
const amountTag = zap.tags.find(([name]) => name === 'amount')?.[1];
|
|
|
|
if (amountTag) {
|
|
|
|
const millisats = parseInt(amountTag);
|
|
|
|
sats += Math.floor(millisats / 1000);
|
|
|
|
return;
|
2025-07-13 19:52:58 +00:00
|
|
|
}
|
|
|
|
|
2025-07-14 01:03:04 +00:00
|
|
|
// Method 2: Extract from bolt11 invoice
|
|
|
|
const bolt11Tag = zap.tags.find(([name]) => name === 'bolt11')?.[1];
|
|
|
|
if (bolt11Tag) {
|
|
|
|
try {
|
|
|
|
const invoiceSats = nip57.getSatoshisAmountFromBolt11(bolt11Tag);
|
|
|
|
sats += invoiceSats;
|
|
|
|
return;
|
|
|
|
} catch (error) {
|
|
|
|
console.warn('Failed to parse bolt11 amount:', error);
|
|
|
|
}
|
|
|
|
}
|
2025-07-13 19:52:58 +00:00
|
|
|
|
2025-07-14 01:03:04 +00:00
|
|
|
// Method 3: Parse from description (zap request JSON)
|
|
|
|
const descriptionTag = zap.tags.find(([name]) => name === 'description')?.[1];
|
|
|
|
if (descriptionTag) {
|
|
|
|
try {
|
|
|
|
const zapRequest = JSON.parse(descriptionTag);
|
|
|
|
const requestAmountTag = zapRequest.tags?.find(([name]: string[]) => name === 'amount')?.[1];
|
|
|
|
if (requestAmountTag) {
|
|
|
|
const millisats = parseInt(requestAmountTag);
|
|
|
|
sats += Math.floor(millisats / 1000);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
} catch (error) {
|
|
|
|
console.warn('Failed to parse description JSON:', error);
|
|
|
|
}
|
2025-07-13 19:52:58 +00:00
|
|
|
}
|
2025-07-14 01:03:04 +00:00
|
|
|
|
|
|
|
console.warn('Could not extract amount from zap receipt:', zap.id);
|
2025-07-13 19:52:58 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
2025-07-14 01:03:04 +00:00
|
|
|
return { zapCount: count, totalSats: sats, zaps: zapEvents };
|
|
|
|
}, [zapEvents, actualTarget]);
|
2025-07-13 19:52:58 +00:00
|
|
|
|
2025-07-12 19:19:41 +00:00
|
|
|
const zap = async (amount: number, comment: string) => {
|
|
|
|
if (amount <= 0) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
setIsZapping(true);
|
2025-07-13 17:53:30 +00:00
|
|
|
setInvoice(null); // Clear any previous invoice at the start
|
2025-07-12 19:19:41 +00:00
|
|
|
|
|
|
|
if (!user) {
|
|
|
|
toast({
|
|
|
|
title: 'Login required',
|
|
|
|
description: 'You must be logged in to send a zap.',
|
|
|
|
variant: 'destructive',
|
|
|
|
});
|
|
|
|
setIsZapping(false);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2025-07-14 01:03:04 +00:00
|
|
|
if (!actualTarget) {
|
|
|
|
toast({
|
|
|
|
title: 'Event not found',
|
|
|
|
description: 'Could not find the event to zap.',
|
|
|
|
variant: 'destructive',
|
|
|
|
});
|
|
|
|
setIsZapping(false);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2025-07-12 19:19:41 +00:00
|
|
|
try {
|
2025-07-13 20:12:42 +00:00
|
|
|
if (!author.data || !author.data?.metadata || !author.data?.event ) {
|
2025-07-12 19:19:41 +00:00
|
|
|
toast({
|
|
|
|
title: 'Author not found',
|
|
|
|
description: 'Could not find the author of this item.',
|
|
|
|
variant: 'destructive',
|
|
|
|
});
|
|
|
|
setIsZapping(false);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2025-07-13 20:12:42 +00:00
|
|
|
const { lud06, lud16 } = author.data.metadata;
|
|
|
|
if (!lud06 && !lud16) {
|
2025-07-12 19:19:41 +00:00
|
|
|
toast({
|
2025-07-13 05:59:56 +00:00
|
|
|
title: 'Lightning address not found',
|
2025-07-13 17:53:30 +00:00
|
|
|
description: 'The author does not have a lightning address configured.',
|
|
|
|
variant: 'destructive',
|
|
|
|
});
|
|
|
|
setIsZapping(false);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Get zap endpoint using the old reliable method
|
2025-07-13 20:12:42 +00:00
|
|
|
const zapEndpoint = await nip57.getZapEndpoint(author.data.event);
|
2025-07-13 17:53:30 +00:00
|
|
|
if (!zapEndpoint) {
|
|
|
|
toast({
|
|
|
|
title: 'Zap endpoint not found',
|
|
|
|
description: 'Could not find a zap endpoint for the author.',
|
2025-07-12 19:19:41 +00:00
|
|
|
variant: 'destructive',
|
|
|
|
});
|
|
|
|
setIsZapping(false);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2025-07-14 01:03:04 +00:00
|
|
|
// Create zap request - use appropriate event format based on kind
|
|
|
|
// For addressable events (30000-39999), pass the object to get 'a' tag
|
|
|
|
// For all other events, pass the ID string to get 'e' tag
|
|
|
|
const event = (actualTarget.kind >= 30000 && actualTarget.kind < 40000)
|
|
|
|
? actualTarget
|
|
|
|
: actualTarget.id;
|
|
|
|
|
2025-07-12 19:19:41 +00:00
|
|
|
const zapAmount = amount * 1000; // convert to millisats
|
2025-07-14 01:03:04 +00:00
|
|
|
|
2025-07-13 17:53:30 +00:00
|
|
|
const zapRequest = nip57.makeZapRequest({
|
2025-07-14 01:03:04 +00:00
|
|
|
profile: actualTarget.pubkey,
|
|
|
|
event: event,
|
2025-07-12 19:19:41 +00:00
|
|
|
amount: zapAmount,
|
2025-07-13 20:12:42 +00:00
|
|
|
relays: [config.relayUrl],
|
|
|
|
comment
|
2025-07-12 19:19:41 +00:00
|
|
|
});
|
|
|
|
|
2025-07-14 01:03:04 +00:00
|
|
|
// Sign the zap request (but don't publish to relays - only send to LNURL endpoint)
|
|
|
|
if (!user.signer) {
|
|
|
|
throw new Error('No signer available');
|
|
|
|
}
|
|
|
|
const signedZapRequest = await user.signer.signEvent(zapRequest);
|
|
|
|
|
|
|
|
try {
|
|
|
|
const res = await fetch(`${zapEndpoint}?amount=${zapAmount}&nostr=${encodeURI(JSON.stringify(signedZapRequest))}`);
|
2025-07-13 17:53:30 +00:00
|
|
|
const responseData = await res.json();
|
2025-07-13 06:47:45 +00:00
|
|
|
|
2025-07-13 17:53:30 +00:00
|
|
|
if (!res.ok) {
|
|
|
|
throw new Error(`HTTP ${res.status}: ${responseData.reason || 'Unknown error'}`);
|
|
|
|
}
|
|
|
|
|
|
|
|
const newInvoice = responseData.pr;
|
|
|
|
if (!newInvoice || typeof newInvoice !== 'string') {
|
|
|
|
throw new Error('Lightning service did not return a valid invoice');
|
|
|
|
}
|
|
|
|
|
|
|
|
// Get the current active NWC connection dynamically
|
|
|
|
const currentNWCConnection = getActiveConnection();
|
|
|
|
|
|
|
|
// Try NWC first if available and properly connected
|
|
|
|
if (currentNWCConnection && currentNWCConnection.connectionString && currentNWCConnection.isConnected) {
|
|
|
|
try {
|
2025-07-13 20:12:42 +00:00
|
|
|
await sendPayment(currentNWCConnection, newInvoice);
|
2025-07-13 17:53:30 +00:00
|
|
|
|
|
|
|
// Clear states immediately on success
|
|
|
|
setIsZapping(false);
|
|
|
|
setInvoice(null);
|
|
|
|
|
|
|
|
toast({
|
|
|
|
title: 'Zap successful!',
|
|
|
|
description: `You sent ${amount} sats via NWC to the author.`,
|
|
|
|
});
|
|
|
|
|
2025-07-14 01:03:04 +00:00
|
|
|
// Invalidate zap queries to refresh counts
|
|
|
|
queryClient.invalidateQueries({ queryKey: ['zaps'] });
|
|
|
|
|
2025-07-13 17:53:30 +00:00
|
|
|
// Close dialog last to ensure clean state
|
|
|
|
onZapSuccess?.();
|
|
|
|
return;
|
|
|
|
} catch (nwcError) {
|
|
|
|
console.error('NWC payment failed, falling back:', nwcError);
|
|
|
|
|
|
|
|
// Show specific NWC error to user for debugging
|
|
|
|
const errorMessage = nwcError instanceof Error ? nwcError.message : 'Unknown NWC error';
|
|
|
|
toast({
|
|
|
|
title: 'NWC payment failed',
|
|
|
|
description: `${errorMessage}. Falling back to other payment methods...`,
|
|
|
|
variant: 'destructive',
|
|
|
|
});
|
|
|
|
}
|
2025-07-13 20:12:42 +00:00
|
|
|
} else if (webln) { // Try WebLN next
|
2025-07-13 17:53:30 +00:00
|
|
|
await webln.sendPayment(newInvoice);
|
|
|
|
|
|
|
|
// Clear states immediately on success
|
|
|
|
setIsZapping(false);
|
|
|
|
setInvoice(null);
|
|
|
|
|
|
|
|
toast({
|
|
|
|
title: 'Zap successful!',
|
|
|
|
description: `You sent ${amount} sats to the author.`,
|
|
|
|
});
|
|
|
|
|
2025-07-14 01:03:04 +00:00
|
|
|
// Invalidate zap queries to refresh counts
|
|
|
|
queryClient.invalidateQueries({ queryKey: ['zaps'] });
|
|
|
|
|
2025-07-13 17:53:30 +00:00
|
|
|
// Close dialog last to ensure clean state
|
|
|
|
onZapSuccess?.();
|
2025-07-13 20:12:42 +00:00
|
|
|
} else { // Default - show QR code and manual Lightning URI
|
2025-07-13 17:53:30 +00:00
|
|
|
setInvoice(newInvoice);
|
|
|
|
setIsZapping(false);
|
|
|
|
}
|
|
|
|
} catch (err) {
|
|
|
|
console.error('Zap error:', err);
|
|
|
|
toast({
|
|
|
|
title: 'Zap failed',
|
|
|
|
description: (err as Error).message,
|
|
|
|
variant: 'destructive',
|
|
|
|
});
|
|
|
|
setIsZapping(false);
|
|
|
|
}
|
2025-07-12 19:19:41 +00:00
|
|
|
} catch (err) {
|
2025-07-13 05:59:56 +00:00
|
|
|
console.error('Zap error:', err);
|
2025-07-12 19:19:41 +00:00
|
|
|
toast({
|
|
|
|
title: 'Zap failed',
|
|
|
|
description: (err as Error).message,
|
|
|
|
variant: 'destructive',
|
|
|
|
});
|
|
|
|
setIsZapping(false);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2025-07-14 07:23:33 +00:00
|
|
|
const resetInvoice = useCallback(() => {
|
|
|
|
setInvoice(null);
|
|
|
|
}, []);
|
|
|
|
|
2025-07-13 06:47:45 +00:00
|
|
|
return {
|
|
|
|
zaps,
|
2025-07-14 01:03:04 +00:00
|
|
|
zapCount,
|
|
|
|
totalSats,
|
2025-07-13 06:47:45 +00:00
|
|
|
...query,
|
|
|
|
zap,
|
|
|
|
isZapping,
|
|
|
|
invoice,
|
|
|
|
setInvoice,
|
2025-07-14 07:23:33 +00:00
|
|
|
resetInvoice,
|
2025-07-13 06:47:45 +00:00
|
|
|
};
|
2025-07-12 19:19:41 +00:00
|
|
|
}
|