mkstack/src/hooks/useZaps.ts

311 lines
9.9 KiB
TypeScript
Raw Normal View History

2025-07-12 19:19:41 +00:00
import { useState } from 'react';
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';
import type { NWCConnection } from '@/hooks/useNWC';
2025-07-13 05:59:56 +00:00
import { nip57, nip19 } from 'nostr-tools';
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';
// 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;
if (!walletPubkey || !secret || relayParam.length === 0) {
return null;
}
return {
2025-07-13 17:53:30 +00:00
connectionString: uri,
alias: 'Parsed NWC',
isConnected: false,
};
} catch {
return null;
}
}
2025-07-13 17:53:30 +00:00
export function useZaps(target: 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-12 19:19:41 +00:00
const author = useAuthor(target?.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 04:09:32 +00:00
const naddr =
target.kind >= 30000 && target.kind < 40000
? nip19.naddrEncode({
identifier: target.tags.find((t) => t[0] === 'd')?.[1] || '',
pubkey: target.pubkey,
kind: target.kind,
})
: undefined;
const queryKey = naddr ? `naddr:${naddr}` : `event:${target.id}`;
2025-07-12 19:19:41 +00:00
const { data: zaps, ...query } = useQuery<NostrEvent[], Error>({
queryKey: ['zaps', queryKey],
queryFn: async (c) => {
2025-07-13 04:09:32 +00:00
if (!target.id && !naddr) return [];
2025-07-12 19:19:41 +00:00
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(1500)]);
2025-07-12 19:23:50 +00:00
2025-07-12 19:19:41 +00:00
const filters: NostrFilter[] = [];
2025-07-13 04:09:32 +00:00
if (naddr) {
2025-07-12 19:19:41 +00:00
try {
2025-07-13 04:09:32 +00:00
const decoded = nip19.decode(naddr);
2025-07-12 19:19:41 +00:00
if (decoded.type === 'naddr') {
const { kind, pubkey, identifier } = decoded.data;
filters.push({
kinds: [9735],
'#a': [`${kind}:${pubkey}:${identifier}`],
});
}
} catch (e) {
2025-07-13 04:09:32 +00:00
console.error("Invalid naddr", naddr, e);
2025-07-12 19:19:41 +00:00
}
} else {
filters.push({
kinds: [9735],
'#e': [target.id],
});
}
if (filters.length === 0) return [];
const events = await nostr.query(filters, { signal });
return events;
},
2025-07-13 04:09:32 +00:00
enabled: !!target.id || !!naddr,
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-12 19:19:41 +00:00
profile: target.pubkey,
2025-07-13 17:53:30 +00:00
event: target.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 17:53:30 +00:00
// Handle addressable events (restored from old implementation)
if (naddr) {
const decoded = nip19.decode(naddr).data as nip19.AddressPointer;
zapRequest.tags.push(["a", `${decoded.kind}:${decoded.pubkey}:${decoded.identifier}`]);
zapRequest.tags = zapRequest.tags.filter(t => t[0] !== 'e');
}
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 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);
toast({
2025-07-13 17:53:30 +00:00
title: 'Zap failed',
description: 'Failed to create zap request',
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);
}
};
return {
zaps,
...query,
zap,
isZapping,
invoice,
setInvoice,
parseNWCUri,
};
2025-07-12 19:19:41 +00:00
}