refactor: simplify .bit search resolution per review feedback

Address @vitorpamplona's review comments on the last commit:

1. Remove NamecoinResolutionState sealed class and status banners
   — the existing NIP-05 resolver already handles .bit, no need for
   separate Namecoin-specific UI state (spinner, chain emoji, error)

2. Remove separate toNip05IdOrNull() resolution path
   — widen the existing `if (term.contains('@'))` gate in both
   SearchBarViewModel and UserSuggestionState to also accept bare
   .bit domains (synthesize _@domain.bit → existing NIP-05 flow)

3. Remove d/ and id/ identifier support from search
   — per feedback, only NIP-05 style resolution (.bit) in search

4. Remove special Namecoin result rendering in ImportFollowList
   — just show UserLine for all results, no separation needed

5. Keep a lightweight 'Namecoin' label on resolved users in search
   — namecoinResolvedUser StateFlow tracks the .bit-resolved user,
   shown as a small label above the UserCompose row

Net: -405 lines, +50 lines across 6 files.
This commit is contained in:
M
2026-03-26 08:43:39 +11:00
parent 0d83af8912
commit 67fd48c5b8
6 changed files with 50 additions and 405 deletions
@@ -30,33 +30,23 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State
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.UserSearchDataSourceSubscription import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDataSourceSubscription
import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.ObserveAndRenderNIP05VerifiedSymbol
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.WatchAndDisplayNip05Row
import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.NIP05IconSize
import com.vitorpamplona.amethyst.ui.theme.Size55dp import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.nip05
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -155,51 +145,3 @@ fun UserLine(
}, },
) )
} }
@Composable
private fun WatchAndDisplayNip05Row(
user: User,
accountViewModel: AccountViewModel,
) {
val nip05StateMetadata by user.nip05State().flow.collectAsStateWithLifecycle()
when (val nip05State = nip05StateMetadata) {
is Nip05State.Exists -> {
NonClickableObserveAndDisplayNIP05(nip05State, accountViewModel)
}
else -> {
Text(
text = user.pubkeyDisplayHex(),
fontSize = Font14SP,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun NonClickableObserveAndDisplayNIP05(
nip05State: Nip05State.Exists,
accountViewModel: AccountViewModel,
) {
if (nip05State.nip05.name != "_") {
Text(
text = remember(nip05State) { AnnotatedString(nip05State.nip05.name) },
fontSize = Font14SP,
color = MaterialTheme.colorScheme.nip05,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
ObserveAndRenderNIP05VerifiedSymbol(nip05State, 1, NIP05IconSize, accountViewModel)
Text(
text = nip05State.nip05.domain,
style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.nip05, fontSize = Font14SP),
maxLines = 1,
overflow = TextOverflow.Visible,
)
}
@@ -31,7 +31,6 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull
import com.vitorpamplona.quartz.nip05DnsIdentifiers.INip05Client import com.vitorpamplona.quartz.nip05DnsIdentifiers.INip05Client
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
@@ -58,44 +57,6 @@ val userUriPrefixes =
DualCase("nostr:nprofile"), 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 @Stable
class UserSuggestionState( class UserSuggestionState(
val account: Account, val account: Account,
@@ -105,9 +66,6 @@ class UserSuggestionState(
val currentWord = MutableStateFlow("") val currentWord = MutableStateFlow("")
val searchDataSourceState = SearchQueryState(MutableStateFlow(""), account) val searchDataSourceState = SearchQueryState(MutableStateFlow(""), account)
/** Tracks Namecoin resolution status for the UI. */
val namecoinState = MutableStateFlow<NamecoinResolutionState>(NamecoinResolutionState.Idle)
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
val searchTerm = val searchTerm =
currentWord currentWord
@@ -124,38 +82,30 @@ class UserSuggestionState(
.map(::userSearchTermOrNull) .map(::userSearchTermOrNull)
.map { prefix -> .map { prefix ->
if (prefix != null) { if (prefix != null) {
val nip05 = toNip05IdOrNull(prefix) // NIP-05 resolution: user@domain or bare .bit domain
val nip05 =
if (prefix.contains('@')) {
Nip05Id.parse(prefix)
} else if (prefix.endsWith(".bit", ignoreCase = true)) {
Nip05Id("_", prefix.lowercase())
} else {
null
}
if (nip05 != null) { if (nip05 != null) {
val isNamecoin = NamecoinNameResolver.isNamecoinIdentifier(nip05.toValue()) runCatching {
if (isNamecoin) namecoinState.emit(NamecoinResolutionState.Resolving) nip05Client.get(nip05)?.let { info ->
val user = account.cache.checkGetOrCreateUser(info.pubkey)
val user = if (user != null) {
runCatching { info.relays.forEach {
nip05Client.get(nip05)?.let { info -> it.normalizeRelayUrlOrNull()?.let { relay ->
val u = account.cache.checkGetOrCreateUser(info.pubkey) account.cache.relayHints.addKey(user.pubkey(), relay)
if (u != null) {
info.relays.forEach {
it.normalizeRelayUrlOrNull()?.let { relay ->
account.cache.relayHints.addKey(u.pubkey(), relay)
}
} }
} }
u
} }
}.getOrNull() user
if (isNamecoin) {
if (user != null) {
namecoinState.emit(NamecoinResolutionState.Resolved(user, prefix))
} else {
namecoinState.emit(NamecoinResolutionState.Error("Could not resolve $prefix via Namecoin"))
} }
} else { }.getOrNull()
namecoinState.emit(NamecoinResolutionState.Idle)
}
user
} else if (prefix.startsWithAny(userUriPrefixes)) { } else if (prefix.startsWithAny(userUriPrefixes)) {
namecoinState.emit(NamecoinResolutionState.Idle)
runCatching { runCatching {
Nip19Parser.uriToRoute(prefix)?.entity?.let { parsed -> Nip19Parser.uriToRoute(prefix)?.entity?.let { parsed ->
when (parsed) { when (parsed) {
@@ -182,14 +132,11 @@ class UserSuggestionState(
} }
}.getOrNull() }.getOrNull()
} else if (prefix.length == 64 && Hex.isHex64(prefix)) { } else if (prefix.length == 64 && Hex.isHex64(prefix)) {
namecoinState.emit(NamecoinResolutionState.Idle)
account.cache.getOrCreateUser(prefix) account.cache.getOrCreateUser(prefix)
} else { } else {
namecoinState.emit(NamecoinResolutionState.Idle)
null null
} }
} else { } else {
namecoinState.emit(NamecoinResolutionState.Idle)
null null
} }
}.flowOn(Dispatchers.IO) }.flowOn(Dispatchers.IO)
@@ -20,11 +20,6 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.newUser 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@@ -42,7 +37,6 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -73,7 +67,6 @@ import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDa
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton 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.UserLine
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -107,10 +100,6 @@ class ImportFollowListSelectUserViewModel(
userSuggestions.results userSuggestions.results
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
val namecoinState =
userSuggestions.namecoinState
.stateIn(viewModelScope, SharingStarted.Eagerly, NamecoinResolutionState.Idle)
class Factory( class Factory(
val account: Account, val account: Account,
val nip05Client: INip05Client, val nip05Client: INip05Client,
@@ -177,10 +166,6 @@ private fun InputSelectUserBody(
supportingText = { Text(stringRes(R.string.supports_npub_nip_05_hex_and_namecoin_bit_d_id)) }, 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)) Spacer(Modifier.height(8.dp))
CustomShowUserSuggestionList( CustomShowUserSuggestionList(
@@ -200,88 +185,6 @@ 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 @Composable
private fun ImportHeader() { private fun ImportHeader() {
Column { Column {
@@ -341,7 +244,6 @@ fun CustomWatchResponses(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val suggestions by viewModel.results.collectAsStateWithLifecycle() val suggestions by viewModel.results.collectAsStateWithLifecycle()
val ncState by viewModel.namecoinState.collectAsStateWithLifecycle()
if (suggestions.isNotEmpty()) { if (suggestions.isNotEmpty()) {
LazyColumn( LazyColumn(
@@ -350,28 +252,7 @@ fun CustomWatchResponses(
state = viewModel.listState, state = viewModel.listState,
) { ) {
itemsIndexed(suggestions, key = { _, item -> item.pubkeyHex }) { _, item -> itemsIndexed(suggestions, key = { _, item -> item.pubkeyHex }) { _, item ->
val isNamecoinResult = UserLine(item, accountViewModel) { onSelect(item) }
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( HorizontalDivider(
thickness = DividerThickness, thickness = DividerThickness,
) )
@@ -36,7 +36,6 @@ 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.NamecoinResolutionState
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.userUriPrefixes 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.core.toHexKey
@@ -44,7 +43,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull
import com.vitorpamplona.quartz.nip05DnsIdentifiers.INip05Client import com.vitorpamplona.quartz.nip05DnsIdentifiers.INip05Client
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
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.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
@@ -59,6 +57,7 @@ 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
@@ -68,26 +67,6 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update 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 @Stable
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
class SearchBarViewModel( class SearchBarViewModel(
@@ -101,9 +80,6 @@ class SearchBarViewModel(
val invalidations = MutableStateFlow(0) val invalidations = MutableStateFlow(0)
val searchValueFlow = MutableStateFlow("") val searchValueFlow = MutableStateFlow("")
/** Tracks Namecoin resolution status for the UI. */
val namecoinState = MutableStateFlow<NamecoinResolutionState>(NamecoinResolutionState.Idle)
val searchTerm = val searchTerm =
searchValueFlow searchValueFlow
.debounce(300) .debounce(300)
@@ -115,16 +91,27 @@ class SearchBarViewModel(
val listState: LazyListState = LazyListState(0, 0) val listState: LazyListState = LazyListState(0, 0)
/** The user resolved via Namecoin (.bit), if any, for the current search. */
private val _namecoinResolvedUser = MutableStateFlow<User?>(null)
val namecoinResolvedUser: StateFlow<User?> = _namecoinResolvedUser
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
val directNip05Resolver: Flow<User?> = val directNip05Resolver: Flow<User?> =
searchTerm searchTerm
.debounce(400) .debounce(400)
.mapLatest { term -> .mapLatest { term ->
val nip05 = toNip05IdOrNull(term) // NIP-05 resolution: user@domain or bare .bit domain
val nip05 =
if (term.contains('@')) {
Nip05Id.parse(term)
} else if (term.endsWith(".bit", ignoreCase = true)) {
// Bare .bit domain → synthesize _@domain.bit
Nip05Id("_", term.lowercase())
} else {
null
}
val isNamecoin = nip05 != null && nip05.domain.endsWith(".bit", ignoreCase = true)
if (nip05 != null) { if (nip05 != null) {
val isNamecoin = NamecoinNameResolver.isNamecoinIdentifier(nip05.toValue())
if (isNamecoin) namecoinState.emit(NamecoinResolutionState.Resolving)
val user = val user =
runCatching { runCatching {
nip05Client.get(nip05)?.let { info -> nip05Client.get(nip05)?.let { info ->
@@ -139,19 +126,10 @@ class SearchBarViewModel(
u u
} }
}.getOrNull() }.getOrNull()
_namecoinResolvedUser.value = if (isNamecoin) user else null
if (isNamecoin) {
if (user != null) {
namecoinState.emit(NamecoinResolutionState.Resolved(user, term))
} else {
namecoinState.emit(NamecoinResolutionState.Error("Could not resolve $term via Namecoin"))
}
} else {
namecoinState.emit(NamecoinResolutionState.Idle)
}
user user
} else if (term.startsWithAny(userUriPrefixes)) { } else if (term.startsWithAny(userUriPrefixes)) {
namecoinState.emit(NamecoinResolutionState.Idle) _namecoinResolvedUser.value = null
runCatching { runCatching {
Nip19Parser.uriToRoute(term)?.entity?.let { parsed -> Nip19Parser.uriToRoute(term)?.entity?.let { parsed ->
when (parsed) { when (parsed) {
@@ -178,10 +156,10 @@ class SearchBarViewModel(
} }
}.getOrNull() }.getOrNull()
} else if (term.length == 64 && Hex.isHex64(term)) { } else if (term.length == 64 && Hex.isHex64(term)) {
namecoinState.emit(NamecoinResolutionState.Idle) _namecoinResolvedUser.value = null
account.cache.getOrCreateUser(term) account.cache.getOrCreateUser(term)
} else { } else {
namecoinState.emit(NamecoinResolutionState.Idle) _namecoinResolvedUser.value = null
null null
} }
}.flowOn(Dispatchers.IO) }.flowOn(Dispatchers.IO)
@@ -20,29 +20,20 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.search 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.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -76,7 +67,6 @@ import com.vitorpamplona.amethyst.ui.note.ClearTextIcon
import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.SearchIcon import com.vitorpamplona.amethyst.ui.note.SearchIcon
import com.vitorpamplona.amethyst.ui.note.UserCompose 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.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChannelName import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChannelName
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoClickableRow import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoClickableRow
@@ -170,10 +160,7 @@ private fun SearchBar(
} }
} }
Column { SearchTextField(searchBarViewModel, Modifier.statusBarsPadding())
SearchTextField(searchBarViewModel, Modifier.statusBarsPadding())
SearchNamecoinStatusBanner(searchBarViewModel)
}
} }
@Composable @Composable
@@ -229,88 +216,6 @@ 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 @Composable
private fun DisplaySearchResults( private fun DisplaySearchResults(
searchBarViewModel: SearchBarViewModel, searchBarViewModel: SearchBarViewModel,
@@ -324,11 +229,11 @@ private fun DisplaySearchResults(
val hashTags by searchBarViewModel.hashtagResults.collectAsStateWithLifecycle() val hashTags by searchBarViewModel.hashtagResults.collectAsStateWithLifecycle()
val relays by searchBarViewModel.relayResults.collectAsStateWithLifecycle() val relays by searchBarViewModel.relayResults.collectAsStateWithLifecycle()
val users by searchBarViewModel.searchResultsUsers.collectAsStateWithLifecycle() val users by searchBarViewModel.searchResultsUsers.collectAsStateWithLifecycle()
val namecoinUser by searchBarViewModel.namecoinResolvedUser.collectAsStateWithLifecycle()
val publicChatChannels by searchBarViewModel.searchResultsPublicChatChannels.collectAsStateWithLifecycle() val publicChatChannels by searchBarViewModel.searchResultsPublicChatChannels.collectAsStateWithLifecycle()
val ephemeralChannels by searchBarViewModel.searchResultsEphemeralChannels.collectAsStateWithLifecycle() val ephemeralChannels by searchBarViewModel.searchResultsEphemeralChannels.collectAsStateWithLifecycle()
val liveActivityChannels by searchBarViewModel.searchResultsLiveActivityChannels.collectAsStateWithLifecycle() val liveActivityChannels by searchBarViewModel.searchResultsLiveActivityChannels.collectAsStateWithLifecycle()
val notes by searchBarViewModel.searchResultsNotes.collectAsStateWithLifecycle() val notes by searchBarViewModel.searchResultsNotes.collectAsStateWithLifecycle()
val ncState by searchBarViewModel.namecoinState.collectAsStateWithLifecycle()
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxHeight(), modifier = Modifier.fillMaxHeight(),
@@ -351,22 +256,14 @@ private fun DisplaySearchResults(
users, users,
key = { _, item -> "u" + item.pubkeyHex }, key = { _, item -> "u" + item.pubkeyHex },
) { _, item -> ) { _, item ->
val isNamecoinResult = if (namecoinUser == item) {
ncState is NamecoinResolutionState.Resolved && Text(
(ncState as NamecoinResolutionState.Resolved).user == item "\u26D3\uFE0F Namecoin",
style = MaterialTheme.typography.labelSmall,
if (isNamecoinResult) { color = MaterialTheme.colorScheme.primary,
Row( fontWeight = FontWeight.Medium,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp), 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) UserCompose(item, accountViewModel = accountViewModel, nav = nav)
+1 -1
View File
@@ -1820,7 +1820,7 @@
<string name="select_users_to_follow">Select Users to Follow</string> <string name="select_users_to_follow">Select Users to Follow</string>
<string name="profile_to_import_from">Profile to import from</string> <string name="profile_to_import_from">Profile to import from</string>
<string name="name_search_npub1_alice_example_com">search, npub1…, alice@example.com</string> <string name="name_search_npub1_alice_example_com">search, npub1…, alice@example.com</string>
<string name="supports_npub_nip_05_hex_and_namecoin_bit_d_id">Supports npub, nprofile, NIP-05, hex, and namecoin (.bit, d/, id/)</string> <string name="supports_npub_nip_05_hex_and_namecoin_bit_d_id">Supports npub, nprofile, NIP-05, hex, and Namecoin (.bit)</string>
<string name="look_up_follow_list">Look Up Follow List</string> <string name="look_up_follow_list">Look Up Follow List</string>
<string name="tip">Tip</string> <string name="tip">Tip</string>
<string name="accounts_found">%1$d accounts found</string> <string name="accounts_found">%1$d accounts found</string>