feat(onchain-zaps): user search + chip wrapping in send dialog

Recipient field now uses ShowUserSuggestionList + UserSuggestionState
(same plumbing as the post composer @-mention and AwardBadgeScreen).
The user can type a display name, NIP-05, or paste an npub/hex; picked
recipients render as a chip with avatar + name + clear button. Raw
npub paste still works as a fallback when no result is picked.

Fee tier chips moved from Row to FlowRow so 'Slow · 1 sat/vB',
'Normal · 12 sat/vB', 'Fast · 50 sat/vB' wrap to a second line on
narrow dialog widths instead of clipping the rightmost chip.
This commit is contained in:
Claude
2026-05-16 20:05:09 +00:00
parent 1d5ce994a6
commit 5aedcd0a19
@@ -22,18 +22,24 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
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.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -42,10 +48,18 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -56,6 +70,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import androidx.compose.material3.ExperimentalMaterial3Api as ExpM3
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
private enum class FeeTier(
val label: String,
@@ -77,9 +92,10 @@ private fun FeeEstimates.rateFor(tier: FeeTier): Double =
* tier / comment, then run [com.vitorpamplona.amethyst.model.Account.sendOnchainZap]
* and show progress + the result.
*
* When [recipientPubKey] is null the user enters a recipient npub and the zap
* targets that profile. When provided (e.g. from a note's zap menu) the
* recipient is fixed and [zappedEvent] attributes the zap to that event.
* When [recipientPubKey] is null the user searches for a recipient by display
* name, NIP-05, or pastes an npub directly; the zap targets that profile. When
* provided (e.g. from a note's zap menu) the recipient is fixed and
* [zappedEvent] attributes the zap to that event.
*/
@OptIn(ExpM3::class)
@Composable
@@ -91,7 +107,16 @@ fun OnchainZapSendDialog(
) {
val scope = rememberCoroutineScope()
var npubInput by remember { mutableStateOf("") }
val userSuggestions =
remember {
UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder())
}
DisposableEffect(Unit) {
onDispose { userSuggestions.reset() }
}
var searchInput by remember { mutableStateOf("") }
var selectedUser by remember { mutableStateOf<User?>(null) }
var amountInput by remember { mutableStateOf("") }
var comment by remember { mutableStateOf("") }
var feeTier by remember { mutableStateOf(FeeTier.NORMAL) }
@@ -100,15 +125,21 @@ fun OnchainZapSendDialog(
var sending by remember { mutableStateOf(false) }
var result by remember { mutableStateOf<OnchainZapSendResult?>(null) }
// Fetch recommended fee rates once when the dialog opens.
LaunchedEffect(Unit) {
val backend = LocalCache.onchainBackend ?: return@LaunchedEffect
fees =
runCatching { withContext(Dispatchers.IO) { backend.feeEstimates() } }.getOrNull()
}
// Recipient resolution priority:
// 1. preset recipientPubKey (note zap menu)
// 2. user picked from the suggestion dropdown
// 3. raw npub / hex pasted into the search field that parses cleanly
val resolvedRecipient: HexKey? =
recipientPubKey ?: npubInput.trim().takeIf { it.isNotEmpty() }?.let { decodePublicKeyAsHexOrNull(it) }
recipientPubKey
?: selectedUser?.pubkeyHex
?: searchInput.trim().takeIf { it.isNotEmpty() }?.let { decodePublicKeyAsHexOrNull(it) }
val amountSats = amountInput.trim().toLongOrNull()
val canSend =
!sending &&
@@ -124,14 +155,8 @@ fun OnchainZapSendDialog(
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
when (val r = result) {
is OnchainZapSendResult.Success -> {
SuccessBody(r)
}
is OnchainZapSendResult.Failure -> {
FailureBody(r)
}
is OnchainZapSendResult.Success -> SuccessBody(r)
is OnchainZapSendResult.Failure -> FailureBody(r)
null -> {
if (sending) {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -140,9 +165,29 @@ fun OnchainZapSendDialog(
}
} else {
SendForm(
accountViewModel = accountViewModel,
recipientPubKey = recipientPubKey,
npubInput = npubInput,
onNpubChange = { npubInput = it },
userSuggestions = userSuggestions,
selectedUser = selectedUser,
onSelectUser = {
selectedUser = it
searchInput = ""
userSuggestions.reset()
},
onClearUser = {
selectedUser = null
searchInput = ""
userSuggestions.reset()
},
searchInput = searchInput,
onSearchChange = { newValue ->
searchInput = newValue
if (newValue.length > 2) {
userSuggestions.processCurrentWord(newValue)
} else {
userSuggestions.reset()
}
},
amountInput = amountInput,
onAmountChange = { amountInput = it },
comment = comment,
@@ -196,9 +241,14 @@ fun OnchainZapSendDialog(
@OptIn(ExpM3::class)
@Composable
private fun SendForm(
accountViewModel: AccountViewModel,
recipientPubKey: HexKey?,
npubInput: String,
onNpubChange: (String) -> Unit,
userSuggestions: UserSuggestionState,
selectedUser: User?,
onSelectUser: (User) -> Unit,
onClearUser: () -> Unit,
searchInput: String,
onSearchChange: (String) -> Unit,
amountInput: String,
onAmountChange: (String) -> Unit,
comment: String,
@@ -207,22 +257,16 @@ private fun SendForm(
onFeeTierChange: (FeeTier) -> Unit,
fees: FeeEstimates?,
) {
if (recipientPubKey == null) {
OutlinedTextField(
value = npubInput,
onValueChange = onNpubChange,
label = { Text("Recipient npub") },
singleLine = true,
isError = npubInput.isNotBlank() && decodePublicKeyAsHexOrNull(npubInput.trim()) == null,
modifier = Modifier.fillMaxWidth(),
)
} else {
Text(
text = "Zapping the post author",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
RecipientPicker(
accountViewModel = accountViewModel,
recipientPubKey = recipientPubKey,
userSuggestions = userSuggestions,
selectedUser = selectedUser,
onSelectUser = onSelectUser,
onClearUser = onClearUser,
searchInput = searchInput,
onSearchChange = onSearchChange,
)
OutlinedTextField(
value = amountInput,
@@ -245,20 +289,27 @@ private fun SendForm(
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth(),
) {
FeeTier.entries.forEach { tier ->
val rate = fees?.rateFor(tier)
FilterChip(
selected = feeTier == tier,
onClick = { onFeeTierChange(tier) },
label = {
Text(
Column {
Text(tier.label)
if (rate != null) {
"${tier.label} · ${formatRate(rate)} sat/vB"
} else {
tier.label
},
)
Text(
text = "${formatRate(rate)} sat/vB",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
)
}
@@ -272,6 +323,102 @@ private fun SendForm(
}
}
@Composable
private fun RecipientPicker(
accountViewModel: AccountViewModel,
recipientPubKey: HexKey?,
userSuggestions: UserSuggestionState,
selectedUser: User?,
onSelectUser: (User) -> Unit,
onClearUser: () -> Unit,
searchInput: String,
onSearchChange: (String) -> Unit,
) {
if (recipientPubKey != null) {
Text(
text = "Zapping the post author",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
return
}
if (selectedUser != null) {
SelectedRecipientChip(selectedUser, accountViewModel, onClearUser)
return
}
OutlinedTextField(
value = searchInput,
onValueChange = onSearchChange,
label = { Text("Recipient") },
placeholder = { Text("name, NIP-05 or npub") },
singleLine = true,
isError =
searchInput.isNotBlank() &&
searchInput.length > 50 &&
decodePublicKeyAsHexOrNull(searchInput.trim()) == null,
modifier = Modifier.fillMaxWidth(),
)
if (searchInput.length > 2) {
ShowUserSuggestionList(
userSuggestions = userSuggestions,
onSelect = onSelectUser,
accountViewModel = accountViewModel,
modifier = Modifier.heightIn(0.dp, 200.dp),
)
}
}
@Composable
private fun SelectedRecipientChip(
user: User,
accountViewModel: AccountViewModel,
onClear: () -> Unit,
) {
Surface(
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
UserPicture(
userHex = user.pubkeyHex,
size = 32.dp,
accountViewModel = accountViewModel,
nav = EmptyNav(),
)
Column(modifier = Modifier.weight(1f).padding(start = 10.dp)) {
Text(
text = user.toBestDisplayName(),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = user.pubkeyDisplayHex(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
IconButton(onClick = onClear) {
SymbolIcon(
symbol = MaterialSymbols.Close,
contentDescription = "Change recipient",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun SuccessBody(result: OnchainZapSendResult.Success) {
Text("Onchain zap sent.", style = MaterialTheme.typography.bodyLarge)