From 0cb07761ce4e6574110e4f3265595fc7350fc174 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 00:41:04 +0000 Subject: [PATCH] feat(cashu): add-recommendation input + suggest only on typed text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes in the Cashu Wallet Settings flow: 1. My Mint Recommendations now has its own input row. A new OutlinedTextField under the section header lets the user paste a mint URL and tap "+" to publish a kind:38000 recommendation without having to leave Settings, find the mint elsewhere, and thumbs-up it. As the user types, the same cache-backed directory autocomplete that AddCashuWallet uses surfaces matching mints from the kind:10019 / kind:38000 / kind:38172 the cache already holds — tap a suggestion to one-shot recommend (publish + clear the field), useful for chaining several adds. Suggestions are filtered to drop URLs the user has already recommended (de-duped by the lowercased / trailing-slash-stripped mint URL across the user's own kind:38000s) so the same row never appears in both the autocomplete and the list directly below. 2. Autocomplete reacts only to typed text, not to an empty field. The first iteration showed the whole directory the moment the field gained focus — i.e. on the Edit Cashu Wallet mint-URL popup the user saw mint URLs without typing anything, which felt like the form was pre-populating itself. Both call sites (AddCashuWalletScreen + CashuWalletSettingsScreen) now early-return an empty suggestion list when the trimmed input is blank, so the dropdown is purely a reaction to what the user types. https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP --- .../loggedIn/wallet/AddCashuWalletScreen.kt | 16 +- .../wallet/CashuWalletSettingsScreen.kt | 148 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 1 + 3 files changed, 160 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddCashuWalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddCashuWalletScreen.kt index e0c14bc96a..f4d25c4e90 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddCashuWalletScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddCashuWalletScreen.kt @@ -244,14 +244,20 @@ fun AddCashuWalletScreen( // the current input (no point suggesting what they already // typed). Wrapped in `derivedStateOf` so the recompute only // fires when `mintInput` or `mints` change, not on every - // recomposition of the surrounding form. + // recomposition of the surrounding form. An empty field + // shows no suggestions — the autocomplete should react to + // typing, not dump every mint we've ever seen unsolicited. val suggestions by remember(mints) { derivedStateOf { val typed = mintInput.trim().trimEnd('/').lowercase() - val alreadyAdded = mints.map { it.lowercase().trimEnd('/') }.toSet() - LocalCache.mintDirectory - .suggest(typed, limit = 6) - .filter { it != typed && it !in alreadyAdded } + if (typed.isEmpty()) { + emptyList() + } else { + val alreadyAdded = mints.map { it.lowercase().trimEnd('/') }.toSet() + LocalCache.mintDirectory + .suggest(typed, limit = 6) + .filter { it != typed && it !in alreadyAdded } + } } } if (suggestions.isNotEmpty()) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletSettingsScreen.kt index 5a929bf2b9..69da5d8f5b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletSettingsScreen.kt @@ -41,12 +41,15 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -60,6 +63,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -92,6 +96,11 @@ fun CashuWalletSettingsScreen( val recommendations by viewModel.ownRecommendations.collectAsState() var pendingDelete by remember { mutableStateOf(null) } + var newRecommendationInput by remember { mutableStateOf("") } + + // Kick the one-shot directory backfill so the autocomplete is useful + // on first screen open instead of waiting for new relay deliveries. + LaunchedEffect(Unit) { LocalCache.ensureMintDirectoryBackfilled() } Scaffold( topBar = { @@ -136,6 +145,65 @@ fun CashuWalletSettingsScreen( ) } + // Add-recommendation input + autocomplete. Suggestions come + // from LocalCache.mintDirectory (kind:10019 / kind:38000 / + // kind:38172 the cache has seen), filtered to drop URLs the + // user has already recommended so they can't double-publish + // and the same row doesn't show up twice on screen. + item { + val alreadyRecommended = + remember(recommendations) { + recommendations + .flatMap { it.mintUrls() } + .map { it.lowercase().trimEnd('/') } + .toSet() + } + val suggestions by remember(newRecommendationInput, alreadyRecommended) { + derivedStateOf { + val typed = newRecommendationInput.trim().trimEnd('/').lowercase() + // Only react to what the user types — never show + // the full directory on an empty field, which + // would dump every mint we've ever seen as + // unsolicited suggestions. + if (typed.isEmpty()) { + emptyList() + } else { + LocalCache.mintDirectory + .suggest(typed, limit = 6) + .filter { it != typed && it !in alreadyRecommended } + } + } + } + AddRecommendationRow( + input = newRecommendationInput, + onInputChange = { newRecommendationInput = it }, + canAdd = + newRecommendationInput.isNotBlank() && + newRecommendationInput.trim().trimEnd('/').lowercase() !in alreadyRecommended, + onAdd = { + val trimmed = newRecommendationInput.trim().trimEnd('/') + if (trimmed.isNotEmpty()) { + viewModel.recommendMint(trimmed) + newRecommendationInput = "" + } + }, + ) + if (suggestions.isNotEmpty()) { + Spacer(modifier = Modifier.height(6.dp)) + RecommendationSuggestionList( + suggestions = suggestions, + onPick = { url -> + // One-tap recommend: publish straight from + // the suggestion and clear the field so the + // user can chain multiple adds without + // re-tapping the text input. + viewModel.recommendMint(url) + newRecommendationInput = "" + }, + ) + } + } + if (recommendations.isEmpty()) { item { EmptyRecommendationsHint() } } else { @@ -228,6 +296,86 @@ private fun SettingsRow( } } +@Composable +private fun AddRecommendationRow( + input: String, + onInputChange: (String) -> Unit, + canAdd: Boolean, + onAdd: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = input, + onValueChange = onInputChange, + label = { Text(stringRes(R.string.cashu_settings_add_recommendation)) }, + placeholder = { Text("https://mint.example.com") }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(8.dp)) + IconButton(onClick = onAdd, enabled = canAdd) { + Icon( + symbol = MaterialSymbols.Add, + contentDescription = stringRes(R.string.cashu_settings_add_recommendation), + modifier = Modifier.size(22.dp), + tint = + if (canAdd) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } +} + +@Composable +private fun RecommendationSuggestionList( + suggestions: List, + onPick: (String) -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column(modifier = Modifier.padding(vertical = 4.dp)) { + suggestions.forEach { url -> + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { onPick(url) } + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + symbol = MaterialSymbols.AccountBalanceWallet, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = url, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + ) + Icon( + symbol = MaterialSymbols.ThumbUp, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } +} + @Composable private fun EmptyRecommendationsHint() { Card( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 73729db8af..1f9d49a8a4 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1926,6 +1926,7 @@ Delete recommendation Retract recommendation? Publish a delete request for your kind:38000 recommendation of %1$s. Relays that honor NIP-09 will drop it. + Recommend a mint Pay invoice Get quote Asking mint for a quote…