mirror of
https://github.com/mroxso/zelo-news.git
synced 2026-06-04 09:31:14 +02:00
feat: implement split zap functionality with recipient handling and invoice preparation
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user