diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt index 4fc579daa9..9695346a70 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt @@ -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.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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListSelectUserScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListSelectUserScreen.kt index 9b0c9286c6..c53c8b8891 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListSelectUserScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListSelectUserScreen.kt @@ -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, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt index d29bb70375..58bcb90c2b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt @@ -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.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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt index 68d952c017..e626a094cd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt @@ -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(