mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 16:14:40 +00:00
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:
+11
-3
@@ -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)
|
||||
}
|
||||
|
||||
+146
@@ -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)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
+11
-3
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user