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.
This commit is contained in:
Claude
2026-06-09 22:49:32 +00:00
parent 11891ace8c
commit 503f33eb2e
4 changed files with 171 additions and 6 deletions
@@ -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<String?>(null) }
var payingInvoice by remember { mutableStateOf<String?>(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)
}
@@ -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)) }
},
)
}
@@ -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<String?>(null) }
var payingInvoice by remember { mutableStateOf<String?>(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(
+3
View File
@@ -115,6 +115,9 @@
<string name="clink_lightning_offer">Lightning Offer</string>
<string name="clink_requesting_invoice">Requesting invoice…</string>
<string name="clink_debit_no_response">The debit service did not complete the payment.</string>
<string name="clink_confirm_payment_title">Confirm payment</string>
<string name="clink_confirm_pay_amount_via_source">Pay %1$s via %2$s?</string>
<string name="clink_confirm_pay_via_source">Pay this invoice via %1$s?</string>
<string name="clink_debit_pay_only">Pay only</string>
<string name="wallet_add_clink_title">CLINK Debit</string>
<string name="wallet_add_clink_description">Pay and zap from a wallet that pre-authorized your account. Spend only — no balance or history.</string>