From b8191cccb63505b8bfe885f4f04182bf7707c1c6 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 25 May 2026 11:58:53 +0200 Subject: [PATCH] reinstate lightning --- src/components/NoteStats/ZapButton.tsx | 51 ++++- .../PaytoDialog/LightningInvoiceSection.tsx | 36 ++- src/components/PaytoDialog/index.tsx | 13 +- src/components/ProfileZapButton/index.tsx | 39 +++- src/components/Sidebar/index.tsx | 12 +- .../ZapDialog/Nip57QuickZapButton.tsx | 33 +++ .../ZapDialog/PostPaymentMessagePrompt.tsx | 9 +- .../ZapDialog/SuperchatRequestForm.tsx | 11 +- src/components/ZapDialog/index.tsx | 37 +++- src/constants.ts | 2 +- src/hooks/useNip57QuickZap.ts | 123 +++++++++++ src/i18n/locales/en.ts | 6 + src/lib/lightning-zap-amount.test.ts | 4 +- src/lib/lightning.ts | 5 +- src/lib/merge-payment-methods.ts | 35 ++- src/lib/nip57-zap.ts | 45 ++++ src/lib/post-payment-context.ts | 4 + src/lib/wallet-connection-details.ts | 21 +- src/lib/webln-payment.ts | 32 ++- .../IncludePublicZapReceiptSwitch.tsx | 25 +++ .../WalletPage/LightningAddressInput.tsx | 2 +- .../WalletPage/WalletConnectionDetails.tsx | 37 +++- .../WalletPage/WalletZapSendingSettings.tsx | 4 + src/providers/ZapProvider.tsx | 10 +- src/services/lightning.service.ts | 209 +++++++++++++++++- 25 files changed, 734 insertions(+), 71 deletions(-) create mode 100644 src/components/ZapDialog/Nip57QuickZapButton.tsx create mode 100644 src/hooks/useNip57QuickZap.ts create mode 100644 src/lib/nip57-zap.ts create mode 100644 src/pages/secondary/WalletPage/IncludePublicZapReceiptSwitch.tsx diff --git a/src/components/NoteStats/ZapButton.tsx b/src/components/NoteStats/ZapButton.tsx index 5f1f5fe7..c1db668a 100644 --- a/src/components/NoteStats/ZapButton.tsx +++ b/src/components/NoteStats/ZapButton.tsx @@ -1,4 +1,6 @@ import { useNoteStatsById } from '@/hooks/useNoteStatsById' +import { useLongPressAction } from '@/hooks/use-long-press-action' +import { useNip57QuickZap } from '@/hooks/useNip57QuickZap' import { recipientHasAnyPaymentOptions } from '@/lib/merge-payment-methods' import { buildRecipientPaymentData, @@ -16,6 +18,7 @@ import { kinds, type Event } from 'nostr-tools' import { Zap } from 'lucide-react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import { Skeleton } from '@/components/ui/skeleton' import ZapDialog from '../ZapDialog' import PostPaymentMessagePrompt from '../ZapDialog/PostPaymentMessagePrompt' import { mergePostPaymentContext, type PostPaymentContext } from '@/lib/post-payment-context' @@ -210,13 +213,43 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB } }, [authorPubkey, isSelf, feedProfileSyncKey, applyTipAvailability]) + const recipientPaymentForZap = useMemo( + () => + tipPaymentData ?? + buildRecipientPaymentData( + null, + feedProfile && !feedProfile.batchPlaceholder ? feedProfile : null, + null + ), + [tipPaymentData, feedProfile] + ) + + const { canQuickNip57Zap, sendQuickZap, zapping } = useNip57QuickZap({ + recipientPubkey: event.pubkey, + referencedEvent: event, + recipientPayment: recipientPaymentForZap, + onPostPaymentRequest: handlePostPaymentRequest, + onZapDialogClose: () => setOpenPaymentDialog(false) + }) + + const longPressZap = useLongPressAction(() => sendQuickZap(), { + enabled: canQuickNip57Zap && !disable + }) + const handleOpenPaymentMethods = (e: React.MouseEvent) => { e.stopPropagation() e.preventDefault() + if (longPressZap.consumeIfLongPress()) return if (disable) return setOpenPaymentDialog(true) } + const zapButtonTitle = disable + ? t('Zaps') + : canQuickNip57Zap + ? t('Payment methods — long-press to zap') + : t('Payment methods') + return ( <>
@@ -224,14 +257,21 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB type="button" className={cn( 'group flex h-full items-center pl-3 pr-1', - disable ? 'cursor-not-allowed' : 'cursor-pointer' + disable || zapping ? 'cursor-not-allowed' : 'cursor-pointer' )} - title={disable ? t('Zaps') : t('Payment methods')} - aria-label={disable ? t('Zaps') : t('Payment methods')} - disabled={disable} + title={zapButtonTitle} + aria-label={zapButtonTitle} + disabled={disable || zapping} onClick={handleOpenPaymentMethods} + onPointerDown={longPressZap.onPointerDown} + onPointerUp={longPressZap.onPointerUp} + onPointerLeave={longPressZap.onPointerLeave} + onPointerCancel={longPressZap.onPointerCancel} > - + ) : ( + + )} {showZapAmount ? ( diff --git a/src/components/PaytoDialog/LightningInvoiceSection.tsx b/src/components/PaytoDialog/LightningInvoiceSection.tsx index 1bb677db..2dcdbf14 100644 --- a/src/components/PaytoDialog/LightningInvoiceSection.tsx +++ b/src/components/PaytoDialog/LightningInvoiceSection.tsx @@ -11,6 +11,7 @@ import { formatAmount, formatSatsGrouped, getAmountFromInvoice, + LN_INVOICE_COFFEE_PRESET_SATS, LN_INVOICE_PRESET_SATS, MIN_ZAP_SATS, } from '@/lib/lightning' @@ -48,7 +49,11 @@ export default function LightningInvoiceSection({ /** Fired when a BOLT11 invoice is created or cleared (for Phoenix / external wallet links). */ onBolt11InvoiceChange?: (invoice: string | null) => void /** After a wallet payment succeeds (dialog stays open for the user to choose next steps). */ - onPaymentFlowComplete?: (details?: { amountMsat: number; payto: string }) => void + onPaymentFlowComplete?: (details?: { + amountMsat: number + payto: string + messageDraft?: string + }) => void }) { const { t } = useTranslation() const { defaultZapSats, isWalletConnected } = useZap() @@ -139,24 +144,29 @@ export default function LightningInvoiceSection({ } } - const paymentDetails = useMemo( - () => ({ - amountMsat: clampZapSats(sats) * 1000, - payto: formatPaytoTagValue(buildPaytoUri('lightning', lightningAddress)) - }), - [sats, lightningAddress] - ) + const buildPaymentDetails = (pr: string) => { + let amountMsat = clampZapSats(sats) * 1000 + try { + amountMsat = getAmountFromInvoice(pr) * 1000 + } catch { + /* use form amount */ + } + return { + amountMsat, + payto: formatPaytoTagValue(buildPaytoUri('lightning', lightningAddress)), + messageDraft: invoiceDescription?.trim() || description.trim() || undefined + } + } const handlePay = async () => { if (!invoice) return try { setPaying(true) - const result = await lightning.payInvoice(invoice, undefined, (flowResult) => { - if (flowResult) onPaymentFlowComplete?.(paymentDetails) - }) + const result = await lightning.payInvoice(invoice, undefined) if (!mountedRef.current) return if (result) { toast.success(t('Payment sent')) + onPaymentFlowComplete?.(buildPaymentDetails(invoice)) setInvoice(null) setInvoiceDescription(null) } @@ -210,19 +220,23 @@ export default function LightningInvoiceSection({ > {LN_INVOICE_PRESET_SATS.map((preset) => { const active = sats === preset + const isCoffeePreset = preset === LN_INVOICE_COFFEE_PRESET_SATS return ( ) })} diff --git a/src/components/PaytoDialog/index.tsx b/src/components/PaytoDialog/index.tsx index 6770a574..02ee6633 100644 --- a/src/components/PaytoDialog/index.tsx +++ b/src/components/PaytoDialog/index.tsx @@ -70,7 +70,7 @@ export default function PaytoDialog({ const [postPaymentOpen, setPostPaymentOpen] = useState(false) const [postPaymentContext, setPostPaymentContext] = useState(null) const [completedPaymentDetails, setCompletedPaymentDetails] = useState< - Partial> | null + Partial> | null >(null) const info = getPaytoTypeInfo(type) const label = info?.label ?? type @@ -116,9 +116,11 @@ export default function PaytoDialog({ ) if (onPostPaymentRequest) { onPostPaymentRequest(built) + onOpenChange(false) return } if (!offerTipNoticeOnClose) return + onOpenChange(false) setPostPaymentContext(built) setPostPaymentOpen(true) }, @@ -219,10 +221,11 @@ export default function PaytoDialog({ paytoUri={paytoUri} onBolt11InvoiceChange={setBolt11Invoice} onPaymentFlowComplete={(details) => { - setCompletedPaymentDetails({ - amountMsat: details?.amountMsat, - payto: details?.payto - }) + if (!details) return + setCompletedPaymentDetails(details) + if (canOfferPostPayment) { + requestAnimationFrame(() => openPostPaymentPrompt(details)) + } }} /> ) : isLightning ? null : ( diff --git a/src/components/ProfileZapButton/index.tsx b/src/components/ProfileZapButton/index.tsx index 9fac498f..c4858745 100644 --- a/src/components/ProfileZapButton/index.tsx +++ b/src/components/ProfileZapButton/index.tsx @@ -1,7 +1,12 @@ import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { useLongPressAction } from '@/hooks/use-long-press-action' +import { useNip57QuickZap } from '@/hooks/useNip57QuickZap' +import { useRecipientPaymentData } from '@/hooks/useRecipientAlternativePayments' import { useNostr } from '@/providers/NostrProvider' import { Zap } from 'lucide-react' import { useState } from 'react' +import { useTranslation } from 'react-i18next' import ZapDialog from '../ZapDialog' export default function ProfileZapButton({ @@ -13,10 +18,22 @@ export default function ProfileZapButton({ openZapDialog?: boolean setOpenZapDialog?: (open: boolean) => void }) { + const { t } = useTranslation() const { checkLogin } = useNostr() const [internalOpen, setInternalOpen] = useState(false) const open = setOpenZapDialog ? (openZapDialog ?? false) : internalOpen const setOpen = setOpenZapDialog ?? setInternalOpen + const recipientPayment = useRecipientPaymentData(pubkey, true) + + const { canQuickNip57Zap, sendQuickZap, zapping } = useNip57QuickZap({ + recipientPubkey: pubkey, + recipientPayment, + onZapDialogClose: () => setOpen(false) + }) + + const longPressZap = useLongPressAction(() => sendQuickZap(), { enabled: canQuickNip57Zap }) + + const title = canQuickNip57Zap ? t('Payment methods — long-press to zap') : t('Payment methods') return ( <> @@ -24,11 +41,27 @@ export default function ProfileZapButton({ variant="secondary" size="icon" className="rounded-full" - onClick={() => checkLogin(() => setOpen(true))} + title={title} + aria-label={title} + disabled={zapping} + onClick={() => { + if (longPressZap.consumeIfLongPress()) return + checkLogin(() => setOpen(true)) + }} + onPointerDown={longPressZap.onPointerDown} + onPointerUp={longPressZap.onPointerUp} + onPointerLeave={longPressZap.onPointerLeave} + onPointerCancel={longPressZap.onPointerCancel} > - + {zapping ? ( + + ) : ( + + )} - {!setOpenZapDialog && } + {!setOpenZapDialog && ( + + )} ) } diff --git a/src/components/Sidebar/index.tsx b/src/components/Sidebar/index.tsx index 6038065e..02738975 100644 --- a/src/components/Sidebar/index.tsx +++ b/src/components/Sidebar/index.tsx @@ -23,15 +23,15 @@ export default function PrimaryPageSidebar() { if (isSmallScreen) return null return ( -
+
-
-
-
+
+
+
{/* Full-bleed banner at xl: span entire sidebar column (undo pl-4 + pr-6) */}
- +
@@ -50,7 +50,7 @@ export default function PrimaryPageSidebar() {
-
+
diff --git a/src/components/ZapDialog/Nip57QuickZapButton.tsx b/src/components/ZapDialog/Nip57QuickZapButton.tsx new file mode 100644 index 00000000..1fe15030 --- /dev/null +++ b/src/components/ZapDialog/Nip57QuickZapButton.tsx @@ -0,0 +1,33 @@ +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { superchatLightningAccentClass } from '@/lib/superchat-ui' +import { cn } from '@/lib/utils' +import { Zap } from 'lucide-react' + +export default function Nip57QuickZapButton({ + label, + zapping, + onClick, + className +}: { + label: string + zapping: boolean + onClick: () => void + className?: string +}) { + return ( + + ) +} diff --git a/src/components/ZapDialog/PostPaymentMessagePrompt.tsx b/src/components/ZapDialog/PostPaymentMessagePrompt.tsx index e5615ba8..21eb38e8 100644 --- a/src/components/ZapDialog/PostPaymentMessagePrompt.tsx +++ b/src/components/ZapDialog/PostPaymentMessagePrompt.tsx @@ -28,6 +28,11 @@ type Step = 'choice' | 'public-message' | 'superchat' const footerButtonClass = 'w-full min-w-0 sm:w-auto' +function postPaymentInitialStep(context?: PostPaymentContext | null): Step { + if (context?.amountMsat != null && context.payto) return 'superchat' + return 'choice' +} + export default function PostPaymentMessagePrompt({ open, onOpenChange, @@ -46,9 +51,9 @@ export default function PostPaymentMessagePrompt({ useEffect(() => { if (open) { - setStep('choice') + setStep(postPaymentInitialStep(paymentContext)) } - }, [open, recipientPubkey]) + }, [open, recipientPubkey, paymentContext?.amountMsat, paymentContext?.payto, paymentContext?.messageDraft]) useEffect(() => { if (!open || step !== 'choice') return diff --git a/src/components/ZapDialog/SuperchatRequestForm.tsx b/src/components/ZapDialog/SuperchatRequestForm.tsx index 335522af..b6e89445 100644 --- a/src/components/ZapDialog/SuperchatRequestForm.tsx +++ b/src/components/ZapDialog/SuperchatRequestForm.tsx @@ -35,13 +35,18 @@ export default function SuperchatRequestForm({ const { t } = useTranslation() const { publish, checkLogin, pubkey: selfPubkey } = useNostr() const [message, setMessage] = useState('') - const [amountSats, setAmountSats] = useState(() => - paymentContext?.amountMsat ? clampZapSats(Math.floor(paymentContext.amountMsat / 1000)) : 0 - ) + const [amountSats, setAmountSats] = useState(0) const [minPow, setMinPow] = useState(0) const [sending, setSending] = useState(false) const textareaRef = useRef(null) + useEffect(() => { + setMessage(paymentContext?.messageDraft ?? '') + setAmountSats( + paymentContext?.amountMsat ? clampZapSats(Math.floor(paymentContext.amountMsat / 1000)) : 0 + ) + }, [paymentContext?.messageDraft, paymentContext?.amountMsat]) + const amountMsat = amountSats > 0 ? clampZapSats(amountSats) * 1000 : undefined useEffect(() => { diff --git a/src/components/ZapDialog/index.tsx b/src/components/ZapDialog/index.tsx index fa0c9893..9d076a67 100644 --- a/src/components/ZapDialog/index.tsx +++ b/src/components/ZapDialog/index.tsx @@ -15,6 +15,7 @@ import { import PaymentMethodsSection from '@/components/PaymentMethodsSection' import UserAvatar from '@/components/UserAvatar' import Username from '@/components/Username' +import { useNip57QuickZap } from '@/hooks/useNip57QuickZap' import { useSenderPaytoTypes } from '@/hooks/useSenderPaytoTypes' import { mergeRecipientPaymentData, @@ -33,6 +34,7 @@ import { NostrEvent } from 'nostr-tools' import { Dispatch, SetStateAction, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import PostPaymentMessagePrompt from './PostPaymentMessagePrompt' +import Nip57QuickZapButton from './Nip57QuickZapButton' export default function ZapDialog({ open, @@ -93,18 +95,33 @@ export default function ZapDialog({ return groupPaymentMethodsForDisplay(merged, senderPaytoTypes) }, [recipientPayment, senderPaytoTypes]) + const { canQuickNip57Zap, quickZapLabel, sendQuickZap, zapping } = useNip57QuickZap({ + recipientPubkey: pubkey, + referencedEvent: event, + recipientPayment, + onPostPaymentRequest: openPostPaymentPrompt, + onZapDialogClose: () => setOpen(false) + }) + const dialogTitle = t('Payment methods') const body = - paymentGroups.length > 0 ? ( - + paymentGroups.length > 0 || canQuickNip57Zap ? ( + <> + {canQuickNip57Zap ? ( + + ) : null} + {paymentGroups.length > 0 ? ( + + ) : null} + ) : (

{t('No payment methods available for this profile')} diff --git a/src/constants.ts b/src/constants.ts index b1f61d83..26d4c098 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -639,7 +639,7 @@ export const ExtendedKind = { EVENTS_I_MUTED_NOTIFICATIONS_LIST: 19132 } -/** WebLN wallet connect + pay BOLT11 from LNURL-pay invoices (no NIP-57 zaps). */ +/** WebLN wallet connect + pay BOLT11; NIP-57 zaps when LNURL-pay supports nostr. */ export const LIGHTNING_WALLET_PAY_ENABLED = true /** Kind 0 + NIP-A3 payment: publish to profile mirrors, full outbox (NIP-65 + HTTP + cache), and IndexedDB. */ diff --git a/src/hooks/useNip57QuickZap.ts b/src/hooks/useNip57QuickZap.ts new file mode 100644 index 00000000..52b14d79 --- /dev/null +++ b/src/hooks/useNip57QuickZap.ts @@ -0,0 +1,123 @@ +import { buildOrderedZapLightningAddresses } from '@/lib/merge-payment-methods' +import { mergePostPaymentContext, type PostPaymentContext } from '@/lib/post-payment-context' +import { buildPaytoUri } from '@/lib/payto' +import { formatNpub, pubkeyToNpub } from '@/lib/pubkey' +import { useNostr } from '@/providers/NostrProvider' +import { useZap } from '@/providers/ZapProvider' +import lightning from '@/services/lightning.service' +import noteStatsService from '@/services/note-stats.service' +import type { RecipientPaymentData } from '@/hooks/useRecipientAlternativePayments' +import { NostrEvent } from 'nostr-tools' +import { useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import { formatSatsGrouped } from '@/lib/lightning' + +export function useNip57QuickZap(opts: { + recipientPubkey: string + referencedEvent?: NostrEvent + recipientPayment: RecipientPaymentData + onPostPaymentRequest?: (context: PostPaymentContext) => void + onZapDialogClose?: () => void +}) { + const { t } = useTranslation() + const { pubkey, checkLogin } = useNostr() + const { isWalletConnected, defaultZapSats, defaultZapComment } = useZap() + const [zapping, setZapping] = useState(false) + + const lightningAddressOptions = useMemo( + () => + buildOrderedZapLightningAddresses({ + profileEvent: opts.recipientPayment.profileEvent, + profile: opts.recipientPayment.profile, + paymentInfo: opts.recipientPayment.paymentInfo + }), + [opts.recipientPayment] + ) + + const canQuickNip57Zap = + isWalletConnected && + defaultZapSats >= 1 && + lightningAddressOptions.length > 0 && + !!pubkey && + pubkey !== opts.recipientPubkey + + const recipientNpubLabel = useMemo(() => { + const npub = pubkeyToNpub(opts.recipientPubkey) + return npub ? formatNpub(npub) : opts.recipientPubkey.slice(0, 12) + }, [opts.recipientPubkey]) + + const quickZapLabel = t('Zap this npub n sats', { + npub: recipientNpubLabel, + n: formatSatsGrouped(defaultZapSats) + }) + + const sendQuickZap = useCallback(() => { + if (!canQuickNip57Zap || zapping) return + checkLogin(async () => { + if (!pubkey) return + try { + setZapping(true) + const paymentDetails = { + amountMsat: defaultZapSats * 1000, + paytoUri: buildPaytoUri('lightning', lightningAddressOptions[0] ?? ''), + messageDraft: defaultZapComment.trim() || undefined + } + const zapResult = await lightning.zap( + pubkey, + opts.referencedEvent ?? opts.recipientPubkey, + defaultZapSats, + defaultZapComment, + opts.onZapDialogClose, + (result) => { + if (!result) return + opts.onPostPaymentRequest?.( + mergePostPaymentContext( + { + recipientPubkey: opts.recipientPubkey, + referencedEvent: opts.referencedEvent + }, + paymentDetails + ) + ) + }, + { + address: lightningAddressOptions[0], + candidates: lightningAddressOptions + } + ) + if (!zapResult) return + if (opts.referencedEvent) { + noteStatsService.addZap( + pubkey, + opts.referencedEvent.id, + zapResult.invoice, + defaultZapSats, + defaultZapComment + ) + } + } catch (error) { + toast.error(`${t('Zap failed')}: ${(error as Error).message}`) + } finally { + setZapping(false) + } + }) + }, [ + canQuickNip57Zap, + zapping, + checkLogin, + pubkey, + defaultZapSats, + defaultZapComment, + lightningAddressOptions, + opts, + t + ]) + + return { + canQuickNip57Zap, + quickZapLabel, + sendQuickZap, + zapping + } +} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 6f1e836a..9262cac0 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -152,6 +152,7 @@ export default { 'Copy invoice': 'Copy invoice', 'Failed to create invoice': 'Failed to create invoice', 'Amount (sats)': 'Amount (sats)', + 'Buy them a coffee.': 'Buy them a coffee.', 'Approximate equivalent:': 'Approximate equivalent:', 'Pay with connected wallet': 'Pay with connected wallet', 'Payment sent': 'Payment sent', @@ -662,10 +663,15 @@ export default { sats: 'sats', 'Zap to': 'Zap to', 'Zap n sats': 'Zap {{n}} sats', + 'Zap this npub n sats': 'Zap {{npub}} {{n}} sats', + 'Payment methods — long-press to zap': 'Payment methods — long-press to zap', zapComment: 'Comment', 'Default zap amount': 'Default zap amount', 'Default zap comment': 'Default zap comment', 'Lightning Address (or LNURL)': 'Lightning Address (or LNURL)', + 'Lightning address you will receive zaps at.': 'Lightning address you will receive zaps at.', + 'Connected wallet address': 'Connected wallet address', + 'This wallet did not report a Lightning address.': 'This wallet did not report a Lightning address.', 'Quick zap': 'Quick zap', 'Preferred payto category': 'Preferred payto category', 'Show this category expanded on payment method lists; other categories collapse behind an accordion.': diff --git a/src/lib/lightning-zap-amount.test.ts b/src/lib/lightning-zap-amount.test.ts index a47ed183..630f1618 100644 --- a/src/lib/lightning-zap-amount.test.ts +++ b/src/lib/lightning-zap-amount.test.ts @@ -20,10 +20,10 @@ function groupedDisplayString(amount: number): string { describe('lightning zap amounts', () => { describe('LN invoice presets and defaults', () => { - it('defines min, default, and six preset buttons', () => { + it('defines min, default, and five preset buttons', () => { expect(MIN_ZAP_SATS).toBe(210) expect(DEFAULT_ZAP_SATS).toBe(420) - expect([...LN_INVOICE_PRESET_SATS]).toEqual([210, 420, 2100, 4200, 21_000, 42_000]) + expect([...LN_INVOICE_PRESET_SATS]).toEqual([210, 420, 2100, 4200, 21_000]) expect(LN_INVOICE_PRESET_SATS[0]).toBe(MIN_ZAP_SATS) expect(LN_INVOICE_PRESET_SATS).toContain(DEFAULT_ZAP_SATS) }) diff --git a/src/lib/lightning.ts b/src/lib/lightning.ts index f6fae39a..8f66072a 100644 --- a/src/lib/lightning.ts +++ b/src/lib/lightning.ts @@ -26,7 +26,10 @@ export const MIN_ZAP_SATS = 210 export const DEFAULT_ZAP_SATS = 420 /** Quick-pick amounts on the Lightning invoice page. */ -export const LN_INVOICE_PRESET_SATS = [210, 420, 2100, 4200, 21_000, 42_000] as const +export const LN_INVOICE_PRESET_SATS = [210, 420, 2100, 4200, 21_000] as const + +/** Invoice quick-pick with coffee tip affordance (wider preset button). */ +export const LN_INVOICE_COFFEE_PRESET_SATS = 4200 /** Leading digit group + BTC hint styling above this amount (exclusive). */ export const ZAP_SATS_HIGHLIGHT_ABOVE = 999_999 diff --git a/src/lib/merge-payment-methods.ts b/src/lib/merge-payment-methods.ts index b38e1af7..55a9418a 100644 --- a/src/lib/merge-payment-methods.ts +++ b/src/lib/merge-payment-methods.ts @@ -1,11 +1,12 @@ -import { getPaymentInfoFromEvent } from '@/lib/event-metadata' +import { getPaymentInfoFromEvent, getProfileFromEvent } from '@/lib/event-metadata' import { buildPaytoUri, getCanonicalPaytoType, getPaytoEditorTypeLabel, getPaytoTypeInfo, isKnownPaytoType, - isLightningPaytoType + isLightningPaytoType, + isZappableLightningPaytoType } from '@/lib/payto' import { extractKind0PaymentMethodsFromProfileJson } from '@/lib/payto-kind0-import' import { normalizePaypalAuthority } from '@/lib/payto-paypal-url' @@ -567,3 +568,33 @@ export function groupPaymentMethodsForDisplay( ) } +/** Lightning payto authorities from kind 0 / 10133 in merge order (NIP-57 candidates). */ +export function buildOrderedZapLightningAddresses(opts: { + profileEvent?: Event | null + profile?: TProfile | null + paymentInfo: ReturnType | null + preferredAddress?: string | null +}): string[] { + const ev = opts.profileEvent + const profile = + ev?.kind === kinds.Metadata ? getProfileFromEvent(ev) : (opts.profile ?? null) + + const addrs = mergePaymentMethods(opts.paymentInfo, profile, ev) + .filter((m) => isZappableLightningPaytoType(m.type)) + .map((m) => m.authority) + + return prioritizeZapLightningAddress(addrs, opts.preferredAddress ?? undefined) +} + +/** Move `preferred` to the front when present; append if not already listed. */ +export function prioritizeZapLightningAddress(candidates: string[], preferred?: string): string[] { + if (!preferred?.trim()) return candidates + const norm = normalizePaymentAuthority('lightning', preferred) + const idx = candidates.findIndex((c) => normalizePaymentAuthority('lightning', c) === norm) + if (idx === -1) { + return [resolveLightningAuthority(preferred.trim()), ...candidates] + } + const rest = candidates.filter((_, i) => i !== idx) + return [candidates[idx], ...rest] +} + diff --git a/src/lib/nip57-zap.ts b/src/lib/nip57-zap.ts new file mode 100644 index 00000000..39f3cd50 --- /dev/null +++ b/src/lib/nip57-zap.ts @@ -0,0 +1,45 @@ +import { ExtendedKind } from '@/constants' +import { getReplaceableCoordinateFromEvent } from '@/lib/event' +import { bech32 } from '@scure/base' +import { kinds, type Event, type NostrEvent } from 'nostr-tools' +import { utf8Encoder } from 'nostr-tools/utils' + +export type ZapRequestTarget = + | { pubkey: string; event?: undefined } + | { pubkey: string; event: NostrEvent } + +export function encodeLnurlBech32(url: string): string { + const trimmed = url.trim() + if (trimmed.toLowerCase().startsWith('lnurl')) return trimmed + const words = bech32.toWords(utf8Encoder.encode(trimmed)) + return bech32.encode('lnurl', words, 1024) +} + +/** Unsigned kind 9734 draft for signing via the active nostr signer. */ +export function buildZapRequestDraft(opts: { + target: ZapRequestTarget + amountMsat: number + lnurlBech32: string + relays: string[] + comment?: string +}): Event { + const { target, amountMsat, lnurlBech32, relays, comment = '' } = opts + const tags: string[][] = [['relays', ...relays], ['amount', String(amountMsat)], ['lnurl', lnurlBech32], ['p', target.pubkey]] + + if (target.event) { + if (kinds.isAddressableKind(target.event.kind)) { + tags.push(['a', getReplaceableCoordinateFromEvent(target.event)]) + } else { + tags.push(['e', target.event.id]) + } + tags.push(['k', String(target.event.kind)]) + } + + return { + kind: ExtendedKind.ZAP_REQUEST, + content: comment, + tags, + created_at: Math.floor(Date.now() / 1000), + pubkey: '' + } as Event +} diff --git a/src/lib/post-payment-context.ts b/src/lib/post-payment-context.ts index c8fa9abb..a8e819ab 100644 --- a/src/lib/post-payment-context.ts +++ b/src/lib/post-payment-context.ts @@ -9,6 +9,8 @@ export type PostPaymentContext = { amountMsat?: number /** payto tag value without the `payto://` prefix. */ payto?: string + /** Pre-fill kind 9740 message (e.g. LNURL-pay invoice description). */ + messageDraft?: string /** Thread or wall reference for superchat placement. */ referencedEvent?: NostrEvent } @@ -21,6 +23,7 @@ type BuildPostPaymentContextParams = { paytoUri?: string paytoType?: string paytoAuthority?: string + messageDraft?: string referencedEvent?: NostrEvent } @@ -37,6 +40,7 @@ export function buildPostPaymentContext(params: BuildPostPaymentContextParams): recipientPubkey: params.recipientPubkey, amountMsat: params.amountMsat, payto, + messageDraft: params.messageDraft?.trim() || undefined, referencedEvent: params.referencedEvent } } diff --git a/src/lib/wallet-connection-details.ts b/src/lib/wallet-connection-details.ts index 1129685b..3f0d9637 100644 --- a/src/lib/wallet-connection-details.ts +++ b/src/lib/wallet-connection-details.ts @@ -4,22 +4,34 @@ export type TWalletConnectionDetails = { connectorName: string connectorType: string nwcRelayUrl: string | null + /** `lud16` query param on the NWC connection URI, when present. */ + nwcLud16FromUrl: string | null } -/** Parse `relay=` from a nostr+walletconnect:// connection string. */ -export function parseNwcRelayUrl(nwcUrl: string): string | null { +function nwcUrlAsHttpUrl(nwcUrl: string): URL | null { try { const httpLike = nwcUrl .replace(/^nostr\+walletconnect:\/\//i, 'http://') .replace(/^nostrwalletconnect:\/\//i, 'http://') .replace(/^nostr\+walletconnect:/i, 'http://') .replace(/^nostrwalletconnect:/i, 'http://') - return new URL(httpLike).searchParams.get('relay') + return new URL(httpLike) } catch { return null } } +/** Parse `relay=` from a nostr+walletconnect:// connection string. */ +export function parseNwcRelayUrl(nwcUrl: string): string | null { + return nwcUrlAsHttpUrl(nwcUrl)?.searchParams.get('relay') ?? null +} + +/** Parse optional `lud16=` from a nostr+walletconnect:// connection string. */ +export function parseNwcLud16FromUrl(nwcUrl: string): string | null { + const lud16 = nwcUrlAsHttpUrl(nwcUrl)?.searchParams.get('lud16')?.trim() + return lud16 || null +} + /** Read the active Bitcoin Connect wallet config (from localStorage-backed store). */ export function getBitcoinConnectWalletDetails(): TWalletConnectionDetails | null { const config = getConnectorConfig() @@ -27,6 +39,7 @@ export function getBitcoinConnectWalletDetails(): TWalletConnectionDetails | nul return { connectorName: config.connectorName, connectorType: config.connectorType, - nwcRelayUrl: config.nwcUrl ? parseNwcRelayUrl(config.nwcUrl) : null + nwcRelayUrl: config.nwcUrl ? parseNwcRelayUrl(config.nwcUrl) : null, + nwcLud16FromUrl: config.nwcUrl ? parseNwcLud16FromUrl(config.nwcUrl) : null } } diff --git a/src/lib/webln-payment.ts b/src/lib/webln-payment.ts index 21d30456..c92893e7 100644 --- a/src/lib/webln-payment.ts +++ b/src/lib/webln-payment.ts @@ -11,12 +11,40 @@ function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } +type NwcClientLike = { + getInfo?: () => Promise<{ lud16?: string; lud06?: string }> +} + +/** NIP-47 `get_info` may expose the wallet’s lud16; WebLN `getInfo()` often omits it. */ +export async function resolveWalletLightningAddress( + provider: WebLNProvider, + info?: GetInfoResponse | null +): Promise { + const extended = (info ?? null) as (GetInfoResponse & { lud16?: string; lud06?: string }) | null + if (extended?.lud16?.trim()) return extended.lud16.trim() + if (extended?.lud06?.trim()) return extended.lud06.trim() + + const client = (provider as WebLNProvider & { client?: NwcClientLike }).client + if (!client?.getInfo) return null + + try { + const nip47 = await client.getInfo() + if (nip47.lud16?.trim()) return nip47.lud16.trim() + if (nip47.lud06?.trim()) return nip47.lud06.trim() + } catch { + /* wallet did not report a receive address */ + } + return null +} + /** Enable WebLN and load wallet info so NWC encryption is negotiated before paying. */ export async function prepareConnectedWebLNProvider( provider: WebLNProvider -): Promise { +): Promise<{ info: GetInfoResponse; walletLightningAddress: string | null }> { await provider.enable() - return provider.getInfo() + const info = await provider.getInfo() + const walletLightningAddress = await resolveWalletLightningAddress(provider, info) + return { info, walletLightningAddress } } export async function sendWebLNPaymentWithRetry( diff --git a/src/pages/secondary/WalletPage/IncludePublicZapReceiptSwitch.tsx b/src/pages/secondary/WalletPage/IncludePublicZapReceiptSwitch.tsx new file mode 100644 index 00000000..f39b3f23 --- /dev/null +++ b/src/pages/secondary/WalletPage/IncludePublicZapReceiptSwitch.tsx @@ -0,0 +1,25 @@ +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' +import { useZap } from '@/providers/ZapProvider' +import { useTranslation } from 'react-i18next' + +export default function IncludePublicZapReceiptSwitch() { + const { t } = useTranslation() + const { includePublicZapReceipt, updateIncludePublicZapReceipt } = useZap() + + return ( +

+ + +
+ ) +} diff --git a/src/pages/secondary/WalletPage/LightningAddressInput.tsx b/src/pages/secondary/WalletPage/LightningAddressInput.tsx index fb6deece..fd7ad4c6 100644 --- a/src/pages/secondary/WalletPage/LightningAddressInput.tsx +++ b/src/pages/secondary/WalletPage/LightningAddressInput.tsx @@ -52,7 +52,7 @@ export default function LightningAddressInput() { return (
- +
{ if (!details.nwcRelayUrl) return navigator.clipboard.writeText(details.nwcRelayUrl) toast.success(t('Copied to clipboard')) } + const copyAddress = () => { + if (!connectedWalletAddress) return + navigator.clipboard.writeText(connectedWalletAddress) + toast.success(t('Copied to clipboard')) + } + return (
@@ -31,6 +40,32 @@ export default function WalletConnectionDetails() { ({details.connectorType})

+
+

+ {t('Connected wallet address')} +

+ {connectedWalletAddress ? ( +
+

+ {connectedWalletAddress} +

+ +
+ ) : ( +

+ {t('This wallet did not report a Lightning address.')} +

+ )} +

{t('NWC relay')} diff --git a/src/pages/secondary/WalletPage/WalletZapSendingSettings.tsx b/src/pages/secondary/WalletPage/WalletZapSendingSettings.tsx index b7bd05bd..d0c372ce 100644 --- a/src/pages/secondary/WalletPage/WalletZapSendingSettings.tsx +++ b/src/pages/secondary/WalletPage/WalletZapSendingSettings.tsx @@ -14,6 +14,8 @@ import { useZap } from '@/providers/ZapProvider' import { disconnect, launchModal } from '@getalby/bitcoin-connect-react' import { useTranslation } from 'react-i18next' import DefaultZapAmountInput from './DefaultZapAmountInput' +import DefaultZapCommentInput from './DefaultZapCommentInput' +import IncludePublicZapReceiptSwitch from './IncludePublicZapReceiptSwitch' import WalletConnectionDetails from './WalletConnectionDetails' export default function WalletZapSendingSettings() { @@ -51,6 +53,8 @@ export default function WalletZapSendingSettings() {

+ + ) } diff --git a/src/providers/ZapProvider.tsx b/src/providers/ZapProvider.tsx index d54360b1..360b48f3 100644 --- a/src/providers/ZapProvider.tsx +++ b/src/providers/ZapProvider.tsx @@ -12,6 +12,8 @@ type TZapContext = { isWalletConnected: boolean provider: WebLNProvider | null walletInfo: GetInfoResponse | null + /** lud16/lud06 reported by the connected NWC wallet, when available. */ + walletLightningAddress: string | null defaultZapSats: number updateDefaultSats: (sats: number) => void defaultZapComment: string @@ -43,6 +45,7 @@ export function ZapProvider({ children }: { children: React.ReactNode }) { const [isWalletConnected, setIsWalletConnected] = useState(false) const [provider, setProvider] = useState(null) const [walletInfo, setWalletInfo] = useState(null) + const [walletLightningAddress, setWalletLightningAddress] = useState(null) useEffect(() => { if (!LIGHTNING_WALLET_PAY_ENABLED) return @@ -50,11 +53,13 @@ export function ZapProvider({ children }: { children: React.ReactNode }) { const unSubOnConnected = onConnected((provider) => { setIsWalletConnected(false) setWalletInfo(null) + setWalletLightningAddress(null) void prepareConnectedWebLNProvider(provider) - .then((info) => { + .then(({ info, walletLightningAddress }) => { setProvider(provider) lightningService.provider = provider setWalletInfo(info) + setWalletLightningAddress(walletLightningAddress) setIsWalletConnected(true) }) .catch((error) => { @@ -68,6 +73,8 @@ export function ZapProvider({ children }: { children: React.ReactNode }) { const unSubOnDisconnected = onDisconnected(() => { setIsWalletConnected(false) setProvider(null) + setWalletInfo(null) + setWalletLightningAddress(null) lightningService.provider = null }) @@ -103,6 +110,7 @@ export function ZapProvider({ children }: { children: React.ReactNode }) { isWalletConnected, provider, walletInfo, + walletLightningAddress, defaultZapSats, updateDefaultSats, defaultZapComment, diff --git a/src/services/lightning.service.ts b/src/services/lightning.service.ts index 0eb97f85..a5796d12 100644 --- a/src/services/lightning.service.ts +++ b/src/services/lightning.service.ts @@ -1,26 +1,33 @@ import { CODY_PUBKEY, FAST_READ_RELAY_URLS, + FAST_WRITE_RELAY_URLS, IMWALD_MAINTAINER_PUBKEY } from '@/constants' -import { getZapInfoFromEvent } from '@/lib/event-metadata' +import { getProfileFromEvent, getZapInfoFromEvent } from '@/lib/event-metadata' import { closeModal, init, launchPaymentModal } from '@getalby/bitcoin-connect-react' import { isNwcWalletServiceInfoError, sendWebLNPaymentWithRetry } from '@/lib/webln-payment' +import { Invoice } from '@getalby/lightning-tools' import { bech32 } from '@scure/base' import { WebLNProvider } from '@webbtc/webln-types' import dayjs from 'dayjs' -import { kinds, NostrEvent } from 'nostr-tools' +import { Filter, kinds, NostrEvent } from 'nostr-tools' +import { SubCloser } from 'nostr-tools/abstract-pool' import { utf8Decoder } from 'nostr-tools/utils' -import { queryService } from './client.service' +import client, { queryService, replaceableEventService } from './client.service' import { clampZapSats } from '@/lib/lightning' import { fetchWithTimeout } from '@/lib/fetch-with-timeout' import { buildLnurlPayCallbackUrl, parseLnurlCommentAllowed } from '@/lib/lnurl-pay' +import { buildZapRequestDraft, encodeLnurlBech32 } from '@/lib/nip57-zap' +import { prioritizeZapLightningAddress } from '@/lib/merge-payment-methods' import logger from '@/lib/logger' import { runAfterReleasingRadixScrollLock } from '@/lib/react-remove-scroll-body-cleanup' +import storage from '@/services/local-storage.service' +import { TProfile } from '@/types' export type TRecentSupporter = { pubkey: string; amount: number; comment?: string } @@ -65,14 +72,143 @@ class LightningService { onPaymentFlowComplete?: (result: PaymentFlowResult) => void, zapLightning?: { address?: string; candidates?: string[] } ): Promise { - void sender - void recipientOrEvent - void sats - void comment - void closeOuterModel - void onPaymentFlowComplete - void zapLightning - throw new Error('NIP-57 zaps are not supported; use payment targets or LNURL-pay invoices') + if (!client.signer) { + throw new Error('You need to be logged in to zap') + } + const clampedSats = clampZapSats(sats) + if (clampedSats < 1) { + throw new Error('Amount must be at least 1 sat') + } + + const { recipient, event } = + typeof recipientOrEvent === 'string' + ? { recipient: recipientOrEvent } + : { recipient: recipientOrEvent.pubkey, event: recipientOrEvent } + + const [profile, senderRelayList] = await Promise.all([ + (async () => { + const profileEvent = await replaceableEventService.fetchReplaceableEvent(recipient, kinds.Metadata) + return profileEvent ? getProfileFromEvent(profileEvent) : undefined + })(), + sender + ? client.fetchRelayList(sender) + : Promise.resolve({ read: FAST_READ_RELAY_URLS, write: FAST_WRITE_RELAY_URLS }) + ]) + if (!profile) { + throw new Error('Recipient not found') + } + + const zapEndpoint = await this.getZapEndpoint(profile, zapLightning) + if (!zapEndpoint) { + throw new Error("Recipient's lightning address does not support NIP-57 zaps") + } + const { callback, lnurlBech32 } = zapEndpoint + const amount = clampedSats * 1000 + const relays = storage.getIncludePublicZapReceipt() + ? senderRelayList.write.concat(FAST_READ_RELAY_URLS).slice(0, 8) + : [] + + const zapRequestDraft = buildZapRequestDraft({ + target: event ? { pubkey: recipient, event } : { pubkey: recipient }, + amountMsat: amount, + lnurlBech32, + relays, + comment + }) + const zapRequest = await client.signer.signEvent(zapRequestDraft) + const zapRequestUrl = buildLnurlPayCallbackUrl(callback, { + amount: String(amount), + nostr: JSON.stringify(zapRequest), + lnurl: lnurlBech32 + }) + const zapRequestRes = await fetchWithTimeout(zapRequestUrl, { timeoutMs: 25_000 }) + const zapRequestResBody = (await zapRequestRes.json()) as { + pr?: string + verify?: string + reason?: string + error?: string + message?: string + } + if (zapRequestResBody.error) { + throw new Error(zapRequestResBody.message ?? String(zapRequestResBody.error)) + } + const { pr, verify, reason } = zapRequestResBody + if (!pr) { + throw new Error(reason ?? 'Failed to create invoice') + } + + if (this.provider) { + try { + const { preimage } = await sendWebLNPaymentWithRetry(this.provider, pr) + closeOuterModel?.() + const result = { preimage, invoice: pr } + onPaymentFlowComplete?.(result) + return result + } catch (error) { + if (!isNwcWalletServiceInfoError(error)) { + throw error + } + } + } + + return new Promise((resolve) => { + runAfterReleasingRadixScrollLock(closeOuterModel, () => { + closeModal() + let checkPaymentInterval: ReturnType | undefined + let subCloser: SubCloser | undefined + const finish = (result: PaymentFlowResult) => { + clearInterval(checkPaymentInterval) + subCloser?.close() + onPaymentFlowComplete?.(result) + resolve(result) + } + const { setPaid } = launchPaymentModal({ + invoice: pr, + onPaid: (response) => { + finish({ preimage: response.preimage, invoice: pr }) + }, + onCancelled: () => { + finish(null) + } + }) + + if (verify) { + checkPaymentInterval = setInterval(async () => { + const invoice = new Invoice({ pr, verify }) + const paid = await invoice.verifyPayment() + + if (paid && invoice.preimage) { + setPaid({ + preimage: invoice.preimage + }) + } + }, 1000) + } else { + const filter: Filter = { + kinds: [kinds.Zap], + '#p': [recipient], + since: dayjs().subtract(1, 'minute').unix() + } + if (event) { + filter['#e'] = [event.id] + } + subCloser = client.subscribe( + senderRelayList.write.concat(FAST_READ_RELAY_URLS).slice(0, 4), + filter, + { + onevent: (evt) => { + const info = getZapInfoFromEvent(evt) + if (!info) return + + if (info.invoice === pr) { + setPaid({ preimage: info.preimage ?? '' }) + } + } + } + ) + } + }) + }) } async payInvoice( @@ -209,6 +345,57 @@ class LightningService { return body.pr } + private async getZapEndpoint( + profile: TProfile, + zapLightning?: { address?: string; candidates?: string[] } + ): Promise { + const candidates = zapLightning?.candidates?.length + ? prioritizeZapLightningAddress(zapLightning.candidates, zapLightning.address) + : this.lightningAddressCandidates(profile, zapLightning?.address) + for (const addr of candidates) { + const resolved = await this.fetchLnurlPayZapEndpoint(addr) + if (resolved) return resolved + } + return null + } + + /** Ordered lightning identifiers from kind 0 (lud16/lud06 + `w` lightning rows); de-duplicated. */ + private lightningAddressCandidates(profile: TProfile, preferredFirst?: string): string[] { + const raw = + profile.lightningAddressList?.length && profile.lightningAddressList.length > 0 + ? profile.lightningAddressList + : profile.lightningAddress + ? [profile.lightningAddress] + : [] + const out: string[] = [] + const seen = new Set() + for (const a of raw) { + const t = a?.trim() + if (!t) continue + const k = t.toLowerCase() + if (seen.has(k)) continue + seen.add(k) + out.push(t) + } + return prioritizeZapLightningAddress(out, preferredFirst) + } + + private async fetchLnurlPayZapEndpoint(lightningAddress: string): Promise { + const meta = await this.resolveLnurlPayMetadata(lightningAddress) + if (!meta?.allowsNostr || !meta.nostrPubkey) return null + const trimmed = lightningAddress.trim() + const lnurlBech32 = trimmed.toLowerCase().startsWith('lnurl') + ? trimmed + : encodeLnurlBech32(meta.lnurl) + return { callback: meta.callback, lnurlBech32 } + } + private async resolveLnurlPayMetadata(lightningAddress: string): Promise