2025-07-13 19:52:58 +00:00
|
|
|
import { useState, useMemo } 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';
|
|
|
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
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';
|
|
|
|
import { useQuery } from '@tanstack/react-query';
|
|
|
|
import { useNostr } from '@nostrify/react';
|
|
|
|
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
|
|
|
|
2025-07-13 06:47:45 +00:00
|
|
|
// NWC utility functions
|
|
|
|
function parseNWCUri(uri: string): NWCConnection | null {
|
|
|
|
try {
|
|
|
|
const url = new URL(uri);
|
|
|
|
if (url.protocol !== 'nostr+walletconnect:') {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
const walletPubkey = url.pathname.replace('//', '');
|
|
|
|
const secret = url.searchParams.get('secret');
|
|
|
|
const relayParam = url.searchParams.getAll('relay');
|
2025-07-13 17:53:30 +00:00
|
|
|
const _lud16 = url.searchParams.get('lud16') || undefined;
|
2025-07-13 06:47:45 +00:00
|
|
|
|
|
|
|
if (!walletPubkey || !secret || relayParam.length === 0) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
2025-07-13 17:53:30 +00:00
|
|
|
connectionString: uri,
|
|
|
|
alias: 'Parsed NWC',
|
|
|
|
isConnected: false,
|
2025-07-13 06:47:45 +00:00
|
|
|
};
|
|
|
|
} catch {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-07-13 19:52:58 +00:00
|
|
|
export function useZaps(
|
|
|
|
targets: Event | Event[],
|
|
|
|
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-13 17:53:30 +00:00
|
|
|
const { mutate: publishEvent } = useNostrPublish();
|
2025-07-13 19:52:58 +00:00
|
|
|
|
|
|
|
// Normalize targets to array for consistent handling
|
|
|
|
const targetArray = useMemo(() =>
|
|
|
|
Array.isArray(targets) ? targets : (targets ? [targets] : []),
|
|
|
|
[targets]
|
|
|
|
);
|
|
|
|
const isBatchMode = Array.isArray(targets);
|
|
|
|
const primaryTarget = targetArray[0]; // For single-target operations like zapping
|
|
|
|
|
|
|
|
const author = useAuthor(primaryTarget?.pubkey);
|
2025-07-13 17:53:30 +00:00
|
|
|
const { sendPayment, getActiveConnection, connections, activeConnection } = useNWC();
|
2025-07-12 19:19:41 +00:00
|
|
|
const [isZapping, setIsZapping] = useState(false);
|
|
|
|
const [invoice, setInvoice] = useState<string | null>(null);
|
|
|
|
|
2025-07-13 19:52:58 +00:00
|
|
|
// Create query key based on mode
|
|
|
|
const queryKey = isBatchMode
|
|
|
|
? ['zaps-batch', targetArray.map(t => t.id).sort()]
|
|
|
|
: ['zaps-single', primaryTarget.id];
|
2025-07-12 19:19:41 +00:00
|
|
|
|
2025-07-13 19:52:58 +00:00
|
|
|
const { data: zapEvents, ...query } = useQuery<NostrEvent[], Error>({
|
|
|
|
queryKey,
|
2025-07-12 19:19:41 +00:00
|
|
|
queryFn: async (c) => {
|
|
|
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(1500)]);
|
|
|
|
const filters: NostrFilter[] = [];
|
|
|
|
|
2025-07-13 19:52:58 +00:00
|
|
|
if (isBatchMode) {
|
|
|
|
// Batch mode: get zaps for all events at once
|
|
|
|
const eventIds = targetArray.map(t => t.id).filter(Boolean);
|
|
|
|
const addressableEvents = targetArray.filter(t => t.kind >= 30000 && t.kind < 40000);
|
|
|
|
|
|
|
|
if (eventIds.length > 0) {
|
|
|
|
filters.push({
|
|
|
|
kinds: [9735],
|
|
|
|
'#e': eventIds,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
// Handle addressable events
|
|
|
|
if (addressableEvents.length > 0) {
|
|
|
|
const addresses = addressableEvents.map(event => {
|
|
|
|
const identifier = event.tags.find((t) => t[0] === 'd')?.[1] || '';
|
|
|
|
return `${event.kind}:${event.pubkey}:${identifier}`;
|
|
|
|
});
|
|
|
|
filters.push({
|
|
|
|
kinds: [9735],
|
|
|
|
'#a': addresses,
|
|
|
|
});
|
2025-07-12 19:19:41 +00:00
|
|
|
}
|
|
|
|
} else {
|
2025-07-13 19:52:58 +00:00
|
|
|
// Single mode: get zaps for one event
|
|
|
|
const target = primaryTarget;
|
|
|
|
if (target.kind >= 30000 && target.kind < 40000) {
|
|
|
|
const identifier = target.tags.find((t) => t[0] === 'd')?.[1] || '';
|
|
|
|
filters.push({
|
|
|
|
kinds: [9735],
|
|
|
|
'#a': [`${target.kind}:${target.pubkey}:${identifier}`],
|
|
|
|
});
|
|
|
|
} else {
|
|
|
|
filters.push({
|
|
|
|
kinds: [9735],
|
|
|
|
'#e': [target.id],
|
|
|
|
});
|
|
|
|
}
|
2025-07-12 19:19:41 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if (filters.length === 0) return [];
|
|
|
|
const events = await nostr.query(filters, { signal });
|
|
|
|
return events;
|
|
|
|
},
|
2025-07-13 19:52:58 +00:00
|
|
|
enabled: targetArray.length > 0 && targetArray.every(t => t.id),
|
2025-07-12 19:19:41 +00:00
|
|
|
});
|
|
|
|
|
2025-07-13 19:52:58 +00:00
|
|
|
// Process zap events into organized data
|
|
|
|
const zapData = useMemo(() => {
|
|
|
|
if (!zapEvents) return {};
|
|
|
|
|
|
|
|
const organized: Record<string, { count: number; totalSats: number; events: NostrEvent[] }> = {};
|
|
|
|
|
|
|
|
zapEvents.forEach(zap => {
|
|
|
|
// Find which event this zap is for
|
|
|
|
const eventTag = zap.tags.find(([name]) => name === 'e')?.[1];
|
|
|
|
const addressTag = zap.tags.find(([name]) => name === 'a')?.[1];
|
|
|
|
|
|
|
|
let targetId: string | undefined;
|
|
|
|
|
|
|
|
if (eventTag) {
|
|
|
|
targetId = eventTag;
|
|
|
|
} else if (addressTag) {
|
|
|
|
// Find the target event that matches this address
|
|
|
|
const target = targetArray.find(t => {
|
|
|
|
if (t.kind >= 30000 && t.kind < 40000) {
|
|
|
|
const identifier = t.tags.find((tag) => tag[0] === 'd')?.[1] || '';
|
|
|
|
const address = `${t.kind}:${t.pubkey}:${identifier}`;
|
|
|
|
return address === addressTag;
|
|
|
|
}
|
|
|
|
return false;
|
|
|
|
});
|
|
|
|
targetId = target?.id;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!targetId) return;
|
|
|
|
|
|
|
|
if (!organized[targetId]) {
|
|
|
|
organized[targetId] = { count: 0, totalSats: 0, events: [] };
|
|
|
|
}
|
|
|
|
|
|
|
|
organized[targetId].count++;
|
|
|
|
organized[targetId].events.push(zap);
|
|
|
|
|
|
|
|
// Extract amount from amount tag
|
|
|
|
const amountTag = zap.tags.find(([name]) => name === 'amount')?.[1];
|
|
|
|
if (amountTag) {
|
|
|
|
const sats = Math.floor(parseInt(amountTag) / 1000); // Convert millisats to sats
|
|
|
|
organized[targetId].totalSats += sats;
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
return organized;
|
|
|
|
}, [zapEvents, targetArray]);
|
|
|
|
|
|
|
|
// For single mode, return the data for the primary target
|
|
|
|
const singleTargetData = isBatchMode ? undefined : zapData[primaryTarget.id];
|
|
|
|
const zaps = singleTargetData?.events;
|
|
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
2025-07-13 05:59:56 +00:00
|
|
|
if (!author.data || !author.data?.metadata) {
|
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 17:53:30 +00:00
|
|
|
const { lud16 } = author.data.metadata;
|
|
|
|
if (!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
|
|
|
|
const zapEndpoint = await nip57.getZapEndpoint(author.data.event as Event);
|
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
|
|
|
const zapAmount = amount * 1000; // convert to millisats
|
2025-07-13 17:53:30 +00:00
|
|
|
const relays = [config.relayUrl];
|
|
|
|
|
|
|
|
// Create zap request (unsigned, like the old implementation)
|
|
|
|
const zapRequest = nip57.makeZapRequest({
|
2025-07-13 19:52:58 +00:00
|
|
|
profile: primaryTarget.pubkey,
|
|
|
|
event: primaryTarget.id,
|
2025-07-12 19:19:41 +00:00
|
|
|
amount: zapAmount,
|
2025-07-13 17:53:30 +00:00
|
|
|
relays,
|
2025-07-12 19:19:41 +00:00
|
|
|
comment: comment,
|
|
|
|
});
|
|
|
|
|
2025-07-13 19:52:58 +00:00
|
|
|
// Handle addressable events
|
|
|
|
if (primaryTarget.kind >= 30000 && primaryTarget.kind < 40000) {
|
|
|
|
const identifier = primaryTarget.tags.find((t) => t[0] === 'd')?.[1] || '';
|
|
|
|
zapRequest.tags.push(["a", `${primaryTarget.kind}:${primaryTarget.pubkey}:${identifier}`]);
|
2025-07-13 17:53:30 +00:00
|
|
|
zapRequest.tags = zapRequest.tags.filter(t => t[0] !== 'e');
|
|
|
|
}
|
2025-07-13 06:47:45 +00:00
|
|
|
|
2025-07-13 17:53:30 +00:00
|
|
|
// Sign and publish the zap request
|
|
|
|
publishEvent(zapRequest, {
|
|
|
|
onSuccess: async (event) => {
|
|
|
|
try {
|
|
|
|
// Use the old fetch method - more reliable than LNURL validation
|
|
|
|
const res = await fetch(`${zapEndpoint}?amount=${zapAmount}&nostr=${encodeURI(JSON.stringify(event))}`);
|
|
|
|
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();
|
|
|
|
|
|
|
|
console.debug('Zap payment - detailed state check:', {
|
|
|
|
// Raw state
|
|
|
|
connectionsLength: connections.length,
|
|
|
|
activeConnectionString: activeConnection ? activeConnection.substring(0, 50) + '...' : null,
|
|
|
|
|
|
|
|
// Connection details
|
|
|
|
connections: connections.map(c => ({
|
|
|
|
alias: c.alias,
|
|
|
|
isConnected: c.isConnected,
|
|
|
|
connectionString: c.connectionString.substring(0, 50) + '...'
|
|
|
|
})),
|
|
|
|
|
|
|
|
// getActiveConnection result
|
|
|
|
currentNWCConnection: currentNWCConnection ? {
|
|
|
|
alias: currentNWCConnection.alias,
|
|
|
|
isConnected: currentNWCConnection.isConnected,
|
|
|
|
connectionString: currentNWCConnection.connectionString.substring(0, 50) + '...'
|
|
|
|
} : null
|
|
|
|
});
|
|
|
|
|
|
|
|
// Try NWC first if available and properly connected
|
|
|
|
if (currentNWCConnection && currentNWCConnection.connectionString && currentNWCConnection.isConnected) {
|
|
|
|
try {
|
|
|
|
console.debug('Attempting NWC payment...', {
|
|
|
|
amount,
|
|
|
|
alias: currentNWCConnection.alias,
|
|
|
|
invoiceLength: newInvoice.length
|
|
|
|
});
|
|
|
|
|
|
|
|
const response = await sendPayment(currentNWCConnection, newInvoice);
|
|
|
|
|
|
|
|
console.debug('NWC payment successful:', { preimage: response.preimage });
|
|
|
|
|
|
|
|
// Clear states immediately on success
|
|
|
|
setIsZapping(false);
|
|
|
|
setInvoice(null);
|
|
|
|
|
|
|
|
toast({
|
|
|
|
title: 'Zap successful!',
|
|
|
|
description: `You sent ${amount} sats via NWC to the author.`,
|
|
|
|
});
|
|
|
|
|
|
|
|
// 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',
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Fallback to WebLN or manual payment
|
|
|
|
if (webln) {
|
|
|
|
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.`,
|
|
|
|
});
|
|
|
|
|
|
|
|
// Close dialog last to ensure clean state
|
|
|
|
onZapSuccess?.();
|
|
|
|
} else {
|
|
|
|
setInvoice(newInvoice);
|
|
|
|
setIsZapping(false);
|
|
|
|
}
|
|
|
|
} catch (err) {
|
|
|
|
console.error('Zap error:', err);
|
|
|
|
toast({
|
|
|
|
title: 'Zap failed',
|
|
|
|
description: (err as Error).message,
|
|
|
|
variant: 'destructive',
|
|
|
|
});
|
|
|
|
} finally {
|
|
|
|
setIsZapping(false);
|
|
|
|
}
|
|
|
|
},
|
|
|
|
onError: (err) => {
|
|
|
|
console.error('Failed to publish zap request:', err);
|
2025-07-13 06:47:45 +00:00
|
|
|
toast({
|
2025-07-13 17:53:30 +00:00
|
|
|
title: 'Zap failed',
|
|
|
|
description: 'Failed to create zap request',
|
2025-07-13 06:47:45 +00:00
|
|
|
variant: 'destructive',
|
|
|
|
});
|
2025-07-13 17:53:30 +00:00
|
|
|
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-13 06:47:45 +00:00
|
|
|
return {
|
2025-07-13 19:52:58 +00:00
|
|
|
// Legacy single-target API (for backward compatibility)
|
2025-07-13 06:47:45 +00:00
|
|
|
zaps,
|
|
|
|
...query,
|
|
|
|
zap,
|
|
|
|
isZapping,
|
|
|
|
invoice,
|
|
|
|
setInvoice,
|
|
|
|
parseNWCUri,
|
2025-07-13 19:52:58 +00:00
|
|
|
|
|
|
|
// New batch API
|
|
|
|
zapData,
|
|
|
|
isBatchMode,
|
|
|
|
|
|
|
|
// Helper functions
|
|
|
|
getZapData: (eventId: string) => zapData[eventId] || { count: 0, totalSats: 0, events: [] },
|
2025-07-13 06:47:45 +00:00
|
|
|
};
|
2025-07-12 19:19:41 +00:00
|
|
|
}
|