feat: propagate distinct error types from ElectrumX to search UI
Add NamecoinLookupException sealed class to distinguish: - NameNotFound: name queried successfully but doesn't exist on blockchain - NameExpired: name exists but expired (>36000 blocks since last update) - ServersUnreachable: all ElectrumX servers failed with connection errors Previously, all three cases returned null from nameShowWithFallback, making it impossible for the UI to show meaningful feedback. Changes: - ElectrumxClient.connectAndQuery: throws NameNotFound/NameExpired instead of returning null for definitive blockchain answers - ElectrumxClient.nameShow: lets NamecoinLookupException propagate (only catches connection/IO errors as null) - ElectrumxClient.nameShowWithFallback: short-circuits on NameNotFound/ NameExpired (no point trying other servers for definitive answers), throws ServersUnreachable when all servers fail with connection errors - SearchBarViewModel: new namecoinSearchState flow with typed states (Idle/Loading/Resolved/NotFound/Expired/ServersUnreachable) for UI to show loading spinners, resolved profiles, and specific error messages. Derived namecoinResolvedUser from this flow. Discovered while porting to notedeck (damus-io/notedeck#1314).
This commit is contained in:
+44
-8
@@ -34,6 +34,8 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent
|
|||||||
import com.vitorpamplona.amethyst.model.Account
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
import com.vitorpamplona.amethyst.model.LocalCache
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
import com.vitorpamplona.amethyst.model.User
|
import com.vitorpamplona.amethyst.model.User
|
||||||
|
import com.vitorpamplona.amethyst.service.namecoin.NamecoinNameService
|
||||||
|
import com.vitorpamplona.quartz.nip05.namecoin.NamecoinLookupException
|
||||||
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState
|
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState
|
||||||
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
|
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
|
||||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
|
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
|
||||||
@@ -46,8 +48,8 @@ import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
|
|||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.debounce
|
import kotlinx.coroutines.flow.debounce
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.filter
|
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.mapLatest
|
import kotlinx.coroutines.flow.mapLatest
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
@@ -75,28 +77,62 @@ class SearchBarViewModel(
|
|||||||
val searchDataSourceState = SearchQueryState(MutableStateFlow(searchValue), account)
|
val searchDataSourceState = SearchQueryState(MutableStateFlow(searchValue), account)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves Namecoin identifiers (.bit / d/ / id/) via ElectrumX and
|
* Observable state for Namecoin resolution in the search bar.
|
||||||
* returns the matching [User] from LocalCache, or null.
|
* Allows the UI to show loading spinners, resolved profiles, and
|
||||||
|
* specific error messages ("name not found" vs "servers unreachable").
|
||||||
*/
|
*/
|
||||||
private val namecoinResolvedUser =
|
sealed class NamecoinSearchState {
|
||||||
|
data object Idle : NamecoinSearchState()
|
||||||
|
data object Loading : NamecoinSearchState()
|
||||||
|
data class Resolved(val user: User) : NamecoinSearchState()
|
||||||
|
data class NotFound(val name: String) : NamecoinSearchState()
|
||||||
|
data class Expired(val name: String) : NamecoinSearchState()
|
||||||
|
data object ServersUnreachable : NamecoinSearchState()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves Namecoin identifiers (.bit / d/ / id/) via ElectrumX.
|
||||||
|
* Emits typed state so the UI can show loading, resolved profile, or
|
||||||
|
* specific error messages.
|
||||||
|
*/
|
||||||
|
val namecoinSearchState: StateFlow<NamecoinSearchState> =
|
||||||
searchValueFlow
|
searchValueFlow
|
||||||
.debounce(400)
|
.debounce(400)
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.filter { NamecoinNameResolver.isNamecoinIdentifier(it) }
|
|
||||||
.mapLatest { term ->
|
.mapLatest { term ->
|
||||||
|
if (!NamecoinNameResolver.isNamecoinIdentifier(term)) {
|
||||||
|
return@mapLatest NamecoinSearchState.Idle
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
val result = Amethyst.instance.namecoinResolver.resolve(term)
|
val result = Amethyst.instance.namecoinResolver.resolve(term)
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
LocalCache.getOrCreateUser(result.pubkey)
|
NamecoinSearchState.Resolved(LocalCache.getOrCreateUser(result.pubkey))
|
||||||
} else {
|
} else {
|
||||||
null
|
NamecoinSearchState.NotFound(term)
|
||||||
}
|
}
|
||||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
|
} catch (e: NamecoinLookupException.NameNotFound) {
|
||||||
|
NamecoinSearchState.NotFound(e.name)
|
||||||
|
} catch (e: NamecoinLookupException.NameExpired) {
|
||||||
|
NamecoinSearchState.Expired(e.name)
|
||||||
|
} catch (e: NamecoinLookupException.ServersUnreachable) {
|
||||||
|
NamecoinSearchState.ServersUnreachable
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
NamecoinSearchState.ServersUnreachable
|
||||||
}
|
}
|
||||||
}.flowOn(Dispatchers.IO)
|
}.flowOn(Dispatchers.IO)
|
||||||
|
.stateIn(viewModelScope, WhileSubscribed(5000), NamecoinSearchState.Idle)
|
||||||
|
|
||||||
|
/** Convenience: the resolved user (or null) for combining with local search results. */
|
||||||
|
private val namecoinResolvedUser =
|
||||||
|
namecoinSearchState
|
||||||
|
.map { state ->
|
||||||
|
when (state) {
|
||||||
|
is NamecoinSearchState.Resolved -> state.user
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
.stateIn(viewModelScope, WhileSubscribed(5000), null)
|
.stateIn(viewModelScope, WhileSubscribed(5000), null)
|
||||||
|
|
||||||
val searchResultsUsers =
|
val searchResultsUsers =
|
||||||
|
|||||||
+64
-3
@@ -52,6 +52,48 @@ import javax.net.ssl.SSLSocketFactory
|
|||||||
import javax.net.ssl.TrustManager
|
import javax.net.ssl.TrustManager
|
||||||
import javax.net.ssl.X509TrustManager
|
import javax.net.ssl.X509TrustManager
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of an ElectrumX name_show query.
|
||||||
|
*
|
||||||
|
* Maps to the JSON fields returned by Namecoin Core / Electrum-NMC:
|
||||||
|
* { "name": "d/example", "value": "{...}", "txid": "abc...", "height": 12345, ... }
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class NameShowResult(
|
||||||
|
val name: String,
|
||||||
|
val value: String,
|
||||||
|
val txid: String? = null,
|
||||||
|
val height: Int? = null,
|
||||||
|
val expiresIn: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specific exception types for Namecoin resolution failures.
|
||||||
|
* Allows callers to distinguish "name doesn't exist" from "servers unreachable".
|
||||||
|
*/
|
||||||
|
sealed class NamecoinLookupException(message: String, cause: Throwable? = null) : Exception(message, cause) {
|
||||||
|
/** The name was queried successfully but does not exist on the blockchain. */
|
||||||
|
class NameNotFound(val name: String) : NamecoinLookupException("Name not found: $name")
|
||||||
|
|
||||||
|
/** The name has expired (>36000 blocks since last update). */
|
||||||
|
class NameExpired(val name: String) : NamecoinLookupException("Name expired: $name")
|
||||||
|
|
||||||
|
/** All ElectrumX servers were unreachable or returned errors. */
|
||||||
|
class ServersUnreachable(val lastError: Throwable? = null) :
|
||||||
|
NamecoinLookupException("All ElectrumX servers unreachable", lastError)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a single ElectrumX server endpoint.
|
||||||
|
*/
|
||||||
|
data class ElectrumxServer(
|
||||||
|
val host: String,
|
||||||
|
val port: Int,
|
||||||
|
val useSsl: Boolean = true,
|
||||||
|
/** If true, accept any certificate (self-signed, expired, etc.) */
|
||||||
|
val trustAllCerts: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lightweight, query-only ElectrumX client for Namecoin name resolution.
|
* Lightweight, query-only ElectrumX client for Namecoin name resolution.
|
||||||
*
|
*
|
||||||
@@ -126,6 +168,9 @@ class ElectrumXClient(
|
|||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
try {
|
try {
|
||||||
connectAndQuery(identifier, server)
|
connectAndQuery(identifier, server)
|
||||||
|
} catch (e: NamecoinLookupException) {
|
||||||
|
// Propagate name-not-found and expired — these are definitive answers.
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Log but don't crash — callers handle null gracefully.
|
// Log but don't crash — callers handle null gracefully.
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
@@ -140,15 +185,31 @@ class ElectrumXClient(
|
|||||||
* @param identifier Full Namecoin name, e.g. "d/example"
|
* @param identifier Full Namecoin name, e.g. "d/example"
|
||||||
* @param servers Ordered server list to try; defaults to [DEFAULT_ELECTRUMX_SERVERS]
|
* @param servers Ordered server list to try; defaults to [DEFAULT_ELECTRUMX_SERVERS]
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Try each server in order until one succeeds.
|
||||||
|
*
|
||||||
|
* @throws NamecoinLookupException.NameNotFound if the name definitively doesn't exist
|
||||||
|
* @throws NamecoinLookupException.NameExpired if the name has expired
|
||||||
|
* @throws NamecoinLookupException.ServersUnreachable if all servers failed with connection errors
|
||||||
|
*/
|
||||||
override suspend fun nameShowWithFallback(
|
override suspend fun nameShowWithFallback(
|
||||||
identifier: String,
|
identifier: String,
|
||||||
servers: List<ElectrumxServer>,
|
servers: List<ElectrumxServer>,
|
||||||
): NameShowResult? {
|
): NameShowResult? {
|
||||||
|
var lastError: Exception? = null
|
||||||
for (server in servers) {
|
for (server in servers) {
|
||||||
|
try {
|
||||||
val result = nameShow(identifier, server)
|
val result = nameShow(identifier, server)
|
||||||
if (result != null) return result
|
if (result != null) return result
|
||||||
|
} catch (e: NamecoinLookupException.NameNotFound) {
|
||||||
|
throw e // Definitive answer from blockchain — no point trying other servers
|
||||||
|
} catch (e: NamecoinLookupException.NameExpired) {
|
||||||
|
throw e // Definitive answer
|
||||||
|
} catch (e: Exception) {
|
||||||
|
lastError = e // Server error — try next server
|
||||||
}
|
}
|
||||||
return null
|
}
|
||||||
|
throw NamecoinLookupException.ServersUnreachable(lastError)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── internals ──────────────────────────────────────────────────────
|
// ── internals ──────────────────────────────────────────────────────
|
||||||
@@ -177,7 +238,7 @@ class ElectrumXClient(
|
|||||||
writer.println(historyReq)
|
writer.println(historyReq)
|
||||||
val historyResponse = reader.readLine() ?: return null
|
val historyResponse = reader.readLine() ?: return null
|
||||||
val historyEntries = parseHistoryResponse(historyResponse) ?: return null
|
val historyEntries = parseHistoryResponse(historyResponse) ?: return null
|
||||||
if (historyEntries.isEmpty()) return null
|
if (historyEntries.isEmpty()) throw NamecoinLookupException.NameNotFound(identifier)
|
||||||
|
|
||||||
// 4. Get the latest transaction (last entry = most recent update)
|
// 4. Get the latest transaction (last entry = most recent update)
|
||||||
val latestEntry = historyEntries.last()
|
val latestEntry = historyEntries.last()
|
||||||
@@ -198,7 +259,7 @@ class ElectrumXClient(
|
|||||||
if (currentHeight != null && height > 0) {
|
if (currentHeight != null && height > 0) {
|
||||||
val blocksSinceUpdate = currentHeight - height
|
val blocksSinceUpdate = currentHeight - height
|
||||||
if (blocksSinceUpdate >= NAME_EXPIRE_DEPTH) {
|
if (blocksSinceUpdate >= NAME_EXPIRE_DEPTH) {
|
||||||
return null // Name has expired
|
throw NamecoinLookupException.NameExpired(identifier)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user