enforces specific keys when using nip05/namecoin

This commit is contained in:
Vitor Pamplona
2026-03-10 16:41:59 -04:00
parent 9a3b11e522
commit 26b7efafe8
2 changed files with 72 additions and 75 deletions
@@ -29,30 +29,37 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent 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.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.amethyst.ui.note.creators.userSuggestions.userUriPrefixes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinLookupException import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver import com.vitorpamplona.quartz.nip05DnsIdentifiers.INip05Client
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id
import com.vitorpamplona.quartz.nip10Notes.content.findHashtags import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.Rfc3986 import com.vitorpamplona.quartz.utils.Rfc3986
import com.vitorpamplona.quartz.utils.startsWithAny
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
import kotlinx.coroutines.flow.StateFlow
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.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
@@ -62,6 +69,7 @@ import kotlinx.coroutines.flow.update
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
class SearchBarViewModel( class SearchBarViewModel(
val account: Account, val account: Account,
val nip05Client: INip05Client,
) : ViewModel(), ) : ViewModel(),
InvalidatableContent { InvalidatableContent {
val focusRequester = FocusRequester() val focusRequester = FocusRequester()
@@ -79,86 +87,73 @@ class SearchBarViewModel(
val searchDataSourceState = SearchQueryState(MutableStateFlow(searchValue), account) val searchDataSourceState = SearchQueryState(MutableStateFlow(searchValue), account)
/** val directNip05Resolver: Flow<User?> =
* Observable state for Namecoin resolution in the search bar.
* Allows the UI to show loading spinners, resolved profiles, and
* specific error messages ("name not found" vs "servers unreachable").
*/
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()
.mapLatest { term -> .mapLatest { term ->
if (!NamecoinNameResolver.isNamecoinIdentifier(term)) { if (term.contains('@')) {
return@mapLatest NamecoinSearchState.Idle runCatching {
} Nip05Id.parse(term)?.let { nip05 ->
try { nip05Client.get(nip05)?.let { info ->
val result = Amethyst.instance.namecoinResolver.resolve(term) val user = account.cache.checkGetOrCreateUser(info.pubkey)
if (result != null) { if (user != null) {
NamecoinSearchState.Resolved(LocalCache.getOrCreateUser(result.pubkey)) info.relays.forEach {
} else { it.normalizeRelayUrlOrNull()?.let { relay ->
NamecoinSearchState.NotFound(term) account.cache.relayHints.addKey(user.pubkey(), relay)
} }
} catch (e: kotlinx.coroutines.CancellationException) { }
throw e }
} catch (e: NamecoinLookupException.NameNotFound) { user
NamecoinSearchState.NotFound(e.name) }
} catch (e: NamecoinLookupException.NameExpired) { }
NamecoinSearchState.Expired(e.name) }.getOrNull()
} catch (e: NamecoinLookupException.ServersUnreachable) { } else if (term.startsWithAny(userUriPrefixes)) {
NamecoinSearchState.ServersUnreachable runCatching {
} catch (_: Exception) { Nip19Parser.uriToRoute(term)?.entity?.let { parsed ->
NamecoinSearchState.ServersUnreachable when (parsed) {
is NSec -> {
account.cache.getOrCreateUser(parsed.toPubKey().toHexKey())
}
is NPub -> {
account.cache.getOrCreateUser(parsed.hex)
}
is NProfile -> {
val user = account.cache.getOrCreateUser(parsed.hex)
parsed.relay.forEach { relay ->
account.cache.relayHints.addKey(user.pubkey(), relay)
}
user
}
else -> {
null
}
}
}
}.getOrNull()
} else if (term.length == 64 && Hex.isHex64(term)) {
account.cache.getOrCreateUser(term)
} else {
null
} }
}.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)
val searchResultsUsers = val searchResultsUsers =
combine( combine(
searchValueFlow.debounce(100), searchValueFlow.debounce(100),
invalidations.debounce(100), invalidations.debounce(100),
namecoinResolvedUser, directNip05Resolver,
) { term, version, namecoinUser -> ) { term, version, nip05Resolver ->
val localResults = LocalCache.findUsersStartingWith(term, account) if (nip05Resolver != null) {
if (namecoinUser != null && localResults.none { it.pubkeyHex == namecoinUser.pubkeyHex }) { return@combine listOf(nip05Resolver)
listOf(namecoinUser) + localResults }
if (term.isNotBlank()) {
LocalCache.findUsersStartingWith(term, account)
} else { } else {
localResults emptyList()
} }
}.flowOn(Dispatchers.IO) }.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList()) .stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
@@ -265,8 +260,9 @@ class SearchBarViewModel(
class Factory( class Factory(
val account: Account, val account: Account,
val nip05: INip05Client,
) : ViewModelProvider.Factory { ) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = SearchBarViewModel(account) as T override fun <T : ViewModel> create(modelClass: Class<T>): T = SearchBarViewModel(account, nip05) as T
} }
} }
@@ -94,6 +94,7 @@ fun SearchScreen(
factory = factory =
SearchBarViewModel.Factory( SearchBarViewModel.Factory(
accountViewModel.account, accountViewModel.account,
accountViewModel.nip05Client,
), ),
) )