further refinement

This commit is contained in:
Chad Curtis 2025-07-14 01:03:04 +00:00
parent 7eecec7506
commit 0bc03017bb
4 changed files with 120 additions and 157 deletions

View File

@ -24,7 +24,7 @@ export function ZapButton({
const { webln, activeNWC } = useWallet(); const { webln, activeNWC } = useWallet();
// Only fetch data if not provided externally // Only fetch data if not provided externally
const { getZapData, isLoading } = useZaps( const { totalSats: fetchedTotalSats, isLoading } = useZaps(
externalZapData ? [] : target, // Empty array prevents fetching if external data provided externalZapData ? [] : target, // Empty array prevents fetching if external data provided
webln, webln,
activeNWC activeNWC
@ -36,20 +36,18 @@ export function ZapButton({
} }
// Use external data if provided, otherwise use fetched data // Use external data if provided, otherwise use fetched data
const zapInfo = externalZapData || getZapData(target.id); const totalSats = externalZapData?.totalSats ?? fetchedTotalSats;
const { count: zapCount, totalSats } = zapInfo; const showLoading = externalZapData?.isLoading || isLoading;
const dataLoading = 'isLoading' in zapInfo ? zapInfo.isLoading : false;
const showLoading = externalZapData?.isLoading || dataLoading || isLoading;
return ( return (
<ZapDialog target={target}> <ZapDialog target={target}>
<div className={className}> <div className={`flex items-center gap-1 ${className}`}>
<Zap className="h-4 w-4 mr-1" /> <Zap className="h-4 w-4" />
<span className="text-xs"> <span className="text-xs">
{showLoading ? ( {showLoading ? (
'...' '...'
) : showCount && zapCount > 0 ? ( ) : showCount && totalSats > 0 ? (
totalSats > 0 ? `${totalSats.toLocaleString()}` : zapCount `${totalSats.toLocaleString()}`
) : ( ) : (
'Zap' 'Zap'
)} )}

View File

@ -28,15 +28,12 @@ export function useNWCInternal() {
// Parse and validate NWC URI // Parse and validate NWC URI
const parseNWCUri = (uri: string): { connectionString: string } | null => { const parseNWCUri = (uri: string): { connectionString: string } | null => {
try { try {
console.debug('Parsing NWC URI:', { uri: uri.substring(0, 50) + '...' });
if (!uri.startsWith('nostr+walletconnect://') && !uri.startsWith('nostrwalletconnect://')) { if (!uri.startsWith('nostr+walletconnect://') && !uri.startsWith('nostrwalletconnect://')) {
console.error('Invalid NWC URI protocol:', { protocol: uri.split('://')[0] }); console.error('Invalid NWC URI protocol:', { protocol: uri.split('://')[0] });
return null; return null;
} }
// Basic validation - let the SDK handle the detailed parsing // Basic validation - let the SDK handle the detailed parsing
console.debug('NWC URI parsing successful');
return { connectionString: uri }; return { connectionString: uri };
} catch (error) { } catch (error) {
console.error('Failed to parse NWC URI:', error); console.error('Failed to parse NWC URI:', error);
@ -152,11 +149,8 @@ export function useNWCInternal() {
setActiveConnection(connections[0].connectionString); setActiveConnection(connections[0].connectionString);
return connections[0]; return connections[0];
} }
if (!activeConnection) { if (!activeConnection) return null;
console.debug('No active connection and no connections');
return null;
}
const found = connections.find(c => c.connectionString === activeConnection); const found = connections.find(c => c.connectionString === activeConnection);
return found || null; return found || null;
@ -183,7 +177,7 @@ export function useNWCInternal() {
try { try {
// Add timeout to prevent hanging // Add timeout to prevent hanging
const timeoutPromise = new Promise((_, reject) => { const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Payment timeout after 15 seconds')), 15); setTimeout(() => reject(new Error('Payment timeout after 15 seconds')), 15000);
}); });
const paymentPromise = client.pay(invoice); const paymentPromise = client.pay(invoice);

View File

@ -85,17 +85,6 @@ export function useWallet() {
preferredMethod, preferredMethod,
}; };
// Debug logging for wallet status changes
useEffect(() => {
console.debug('Wallet status updated:', {
hasWebLN: status.hasWebLN,
hasNWC: status.hasNWC,
connectionsCount: connections.length,
activeNWC: !!status.activeNWC,
preferredMethod: status.preferredMethod
});
}, [status.hasWebLN, status.hasNWC, connections.length, status.activeNWC, status.preferredMethod]);
return { return {
...status, ...status,
hasAttemptedDetection, hasAttemptedDetection,

View File

@ -4,14 +4,13 @@ import { useAuthor } from '@/hooks/useAuthor';
import { useAppContext } from '@/hooks/useAppContext'; import { useAppContext } from '@/hooks/useAppContext';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { useNWC } from '@/hooks/useNWCContext'; import { useNWC } from '@/hooks/useNWCContext';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import type { NWCConnection } from '@/hooks/useNWC'; import type { NWCConnection } from '@/hooks/useNWC';
import { nip57 } from 'nostr-tools'; import { nip57 } from 'nostr-tools';
import type { Event } from 'nostr-tools'; import type { Event } from 'nostr-tools';
import type { WebLNProvider } from 'webln'; import type { WebLNProvider } from 'webln';
import { useQuery } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react'; import { useNostr } from '@nostrify/react';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify'; import type { NostrEvent } from '@nostrify/nostrify';
// NWC utility functions // NWC utility functions
function parseNWCUri(uri: string): NWCConnection | null { function parseNWCUri(uri: string): NWCConnection | null {
@ -40,7 +39,7 @@ function parseNWCUri(uri: string): NWCConnection | null {
} }
export function useZaps( export function useZaps(
targets: Event | Event[], target: Event | Event[],
webln: WebLNProvider | null, webln: WebLNProvider | null,
_nwcConnection: NWCConnection | null, _nwcConnection: NWCConnection | null,
onZapSuccess?: () => void onZapSuccess?: () => void
@ -49,130 +48,102 @@ export function useZaps(
const { toast } = useToast(); const { toast } = useToast();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { config } = useAppContext(); const { config } = useAppContext();
const { mutate: publishEvent } = useNostrPublish(); const queryClient = useQueryClient();
// Normalize targets to array for consistent handling // Handle the case where an empty array is passed (from ZapButton when external data is provided)
const targetArray = useMemo(() => const actualTarget = Array.isArray(target) ? (target.length > 0 ? target[0] : null) : target;
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); const author = useAuthor(actualTarget?.pubkey);
const { sendPayment, getActiveConnection, connections, activeConnection } = useNWC(); const { sendPayment, getActiveConnection } = useNWC();
const [isZapping, setIsZapping] = useState(false); const [isZapping, setIsZapping] = useState(false);
const [invoice, setInvoice] = useState<string | null>(null); const [invoice, setInvoice] = useState<string | null>(null);
// Create query key based on mode
const queryKey = isBatchMode
? ['zaps-batch', targetArray.map(t => t.id).sort()]
: ['zaps-single', primaryTarget.id];
const { data: zapEvents, ...query } = useQuery<NostrEvent[], Error>({ const { data: zapEvents, ...query } = useQuery<NostrEvent[], Error>({
queryKey, queryKey: ['zaps', actualTarget?.id],
staleTime: 30000, // 30 seconds
refetchInterval: 60000, // Refetch every minute to catch new zaps
queryFn: async (c) => { queryFn: async (c) => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(1500)]); if (!actualTarget) return [];
const filters: NostrFilter[] = [];
if (isBatchMode) { const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]);
// 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) { // Query for zap receipts for this specific event
filters.push({ if (actualTarget.kind >= 30000 && actualTarget.kind < 40000) {
kinds: [9735], // Addressable event
'#e': eventIds, const identifier = actualTarget.tags.find((t) => t[0] === 'd')?.[1] || '';
}); const events = await nostr.query([{
} kinds: [9735],
'#a': [`${actualTarget.kind}:${actualTarget.pubkey}:${identifier}`],
// Handle addressable events }], { signal });
if (addressableEvents.length > 0) { return events;
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,
});
}
} else { } else {
// Single mode: get zaps for one event // Regular event
const target = primaryTarget; const events = await nostr.query([{
if (target.kind >= 30000 && target.kind < 40000) { kinds: [9735],
const identifier = target.tags.find((t) => t[0] === 'd')?.[1] || ''; '#e': [actualTarget.id],
filters.push({ }], { signal });
kinds: [9735], return events;
'#a': [`${target.kind}:${target.pubkey}:${identifier}`],
});
} else {
filters.push({
kinds: [9735],
'#e': [target.id],
});
}
} }
if (filters.length === 0) return [];
const events = await nostr.query(filters, { signal });
return events;
}, },
enabled: targetArray.length > 0 && targetArray.every(t => t.id), enabled: !!actualTarget?.id,
}); });
// Process zap events into organized data // Process zap events into simple counts and totals
const zapData = useMemo(() => { const { zapCount, totalSats, zaps } = useMemo(() => {
if (!zapEvents) return {}; if (!zapEvents || !actualTarget) {
return { zapCount: 0, totalSats: 0, zaps: [] };
}
const organized: Record<string, { count: number; totalSats: number; events: NostrEvent[] }> = {}; let count = 0;
let sats = 0;
zapEvents.forEach(zap => { zapEvents.forEach(zap => {
// Find which event this zap is for count++;
const eventTag = zap.tags.find(([name]) => name === 'e')?.[1];
const addressTag = zap.tags.find(([name]) => name === 'a')?.[1];
let targetId: string | undefined; // Try multiple methods to extract the amount:
if (eventTag) { // Method 1: amount tag (from zap request, sometimes copied to receipt)
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]; const amountTag = zap.tags.find(([name]) => name === 'amount')?.[1];
if (amountTag) { if (amountTag) {
const sats = Math.floor(parseInt(amountTag) / 1000); // Convert millisats to sats const millisats = parseInt(amountTag);
organized[targetId].totalSats += sats; sats += Math.floor(millisats / 1000);
return;
} }
// 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);
}
}
// 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);
}
}
console.warn('Could not extract amount from zap receipt:', zap.id);
}); });
return organized;
}, [zapEvents, targetArray]);
// For single mode, return the data for the primary target return { zapCount: count, totalSats: sats, zaps: zapEvents };
const singleTargetData = isBatchMode ? undefined : zapData[primaryTarget.id]; }, [zapEvents, actualTarget]);
const zaps = singleTargetData?.events;
const zap = async (amount: number, comment: string) => { const zap = async (amount: number, comment: string) => {
if (amount <= 0) { if (amount <= 0) {
@ -192,6 +163,16 @@ export function useZaps(
return; return;
} }
if (!actualTarget) {
toast({
title: 'Event not found',
description: 'Could not find the event to zap.',
variant: 'destructive',
});
setIsZapping(false);
return;
}
try { try {
if (!author.data || !author.data?.metadata || !author.data?.event ) { if (!author.data || !author.data?.metadata || !author.data?.event ) {
toast({ toast({
@ -226,21 +207,31 @@ export function useZaps(
return; return;
} }
// Create zap request // 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;
const zapAmount = amount * 1000; // convert to millisats const zapAmount = amount * 1000; // convert to millisats
const zapRequest = nip57.makeZapRequest({ const zapRequest = nip57.makeZapRequest({
profile: primaryTarget.pubkey, profile: actualTarget.pubkey,
event: primaryTarget, event: event,
amount: zapAmount, amount: zapAmount,
relays: [config.relayUrl], relays: [config.relayUrl],
comment comment
}); });
// Sign and publish the zap request // Sign the zap request (but don't publish to relays - only send to LNURL endpoint)
publishEvent(zapRequest, { if (!user.signer) {
onSuccess: async (event) => { throw new Error('No signer available');
try { }
const res = await fetch(`${zapEndpoint}?amount=${zapAmount}&nostr=${encodeURI(JSON.stringify(event))}`); const signedZapRequest = await user.signer.signEvent(zapRequest);
try {
const res = await fetch(`${zapEndpoint}?amount=${zapAmount}&nostr=${encodeURI(JSON.stringify(signedZapRequest))}`);
const responseData = await res.json(); const responseData = await res.json();
if (!res.ok) { if (!res.ok) {
@ -269,6 +260,9 @@ export function useZaps(
description: `You sent ${amount} sats via NWC to the author.`, description: `You sent ${amount} sats via NWC to the author.`,
}); });
// Invalidate zap queries to refresh counts
queryClient.invalidateQueries({ queryKey: ['zaps'] });
// Close dialog last to ensure clean state // Close dialog last to ensure clean state
onZapSuccess?.(); onZapSuccess?.();
return; return;
@ -295,6 +289,9 @@ export function useZaps(
description: `You sent ${amount} sats to the author.`, description: `You sent ${amount} sats to the author.`,
}); });
// Invalidate zap queries to refresh counts
queryClient.invalidateQueries({ queryKey: ['zaps'] });
// Close dialog last to ensure clean state // Close dialog last to ensure clean state
onZapSuccess?.(); onZapSuccess?.();
} else { // Default - show QR code and manual Lightning URI } else { // Default - show QR code and manual Lightning URI
@ -308,20 +305,8 @@ export function useZaps(
description: (err as Error).message, description: (err as Error).message,
variant: 'destructive', variant: 'destructive',
}); });
} finally {
setIsZapping(false); setIsZapping(false);
} }
},
onError: (err) => {
console.error('Failed to publish zap request:', err);
toast({
title: 'Zap failed',
description: 'Failed to create zap request',
variant: 'destructive',
});
setIsZapping(false);
},
});
} catch (err) { } catch (err) {
console.error('Zap error:', err); console.error('Zap error:', err);
toast({ toast({
@ -335,16 +320,13 @@ export function useZaps(
return { return {
zaps, zaps,
zapCount,
totalSats,
...query, ...query,
zap, zap,
isZapping, isZapping,
invoice, invoice,
setInvoice, setInvoice,
parseNWCUri, parseNWCUri,
zapData,
isBatchMode,
// Helper functions
getZapData: (eventId: string) => zapData[eventId] || { count: 0, totalSats: 0, events: [] },
}; };
} }