mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
feat(bolt12): pay a recipient's offer in-app over NWC
Adds a "pay with connected wallet" action to the BOLT12 offer dialog on a profile: when the account has a NIP-47 wallet configured, an amount sheet collects sats and settles the offer over the wallet via the nwc#2 `pay` method (BIP321 bitcoin:?lno=). Falls back to the external bitcoin: intent otherwise. Outcome is surfaced as a toast. This is a plain payment, not a NIP-XX zap (no receipt) — that's the deferred Phase 2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
This commit is contained in:
+28
@@ -163,6 +163,9 @@ import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.IErrorResponseLike
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaySuccessResponse
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
|
||||
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||
@@ -1201,6 +1204,31 @@ class AccountViewModel(
|
||||
)
|
||||
}
|
||||
|
||||
/** True when the account has at least one NIP-47 wallet configured. */
|
||||
fun hasNwcWallet(): Boolean =
|
||||
account.settings.nwcWallets.value
|
||||
.isNotEmpty()
|
||||
|
||||
/**
|
||||
* Pays a recipient's BOLT12 [offer] over the default NWC wallet using the nwc#2
|
||||
* `pay` method, wrapping it as a BIP321 `bitcoin:?lno=` instruction. This is a
|
||||
* plain payment, not a NIP-XX zap (no Nostr receipt); the outcome is surfaced as
|
||||
* a toast. Callers should gate on [hasNwcWallet].
|
||||
*/
|
||||
fun payBolt12OfferViaNwc(
|
||||
offer: String,
|
||||
amountMillisats: Long,
|
||||
) = launchSigner {
|
||||
account.sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats)) { response ->
|
||||
when (response) {
|
||||
is PaySuccessResponse -> toastManager.toast(R.string.bolt12_offers, R.string.bolt12_payment_sent)
|
||||
is IErrorResponseLike ->
|
||||
toastManager.toast(R.string.bolt12_offers, R.string.bolt12_payment_failed, response.errorMessage() ?: "")
|
||||
else -> toastManager.toast(R.string.bolt12_offers, R.string.bolt12_payment_failed, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a Podcasting-2.0 value-for-value split for [totalSats] sats: pays every recipient in
|
||||
* the show/episode's [PodcastValue] block their weighted share (lnaddress via LNURL-pay, node via
|
||||
|
||||
+100
-5
@@ -21,14 +21,21 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -41,8 +48,10 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
@@ -56,6 +65,7 @@ import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.note.payViaBolt12Intent
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.ZeroPadding
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent
|
||||
@@ -80,14 +90,17 @@ fun Bolt12PayButton(
|
||||
event?.offers() ?: emptyList()
|
||||
}
|
||||
if (offers.isNotEmpty()) {
|
||||
Bolt12PayButtonWithOffers(offers)
|
||||
Bolt12PayButtonWithOffers(offers, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Bolt12PayButtonWithOffers(offers: List<String>) {
|
||||
fun Bolt12PayButtonWithOffers(
|
||||
offers: List<String>,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
FilledTonalButton(
|
||||
@@ -107,6 +120,7 @@ fun Bolt12PayButtonWithOffers(offers: List<String>) {
|
||||
if (expanded) {
|
||||
Bolt12OffersDialog(
|
||||
offers = offers,
|
||||
accountViewModel = accountViewModel,
|
||||
onDismiss = { expanded = false },
|
||||
)
|
||||
}
|
||||
@@ -115,12 +129,20 @@ fun Bolt12PayButtonWithOffers(offers: List<String>) {
|
||||
@Composable
|
||||
fun Bolt12OffersDialog(
|
||||
offers: List<String>,
|
||||
accountViewModel: AccountViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val clipboardManager = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Whether we can settle in-app over the user's NIP-47 wallet (nwc#2 `pay`), or
|
||||
// must hand off to an external wallet via a bitcoin: intent.
|
||||
val canPayInApp = remember { accountViewModel.hasNwcWallet() }
|
||||
|
||||
// The offer the user chose to pay over NWC; drives the amount-entry dialog.
|
||||
var nwcPayOffer by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
M3ActionDialog(
|
||||
title = stringRes(R.string.bolt12_offers),
|
||||
onDismiss = onDismiss,
|
||||
@@ -129,6 +151,7 @@ fun Bolt12OffersDialog(
|
||||
offers.forEach { offer ->
|
||||
Bolt12OfferRow(
|
||||
offer = offer,
|
||||
canPayInApp = canPayInApp,
|
||||
onCopy = {
|
||||
scope.launch {
|
||||
clipboardManager.setText(offer)
|
||||
@@ -140,7 +163,7 @@ fun Bolt12OffersDialog(
|
||||
).show()
|
||||
}
|
||||
},
|
||||
onPay = {
|
||||
onPayViaIntent = {
|
||||
payViaBolt12Intent(
|
||||
offer = offer,
|
||||
context = context,
|
||||
@@ -150,17 +173,31 @@ fun Bolt12OffersDialog(
|
||||
},
|
||||
)
|
||||
},
|
||||
onPayInApp = { nwcPayOffer = offer },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nwcPayOffer?.let { offer ->
|
||||
Bolt12NwcAmountDialog(
|
||||
onDismiss = { nwcPayOffer = null },
|
||||
onPay = { amountMillisats ->
|
||||
accountViewModel.payBolt12OfferViaNwc(offer, amountMillisats)
|
||||
nwcPayOffer = null
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Bolt12OfferRow(
|
||||
offer: String,
|
||||
canPayInApp: Boolean,
|
||||
onCopy: () -> Unit,
|
||||
onPay: () -> Unit,
|
||||
onPayViaIntent: () -> Unit,
|
||||
onPayInApp: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -187,7 +224,17 @@ private fun Bolt12OfferRow(
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onPay) {
|
||||
if (canPayInApp) {
|
||||
IconButton(onClick = onPayInApp) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.AccountBalanceWallet,
|
||||
contentDescription = stringRes(R.string.bolt12_pay_with_wallet),
|
||||
modifier = Size20Modifier,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onPayViaIntent) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Bolt,
|
||||
contentDescription = stringRes(R.string.bolt12_offers),
|
||||
@@ -197,3 +244,51 @@ private fun Bolt12OfferRow(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Collects an amount (in sats) and returns it to [onPay] in millisats. */
|
||||
@Composable
|
||||
private fun Bolt12NwcAmountDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onPay: (amountMillisats: Long) -> Unit,
|
||||
) {
|
||||
var sats by remember { mutableStateOf("") }
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.bolt12_pay_with_wallet),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = sats,
|
||||
onValueChange = { new -> sats = new.filter { it.isDigit() } },
|
||||
label = { Text(text = stringRes(R.string.bolt12_payment_amount_sats)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
|
||||
Button(
|
||||
onClick = {
|
||||
val amountSats = sats.toLongOrNull()
|
||||
if (amountSats != null && amountSats > 0) {
|
||||
onPay(amountSats * 1000)
|
||||
}
|
||||
},
|
||||
shape = ButtonBorder,
|
||||
enabled = (sats.toLongOrNull() ?: 0) > 0,
|
||||
) {
|
||||
Text(text = stringRes(R.string.bolt12_pay_with_wallet))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1725,6 +1725,10 @@
|
||||
<string name="bolt12_offer">BOLT12 offer (lno1…)</string>
|
||||
<string name="invalid_bolt12_offer">Not a valid BOLT12 offer</string>
|
||||
<string name="delete_bolt12_offer">Delete BOLT12 offer</string>
|
||||
<string name="bolt12_pay_with_wallet">Pay with connected wallet</string>
|
||||
<string name="bolt12_payment_amount_sats">Amount (sats)</string>
|
||||
<string name="bolt12_payment_sent">BOLT12 payment sent</string>
|
||||
<string name="bolt12_payment_failed">BOLT12 payment failed: %1$s</string>
|
||||
|
||||
<string name="uploading_state_ready">Not Started</string>
|
||||
<string name="uploading_state_compressing">Compressing</string>
|
||||
|
||||
Reference in New Issue
Block a user