enforces specific keys when using nip05/namecoin
This commit is contained in:
+71
-75
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -94,6 +94,7 @@ fun SearchScreen(
|
|||||||
factory =
|
factory =
|
||||||
SearchBarViewModel.Factory(
|
SearchBarViewModel.Factory(
|
||||||
accountViewModel.account,
|
accountViewModel.account,
|
||||||
|
accountViewModel.nip05Client,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user