From 4936d187fe71f932b964b690afa09a263b467ae8 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Sat, 23 May 2026 15:17:08 +0300 Subject: [PATCH] fix(desktop): improve send/receive dialogs and LNURL error surfacing SendDialog: switch to Dialog+Card with X close, inline copiable error messages, button resets to "Pay Invoice" on error for retry. LightningAddressResolver: return error body from callback responses so server error messages (e.g. "Recipient wallet error") surface to user instead of generic "Failed to fetch invoice". Also check "message" field in addition to "reason" for error extraction. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../lnurl/LightningAddressResolver.kt | 13 +- .../desktop/ui/wallet/WalletColumnScreen.kt | 153 ++++++++++++------ 2 files changed, 112 insertions(+), 54 deletions(-) diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/lnurl/LightningAddressResolver.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/lnurl/LightningAddressResolver.kt index d6b7768e16..bde4e94d31 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/lnurl/LightningAddressResolver.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/lnurl/LightningAddressResolver.kt @@ -143,7 +143,7 @@ class LightningAddressResolver( milliSats = milliSats, message = message, zapRequest = if (allowsNostr) zapRequest else null, - ) ?: return@withContext Result.Error("Failed to fetch invoice from callback") + ) ?: return@withContext Result.Error("Failed to connect to payment server") onProgress(0.7f) @@ -157,7 +157,9 @@ class LightningAddressResolver( val pr = invoiceResponse?.get("pr")?.asText()?.ifBlank { null } if (pr == null) { - val reason = invoiceResponse?.get("reason")?.asText()?.ifBlank { null } + val reason = + invoiceResponse?.get("reason")?.asText()?.ifBlank { null } + ?: invoiceResponse?.get("message")?.asText()?.ifBlank { null } return@withContext Result.Error(reason ?: "No invoice in response") } @@ -221,11 +223,8 @@ class LightningAddressResolver( val request = Request.Builder().url(url).build() httpClient.newCall(request).execute().use { response -> - if (response.isSuccessful) { - response.body?.string() - } else { - null - } + // Return body even on error — caller extracts "reason" or "message" from JSON + response.body?.string() } } catch (e: Exception) { if (e is CancellationException) throw e diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/wallet/WalletColumnScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/wallet/WalletColumnScreen.kt index c721a776b7..be6237263d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/wallet/WalletColumnScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/wallet/WalletColumnScreen.kt @@ -34,6 +34,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -253,25 +254,12 @@ fun WalletColumnScreen( if (showSendDialog && nwcConnection != null) { SendDialog( onDismiss = { showSendDialog = false }, - onSend = { invoice -> - scope.launch { - val result = paymentHandler.payInvoice(bolt11 = invoice, nwcConnection = nwcConnection) - when (result) { - is NwcPaymentHandler.PaymentResult.Success -> { - showSendDialog = false - snackbarHostState.showSnackbar("Payment successful!") - } - - is NwcPaymentHandler.PaymentResult.Error -> { - snackbarHostState.showSnackbar("Error: ${result.message}") - } - - is NwcPaymentHandler.PaymentResult.Timeout -> { - snackbarHostState.showSnackbar("Payment timed out") - } - } - } + onSuccess = { + showSendDialog = false + scope.launch { snackbarHostState.showSnackbar("Payment successful!") } }, + paymentHandler = paymentHandler, + nwcConnection = nwcConnection, ) } @@ -468,25 +456,54 @@ private fun ConnectWalletDialog( @Composable private fun SendDialog( onDismiss: () -> Unit, - onSend: (String) -> Unit, + onSuccess: () -> Unit, + paymentHandler: NwcPaymentHandler, + nwcConnection: Nip47URINorm, ) { var invoice by remember { mutableStateOf("") } var isSending by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + Dialog(onDismissRequest = { if (!isSending) onDismiss() }) { + Card( + modifier = Modifier.width(480.dp), + shape = RoundedCornerShape(16.dp), + ) { + Column(modifier = Modifier.padding(24.dp)) { + // Header + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "Send Payment", + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = { if (!isSending) onDismiss() }) { + Icon( + MaterialSymbols.Close, + contentDescription = "Close", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(Modifier.height(16.dp)) - AlertDialog( - onDismissRequest = { if (!isSending) onDismiss() }, - title = { Text("Send Payment") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { OutlinedTextField( value = invoice, - onValueChange = { invoice = it }, + onValueChange = { + invoice = it + errorMessage = null + }, label = { Text("BOLT11 Invoice") }, placeholder = { Text("lnbc...") }, modifier = Modifier.fillMaxWidth(), singleLine = false, maxLines = 6, ) + + Spacer(Modifier.height(8.dp)) + OutlinedButton(onClick = { val clipboard = Toolkit.getDefaultToolkit().systemClipboard val text = @@ -495,33 +512,75 @@ private fun SendDialog( } catch (_: Exception) { null } - if (text != null) invoice = text + if (text != null) { + invoice = text + errorMessage = null + } }) { Text("Paste from Clipboard") } - } - }, - confirmButton = { - Button( - onClick = { - isSending = true - onSend(invoice) - }, - enabled = invoice.isNotBlank() && !isSending, - ) { - if (isSending) { - CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onPrimary) - Spacer(modifier = Modifier.width(8.dp)) - Text("Sending...") - } else { - Text("Pay Invoice") + + // Inline error — copiable + if (errorMessage != null) { + Spacer(Modifier.height(12.dp)) + SelectionContainer { + Text( + text = errorMessage!!, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.fillMaxWidth(), + ) + } + } + + Spacer(Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton(onClick = onDismiss, enabled = !isSending) { + Text("Cancel") + } + Spacer(Modifier.width(8.dp)) + Button( + onClick = { + isSending = true + errorMessage = null + scope.launch { + val result = paymentHandler.payInvoice(bolt11 = invoice, nwcConnection = nwcConnection) + when (result) { + is NwcPaymentHandler.PaymentResult.Success -> onSuccess() + is NwcPaymentHandler.PaymentResult.Error -> { + errorMessage = result.message + isSending = false + } + is NwcPaymentHandler.PaymentResult.Timeout -> { + errorMessage = "Payment timed out" + isSending = false + } + } + } + }, + enabled = invoice.isNotBlank() && !isSending, + ) { + if (isSending) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Sending...") + } else { + Text("Pay Invoice") + } + } } } - }, - dismissButton = { - TextButton(onClick = onDismiss, enabled = !isSending) { Text("Cancel") } - }, - ) + } + } } @Composable