Merge pull request #2956 from mstrofnone/feat/onchain-zap-namecoin-resolution-indicator

feat(onchain-zaps): inline Namecoin resolution indicator + result row
This commit is contained in:
Vitor Pamplona
2026-05-18 16:26:02 -04:00
committed by GitHub
3 changed files with 497 additions and 0 deletions
@@ -0,0 +1,321 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet
import androidx.compose.foundation.background
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinResolveState
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinResolveOutcome
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
/**
* Translate a [NamecoinResolveOutcome] from the quartz resolver into the
* shared [NamecoinResolveState] used elsewhere in the app (e.g. the
* desktop `SearchScreen`'s inline Namecoin lookup row).
*
* Mirrors the wording desktop already ships so the same identifier
* produces the same diagnostic string regardless of which surface
* triggered the lookup. Callers are expected to handle
* [NamecoinResolveOutcome.Success] separately (it needs a [User]
* lookup through [com.vitorpamplona.amethyst.model.LocalCache], which
* this helper has no access to).
*/
fun mapOutcomeToResolveState(outcome: NamecoinResolveOutcome): NamecoinResolveState =
when (outcome) {
is NamecoinResolveOutcome.Success ->
// Success is intentionally NOT handled here — callers must
// resolve the pubkey through LocalCache first.
error("mapOutcomeToResolveState called with Success outcome; resolve via LocalCache instead")
is NamecoinResolveOutcome.NameNotFound -> NamecoinResolveState.NotFound
is NamecoinResolveOutcome.NoNostrField ->
NamecoinResolveState.Error("${outcome.name} is registered but has no Nostr pubkey")
is NamecoinResolveOutcome.MalformedRecord ->
// Surface the parser detail verbatim so the publisher of the
// broken record can locate the bad byte
// (kotlinx.serialization includes a column number).
NamecoinResolveState.Error("${outcome.name} record 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")
NamecoinResolveOutcome.Timeout ->
NamecoinResolveState.Error("Resolution timed out — servers may be slow, try again")
}
/**
* Lightweight syntactic check: does this look like something we should
* route to Namecoin? Mirrors [com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver.isNamecoinIdentifier]
* but tolerates a leading `@` (matches the dropdown's [com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState.userSearchTermOrNull]).
*/
fun looksLikeNamecoinIdentifier(raw: String): Boolean {
val trimmed = raw.trim().removePrefix("@").lowercase()
if (trimmed.length < 5) return false
return trimmed.endsWith(".bit") ||
trimmed.contains("@") && trimmed.substringAfter('@').endsWith(".bit")
}
/**
* Inline Namecoin resolution indicator + result row, sandwiched between
* the recipient text field and the local-cache suggestion dropdown in
* [OnchainZapSendDialog].
*
* Behaviour:
* - Renders nothing when [searchInput] is not a `.bit` identifier.
* - Shows a small spinner row ("Resolving on Namecoin…") while the
* ElectrumX lookup is in flight (after a 300 ms debounce to match
* the dropdown's own debounce).
* - On success, shows the resolved user as a tappable row with a
* `MaterialSymbols.Link` badge labelled "Namecoin"; tapping calls
* [onUserResolved].
* - On failure, shows a single explanatory line in the error colour.
*
* State is held in [NamecoinResolveState] (the same sealed class the
* desktop `SearchScreen` and `NamecoinNameService` already use) so this
* row stays in lockstep with the rest of the app's Namecoin UI.
*
* The composable is intentionally self-contained: it owns its own
* [LaunchedEffect] keyed on [searchInput], so it cancels in-flight
* lookups whenever the user keeps typing.
*/
@Composable
fun NamecoinResolutionRow(
searchInput: String,
accountViewModel: AccountViewModel,
onUserResolved: (User) -> Unit,
) {
val trimmed = remember(searchInput) { searchInput.trim().removePrefix("@") }
if (!looksLikeNamecoinIdentifier(trimmed)) return
var state by remember { mutableStateOf<NamecoinResolveState?>(null) }
LaunchedEffect(trimmed) {
// Match UserSuggestionState's 300 ms debounce so we don't fire a
// lookup on every keystroke.
delay(300)
state = NamecoinResolveState.Loading
val outcome =
withContext(Dispatchers.IO) {
runCatching {
Amethyst.instance.namecoinResolver.resolveDetailed(trimmed)
}.getOrElse {
NamecoinResolveOutcome.ServersUnreachable(
it.message ?: it::class.simpleName ?: "Lookup error",
)
}
}
state =
when (outcome) {
is NamecoinResolveOutcome.Success -> NamecoinResolveState.Resolved(outcome.result)
else -> mapOutcomeToResolveState(outcome)
}
}
Spacer(Modifier.size(8.dp))
when (val s = state) {
null, NamecoinResolveState.Loading -> ResolvingChip(trimmed)
is NamecoinResolveState.Resolved -> ResolvedRow(trimmed, s, accountViewModel, onUserResolved)
NamecoinResolveState.NotFound -> FailedRow("No record for $trimmed on Namecoin.")
is NamecoinResolveState.Error -> FailedRow(s.message)
}
}
@Composable
private fun ResolvingChip(query: String) {
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = "Resolving $query on Namecoin…",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun ResolvedRow(
query: String,
state: NamecoinResolveState.Resolved,
accountViewModel: AccountViewModel,
onUserResolved: (User) -> Unit,
) {
// Look up the User in the same cache the rest of the app uses, exactly
// the way desktop's SearchScreen does. Falls back to a malformed-record
// error row if the pubkey somehow fails the hex shape check.
val user =
remember(state.result.pubkey) {
accountViewModel.account.cache.checkGetOrCreateUser(state.result.pubkey)
}
if (user == null) {
FailedRow(
"${state.result.namecoinName} record is malformed: " +
"pubkey ${state.result.pubkey} is not a valid hex key",
)
return
}
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
modifier =
Modifier
.fillMaxWidth()
.clickable { onUserResolved(user) },
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
UserPicture(
userHex = user.pubkeyHex,
size = 32.dp,
accountViewModel = accountViewModel,
nav = EmptyNav(),
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = user.toBestDisplayName(),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = query,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
NamecoinBadge()
}
}
}
@Composable
private fun NamecoinBadge() {
Surface(
shape = RoundedCornerShape(6.dp),
color = MaterialTheme.colorScheme.primaryContainer,
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
symbol = MaterialSymbols.Link,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(14.dp),
)
Text(
text = "Namecoin",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
fontWeight = FontWeight.SemiBold,
)
}
}
}
@Composable
private fun FailedRow(message: String) {
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.errorContainer,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(
symbol = MaterialSymbols.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.size(18.dp),
)
Text(
text = message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.background(MaterialTheme.colorScheme.errorContainer),
)
}
}
}
@@ -414,6 +414,18 @@ private fun RecipientSection(
modifier = Modifier.fillMaxWidth(),
)
// Inline Namecoin lookup feedback. Local-cache suggestions can race
// ahead of the on-chain resolution (especially when the user has
// resolved a sibling `user@<host>.bit` earlier in the session and the
// current query is the bare host), so we surface the in-flight state +
// the eventual on-chain match in its own row, distinct from the
// generic dropdown. Failures are surfaced here too.
NamecoinResolutionRow(
searchInput = searchInput,
accountViewModel = accountViewModel,
onUserResolved = onSelectUser,
)
if (searchInput.length > 2) {
ShowUserSuggestionList(
userSuggestions = userSuggestions,
@@ -0,0 +1,164 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinResolveState
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNostrResult
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinResolveOutcome
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
class NamecoinResolutionRowTest {
// ── looksLikeNamecoinIdentifier ────────────────────────────────────────
@Test
fun `bare bit hostname is a namecoin identifier`() {
assertTrue(looksLikeNamecoinIdentifier("testls.bit"))
assertTrue(looksLikeNamecoinIdentifier("Example.BIT"))
}
@Test
fun `user at bit hostname is a namecoin identifier`() {
assertTrue(looksLikeNamecoinIdentifier("m@testls.bit"))
assertTrue(looksLikeNamecoinIdentifier("ALICE@example.BIT"))
}
@Test
fun `leading at sign is tolerated like the dropdown does`() {
assertTrue(looksLikeNamecoinIdentifier("@testls.bit"))
assertTrue(looksLikeNamecoinIdentifier("@m@testls.bit"))
}
@Test
fun `dns nip05 is not a namecoin identifier`() {
assertFalse(looksLikeNamecoinIdentifier("alice@example.com"))
assertFalse(looksLikeNamecoinIdentifier("example.com"))
}
@Test
fun `npub-shaped input is not a namecoin identifier`() {
assertFalse(looksLikeNamecoinIdentifier("npub1w90qq8jq8x0z6nyz3vqgsk9vnp0w9p4ldwc0wq4xv2n7v8jch2nq3p6wrx"))
}
@Test
fun `short or empty input is not a namecoin identifier`() {
// Below the minimum length threshold the dropdown also uses
// (UserSuggestionState.userSearchTermOrNull requires >2 chars).
assertFalse(looksLikeNamecoinIdentifier(""))
assertFalse(looksLikeNamecoinIdentifier(".bit"))
}
@Test
fun `single-label bit name is still considered a namecoin identifier`() {
// Namecoin allows single-character labels; "a.bit" is a valid
// (if expensive) registration. Don't filter it out client-side.
assertTrue(looksLikeNamecoinIdentifier("a.bit"))
}
@Test
fun `bit substring elsewhere does not trigger`() {
// "habit" or "rabbit.example.com" shouldn't match.
assertFalse(looksLikeNamecoinIdentifier("rabbit.example.com"))
assertFalse(looksLikeNamecoinIdentifier("ihabit"))
}
@Test
fun `at without bit suffix does not match`() {
// "foo@bar" with no .bit on the right side should not match.
assertFalse(looksLikeNamecoinIdentifier("foo@bar"))
assertFalse(looksLikeNamecoinIdentifier("foo@bar.com"))
}
// ── mapOutcomeToResolveState ───────────────────────────────────────────
// Reuses the shared NamecoinResolveState already used by
// NamecoinNameService and the desktop SearchScreen so all surfaces
// produce the same diagnostic strings for the same outcome.
@Test
fun `name not found maps to NotFound`() {
val state = mapOutcomeToResolveState(NamecoinResolveOutcome.NameNotFound("d/testls"))
assertSame(NamecoinResolveState.NotFound, state)
}
@Test
fun `no nostr field maps to Error and mentions the name`() {
val state = mapOutcomeToResolveState(NamecoinResolveOutcome.NoNostrField("d/noname"))
require(state is NamecoinResolveState.Error)
assertTrue(state.message.contains("d/noname"))
assertTrue(state.message.contains("Nostr"))
}
@Test
fun `malformed record preserves underlying parser detail`() {
val state =
mapOutcomeToResolveState(
NamecoinResolveOutcome.MalformedRecord(
"d/broken",
"Unfinished JSON term at EOF at line 1, column 474",
),
)
require(state is NamecoinResolveState.Error)
assertTrue(state.message.contains("d/broken"))
assertTrue(state.message.contains("Unfinished JSON"))
}
@Test
fun `servers unreachable maps to a generic Error`() {
val state =
mapOutcomeToResolveState(NamecoinResolveOutcome.ServersUnreachable("Connection refused"))
require(state is NamecoinResolveState.Error)
assertTrue(state.message.contains("ElectrumX"))
}
@Test
fun `invalid identifier maps to a generic Error`() {
val state =
mapOutcomeToResolveState(NamecoinResolveOutcome.InvalidIdentifier("not_a_name"))
require(state is NamecoinResolveState.Error)
assertEquals("Invalid Namecoin identifier", state.message)
}
@Test
fun `timeout maps to a timeout Error`() {
val state = mapOutcomeToResolveState(NamecoinResolveOutcome.Timeout)
require(state is NamecoinResolveState.Error)
assertTrue(state.message.contains("timed out"))
}
@Test(expected = IllegalStateException::class)
fun `success outcome must be handled by callers, not mapOutcomeToResolveState`() {
// mapOutcomeToResolveState is documented as failure-only; callers must
// route NamecoinResolveOutcome.Success through LocalCache themselves.
mapOutcomeToResolveState(
NamecoinResolveOutcome.Success(
NamecoinNostrResult(
pubkey = "deadbeef".repeat(8),
relays = emptyList(),
namecoinName = "d/testls",
localPart = "_",
),
),
)
}
}