feat: resolve bare .bit domains and show Namecoin resolution status in UI

Widen the existing NIP-05 resolution gate in UserSuggestionState and
SearchBarViewModel to also accept bare .bit domains and d//id/ Namecoin
identifiers. A synthesised Nip05Id is passed to the existing
nip05Client.get() path — which already routes .bit to the
NamecoinNameResolver — so no separate resolution logic is needed.

Add NamecoinResolutionState (Idle/Resolving/Resolved/Error) as a
StateFlow so the UI can show progress. ImportFollowListSelectUserScreen
and SearchScreen display an animated status banner (spinner while
resolving, chain emoji on success, warning on error) and label
Namecoin-resolved profiles in the results list.
This commit is contained in:
M
2026-03-26 07:40:25 +11:00
parent 2e02edc333
commit 0d83af8912
4 changed files with 353 additions and 18 deletions
@@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull
import com.vitorpamplona.quartz.nip05DnsIdentifiers.INip05Client
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
@@ -57,6 +58,44 @@ val userUriPrefixes =
DualCase("nostr:nprofile"),
)
/** UI state for Namecoin resolution progress. */
sealed class NamecoinResolutionState {
data object Idle : NamecoinResolutionState()
data object Resolving : NamecoinResolutionState()
data class Resolved(
val user: User,
val namecoinName: String,
) : NamecoinResolutionState()
data class Error(
val message: String,
) : NamecoinResolutionState()
}
/** Returns a [Nip05Id] for identifiers that should go through NIP-05 / Namecoin resolution. */
private fun toNip05IdOrNull(prefix: String): Nip05Id? =
when {
prefix.contains('@') -> {
Nip05Id.parse(prefix)
}
NamecoinNameResolver.isNamecoinIdentifier(prefix) -> {
if (prefix.endsWith(".bit", ignoreCase = true)) {
// Bare .bit domain → synthesize _@domain.bit so Nip05Client routes to Namecoin
Nip05Id("_", prefix.lowercase())
} else {
// d/ or id/ — wrap as NIP-05 so it reaches the resolver
Nip05Id("_", prefix.lowercase())
}
}
else -> {
null
}
}
@Stable
class UserSuggestionState(
val account: Account,
@@ -66,6 +105,9 @@ class UserSuggestionState(
val currentWord = MutableStateFlow("")
val searchDataSourceState = SearchQueryState(MutableStateFlow(""), account)
/** Tracks Namecoin resolution status for the UI. */
val namecoinState = MutableStateFlow<NamecoinResolutionState>(NamecoinResolutionState.Idle)
@OptIn(FlowPreview::class)
val searchTerm =
currentWord
@@ -82,23 +124,38 @@ class UserSuggestionState(
.map(::userSearchTermOrNull)
.map { prefix ->
if (prefix != null) {
if (prefix.contains('@')) {
runCatching {
Nip05Id.parse(prefix)?.let { nip05 ->
val nip05 = toNip05IdOrNull(prefix)
if (nip05 != null) {
val isNamecoin = NamecoinNameResolver.isNamecoinIdentifier(nip05.toValue())
if (isNamecoin) namecoinState.emit(NamecoinResolutionState.Resolving)
val user =
runCatching {
nip05Client.get(nip05)?.let { info ->
val user = account.cache.checkGetOrCreateUser(info.pubkey)
if (user != null) {
val u = account.cache.checkGetOrCreateUser(info.pubkey)
if (u != null) {
info.relays.forEach {
it.normalizeRelayUrlOrNull()?.let { relay ->
account.cache.relayHints.addKey(user.pubkey(), relay)
account.cache.relayHints.addKey(u.pubkey(), relay)
}
}
}
user
u
}
}.getOrNull()
if (isNamecoin) {
if (user != null) {
namecoinState.emit(NamecoinResolutionState.Resolved(user, prefix))
} else {
namecoinState.emit(NamecoinResolutionState.Error("Could not resolve $prefix via Namecoin"))
}
}.getOrNull()
} else {
namecoinState.emit(NamecoinResolutionState.Idle)
}
user
} else if (prefix.startsWithAny(userUriPrefixes)) {
namecoinState.emit(NamecoinResolutionState.Idle)
runCatching {
Nip19Parser.uriToRoute(prefix)?.entity?.let { parsed ->
when (parsed) {
@@ -125,11 +182,14 @@ class UserSuggestionState(
}
}.getOrNull()
} else if (prefix.length == 64 && Hex.isHex64(prefix)) {
namecoinState.emit(NamecoinResolutionState.Idle)
account.cache.getOrCreateUser(prefix)
} else {
namecoinState.emit(NamecoinResolutionState.Idle)
null
}
} else {
namecoinState.emit(NamecoinResolutionState.Idle)
null
}
}.flowOn(Dispatchers.IO)
@@ -20,6 +20,11 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.newUser
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -37,6 +42,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -67,6 +73,7 @@ import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDa
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.NamecoinResolutionState
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserLine
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -100,6 +107,10 @@ class ImportFollowListSelectUserViewModel(
userSuggestions.results
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
val namecoinState =
userSuggestions.namecoinState
.stateIn(viewModelScope, SharingStarted.Eagerly, NamecoinResolutionState.Idle)
class Factory(
val account: Account,
val nip05Client: INip05Client,
@@ -166,6 +177,10 @@ private fun InputSelectUserBody(
supportingText = { Text(stringRes(R.string.supports_npub_nip_05_hex_and_namecoin_bit_d_id)) },
)
Spacer(Modifier.height(4.dp))
NamecoinStatusBanner(viewModel)
Spacer(Modifier.height(8.dp))
CustomShowUserSuggestionList(
@@ -185,6 +200,88 @@ private fun InputSelectUserBody(
}
}
@Composable
private fun NamecoinStatusBanner(viewModel: ImportFollowListSelectUserViewModel) {
val ncState by viewModel.namecoinState.collectAsStateWithLifecycle()
AnimatedVisibility(
visible = ncState !is NamecoinResolutionState.Idle,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
when (val state = ncState) {
is NamecoinResolutionState.Resolving -> {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.width(8.dp))
Text(
"Resolving via Namecoin blockchain\u2026",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
is NamecoinResolutionState.Resolved -> {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"\u26D3\uFE0F",
style = MaterialTheme.typography.bodySmall,
)
Spacer(Modifier.width(6.dp))
Text(
"Resolved via Namecoin: ${state.namecoinName}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
)
}
}
is NamecoinResolutionState.Error -> {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"\u26A0\uFE0F",
style = MaterialTheme.typography.bodySmall,
)
Spacer(Modifier.width(6.dp))
Text(
state.message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
is NamecoinResolutionState.Idle -> {
// nothing
}
}
}
}
@Composable
private fun ImportHeader() {
Column {
@@ -244,6 +341,7 @@ fun CustomWatchResponses(
modifier: Modifier = Modifier,
) {
val suggestions by viewModel.results.collectAsStateWithLifecycle()
val ncState by viewModel.namecoinState.collectAsStateWithLifecycle()
if (suggestions.isNotEmpty()) {
LazyColumn(
@@ -252,7 +350,28 @@ fun CustomWatchResponses(
state = viewModel.listState,
) {
itemsIndexed(suggestions, key = { _, item -> item.pubkeyHex }) { _, item ->
UserLine(item, accountViewModel) { onSelect(item) }
val isNamecoinResult =
ncState is NamecoinResolutionState.Resolved &&
(ncState as NamecoinResolutionState.Resolved).user == item
if (isNamecoinResult) {
Column {
Row(
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"\u26D3\uFE0F Namecoin result",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
)
}
UserLine(item, accountViewModel) { onSelect(item) }
}
} else {
UserLine(item, accountViewModel) { onSelect(item) }
}
HorizontalDivider(
thickness = DividerThickness,
)
@@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.NamecoinResolutionState
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.userUriPrefixes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
@@ -43,6 +44,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull
import com.vitorpamplona.quartz.nip05DnsIdentifiers.INip05Client
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
@@ -66,6 +68,26 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
/** Returns a [Nip05Id] for identifiers that should go through NIP-05 / Namecoin resolution. */
private fun toNip05IdOrNull(term: String): Nip05Id? =
when {
term.contains('@') -> {
Nip05Id.parse(term)
}
NamecoinNameResolver.isNamecoinIdentifier(term) -> {
if (term.endsWith(".bit", ignoreCase = true)) {
Nip05Id("_", term.lowercase())
} else {
Nip05Id("_", term.lowercase())
}
}
else -> {
null
}
}
@Stable
@OptIn(FlowPreview::class)
class SearchBarViewModel(
@@ -79,6 +101,9 @@ class SearchBarViewModel(
val invalidations = MutableStateFlow(0)
val searchValueFlow = MutableStateFlow("")
/** Tracks Namecoin resolution status for the UI. */
val namecoinState = MutableStateFlow<NamecoinResolutionState>(NamecoinResolutionState.Idle)
val searchTerm =
searchValueFlow
.debounce(300)
@@ -95,23 +120,38 @@ class SearchBarViewModel(
searchTerm
.debounce(400)
.mapLatest { term ->
if (term.contains('@')) {
runCatching {
Nip05Id.parse(term)?.let { nip05 ->
val nip05 = toNip05IdOrNull(term)
if (nip05 != null) {
val isNamecoin = NamecoinNameResolver.isNamecoinIdentifier(nip05.toValue())
if (isNamecoin) namecoinState.emit(NamecoinResolutionState.Resolving)
val user =
runCatching {
nip05Client.get(nip05)?.let { info ->
val user = account.cache.checkGetOrCreateUser(info.pubkey)
if (user != null) {
val u = account.cache.checkGetOrCreateUser(info.pubkey)
if (u != null) {
info.relays.forEach {
it.normalizeRelayUrlOrNull()?.let { relay ->
account.cache.relayHints.addKey(user.pubkey(), relay)
account.cache.relayHints.addKey(u.pubkey(), relay)
}
}
}
user
u
}
}.getOrNull()
if (isNamecoin) {
if (user != null) {
namecoinState.emit(NamecoinResolutionState.Resolved(user, term))
} else {
namecoinState.emit(NamecoinResolutionState.Error("Could not resolve $term via Namecoin"))
}
}.getOrNull()
} else {
namecoinState.emit(NamecoinResolutionState.Idle)
}
user
} else if (term.startsWithAny(userUriPrefixes)) {
namecoinState.emit(NamecoinResolutionState.Idle)
runCatching {
Nip19Parser.uriToRoute(term)?.entity?.let { parsed ->
when (parsed) {
@@ -138,8 +178,10 @@ class SearchBarViewModel(
}
}.getOrNull()
} else if (term.length == 64 && Hex.isHex64(term)) {
namecoinState.emit(NamecoinResolutionState.Idle)
account.cache.getOrCreateUser(term)
} else {
namecoinState.emit(NamecoinResolutionState.Idle)
null
}
}.flowOn(Dispatchers.IO)
@@ -20,20 +20,29 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.search
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -67,6 +76,7 @@ import com.vitorpamplona.amethyst.ui.note.ClearTextIcon
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.SearchIcon
import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.NamecoinResolutionState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChannelName
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoClickableRow
@@ -160,7 +170,10 @@ private fun SearchBar(
}
}
SearchTextField(searchBarViewModel, Modifier.statusBarsPadding())
Column {
SearchTextField(searchBarViewModel, Modifier.statusBarsPadding())
SearchNamecoinStatusBanner(searchBarViewModel)
}
}
@Composable
@@ -216,6 +229,88 @@ private fun SearchTextField(
}
}
@Composable
private fun SearchNamecoinStatusBanner(searchBarViewModel: SearchBarViewModel) {
val ncState by searchBarViewModel.namecoinState.collectAsStateWithLifecycle()
AnimatedVisibility(
visible = ncState !is NamecoinResolutionState.Idle,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
when (val state = ncState) {
is NamecoinResolutionState.Resolving -> {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.width(8.dp))
Text(
"Resolving via Namecoin blockchain\u2026",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
is NamecoinResolutionState.Resolved -> {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"\u26D3\uFE0F",
style = MaterialTheme.typography.bodySmall,
)
Spacer(Modifier.width(6.dp))
Text(
"Resolved via Namecoin: ${state.namecoinName}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
)
}
}
is NamecoinResolutionState.Error -> {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"\u26A0\uFE0F",
style = MaterialTheme.typography.bodySmall,
)
Spacer(Modifier.width(6.dp))
Text(
state.message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
is NamecoinResolutionState.Idle -> {
// nothing
}
}
}
}
@Composable
private fun DisplaySearchResults(
searchBarViewModel: SearchBarViewModel,
@@ -233,6 +328,7 @@ private fun DisplaySearchResults(
val ephemeralChannels by searchBarViewModel.searchResultsEphemeralChannels.collectAsStateWithLifecycle()
val liveActivityChannels by searchBarViewModel.searchResultsLiveActivityChannels.collectAsStateWithLifecycle()
val notes by searchBarViewModel.searchResultsNotes.collectAsStateWithLifecycle()
val ncState by searchBarViewModel.namecoinState.collectAsStateWithLifecycle()
LazyColumn(
modifier = Modifier.fillMaxHeight(),
@@ -255,6 +351,24 @@ private fun DisplaySearchResults(
users,
key = { _, item -> "u" + item.pubkeyHex },
) { _, item ->
val isNamecoinResult =
ncState is NamecoinResolutionState.Resolved &&
(ncState as NamecoinResolutionState.Resolved).user == item
if (isNamecoinResult) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"\u26D3\uFE0F Namecoin result",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
)
}
}
UserCompose(item, accountViewModel = accountViewModel, nav = nav)
HorizontalDivider(