From 503f33eb2ee4ec4632314e53e2d2073c2123c0bb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 22:49:32 +0000 Subject: [PATCH] feat(clink): pay offer/invoice cards via default source, confirmed in-app In-post payment cards now route their 'Pay' button through the selected default payment source instead of always opening an external wallet: - New shared InvoicePaymentDispatcher: resolves defaultPaymentSource() for a bolt11. External-wallet path fires the intent (the wallet app confirms); NWC and CLINK-debit paths show a ConfirmPaymentDialog first, because a card pay (unlike a deliberate small zap tap) can be a larger/variable amount. - ClinkOfferPreview and InvoicePreview both drive it via a pending-invoice state; InvoicePreview now takes accountViewModel. NWC-only and intent-only users are unaffected. :amethyst compiles; the in-app pay paths remain untested end-to-end. --- .../creators/invoice/ClinkOfferPreview.kt | 14 +- .../invoice/InvoicePaymentDispatcher.kt | 146 ++++++++++++++++++ .../note/creators/invoice/InvoicePreview.kt | 14 +- amethyst/src/main/res/values/strings.xml | 3 + 4 files changed, 171 insertions(+), 6 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePaymentDispatcher.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt index 30ef83f6cc..fbd5a617b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt @@ -50,7 +50,6 @@ import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons import com.vitorpamplona.amethyst.commons.hashtags.Lightning import com.vitorpamplona.amethyst.service.ClinkOfferPayer import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog -import com.vitorpamplona.amethyst.ui.note.payViaIntent import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness @@ -63,7 +62,8 @@ import kotlinx.coroutines.launch /** * Inline card for a CLINK Offers pointer (`noffer1…`) found in a note. Tapping "Pay" * runs the offer round-trip ([ClinkOfferPayer]) to fetch a fresh BOLT-11 over Nostr, - * then hands it to the existing wallet intent. + * then pays it through the user's default payment source (confirmed for in-app wallets, + * see [InvoicePaymentDispatcher]). */ @Composable fun ClinkOfferPreview( @@ -75,6 +75,7 @@ fun ClinkOfferPreview( var requesting by remember { mutableStateOf(false) } var errorMessage by remember { mutableStateOf(null) } + var payingInvoice by remember { mutableStateOf(null) } errorMessage?.let { ErrorMessageDialog( @@ -84,6 +85,13 @@ fun ClinkOfferPreview( ) } + InvoicePaymentDispatcher( + bolt11 = payingInvoice, + accountViewModel = accountViewModel, + onClear = { payingInvoice = null }, + onError = { errorMessage = it }, + ) + Column( modifier = Modifier @@ -148,7 +156,7 @@ fun ClinkOfferPreview( val bolt11 = response?.bolt11 when { - bolt11 != null -> payViaIntent(bolt11, context, { }) { errorMessage = it } + bolt11 != null -> payingInvoice = bolt11 response?.error != null -> errorMessage = response.error else -> errorMessage = stringRes(context, R.string.error_dialog_pay_invoice_error) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePaymentDispatcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePaymentDispatcher.kt new file mode 100644 index 0000000000..9115b3fbb7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePaymentDispatcher.kt @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.creators.invoice + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource +import com.vitorpamplona.amethyst.ui.note.payViaIntent +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.lightning.LnInvoiceUtil +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse + +/** + * Pays a single BOLT-11 from an in-post card (offer card, invoice card) through the + * user's selected default payment source. + * + * Unlike the zap button — a deliberate small-amount tap that fires immediately — a + * card "Pay" can be a larger or variable amount, so an **in-app** payment (NWC or CLINK + * debit) is gated behind a confirmation dialog. The external-wallet path needs no extra + * confirmation: the wallet app presents its own. + * + * Drive it from a nullable `bolt11` state: set it to trigger, [onClear] resets it. + */ +@Composable +fun InvoicePaymentDispatcher( + bolt11: String?, + accountViewModel: AccountViewModel, + onClear: () -> Unit, + onError: (String) -> Unit, + onSuccess: () -> Unit = {}, +) { + if (bolt11 == null) return + val context = LocalContext.current + + val source = remember(bolt11) { accountViewModel.account.settings.defaultPaymentSource() } + + if (source == null) { + // No in-app wallet configured -> hand off to an external wallet app (it confirms). + LaunchedEffect(bolt11) { + payViaIntent(bolt11, context, onSuccess, onError) + onClear() + } + return + } + + val amountSats = + remember(bolt11) { + try { + LnInvoiceUtil.getAmountInSats(bolt11).toLong().takeIf { it > 0 } + } catch (_: Exception) { + null + } + } + + ConfirmPaymentDialog( + amountSats = amountSats, + sourceName = source.name, + onConfirm = { + when (source) { + is PaymentSource.Nwc -> + accountViewModel.sendZapPaymentRequestFor(bolt11, null) { response -> + when (response) { + is PayInvoiceSuccessResponse -> onSuccess() + is PayInvoiceErrorResponse -> + onError( + response.error?.message + ?: response.error?.code?.toString() + ?: stringRes(context, R.string.error_parsing_error_message), + ) + else -> {} + } + } + + is PaymentSource.ClinkDebit -> + accountViewModel.payInvoiceViaClinkDebit(source.wallet.pointer, bolt11) { response -> + if (response?.isOk() == true) { + onSuccess() + } else { + onError( + response?.error?.takeIf { it.isNotBlank() } + ?: stringRes(context, R.string.clink_debit_no_response), + ) + } + } + } + onClear() + }, + onDismiss = onClear, + ) +} + +@Composable +private fun ConfirmPaymentDialog( + amountSats: Long?, + sourceName: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val message = + if (amountSats != null) { + val amountText = "$amountSats ${stringRes(context, R.string.sats)}" + stringRes(context, R.string.clink_confirm_pay_amount_via_source, amountText, sourceName) + } else { + stringRes(context, R.string.clink_confirm_pay_via_source, sourceName) + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(R.string.clink_confirm_payment_title)) }, + text = { Text(message) }, + confirmButton = { + Button(onClick = onConfirm) { Text(stringRes(R.string.pay)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePreview.kt index ae6c23419f..23b5be1417 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePreview.kt @@ -54,7 +54,6 @@ import com.vitorpamplona.amethyst.service.lnurl.CachedLnInvoiceParser import com.vitorpamplona.amethyst.service.lnurl.InvoiceAmount import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog -import com.vitorpamplona.amethyst.ui.note.payViaIntent import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness @@ -89,7 +88,7 @@ fun MayBeInvoicePreview( LoadValueFromInvoice(lnbcWord = lnbcWord) { invoiceAmount -> CrossfadeIfEnabled(targetState = invoiceAmount, label = "MayBeInvoicePreview", accountViewModel = accountViewModel) { if (it != null) { - InvoicePreview(it.invoice, it.amount) + InvoicePreview(it.invoice, it.amount, accountViewModel) } else { Text( text = lnbcWord, @@ -104,10 +103,12 @@ fun MayBeInvoicePreview( fun InvoicePreview( lnInvoice: String, amount: String?, + accountViewModel: AccountViewModel, ) { val context = LocalContext.current var showErrorMessageDialog by remember { mutableStateOf(null) } + var payingInvoice by remember { mutableStateOf(null) } if (showErrorMessageDialog != null) { ErrorMessageDialog( @@ -117,6 +118,13 @@ fun InvoicePreview( ) } + InvoicePaymentDispatcher( + bolt11 = payingInvoice, + accountViewModel = accountViewModel, + onClear = { payingInvoice = null }, + onError = { showErrorMessageDialog = it }, + ) + Column( modifier = Modifier @@ -172,7 +180,7 @@ fun InvoicePreview( Modifier .fillMaxWidth() .padding(vertical = 10.dp), - onClick = { payViaIntent(lnInvoice, context, { }) { showErrorMessageDialog = it } }, + onClick = { payingInvoice = lnInvoice }, shape = QuoteBorder, colors = ButtonDefaults.buttonColors( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 7b05d87c4c..14507ecc85 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -115,6 +115,9 @@ Lightning Offer Requesting invoice… The debit service did not complete the payment. + Confirm payment + Pay %1$s via %2$s? + Pay this invoice via %1$s? Pay only CLINK Debit Pay and zap from a wallet that pre-authorized your account. Spend only — no balance or history.