mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
feat(desktop): support LNURL-pay and lightning addresses in send dialog
Rewrite SendDialog with sealed state machine that auto-detects input type (BOLT11, LNURL bech32, lightning address). For LNURL/address: resolves endpoint, shows amount form with min/max hint, optional comment field, fetches invoice, then pays via NWC. Strips lightning: URI prefix. Inline copiable errors with retry. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
a5405fef34
commit
2b14b77acf
@@ -0,0 +1,88 @@
|
||||
# Brainstorm: Send Dialog LNURL-Pay Support
|
||||
|
||||
**Date:** 2026-05-23
|
||||
**Status:** Ready for planning
|
||||
|
||||
## What We're Building
|
||||
|
||||
Extend the desktop wallet Send dialog to accept LNURL-pay strings and lightning addresses in addition to BOLT11 invoices. The dialog auto-detects input type, resolves LNURL endpoints, and presents an amount/comment form before fetching the final BOLT11 invoice and paying via NWC.
|
||||
|
||||
## Why
|
||||
|
||||
Users commonly receive payment requests as lightning addresses (`user@domain`) or LNURL bech32 strings, not just raw BOLT11 invoices. The current send dialog rejects these with a confusing "Unknown chain url" error from the NWC wallet.
|
||||
|
||||
## Input Types
|
||||
|
||||
| Format | Example | Detection |
|
||||
|--------|---------|-----------|
|
||||
| BOLT11 | `lnbc210n1p4pr...` | Starts with `lnbc` (regex in `LnInvoiceUtil`) |
|
||||
| LNURL | `lnurl1dp68gurn...` | Starts with `lnurl` (decode via `Lud06.toLnUrlp`) |
|
||||
| Lightning address | `user@domain.com` | Contains `@`, split on `@` |
|
||||
|
||||
## Dialog Flow
|
||||
|
||||
### State Machine
|
||||
|
||||
```
|
||||
Input -> detecting type...
|
||||
|
|
||||
|- BOLT11 detected -> [Pay Invoice] (current flow, no change)
|
||||
|
|
||||
|- LNURL/address detected -> resolving endpoint (spinner)...
|
||||
|
|
||||
|- Fixed amount -> show amount (read-only), optional comment -> [Pay]
|
||||
|- Variable amount -> show amount field with min/max hint, optional comment -> [Pay]
|
||||
|- Resolution error -> inline error (copiable)
|
||||
|
|
||||
[Pay] -> fetching invoice (spinner)...
|
||||
|
|
||||
|- Got BOLT11 -> paying via NWC (spinner)...
|
||||
| |- Success -> close dialog, snackbar
|
||||
| |- Error -> inline error, button resets to [Pay]
|
||||
|- Fetch error -> inline error, button resets to [Pay]
|
||||
```
|
||||
|
||||
### UI States
|
||||
|
||||
1. **Input** — text field + paste button (current)
|
||||
2. **Resolving** — spinner below input, input disabled
|
||||
3. **Amount** — shows endpoint info + amount field (with min/max label) + optional comment field
|
||||
4. **Paying** — button shows "Paying...", fields disabled
|
||||
5. **Error** — inline red text (copiable via SelectionContainer), button resets
|
||||
|
||||
### Amount Input
|
||||
|
||||
- Label: `"Amount (1 - 500,000 sats)"` — populated from `minSendable`/`maxSendable` (converted from msats)
|
||||
- If fixed amount (`minSendable == maxSendable`): show read-only, prepopulated
|
||||
- Digits only, validate against range on submit
|
||||
- Comment field: only visible if `commentAllowed > 0` from endpoint response
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- **Auto-detect on paste/type** — no explicit "Resolve" button; detect as user types/pastes
|
||||
- **Single dialog, multi-step** — no separate dialogs for LNURL vs BOLT11
|
||||
- **Reuse `LightningAddressResolver`** — already handles LNURL endpoint fetch + invoice callback
|
||||
- **Comment support** — show optional comment field when endpoint allows it
|
||||
- **Amount in sats** — convert msats from LNURL spec to sats for display
|
||||
- **Error display** — inline, copiable, same pattern as current SendDialog
|
||||
|
||||
## Reusable Code
|
||||
|
||||
| Component | Location | Notes |
|
||||
|-----------|----------|-------|
|
||||
| `Lud06.toLnUrlp()` | quartz | Decodes LNURL bech32 to URL |
|
||||
| `LnInvoiceUtil.findInvoice()` | quartz | Detects BOLT11 pattern |
|
||||
| `LightningAddressResolver.assembleUrl()` | commons | `user@domain` -> endpoint URL |
|
||||
| `LightningAddressResolver.fetchInvoice()` | commons | Full LNURL-pay flow (fetch endpoint, get invoice) |
|
||||
| `NwcPaymentHandler.payInvoice()` | desktopApp | Pay BOLT11 via NWC |
|
||||
|
||||
## Resolved Questions
|
||||
|
||||
- **Input types**: BOLT11 + LNURL + lightning address (all three)
|
||||
- **Amount UX**: Free text field with min/max hint from endpoint
|
||||
- **Comments**: Yes, show comment field if `commentAllowed > 0`
|
||||
- **Flow**: Auto-detect + resolve inline (single dialog, multi-step)
|
||||
|
||||
## Open Questions
|
||||
|
||||
None — all questions resolved during brainstorm.
|
||||
@@ -0,0 +1,183 @@
|
||||
---
|
||||
title: "feat: Support LNURL-pay and lightning addresses in send dialog"
|
||||
type: feat
|
||||
status: active
|
||||
date: 2026-05-23
|
||||
origin: desktopApp/plans/2026-05-23-feat-send-dialog-lnurl-pay-brainstorm.md
|
||||
deepened: 2026-05-23
|
||||
---
|
||||
|
||||
# feat: Support LNURL-pay and lightning addresses in send dialog
|
||||
|
||||
## Enhancement Summary
|
||||
|
||||
**Deepened on:** 2026-05-23
|
||||
**Research agents:** LNURL edge cases, Compose state machine patterns
|
||||
|
||||
### Key Improvements from Research
|
||||
1. `LightningAddressResolver` doesn't parse `minSendable`/`maxSendable`/`commentAllowed` — must fetch and parse endpoint JSON directly in dialog
|
||||
2. Use `ImportFollowListDialog` sealed class state machine pattern (proven in codebase)
|
||||
3. Strip `lightning:` URI prefix from pasted input
|
||||
4. `fetchInvoice()` can be called with just amount (no zap request) — reuse for final invoice fetch
|
||||
|
||||
## Overview
|
||||
|
||||
Extend the desktop SendDialog to accept LNURL bech32 strings and lightning addresses (`user@domain`) in addition to BOLT11 invoices. Auto-detect input type, resolve LNURL endpoints, and present an amount/comment form before fetching the final BOLT11 invoice and paying via NWC.
|
||||
|
||||
## Problem
|
||||
|
||||
User pastes an LNURL or lightning address into the send dialog and gets "Unknown chain url: invalid token" error because the dialog only supports BOLT11 invoices.
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
Single dialog, multi-step flow with auto-detection (see brainstorm).
|
||||
|
||||
### Input Detection
|
||||
|
||||
```kotlin
|
||||
fun classifyInput(input: String): PaymentInput {
|
||||
val trimmed = input.trim()
|
||||
.removePrefix("lightning:") // Strip lightning: URI prefix
|
||||
.trim()
|
||||
// 1. BOLT11: starts with lnbc
|
||||
LnInvoiceUtil.findInvoice(trimmed)?.let { return PaymentInput.Bolt11(it) }
|
||||
// 2. LNURL bech32: starts with lnurl
|
||||
if (trimmed.lowercase().startsWith("lnurl")) {
|
||||
Lud06().toLnUrlp(trimmed)?.let { return PaymentInput.LnurlPay(it) }
|
||||
}
|
||||
// 3. Lightning address: user@domain
|
||||
if (trimmed.contains("@") && trimmed.contains(".")) {
|
||||
val parts = trimmed.split("@")
|
||||
if (parts.size == 2) return PaymentInput.LnurlPay("https://${parts[1]}/.well-known/lnurlp/${parts[0]}")
|
||||
}
|
||||
return PaymentInput.Unknown
|
||||
}
|
||||
```
|
||||
|
||||
### Dialog State Machine
|
||||
|
||||
Follow `ImportFollowListDialog` pattern — sealed class with `LaunchedEffect` auto-transitions.
|
||||
|
||||
```kotlin
|
||||
sealed class SendState {
|
||||
data object Idle : SendState()
|
||||
data class Resolving(val url: String) : SendState()
|
||||
data class NeedsAmount(
|
||||
val lnAddress: String, // original input for fetchInvoice
|
||||
val callback: String,
|
||||
val minSats: Long,
|
||||
val maxSats: Long,
|
||||
val commentAllowed: Int,
|
||||
) : SendState()
|
||||
data class ReadyToPay(val bolt11: String) : SendState()
|
||||
data class FetchingInvoice(val lnAddress: String, val amountSats: Long, val comment: String) : SendState()
|
||||
data object Paying : SendState()
|
||||
data class Error(val message: String) : SendState()
|
||||
}
|
||||
```
|
||||
|
||||
### State Transitions via LaunchedEffect
|
||||
|
||||
```kotlin
|
||||
// Auto-resolve LNURL endpoint when entering Resolving state
|
||||
LaunchedEffect(sendState) {
|
||||
val state = sendState
|
||||
if (state is SendState.Resolving) {
|
||||
// Fetch LNURL-pay JSON using OkHttp directly
|
||||
val json = fetchLnurlPayEndpoint(state.url)
|
||||
// Parse: callback, minSendable, maxSendable, commentAllowed
|
||||
// Transition to NeedsAmount or Error
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-fetch invoice when entering FetchingInvoice state
|
||||
LaunchedEffect(sendState) {
|
||||
val state = sendState
|
||||
if (state is SendState.FetchingInvoice) {
|
||||
val resolver = LightningAddressResolver(DesktopHttpClient.currentClient())
|
||||
val result = resolver.fetchInvoice(
|
||||
lnAddress = state.lnAddress,
|
||||
milliSats = state.amountSats * 1000,
|
||||
message = state.comment,
|
||||
)
|
||||
// Transition to ReadyToPay or Error
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### LNURL Endpoint Parsing (new logic in dialog)
|
||||
|
||||
```kotlin
|
||||
// Fetch and parse LNURL-pay endpoint JSON
|
||||
// Fields needed: callback, minSendable, maxSendable, commentAllowed
|
||||
val lnurlp = mapper.readTree(responseBody)
|
||||
val callback = lnurlp.get("callback")?.asText()
|
||||
val minSendable = lnurlp.get("minSendable")?.asLong() ?: 1000 // msats
|
||||
val maxSendable = lnurlp.get("maxSendable")?.asLong() ?: 100_000_000 // msats
|
||||
val commentAllowed = lnurlp.get("commentAllowed")?.asInt() ?: 0
|
||||
val isFixed = minSendable == maxSendable
|
||||
```
|
||||
|
||||
### UI Layout per State
|
||||
|
||||
| State | Shows |
|
||||
|-------|-------|
|
||||
| Idle | Input field ("Payment request or lightning address"), paste button |
|
||||
| Resolving | Input (disabled), spinner below |
|
||||
| NeedsAmount | Input (disabled, shows address), amount field w/ "Amount (min - max sats)", comment (if allowed), Pay |
|
||||
| ReadyToPay | Input (disabled), Pay button |
|
||||
| FetchingInvoice | Fields disabled, spinner |
|
||||
| Paying | All disabled, "Paying..." button |
|
||||
| Error | Inline red copiable text, Retry button resets to appropriate prior state |
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- **`lightning:lnbc...`** prefix: strip before classification
|
||||
- **Fixed amount** (`minSendable == maxSendable`): prepopulate, make read-only
|
||||
- **Amount out of range**: validate client-side before fetching invoice, show inline error
|
||||
- **Endpoint timeout**: 15s timeout on LNURL fetch, show error
|
||||
- **Invoice amount mismatch**: `fetchInvoice()` already validates this (line 172)
|
||||
- **`commentAllowed = 0`**: hide comment field entirely
|
||||
- **Invalid LNURL bech32**: `Lud06().toLnUrlp()` returns null → stays Unknown
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `WalletColumnScreen.kt` (SendDialog) | Rewrite to multi-step flow with sealed state machine |
|
||||
|
||||
No new files needed. All LNURL utilities already exist in quartz/commons.
|
||||
|
||||
## Reusable Code
|
||||
|
||||
| Component | Location | Usage |
|
||||
|-----------|----------|-------|
|
||||
| `LnInvoiceUtil.findInvoice()` | quartz | Detect BOLT11 |
|
||||
| `Lud06().toLnUrlp()` | quartz | Decode LNURL bech32 |
|
||||
| `LightningAddressResolver.fetchInvoice()` | commons | Fetch invoice with amount (no zap request) |
|
||||
| `DesktopHttpClient.currentClient()` | desktopApp | OkHttpClient |
|
||||
| `NwcPaymentHandler.payInvoice()` | desktopApp | Pay BOLT11 via NWC |
|
||||
| `jacksonObjectMapper()` | already imported | Parse LNURL endpoint JSON |
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Pasting a BOLT11 invoice works as before (no regression)
|
||||
- [ ] Pasting `lightning:lnbc...` works (prefix stripped)
|
||||
- [ ] Pasting an LNURL bech32 string resolves and shows amount form
|
||||
- [ ] Pasting a lightning address (user@domain) resolves and shows amount form
|
||||
- [ ] Fixed-amount LNURL prepopulates amount (read-only)
|
||||
- [ ] Variable-amount LNURL shows input with min/max hint
|
||||
- [ ] Comment field appears when endpoint `commentAllowed > 0`
|
||||
- [ ] Amount validated against min/max before fetching invoice
|
||||
- [ ] Errors shown inline (copiable), button resets for retry
|
||||
- [ ] "Paste from Clipboard" works for all input types
|
||||
- [ ] Label changed from "BOLT11 Invoice" to "Payment request or lightning address"
|
||||
|
||||
## Sources
|
||||
|
||||
- **Origin brainstorm:** desktopApp/plans/2026-05-23-feat-send-dialog-lnurl-pay-brainstorm.md
|
||||
- **State machine pattern:** desktopApp/.../ui/ImportFollowListDialog.kt (sealed class + LaunchedEffect)
|
||||
- `LightningAddressResolver`: commons/src/jvmAndroid/.../LightningAddressResolver.kt:47-234
|
||||
- `Lud06.toLnUrlp()`: quartz/src/commonMain/.../lightning/Lud06.kt:51-58
|
||||
- `LnInvoiceUtil.findInvoice()`: quartz/src/commonMain/.../lightning/LnInvoiceUtil.kt:302-307
|
||||
- Current `SendDialog`: desktopApp/.../ui/wallet/WalletColumnScreen.kt:457-584
|
||||
+311
-62
@@ -63,15 +63,19 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountManager
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.nwc.NwcPaymentHandler
|
||||
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
|
||||
import com.vitorpamplona.amethyst.desktop.ui.auth.QrCodeCanvas
|
||||
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
|
||||
import com.vitorpamplona.quartz.lightning.Lud06
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -453,6 +457,73 @@ private fun ConnectWalletDialog(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sealed state machine for send dialog — supports BOLT11, LNURL, and lightning addresses.
|
||||
*/
|
||||
private sealed class SendState {
|
||||
data object Idle : SendState()
|
||||
|
||||
data class Resolving(
|
||||
val url: String,
|
||||
) : SendState()
|
||||
|
||||
data class NeedsAmount(
|
||||
val originalInput: String,
|
||||
val callbackUrl: String,
|
||||
val minSats: Long,
|
||||
val maxSats: Long,
|
||||
val commentAllowed: Int,
|
||||
) : SendState()
|
||||
|
||||
data class FetchingInvoice(
|
||||
val callbackUrl: String,
|
||||
val amountMilliSats: Long,
|
||||
val comment: String,
|
||||
) : SendState()
|
||||
|
||||
data class ReadyToPay(
|
||||
val bolt11: String,
|
||||
) : SendState()
|
||||
|
||||
data object Paying : SendState()
|
||||
|
||||
data class Error(
|
||||
val message: String,
|
||||
val retryState: SendState,
|
||||
) : SendState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies payment input as BOLT11, LNURL, lightning address, or unknown.
|
||||
*/
|
||||
private fun classifyAndProcess(input: String): SendState {
|
||||
val trimmed =
|
||||
input
|
||||
.trim()
|
||||
.removePrefix("lightning:")
|
||||
.removePrefix("LIGHTNING:")
|
||||
.trim()
|
||||
if (trimmed.isBlank()) return SendState.Idle
|
||||
|
||||
// 1. BOLT11 invoice
|
||||
LnInvoiceUtil.findInvoice(trimmed)?.let { return SendState.ReadyToPay(it) }
|
||||
|
||||
// 2. LNURL bech32
|
||||
if (trimmed.lowercase().startsWith("lnurl")) {
|
||||
Lud06().toLnUrlp(trimmed)?.let { return SendState.Resolving(it) }
|
||||
}
|
||||
|
||||
// 3. Lightning address (user@domain)
|
||||
if (trimmed.contains("@") && trimmed.contains(".")) {
|
||||
val parts = trimmed.split("@")
|
||||
if (parts.size == 2 && parts[0].isNotBlank() && parts[1].contains(".")) {
|
||||
return SendState.Resolving("https://${parts[1]}/.well-known/lnurlp/${parts[0]}")
|
||||
}
|
||||
}
|
||||
|
||||
return SendState.Idle
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SendDialog(
|
||||
onDismiss: () -> Unit,
|
||||
@@ -460,12 +531,104 @@ private fun SendDialog(
|
||||
paymentHandler: NwcPaymentHandler,
|
||||
nwcConnection: Nip47URINorm,
|
||||
) {
|
||||
var invoice by remember { mutableStateOf("") }
|
||||
var isSending by remember { mutableStateOf(false) }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
var input by remember { mutableStateOf("") }
|
||||
var sendState by remember { mutableStateOf<SendState>(SendState.Idle) }
|
||||
var amount by remember { mutableStateOf("") }
|
||||
var comment by remember { mutableStateOf("") }
|
||||
val scope = rememberCoroutineScope()
|
||||
val mapper = remember { jacksonObjectMapper() }
|
||||
|
||||
Dialog(onDismissRequest = { if (!isSending) onDismiss() }) {
|
||||
val isLoading =
|
||||
sendState is SendState.Resolving ||
|
||||
sendState is SendState.FetchingInvoice ||
|
||||
sendState is SendState.Paying
|
||||
|
||||
// Auto-resolve LNURL endpoint
|
||||
LaunchedEffect(sendState) {
|
||||
val state = sendState
|
||||
if (state is SendState.Resolving) {
|
||||
try {
|
||||
val httpClient = DesktopHttpClient.currentClient()
|
||||
val request =
|
||||
okhttp3.Request
|
||||
.Builder()
|
||||
.url(state.url)
|
||||
.build()
|
||||
val response =
|
||||
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||
httpClient.newCall(request).execute()
|
||||
}
|
||||
val body = response.body?.string()
|
||||
if (body == null) {
|
||||
sendState = SendState.Error("Failed to reach payment server", SendState.Idle)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val json = mapper.readTree(body)
|
||||
val callback = json.get("callback")?.asText()?.ifBlank { null }
|
||||
if (callback == null) {
|
||||
val errorMsg = json.get("reason")?.asText() ?: json.get("message")?.asText() ?: "Invalid LNURL endpoint"
|
||||
sendState = SendState.Error(errorMsg, SendState.Idle)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val minMsats = json.get("minSendable")?.asLong() ?: 1000L
|
||||
val maxMsats = json.get("maxSendable")?.asLong() ?: 100_000_000L
|
||||
val commentLen = json.get("commentAllowed")?.asInt() ?: 0
|
||||
val minSats = minMsats / 1000
|
||||
val maxSats = maxMsats / 1000
|
||||
// Fixed amount: prepopulate
|
||||
if (minSats == maxSats) {
|
||||
amount = minSats.toString()
|
||||
}
|
||||
sendState = SendState.NeedsAmount(input, callback, minSats, maxSats, commentLen)
|
||||
} catch (e: Exception) {
|
||||
if (e is kotlinx.coroutines.CancellationException) throw e
|
||||
sendState = SendState.Error("Failed to resolve: ${e.message}", SendState.Idle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-fetch invoice from callback
|
||||
LaunchedEffect(sendState) {
|
||||
val state = sendState
|
||||
if (state is SendState.FetchingInvoice) {
|
||||
try {
|
||||
val httpClient = DesktopHttpClient.currentClient()
|
||||
val urlBinder = if (state.callbackUrl.contains("?")) "&" else "?"
|
||||
val encodedComment = java.net.URLEncoder.encode(state.comment, "utf-8")
|
||||
val url = "${state.callbackUrl}${urlBinder}amount=${state.amountMilliSats}&comment=$encodedComment"
|
||||
val request =
|
||||
okhttp3.Request
|
||||
.Builder()
|
||||
.url(url)
|
||||
.build()
|
||||
val response =
|
||||
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||
httpClient.newCall(request).execute()
|
||||
}
|
||||
val body = response.body?.string()
|
||||
if (body == null) {
|
||||
sendState = SendState.Error("Failed to fetch invoice", SendState.Idle)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val json = mapper.readTree(body)
|
||||
val pr = json.get("pr")?.asText()?.ifBlank { null }
|
||||
if (pr != null) {
|
||||
sendState = SendState.ReadyToPay(pr)
|
||||
} else {
|
||||
val reason = json.get("reason")?.asText() ?: json.get("message")?.asText() ?: "No invoice returned"
|
||||
sendState = SendState.Error(reason, SendState.Idle)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is kotlinx.coroutines.CancellationException) throw e
|
||||
sendState = SendState.Error("Invoice fetch failed: ${e.message}", SendState.Idle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-pay when ReadyToPay (only for LNURL flow — BOLT11 uses button click)
|
||||
// For direct BOLT11 paste, user clicks Pay explicitly
|
||||
|
||||
Dialog(onDismissRequest = { if (!isLoading) onDismiss() }) {
|
||||
Card(
|
||||
modifier = Modifier.width(480.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
@@ -478,7 +641,7 @@ private fun SendDialog(
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(onClick = { if (!isSending) onDismiss() }) {
|
||||
IconButton(onClick = { if (!isLoading) onDismiss() }) {
|
||||
Icon(
|
||||
MaterialSymbols.Close,
|
||||
contentDescription = "Close",
|
||||
@@ -489,43 +652,101 @@ private fun SendDialog(
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Input field
|
||||
OutlinedTextField(
|
||||
value = invoice,
|
||||
value = input,
|
||||
onValueChange = {
|
||||
invoice = it
|
||||
errorMessage = null
|
||||
input = it
|
||||
sendState = SendState.Idle
|
||||
},
|
||||
label = { Text("BOLT11 Invoice") },
|
||||
placeholder = { Text("lnbc...") },
|
||||
label = { Text("Invoice, LNURL, or lightning address") },
|
||||
placeholder = { Text("lnbc..., lnurl1..., or user@domain") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = false,
|
||||
maxLines = 6,
|
||||
maxLines = 4,
|
||||
enabled = sendState is SendState.Idle || sendState is SendState.Error,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
OutlinedButton(onClick = {
|
||||
val clipboard = Toolkit.getDefaultToolkit().systemClipboard
|
||||
val text =
|
||||
try {
|
||||
clipboard.getData(DataFlavor.stringFlavor) as? String
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
if (sendState is SendState.Idle || sendState is SendState.Error) {
|
||||
OutlinedButton(onClick = {
|
||||
val clipboard = Toolkit.getDefaultToolkit().systemClipboard
|
||||
val text =
|
||||
try {
|
||||
clipboard.getData(DataFlavor.stringFlavor) as? String
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
if (text != null) {
|
||||
input = text
|
||||
sendState = classifyAndProcess(text)
|
||||
}
|
||||
if (text != null) {
|
||||
invoice = text
|
||||
errorMessage = null
|
||||
}) {
|
||||
Text("Paste from Clipboard")
|
||||
}
|
||||
}) {
|
||||
Text("Paste from Clipboard")
|
||||
}
|
||||
|
||||
// Inline error — copiable
|
||||
if (errorMessage != null) {
|
||||
// Resolving spinner
|
||||
if (sendState is SendState.Resolving) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Resolving payment request...", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
|
||||
// Amount + comment form (LNURL flow)
|
||||
val needsAmount = sendState as? SendState.NeedsAmount
|
||||
if (needsAmount != null) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
val isFixed = needsAmount.minSats == needsAmount.maxSats
|
||||
OutlinedTextField(
|
||||
value = amount,
|
||||
onValueChange = { new -> if (new.all { it.isDigit() }) amount = new },
|
||||
label = {
|
||||
Text(
|
||||
if (isFixed) {
|
||||
"Amount (${formatSats(needsAmount.minSats)} sats)"
|
||||
} else {
|
||||
"Amount (${formatSats(needsAmount.minSats)} - ${formatSats(needsAmount.maxSats)} sats)"
|
||||
},
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
readOnly = isFixed,
|
||||
)
|
||||
if (needsAmount.commentAllowed > 0) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = comment,
|
||||
onValueChange = { if (it.length <= needsAmount.commentAllowed) comment = it },
|
||||
label = { Text("Comment (optional)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetching invoice spinner
|
||||
if (sendState is SendState.FetchingInvoice) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Fetching invoice...", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
|
||||
// Error display
|
||||
val errorState = sendState as? SendState.Error
|
||||
if (errorState != null) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = errorMessage!!,
|
||||
text = errorState.message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -535,47 +756,75 @@ private fun SendDialog(
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TextButton(onClick = onDismiss, enabled = !isSending) {
|
||||
Text("Cancel")
|
||||
// Action button
|
||||
val canPay =
|
||||
when (sendState) {
|
||||
is SendState.Idle -> input.isNotBlank()
|
||||
is SendState.NeedsAmount -> amount.isNotBlank()
|
||||
is SendState.ReadyToPay -> true
|
||||
is SendState.Error -> true
|
||||
else -> false
|
||||
}
|
||||
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
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
when (val state = sendState) {
|
||||
is SendState.Idle -> {
|
||||
sendState = classifyAndProcess(input)
|
||||
}
|
||||
is SendState.NeedsAmount -> {
|
||||
val amountSats = amount.toLongOrNull() ?: 0L
|
||||
if (amountSats < state.minSats || amountSats > state.maxSats) {
|
||||
sendState =
|
||||
SendState.Error(
|
||||
"Amount must be between ${formatSats(state.minSats)} and ${formatSats(state.maxSats)} sats",
|
||||
state,
|
||||
)
|
||||
} else {
|
||||
sendState = SendState.FetchingInvoice(state.callbackUrl, amountSats * 1000, comment)
|
||||
}
|
||||
}
|
||||
is SendState.ReadyToPay -> {
|
||||
sendState = SendState.Paying
|
||||
scope.launch {
|
||||
val result = paymentHandler.payInvoice(bolt11 = state.bolt11, nwcConnection = nwcConnection)
|
||||
when (result) {
|
||||
is NwcPaymentHandler.PaymentResult.Success -> onSuccess()
|
||||
is NwcPaymentHandler.PaymentResult.Error -> {
|
||||
sendState = SendState.Error(result.message, SendState.Idle)
|
||||
}
|
||||
is NwcPaymentHandler.PaymentResult.Timeout -> {
|
||||
sendState = SendState.Error("Payment timed out", SendState.Idle)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
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")
|
||||
is SendState.Error -> {
|
||||
sendState = state.retryState
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
},
|
||||
enabled = canPay && !isLoading,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(if (sendState is SendState.Paying) "Paying..." else "Processing...")
|
||||
} else {
|
||||
Text(
|
||||
when (sendState) {
|
||||
is SendState.Error -> "Retry"
|
||||
is SendState.NeedsAmount -> "Pay"
|
||||
is SendState.ReadyToPay -> "Pay Invoice"
|
||||
else -> "Continue"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user