feat: implement split zap functionality with recipient handling and invoice preparation

This commit is contained in:
2025-10-29 22:47:54 +01:00
parent 884486ffb6
commit bf6993ad27
2 changed files with 329 additions and 13 deletions

View File

@@ -27,6 +27,7 @@ import { Separator } from '@/components/ui/separator';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { useToast } from '@/hooks/useToast';
import { useZaps } from '@/hooks/useZaps';
import { useWallet } from '@/hooks/useWallet';
@@ -241,7 +242,17 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
const { data: author } = useAuthor(target.pubkey);
const { toast } = useToast();
const { webln, activeNWC, hasWebLN, detectWebLN } = useWallet();
const { zap, isZapping, invoice, setInvoice } = useZaps(target, webln, activeNWC, () => setOpen(false));
const {
zap,
isZapping,
invoice,
setInvoice,
hasSplits,
prepareSplitZaps,
splitInvoices,
paySplitInvoice,
clearSplitInvoices,
} = useZaps(target, webln, activeNWC, () => setOpen(false));
const [amount, setAmount] = useState<number | string>(100);
const [comment, setComment] = useState<string>('');
const [copied, setCopied] = useState(false);
@@ -249,6 +260,14 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
const inputRef = useRef<HTMLInputElement>(null);
const isMobile = useIsMobile();
// Helper subcomponent to render a recipient's display name from pubkey
function SplitRecipientName({ pubkey }: { pubkey: string }) {
const author = useAuthor(pubkey);
const meta = author.data?.metadata;
const name = meta?.display_name || meta?.name || genUserName(pubkey);
return <span className="break-words">{name}</span>;
}
useEffect(() => {
if (target) {
setComment('Zapped with zelo.news!');
@@ -322,20 +341,27 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
if (open) {
setAmount(100);
setInvoice(null);
clearSplitInvoices();
setCopied(false);
setQrCodeUrl('');
} else {
// Clean up state when dialog closes
setAmount(100);
setInvoice(null);
clearSplitInvoices();
setCopied(false);
setQrCodeUrl('');
}
}, [open, setInvoice]);
}, [open, setInvoice, clearSplitInvoices]);
const handleZap = () => {
const finalAmount = typeof amount === 'string' ? parseInt(amount, 10) : amount;
zap(finalAmount, comment);
if (hasSplits) {
// Prepare split invoices instead of single flow
prepareSplitZaps(finalAmount, comment);
} else {
zap(finalAmount, comment);
}
};
const contentProps = {
@@ -421,18 +447,61 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
</DrawerClose>
<DrawerTitle className="text-lg break-words pt-2">
{invoice ? 'Lightning Payment' : 'Send a Zap'}
{invoice ? 'Lightning Payment' : (hasSplits && splitInvoices.length > 0 ? 'Pay Zap Splits' : 'Send a Zap')}
</DrawerTitle>
<DrawerDescription className="text-sm break-words text-center">
{invoice ? (
'Pay with Bitcoin Lightning Network'
) : (
'Zaps are small Bitcoin payments that support the creator of this item. If you enjoyed this, consider sending a zap!'
hasSplits && splitInvoices.length > 0
? 'This zap is split among multiple recipients. Pay each invoice below.'
: 'Zaps are small Bitcoin payments that support the creator of this item. If you enjoyed this, consider sending a zap!'
)}
</DrawerDescription>
</DrawerHeader>
<div className="flex-1 overflow-y-auto px-4 pb-4">
<ZapContent {...contentProps} />
{hasSplits && splitInvoices.length > 0 ? (
<div className="space-y-3">
{splitInvoices.map((item, idx) => (
<Card key={`${item.recipient}-${idx}`}>
<CardContent className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium break-words">
<SplitRecipientName pubkey={item.recipient} />
</div>
<div className="text-[10px] text-muted-foreground font-mono mt-0.5">{item.recipient.slice(0, 8)}</div>
<div className="text-xs text-muted-foreground mt-1">
Weight {item.weight}% {item.amount} sats
</div>
{item.error && (
<div className="text-xs text-red-600 mt-1 break-words">{item.error}</div>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<Button size="sm" onClick={() => paySplitInvoice(idx)} disabled={!item.invoice || item.isPaying || item.paid}>
<Zap className="h-4 w-4 mr-1" />
{item.paid ? 'Paid' : (item.isPaying ? 'Paying…' : 'Pay')}
</Button>
{item.invoice && (
<>
<Button size="icon" variant="outline" onClick={() => navigator.clipboard.writeText(item.invoice!) }>
<Copy className="h-4 w-4" />
</Button>
<Button size="icon" variant="outline" onClick={() => window.open(`lightning:${item.invoice}`, '_blank')}>
<ExternalLink className="h-4 w-4" />
</Button>
</>
)}
</div>
</div>
</CardContent>
</Card>
))}
</div>
) : (
<ZapContent {...contentProps} />
)}
</div>
</DrawerContent>
</Drawer>
@@ -446,23 +515,64 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
{children}
</div>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px] max-h-[95vh] overflow-hidden" data-testid="zap-modal">
<DialogContent className="sm:max-w-[480px] max-h-[95vh] overflow-hidden" data-testid="zap-modal">
<DialogHeader>
<DialogTitle className="text-lg break-words">
{invoice ? 'Lightning Payment' : 'Send a Zap'}
{invoice ? 'Lightning Payment' : (hasSplits && splitInvoices.length > 0 ? 'Pay Zap Splits' : 'Send a Zap')}
</DialogTitle>
<DialogDescription className="text-sm text-center break-words">
{invoice ? (
'Pay with Bitcoin Lightning Network'
) : (
<>
Zaps are small Bitcoin payments that support the creator of this item. If you enjoyed this, consider sending a zap!
</>
hasSplits && splitInvoices.length > 0
? 'This zap is split among multiple recipients. Pay each invoice below.'
: <>Zaps are small Bitcoin payments that support the creator of this item. If you enjoyed this, consider sending a zap!</>
)}
</DialogDescription>
</DialogHeader>
<div className="overflow-y-auto">
<ZapContent {...contentProps} />
<div className="overflow-y-auto p-1">
{hasSplits && splitInvoices.length > 0 ? (
<div className="space-y-3">
{splitInvoices.map((item, idx) => (
<Card key={`${item.recipient}-${idx}`}>
<CardContent className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium break-words">
<SplitRecipientName pubkey={item.recipient} />
</div>
<div className="text-[10px] text-muted-foreground font-mono mt-0.5">{item.recipient.slice(0, 8)}</div>
<div className="text-xs text-muted-foreground mt-1">
Weight {item.weight}% {item.amount} sats
</div>
{item.error && (
<div className="text-xs text-red-600 mt-1 break-words">{item.error}</div>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<Button size="sm" onClick={() => paySplitInvoice(idx)} disabled={!item.invoice || item.isPaying || item.paid}>
<Zap className="h-4 w-4 mr-1" />
{item.paid ? 'Paid' : (item.isPaying ? 'Paying…' : 'Pay')}
</Button>
{item.invoice && (
<>
<Button size="icon" variant="outline" onClick={() => navigator.clipboard.writeText(item.invoice!) }>
<Copy className="h-4 w-4" />
</Button>
<Button size="icon" variant="outline" onClick={() => window.open(`lightning:${item.invoice}`, '_blank')}>
<ExternalLink className="h-4 w-4" />
</Button>
</>
)}
</div>
</div>
</CardContent>
</Card>
))}
</div>
) : (
<ZapContent {...contentProps} />
)}
</div>
</DialogContent>
</Dialog>

View File

@@ -31,12 +31,27 @@ export function useZaps(
const { sendPayment, getActiveConnection } = useNWC();
const [isZapping, setIsZapping] = useState(false);
const [invoice, setInvoice] = useState<string | null>(null);
// Split zap support
type SplitInvoice = {
recipient: string; // hex pubkey
weight: number; // 1..100
relays: string[];
amount: number; // sats
zapEndpoint?: string;
zapRequest?: unknown;
invoice?: string;
isPaying?: boolean;
paid?: boolean;
error?: string;
};
const [splitInvoices, setSplitInvoices] = useState<SplitInvoice[]>([]);
// Cleanup state when component unmounts
useEffect(() => {
return () => {
setIsZapping(false);
setInvoice(null);
setSplitInvoices([]);
};
}, []);
@@ -335,6 +350,192 @@ export function useZaps(
}
};
// Detect zap splits on the target event
const splitTags = useMemo(() => {
if (!actualTarget?.tags) return [] as string[][];
return actualTarget.tags.filter((t) => t[0] === 'zap');
}, [actualTarget]);
const hasSplits = splitTags.length > 1; // only treat as split flow when >1 recipients
// Helper: resolve zap endpoint for a recipient pubkey by fetching their kind 0
const resolveZapEndpoint = useCallback(async (pubkey: string, signal: AbortSignal): Promise<string | null> => {
try {
const events = await nostr.query([
{ kinds: [0], authors: [pubkey], limit: 1 },
], { signal });
const profile = events?.[0];
if (!profile) return null;
const endpoint = await nip57.getZapEndpoint(profile as unknown as Event);
return endpoint ?? null;
} catch {
// ignore network errors
return null;
}
}, [nostr]);
// Prepare split zap invoices (does not auto-pay). Only used when hasSplits === true
const prepareSplitZaps = useCallback(async (amount: number, comment: string) => {
if (!user) {
toast({ title: 'Login required', description: 'You must be logged in to send a zap.', variant: 'destructive' });
return;
}
if (!actualTarget) {
toast({ title: 'Event not found', description: 'Could not find the event to zap.', variant: 'destructive' });
return;
}
// Parse recipients
const recipients = splitTags.map((t) => {
// t: ["zap", <hexpub>, "weight", "<n>", "relays", <relay1>...]
const recipient = t[1] || '';
const weightIdx = t.findIndex((x) => x === 'weight');
const weight = weightIdx >= 0 ? Math.max(0, Math.min(100, parseInt(t[weightIdx + 1] || '0', 10) || 0)) : 0;
const relaysIdx = t.findIndex((x) => x === 'relays');
const relays = relaysIdx >= 0 ? t.slice(relaysIdx + 1) : [];
return { recipient, weight, relays };
}).filter(r => r.recipient);
const totalWeight = recipients.reduce((acc, r) => acc + r.weight, 0);
if (recipients.length <= 1 || totalWeight <= 0) {
// Fallback to single zap flow
await zap(amount, comment);
return;
}
// Compute per-recipient sats, distribute remainder to the largest weight
const sats = Math.max(1, Math.trunc(amount));
const computed: SplitInvoice[] = recipients.map(r => ({ ...r, amount: 0, relays: r.relays, weight: r.weight } as SplitInvoice));
let assigned = 0;
let maxIdx = 0;
let maxWeight = -1;
computed.forEach((r, i) => {
const share = Math.floor((sats * r.weight) / totalWeight);
r.amount = share;
assigned += share;
if (r.weight > maxWeight) { maxWeight = r.weight; maxIdx = i; }
});
const remainder = sats - assigned;
if (remainder > 0) {
computed[maxIdx].amount += remainder;
}
setIsZapping(true);
setSplitInvoices([]);
const signal = AbortSignal.timeout(10000);
try {
// Prepare zap requests and fetch invoices for each recipient in parallel
const results = await Promise.all(computed.map(async (item) => {
const endpoint = await resolveZapEndpoint(item.recipient, signal);
if (!endpoint) {
return { ...item, error: 'Zap endpoint not found' } as SplitInvoice;
}
// Build zap request; use same event reference logic as single flow
const eventRef = (actualTarget.kind >= 30000 && actualTarget.kind < 40000)
? actualTarget
: actualTarget.id;
const zapAmountMsat = item.amount * 1000;
const zr = nip57.makeZapRequest({
profile: item.recipient,
event: eventRef,
amount: zapAmountMsat,
relays: [config.relayUrl],
comment,
});
// Ensure original split tags are included so servers that support server-side split can verify context
try {
if (Array.isArray(actualTarget.tags)) {
const splitTagsLocal = actualTarget.tags.filter((t) => t[0] === 'zap');
const zrobj = zr as unknown as { tags?: string[][] };
zrobj.tags = Array.isArray(zrobj.tags) ? zrobj.tags : [];
zrobj.tags.push(...splitTagsLocal);
}
} catch {
// ignore tag merge errors
}
if (!user.signer) {
return { ...item, error: 'No signer available' } as SplitInvoice;
}
const signed = await user.signer.signEvent(zr);
try {
const res = await fetch(`${endpoint}?amount=${zapAmountMsat}&nostr=${encodeURI(JSON.stringify(signed))}`);
const json = await res.json();
if (!res.ok) {
const reason = json?.reason || 'Unknown error';
return { ...item, zapEndpoint: endpoint, zapRequest: signed, error: `HTTP ${res.status}: ${reason}` } as SplitInvoice;
}
const pr = json?.pr as string | undefined;
if (!pr) {
return { ...item, zapEndpoint: endpoint, zapRequest: signed, error: 'Lightning service did not return a valid invoice' } as SplitInvoice;
}
return { ...item, zapEndpoint: endpoint, zapRequest: signed, invoice: pr } as SplitInvoice;
} catch (e) {
return { ...item, zapEndpoint: endpoint, zapRequest: signed, error: (e as Error).message } as SplitInvoice;
}
}));
setSplitInvoices(results);
} finally {
setIsZapping(false);
}
}, [user, actualTarget, splitTags, config.relayUrl, resolveZapEndpoint, toast, zap]);
const paySplitInvoice = useCallback(async (idx: number) => {
const item = splitInvoices[idx];
if (!item || !item.invoice) {
toast({ title: 'Payment error', description: 'No invoice available for this split', variant: 'destructive' });
return;
}
// Get current active connection fresh
const currentNWCConnection = getActiveConnection();
const inv = item.invoice;
// Optimistic UI: set isPaying
setSplitInvoices((prev) => prev.map((it, i) => i === idx ? { ...it, isPaying: true, error: undefined } : it));
try {
if (currentNWCConnection && currentNWCConnection.connectionString && currentNWCConnection.isConnected) {
try {
await sendPayment(currentNWCConnection, inv);
setSplitInvoices((prev) => prev.map((it, i) => i === idx ? { ...it, isPaying: false, paid: true } : it));
toast({ title: 'Zap successful!', description: `You sent ${item.amount} sats via NWC.` });
queryClient.invalidateQueries({ queryKey: ['zaps'] });
return;
} catch (nwcError) {
const msg = nwcError instanceof Error ? nwcError.message : 'Unknown NWC error';
toast({ title: 'NWC payment failed', description: msg, variant: 'destructive' });
}
}
if (webln) {
try {
await webln.sendPayment(inv);
setSplitInvoices((prev) => prev.map((it, i) => i === idx ? { ...it, isPaying: false, paid: true } : it));
toast({ title: 'Zap successful!', description: `You sent ${item.amount} sats.` });
queryClient.invalidateQueries({ queryKey: ['zaps'] });
return;
} catch (weblnError) {
const msg = weblnError instanceof Error ? weblnError.message : 'Unknown WebLN error';
setSplitInvoices((prev) => prev.map((it, i) => i === idx ? { ...it, isPaying: false, error: msg } : it));
toast({ title: 'WebLN payment failed', description: msg, variant: 'destructive' });
return;
}
}
// Fallback: no automatic method; just show error encouraging copy/open
setSplitInvoices((prev) => prev.map((it, i) => i === idx ? { ...it, isPaying: false, error: 'No wallet connected. Use Copy or Open.' } : it));
} catch (e) {
const msg = e instanceof Error ? e.message : 'Payment failed';
setSplitInvoices((prev) => prev.map((it, i) => i === idx ? { ...it, isPaying: false, error: msg } : it));
toast({ title: 'Payment failed', description: msg, variant: 'destructive' });
}
}, [splitInvoices, toast, queryClient, webln, getActiveConnection, sendPayment]);
const clearSplitInvoices = useCallback(() => setSplitInvoices([]), []);
const resetInvoice = useCallback(() => {
setInvoice(null);
}, []);
@@ -345,6 +546,11 @@ export function useZaps(
totalSats,
...query,
zap,
hasSplits,
prepareSplitZaps,
splitInvoices,
paySplitInvoice,
clearSplitInvoices,
isZapping,
invoice,
setInvoice,