mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
feat(cashu): mint URL directory + autocomplete
Adds a cache-backed Cashu mint directory sibling to LocalCache.relayHints that aggregates mint URLs from every relevant event the cache sees, and wires it into the AddCashuWallet mint-URL text field as inline autocomplete so users don't have to remember mint URLs. What feeds the directory: - NutzapInfoEvent (kind:10019) — every nostr user with a Cashu wallet publishes their accepted mints there. A typical inbox of cached profiles seeds a useful starter directory automatically. - MintRecommendationEvent (kind:38000) — explicit public vouches. - CashuMintEvent (kind:38172) — formal mint announcements from the NIP-87 directory subscription. How it's populated: - LocalCache.updateMintIndex(event) is called from justConsumeAndUpdateIndexes alongside updateHintIndexes, so every new event with a mint URL adds to the index. wasNew gating prevents re-emissions from inflating popularity counters. - LocalCache.ensureMintDirectoryBackfilled() does a one-shot scan of the existing notes + addressables maps. The autocomplete UI kicks this in a LaunchedEffect on screen open so suggestions are useful before the next relay round-trip. Where it surfaces today: - AddCashuWalletScreen — under the mint-URL OutlinedTextField, a MintSuggestionList card shows up to 6 cache-derived suggestions ranked by popularity desc + URL asc. Tapping a row fills the field (does not auto-add — users typically want to Verify first). Filters out URLs the user already added and exact matches of what they typed. The MintPicker dropdown inside the Receive / Send dialogs is unchanged — those only need to choose between mints the user already has in their wallet, so no directory autocomplete applies there. Tests: 8 unit tests cover normalisation (case-insensitive, trailing-slash stripping, http(s) gating), popularity ranking, substring filtering, limit enforcement, and malformed-URL handling. URL normalisation: trimmed, lower-cased, trailing `/` stripped, scheme must be http(s). Same URL with different casing or trailing slash collapses to one entry so popularity counts correctly. Implementation notes: - MintDirectoryIndex lives in commons/jvmAndroid (uses ConcurrentHashMap; iOS doesn't ship Cashu wallet yet). - Thread-safe; safe to read from any dispatcher. - No persistence — purely in-memory, accumulates over the session. - Entries are never removed: stale entries don't hurt (user always verifies before adding), and tracking which event added which URL would add bookkeeping without UX benefit. https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
This commit is contained in:
@@ -25,6 +25,7 @@ package com.vitorpamplona.amethyst.model
|
||||
import android.util.LruCache
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.cashu.MintDirectoryIndex
|
||||
import com.vitorpamplona.amethyst.commons.model.Channel
|
||||
import com.vitorpamplona.amethyst.commons.model.OnchainZapStatus
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
@@ -338,6 +339,45 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
|
||||
val relayHints = HintIndexer()
|
||||
|
||||
/**
|
||||
* Cashu mint URL directory, populated passively as
|
||||
* `NutzapInfoEvent` / `MintRecommendationEvent` / `CashuMintEvent`
|
||||
* flow through `updateMintIndex`. Backs the autocomplete in any text
|
||||
* field where the user types a mint URL.
|
||||
*
|
||||
* Use [mintDirectoryBackfilled] semantics via [ensureMintDirectoryBackfilled]
|
||||
* when reading from the UI — the first call sweeps already-cached
|
||||
* events so suggestions are useful before the next relay round-trip.
|
||||
*/
|
||||
val mintDirectory = MintDirectoryIndex()
|
||||
|
||||
@Volatile private var mintDirectoryBackfilled = false
|
||||
private val mintDirectoryBackfillLock = Any()
|
||||
|
||||
/**
|
||||
* Sweeps `notes` + `addressables` for any NIP-87 / NIP-61 event the
|
||||
* cache already holds and feeds them into [mintDirectory]. The wallet
|
||||
* directory + every other user's kind:10019 we've ever loaded may
|
||||
* have arrived BEFORE [updateMintIndex] existed (or before any
|
||||
* caller cared), so without this backfill the autocomplete is empty
|
||||
* until new events arrive.
|
||||
*
|
||||
* Idempotent — subsequent calls return immediately. Best-effort: a
|
||||
* scan failure is swallowed so the index stays usable even if the
|
||||
* cache is in an unexpected state.
|
||||
*/
|
||||
fun ensureMintDirectoryBackfilled() {
|
||||
if (mintDirectoryBackfilled) return
|
||||
synchronized(mintDirectoryBackfillLock) {
|
||||
if (mintDirectoryBackfilled) return
|
||||
runCatching {
|
||||
notes.forEach { _, note -> note.event?.let(::updateMintIndex) }
|
||||
addressables.forEach { _, note -> note.event?.let(::updateMintIndex) }
|
||||
}
|
||||
mintDirectoryBackfilled = true
|
||||
}
|
||||
}
|
||||
|
||||
val deletionIndex = DeletionIndex()
|
||||
|
||||
/**
|
||||
@@ -2922,6 +2962,7 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
|
||||
if (wasNew) {
|
||||
updateHintIndexes(event)
|
||||
updateMintIndex(event)
|
||||
}
|
||||
|
||||
if (relay != null) {
|
||||
@@ -2985,6 +3026,29 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Feeds the Cashu mint URL directory from every event that names a
|
||||
* mint, regardless of which user authored it. Three sources today:
|
||||
*
|
||||
* - NutzapInfoEvent (kind:10019) — every nostr user with a Cashu
|
||||
* wallet publishes their accepted mints here, so a typical inbox
|
||||
* of cached profiles seeds a useful starter directory.
|
||||
* - MintRecommendationEvent (kind:38000) — explicit public vouches.
|
||||
* - CashuMintEvent (kind:38172) — formal mint announcements.
|
||||
*
|
||||
* Called only when the event is newly consumed (mirrors
|
||||
* updateHintIndexes) so a re-emission of a cached event doesn't
|
||||
* inflate the popularity counter.
|
||||
*/
|
||||
fun updateMintIndex(event: Event) {
|
||||
when (event) {
|
||||
is NutzapInfoEvent -> mintDirectory.addAll(event.mints().map { it.mintUrl })
|
||||
is MintRecommendationEvent -> mintDirectory.addAll(event.mintUrls())
|
||||
is CashuMintEvent -> event.mintUrl()?.let { mintDirectory.add(it) }
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun justConsumeInnerInner(
|
||||
event: Event,
|
||||
|
||||
+80
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -31,6 +32,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
@@ -49,6 +51,7 @@ 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.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -62,6 +65,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
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.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
@@ -83,6 +87,11 @@ fun AddCashuWalletScreen(
|
||||
|
||||
val mints = remember { mutableStateListOf<String>() }
|
||||
var mintInput by remember { mutableStateOf("") }
|
||||
|
||||
// Kick the one-shot backfill so the mint-URL autocomplete has
|
||||
// suggestions on first open instead of waiting for the next relay
|
||||
// round-trip. Cheap (one sweep over the existing cache); idempotent.
|
||||
LaunchedEffect(Unit) { LocalCache.ensureMintDirectoryBackfilled() }
|
||||
var keyMode by remember {
|
||||
mutableStateOf(
|
||||
if (isEditMode) CashuWalletViewModel.P2pkKeyMode.KeepCurrent else CashuWalletViewModel.P2pkKeyMode.AutoGenerate,
|
||||
@@ -229,6 +238,33 @@ fun AddCashuWalletScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Autocomplete from the cache-backed mint directory.
|
||||
// Suggestions are filtered to URLs the user hasn't already
|
||||
// added and that aren't an exact case-insensitive match for
|
||||
// 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.
|
||||
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 (suggestions.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
MintSuggestionList(
|
||||
suggestions = suggestions,
|
||||
onPick = { url ->
|
||||
mintInput = url
|
||||
viewModel.resetMintPing()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
when (val ps = pingState) {
|
||||
is MintPingState.Ok -> {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
@@ -377,3 +413,47 @@ private fun P2pkRadio(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache-backed mint-URL autocomplete rendered inline under the mint
|
||||
* input. Each row fills the text field on tap so the user can verify /
|
||||
* add the same way they would with a hand-typed URL — we deliberately
|
||||
* don't auto-add on tap because users often want to ping first.
|
||||
*/
|
||||
@Composable
|
||||
private fun MintSuggestionList(
|
||||
suggestions: List<String>,
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.cashu
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* In-memory directory of Cashu mint URLs the app has seen on the network,
|
||||
* sibling to `LocalCache.relayHints`. Used to offer autocomplete in any
|
||||
* text field where the user types a mint URL — typing "min" suggests
|
||||
* mints other nostr users have already published in a kind:10019
|
||||
* (`NutzapInfoEvent`), kind:38000 (`MintRecommendationEvent`), or kind:38172
|
||||
* (`CashuMintEvent`), so the user doesn't have to memorise URLs.
|
||||
*
|
||||
* Population is passive — `LocalCache.updateMintIndex(event)` adds entries
|
||||
* as events flow through `justConsumeAndUpdateIndexes`. Entries are never
|
||||
* removed: the index is a best-effort suggestion source, not authoritative,
|
||||
* and a still-listed mint URL never hurts (the user can always tap Verify
|
||||
* before adding it). Each call to [add] increments a counter that
|
||||
* [suggest] uses to rank popular mints first.
|
||||
*
|
||||
* Normalisation: incoming URLs are trimmed, lower-cased, and the trailing
|
||||
* `/` is stripped so `https://Mint.Example.com/` and `https://mint.example.com`
|
||||
* collapse to the same entry. URLs that don't carry an `http://` or
|
||||
* `https://` scheme are dropped — autocomplete on garbage tags would
|
||||
* surface noise.
|
||||
*
|
||||
* Thread-safe via `ConcurrentHashMap`; safe to call from any dispatcher.
|
||||
*/
|
||||
class MintDirectoryIndex {
|
||||
private val counts = ConcurrentHashMap<String, Int>()
|
||||
|
||||
fun add(rawUrl: String) {
|
||||
val key = normalize(rawUrl) ?: return
|
||||
counts.merge(key, 1, Int::plus)
|
||||
}
|
||||
|
||||
fun addAll(rawUrls: Iterable<String>) = rawUrls.forEach(::add)
|
||||
|
||||
/**
|
||||
* Up to [limit] mint URLs whose normalised form contains [query]
|
||||
* (case-insensitive substring), ranked by occurrence count descending
|
||||
* then by URL ascending. Empty [query] returns the most popular mints
|
||||
* overall, capped at [limit].
|
||||
*/
|
||||
fun suggest(
|
||||
query: String,
|
||||
limit: Int = 8,
|
||||
): List<String> {
|
||||
val needle = query.trim().lowercase()
|
||||
val ranking =
|
||||
compareByDescending<Map.Entry<String, Int>> { it.value }
|
||||
.thenBy { it.key }
|
||||
return counts.entries
|
||||
.asSequence()
|
||||
.filter { needle.isEmpty() || it.key.contains(needle) }
|
||||
.sortedWith(ranking)
|
||||
.take(limit)
|
||||
.map { it.key }
|
||||
.toList()
|
||||
}
|
||||
|
||||
/** Total number of unique mint URLs in the index. */
|
||||
fun size(): Int = counts.size
|
||||
|
||||
/** Clears the index — for tests; production should let it accumulate. */
|
||||
fun clear() = counts.clear()
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Returns the canonical lowercased URL with trailing `/` stripped,
|
||||
* or `null` if the input doesn't look like an HTTP(S) URL.
|
||||
*/
|
||||
fun normalize(raw: String): String? {
|
||||
val trimmed = raw.trim()
|
||||
if (trimmed.isEmpty()) return null
|
||||
val lower = trimmed.lowercase()
|
||||
if (!lower.startsWith("https://") && !lower.startsWith("http://")) return null
|
||||
return lower.trimEnd('/')
|
||||
}
|
||||
}
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.cashu
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class MintDirectoryIndexTest {
|
||||
@Test
|
||||
fun `normalize lowercases, trims, strips trailing slash`() {
|
||||
assertEquals("https://mint.example.com", MintDirectoryIndex.normalize(" https://Mint.Example.com/ "))
|
||||
assertEquals("https://mint.example.com", MintDirectoryIndex.normalize("https://mint.example.com"))
|
||||
assertEquals("http://localhost:3338", MintDirectoryIndex.normalize("http://localhost:3338/"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `normalize rejects non-http urls`() {
|
||||
assertNull(MintDirectoryIndex.normalize("mint.example.com"))
|
||||
assertNull(MintDirectoryIndex.normalize("ftp://mint.example.com"))
|
||||
assertNull(MintDirectoryIndex.normalize(""))
|
||||
assertNull(MintDirectoryIndex.normalize(" "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `add dedupes case-insensitively`() {
|
||||
val idx = MintDirectoryIndex()
|
||||
idx.add("https://mint.example.com")
|
||||
idx.add("https://Mint.Example.com/")
|
||||
idx.add("HTTPS://MINT.EXAMPLE.COM")
|
||||
assertEquals(1, idx.size())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `suggest ranks more-popular mints first`() {
|
||||
val idx = MintDirectoryIndex()
|
||||
// mint.b seen 3 times, mint.a once, mint.c twice
|
||||
repeat(3) { idx.add("https://mint.b.example.com") }
|
||||
idx.add("https://mint.a.example.com")
|
||||
repeat(2) { idx.add("https://mint.c.example.com") }
|
||||
|
||||
val all = idx.suggest("")
|
||||
assertEquals(
|
||||
listOf(
|
||||
"https://mint.b.example.com",
|
||||
"https://mint.c.example.com",
|
||||
"https://mint.a.example.com",
|
||||
),
|
||||
all,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `suggest filters by substring case-insensitively`() {
|
||||
val idx = MintDirectoryIndex()
|
||||
idx.add("https://mint.example.com")
|
||||
idx.add("https://nutmix.io")
|
||||
idx.add("https://my-mint.org")
|
||||
|
||||
val hits = idx.suggest("MINT")
|
||||
assertEquals(setOf("https://mint.example.com", "https://my-mint.org"), hits.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `suggest honors limit`() {
|
||||
val idx = MintDirectoryIndex()
|
||||
repeat(10) { i -> idx.add("https://mint-$i.example.com") }
|
||||
assertEquals(3, idx.suggest("", limit = 3).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty query returns all mints sorted`() {
|
||||
val idx = MintDirectoryIndex()
|
||||
idx.add("https://b.example.com")
|
||||
idx.add("https://a.example.com")
|
||||
// Same count, so alphabetical fallback applies.
|
||||
assertEquals(
|
||||
listOf("https://a.example.com", "https://b.example.com"),
|
||||
idx.suggest(""),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `add drops malformed urls silently`() {
|
||||
val idx = MintDirectoryIndex()
|
||||
idx.add("not-a-url")
|
||||
idx.add("")
|
||||
idx.add("ftp://example.com")
|
||||
idx.add("https://valid.example.com")
|
||||
assertEquals(1, idx.size())
|
||||
assertTrue(idx.suggest("").contains("https://valid.example.com"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user