Merge PR: fix(desktop): resolve Namecoin names in the home tab search bar

Merges nostr proposal ae364d99 into main: adds Namecoin (.bit) name
resolution to the desktop home-tab search bar (desktopApp FeedScreen.kt).
Network IO runs off the UI thread, stale lookups cancel via effect re-keying,
and it reuses the shared NamecoinNameResolver. (Nit deferred: extract a shared
rememberNamecoinResolution helper to de-dup with SearchScreen.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-06-26 18:19:23 -04:00
co-authored by Claude Opus 4.8
@@ -90,6 +90,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.nip02FollowList.FollowAction
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinResolveState
import com.vitorpamplona.amethyst.commons.model.nip25Reactions.ReactionAction
import com.vitorpamplona.amethyst.commons.nip64Chess.RelaySyncStatus
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
@@ -117,6 +118,8 @@ import com.vitorpamplona.amethyst.desktop.feeds.DesktopFollowingFeedFilter
import com.vitorpamplona.amethyst.desktop.feeds.DesktopGlobalFeedFilter
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.platform.PlatformInfo
import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinPreferences
import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinService
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode
import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders
@@ -146,6 +149,8 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag
import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinResolveOutcome
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
@@ -1121,6 +1126,59 @@ private fun FeedTabsHeader(
val relayCategories = LocalRelayCategories.current
val searchRelays by relayCategories.searchRelays.collectAsState()
// Namecoin resolution wiring (matches SearchScreen.kt pattern).
// The home tab search bar must resolve <name>.bit / <name>.namecoin
// identifiers in the same way as the full Search screen so users get a
// consistent experience regardless of where they type the query.
val namecoinService = LocalNamecoinService.current
val namecoinPrefs = LocalNamecoinPreferences.current
val namecoinEnabled =
namecoinPrefs
?.settings
?.collectAsState()
?.value
?.enabled ?: false
val isNamecoinQuery =
remember(searchText.text) {
searchText.text.isNotBlank() &&
NamecoinNameResolver.isNamecoinIdentifier(searchText.text.trim())
}
var namecoinState by remember { mutableStateOf<NamecoinResolveState?>(null) }
// Resolve Namecoin identifiers, cancelling stale lookups when the user
// keeps typing. Uses resolveDetailed() so we get typed failure outcomes
// (NotFound / Expired / ServersUnreachable) instead of swallowed exceptions.
LaunchedEffect(searchText.text, namecoinEnabled) {
if (!namecoinEnabled || !isNamecoinQuery || namecoinService == null) {
namecoinState = null
return@LaunchedEffect
}
namecoinState = NamecoinResolveState.Loading
val outcome = namecoinService.resolveDetailed(searchText.text.trim())
namecoinState =
when (outcome) {
is NamecoinResolveOutcome.Success ->
NamecoinResolveState.Resolved(outcome.result)
is NamecoinResolveOutcome.NameNotFound -> NamecoinResolveState.NotFound
is NamecoinResolveOutcome.NoNostrField ->
NamecoinResolveState.Error("Name exists but has no Nostr pubkey")
is NamecoinResolveOutcome.MalformedRecord ->
NamecoinResolveState.Error(
"Namecoin record JSON is malformed: ${outcome.error}",
)
is NamecoinResolveOutcome.ServersUnreachable ->
NamecoinResolveState.Error(
"ElectrumX servers unreachable — check your connection or try again",
)
is NamecoinResolveOutcome.InvalidIdentifier ->
NamecoinResolveState.Error("Invalid Namecoin identifier")
is NamecoinResolveOutcome.Timeout ->
NamecoinResolveState.Error(
"Resolution timed out — servers may be slow, try again",
)
}
}
// Sync search text to AdvancedSearchBarState
LaunchedEffect(searchText.text) {
searchState.updateFromText(searchText.text)
@@ -1376,6 +1434,21 @@ private fun FeedTabsHeader(
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
// Namecoin lookup row — rendered above the regular search
// results when the query looks like a Namecoin identifier
// (matches the pattern used in SearchScreen.kt).
if (isNamecoinQuery && namecoinState != null) {
InlineNamecoinResultRow(
state = namecoinState!!,
query = searchText.text.trim(),
onClick = { pubkeyHex ->
onSearchExpandedChange(false)
onNavigateToProfile(pubkeyHex)
},
)
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
}
if (hasQuery) {
// Results stream in as they arrive
if (hasResults) {
@@ -1474,6 +1547,100 @@ private fun FeedTabsHeader(
}
}
/**
* Compact Namecoin lookup result row shown above the regular search
* results in the home tab inline search bar. Mirrors the resolve / loading
* / error UX of [SearchScreen]'s full Namecoin panel so users see the same
* outcome regardless of where they typed the identifier.
*/
@Composable
private fun InlineNamecoinResultRow(
state: NamecoinResolveState,
query: String,
onClick: (String) -> Unit,
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
"Namecoin lookup",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
when (state) {
is NamecoinResolveState.Loading -> {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
LinearProgressIndicator(modifier = Modifier.width(80.dp))
Text(
"Resolving $query",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
is NamecoinResolveState.Resolved -> {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable { onClick(state.result.pubkey) }
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
MaterialSymbols.Person,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary,
)
Column(modifier = Modifier.weight(1f)) {
Text(
"${state.result.namecoinName}${state.result.pubkey.take(12)}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
if (state.result.relays.isNotEmpty()) {
Text(
"Relays: ${state.result.relays.joinToString(", ")}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Icon(
MaterialSymbols.AutoMirrored.ArrowForward,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
is NamecoinResolveState.NotFound -> {
Text(
"Name not found on Namecoin blockchain",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
is NamecoinResolveState.Error -> {
Text(
"Resolution error: ${state.message}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
}
@Composable
private fun SearchHistorySection(
onOpenFullSearch: () -> Unit,