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:
+188
-41
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user