diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt index 744c65498..f98832772 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt @@ -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(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(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)