Merge pull request #1914 from mstrofnone/desktop-namecoin-port

Port Namecoin NIP-05 resolution to Desktop app
This commit is contained in:
Vitor Pamplona
2026-05-08 08:55:09 -04:00
committed by GitHub
16 changed files with 1947 additions and 136 deletions
@@ -89,6 +89,10 @@ import com.vitorpamplona.amethyst.desktop.platform.applyNativeWindowChrome
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
import com.vitorpamplona.amethyst.desktop.service.images.DesktopImageLoaderSetup
import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool
import com.vitorpamplona.amethyst.desktop.service.namecoin.DesktopNamecoinNameService
import com.vitorpamplona.amethyst.desktop.service.namecoin.DesktopNamecoinPreferences
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.ui.ComposeNoteDialog
import com.vitorpamplona.amethyst.desktop.ui.ConnectingRelaysScreen
@@ -253,6 +257,8 @@ fun main() {
val accountManager = remember { AccountManager.create() }
val accountState by accountManager.accountState.collectAsState()
var showAppDrawer by remember { mutableStateOf(false) }
var showAddColumnDialog by remember { mutableStateOf(false) }
var showImportFollowListDialog by remember { mutableStateOf(false) }
// Tor state at Window level — survives key() app rebuild
var torSettings by remember {
@@ -377,6 +383,18 @@ fun main() {
},
)
Separator()
Item(
"Import Follow List…",
shortcut =
if (isMacOS) {
KeyShortcut(Key.I, meta = true, shift = true)
} else {
KeyShortcut(Key.I, ctrl = true, shift = true)
},
onClick = { showImportFollowListDialog = true },
enabled = accountState is AccountState.LoggedIn,
)
Separator()
Item(
"Logout",
onClick = {
@@ -607,6 +625,8 @@ fun main() {
onDismissAppDrawer = { showAppDrawer = false },
onShowAppDrawer = { showAppDrawer = true },
replyToNote = replyToNote,
showImportFollowListDialog = showImportFollowListDialog,
onDismissImportFollowListDialog = { showImportFollowListDialog = false },
onRestartApp = { appRestartKey++ },
torManager = torManager,
torTypeFlow = torTypeFlow,
@@ -635,6 +655,8 @@ fun App(
onDismissAppDrawer: () -> Unit,
onShowAppDrawer: () -> Unit,
replyToNote: com.vitorpamplona.quartz.nip01Core.core.Event?,
showImportFollowListDialog: Boolean = false,
onDismissImportFollowListDialog: () -> Unit = {},
onRestartApp: () -> Unit = {},
torManager: com.vitorpamplona.amethyst.desktop.tor.DesktopTorManager,
torTypeFlow: kotlinx.coroutines.flow.MutableStateFlow<com.vitorpamplona.amethyst.commons.tor.TorType>,
@@ -911,6 +933,14 @@ fun App(
val account = accountState as AccountState.LoggedIn
val nwcConnection by accountManager.nwcConnection.collectAsState()
// Lazy-load Namecoin services — almost never used, no need to keep in
// memory from the start (matches Android lazy pattern)
val namecoinPreferences = remember { DesktopNamecoinPreferences() }
val namecoinService =
remember {
DesktopNamecoinNameService(preferencesProvider = { namecoinPreferences.current })
}
// Load NWC connection on first composition
LaunchedEffect(Unit) {
accountManager.loadNwcConnection()
@@ -932,6 +962,8 @@ fun App(
onRestartApp()
},
),
LocalNamecoinPreferences provides namecoinPreferences,
LocalNamecoinService provides namecoinService,
) {
MainContent(
layoutMode = layoutMode,
@@ -1425,6 +1457,7 @@ fun RelaySettingsScreen(
com.vitorpamplona.amethyst.commons.tor
.TorSettings(torType = com.vitorpamplona.amethyst.commons.tor.TorType.OFF),
onTorSettingsChanged: (com.vitorpamplona.amethyst.commons.tor.TorSettings) -> Unit = {},
namecoinPreferences: DesktopNamecoinPreferences? = null,
) {
val relayStatuses by relayManager.relayStatuses.collectAsState()
val connectedRelays by relayManager.connectedRelays.collectAsState()
@@ -0,0 +1,140 @@
/*
* 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.desktop.service.namecoin
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinResolveState
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinSettings
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinLookupCache
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNostrResult
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinResolveOutcome
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.net.SocketFactory
/**
* Desktop application-level singleton for Namecoin name resolution.
*
* Same functionality as the Android `NamecoinNameService` but instantiated
* directly (no Koin/Hilt DI). Uses plain JVM sockets (no Tor support on
* Desktop yet).
*/
class DesktopNamecoinNameService(
private val preferencesProvider: () -> NamecoinSettings = { NamecoinSettings.DEFAULT },
) {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val electrumxClient =
ElectrumXClient(
socketFactory = { SocketFactory.getDefault() },
)
private val resolver =
NamecoinNameResolver(
electrumxClient = electrumxClient,
serverListProvider = {
val settings = preferencesProvider()
settings.toElectrumxServers() ?: DEFAULT_ELECTRUMX_SERVERS
},
)
private val cache = NamecoinLookupCache()
// ── Public API ─────────────────────────────────────────────────────
/**
* Resolve a Namecoin identifier to a Nostr pubkey.
*
* Returns cached results when available. This is the primary method
* that the search bar and NIP-05 verifier should call.
*
* @param identifier e.g. "alice@example.bit", "id/bob", "example.bit"
* @return [NamecoinNostrResult] or null
*/
suspend fun resolve(identifier: String): NamecoinNostrResult? {
val cached = cache.get(identifier)
if (cached != null) return cached.result
val result = resolver.resolve(identifier)
cache.put(identifier, result)
return result
}
/**
* Resolve and return just the hex pubkey, or null.
* Convenience for follow-import integration.
*/
suspend fun resolvePubkey(identifier: String): String? = resolve(identifier)?.pubkey
/**
* Resolve with detailed outcome for error reporting.
*/
suspend fun resolveDetailed(identifier: String): NamecoinResolveOutcome = resolver.resolveDetailed(identifier)
/**
* Verify that a Namecoin name maps to the expected pubkey.
*/
suspend fun verifyNip05(
nip05Address: String,
expectedPubkeyHex: String,
): Boolean {
if (!NamecoinNameResolver.isNamecoinIdentifier(nip05Address)) return false
val result = resolve(nip05Address) ?: return false
return result.pubkey.equals(expectedPubkeyHex, ignoreCase = true)
}
/**
* Perform a lookup and emit results via a StateFlow.
*
* Useful for composable UIs that observe resolution state.
*/
fun resolveLive(
identifier: String,
scope: CoroutineScope = this.scope,
): StateFlow<NamecoinResolveState> {
val state = MutableStateFlow<NamecoinResolveState>(NamecoinResolveState.Loading)
scope.launch {
try {
val result = resolve(identifier)
state.value =
if (result != null) {
NamecoinResolveState.Resolved(result)
} else {
NamecoinResolveState.NotFound
}
} catch (e: Exception) {
state.value = NamecoinResolveState.Error(e.message ?: "Unknown error")
}
}
return state
}
/**
* Clear the resolution cache.
*/
suspend fun clearCache() = cache.clear()
}
@@ -0,0 +1,124 @@
/*
* 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.desktop.service.namecoin
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinSettings
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.util.prefs.Preferences
/**
* Persistent storage for [NamecoinSettings] on Desktop.
*
* Uses [java.util.prefs.Preferences] API, following the same pattern as
* [com.vitorpamplona.amethyst.desktop.DesktopPreferences].
*
* The current settings are available synchronously via [settings] (a
* [StateFlow]) and can be read in non-suspend contexts (e.g. in a
* `serverListProvider` lambda).
*/
class DesktopNamecoinPreferences(
private val prefs: Preferences =
Preferences.userNodeForPackage(
DesktopNamecoinPreferences::class.java,
),
) {
private val mapper = jacksonObjectMapper()
companion object {
private const val KEY_ENABLED = "namecoin.enabled"
private const val KEY_CUSTOM_SERVERS = "namecoin.customServers"
}
private val _settings = MutableStateFlow(loadFromDisk())
val settings: StateFlow<NamecoinSettings> = _settings
/** Synchronous snapshot — safe to call from `serverListProvider` lambdas. */
val current: NamecoinSettings get() = _settings.value
/**
* Parsed [ElectrumxServer] list from current custom settings, or `null`
* if the user hasn't configured any (meaning "use defaults").
*/
val customServersOrNull: List<ElectrumxServer>?
get() = current.toElectrumxServers()
// ── Mutators ───────────────────────────────────────────────────────
suspend fun setEnabled(enabled: Boolean) {
val updated = current.copy(enabled = enabled)
persist(updated)
}
suspend fun addServer(server: String) {
if (server.isBlank() || server in current.customServers) return
val updated = current.copy(customServers = current.customServers + server)
persist(updated)
}
suspend fun removeServer(server: String) {
val updated = current.copy(customServers = current.customServers - server)
persist(updated)
}
suspend fun reset() {
persist(NamecoinSettings.DEFAULT)
}
// ── Internal ───────────────────────────────────────────────────────
private fun persist(settings: NamecoinSettings) {
_settings.value = settings
try {
prefs.putBoolean(KEY_ENABLED, settings.enabled)
prefs.put(
KEY_CUSTOM_SERVERS,
mapper.writeValueAsString(settings.customServers.filter { it.isNotBlank() }),
)
prefs.flush()
} catch (e: Exception) {
System.err.println("NamecoinPrefs: Error writing preferences: ${e.message}")
}
}
private fun loadFromDisk(): NamecoinSettings =
try {
val enabled = prefs.getBoolean(KEY_ENABLED, true)
val serversJson = prefs.get(KEY_CUSTOM_SERVERS, null)
val servers =
if (serversJson != null) {
try {
mapper.readValue<List<String>>(serversJson)
} catch (_: Exception) {
emptyList()
}
} else {
emptyList()
}
NamecoinSettings(enabled = enabled, customServers = servers)
} catch (e: Exception) {
System.err.println("NamecoinPrefs: Error reading preferences: ${e.message}")
NamecoinSettings.DEFAULT
}
}
@@ -0,0 +1,30 @@
/*
* 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.desktop.service.namecoin
import androidx.compose.runtime.compositionLocalOf
/**
* CompositionLocal for accessing Namecoin preferences and service
* throughout the Desktop compose tree without explicit parameter threading.
*/
val LocalNamecoinPreferences = compositionLocalOf<DesktopNamecoinPreferences?> { null }
val LocalNamecoinService = compositionLocalOf<DesktopNamecoinNameService?> { null }
@@ -0,0 +1,695 @@
/*
* 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.desktop.ui
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinService
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.net.URLEncoder
import java.util.concurrent.TimeUnit
/**
* State machine for the import follow list flow.
*/
private sealed class ImportState {
data object Idle : ImportState()
data object ResolvingIdentifier : ImportState()
data class IdentifierResolved(
val pubkey: String,
) : ImportState()
data object FetchingFollowList : ImportState()
data class FollowListLoaded(
val sourcePubkey: String,
) : ImportState()
data class Error(
val message: String,
) : ImportState()
data object Publishing : ImportState()
data object Done : ImportState()
}
/**
* A follow entry with mutable selection state.
*/
data class FollowEntry(
val pubkey: String,
val displayName: String? = null,
val selected: Boolean = true,
)
/**
* Resolves a NIP-05 identifier (user@domain.com) to a hex pubkey via HTTP.
*/
private suspend fun resolveNip05Http(identifier: String): String? {
if (!identifier.contains("@") || identifier.endsWith(".bit")) return null
val parts = identifier.split("@", limit = 2)
if (parts.size != 2) return null
val (name, domain) = parts
if (name.isBlank() || domain.isBlank()) return null
val encodedName = URLEncoder.encode(name, "UTF-8")
val url = "https://$domain/.well-known/nostr.json?name=$encodedName"
return withContext(Dispatchers.IO) {
try {
val client =
OkHttpClient
.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build()
val request = Request.Builder().url(url).build()
val response = client.newCall(request).execute()
response.use { resp ->
if (!resp.isSuccessful) return@withContext null
val body = resp.body.string()
val json = jacksonObjectMapper().readTree(body)
json.get("names")?.get(name)?.asText()
}
} catch (_: Exception) {
null
}
}
}
/**
* Import Follow List dialog for Desktop.
*
* Lets users enter an identifier (npub, hex, NIP-05 HTTP, or Namecoin),
* resolves it to a pubkey, fetches their kind 3 (ContactListEvent) from
* relays, displays the follow list with select/deselect toggles, and
* publishes a new kind 3 with the selected follows.
*/
@Composable
fun ImportFollowListDialog(
onDismiss: () -> Unit,
relayManager: DesktopRelayConnectionManager,
account: AccountState.LoggedIn,
localCache: DesktopLocalCache,
) {
val namecoinService = LocalNamecoinService.current
val scope = rememberCoroutineScope()
var input by remember { mutableStateOf("") }
var importState by remember { mutableStateOf<ImportState>(ImportState.Idle) }
val followEntries = remember { mutableStateListOf<FollowEntry>() }
// Track active subscription IDs for cleanup
val activeSubscriptions = remember { mutableStateListOf<String>() }
// Clean up all subscriptions when the dialog is dismissed
DisposableEffect(Unit) {
onDispose {
activeSubscriptions.forEach { subId ->
try {
relayManager.unsubscribe(subId)
} catch (_: Exception) {
// Ignore cleanup errors
}
}
}
}
// When identifier is resolved, auto-start fetching the follow list
LaunchedEffect(importState) {
val state = importState
if (state is ImportState.IdentifierResolved) {
importState = ImportState.FetchingFollowList
val pubkey = state.pubkey
followEntries.clear()
val subId = "import-follows-${System.currentTimeMillis()}"
activeSubscriptions.add(subId)
var receivedContactList = false
// Use connected relays only — available relays may include disconnected ones
val relays = relayManager.connectedRelays.value
if (relays.isEmpty()) {
importState = ImportState.Error("No relays connected. Check your relay settings.")
return@LaunchedEffect
}
relayManager.subscribe(
subId = subId,
filters =
listOf(
Filter(
kinds = listOf(ContactListEvent.KIND),
authors = listOf(pubkey),
limit = 1,
),
),
relays = relays,
listener =
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (event.kind == ContactListEvent.KIND && !receivedContactList) {
receivedContactList = true
val contactList =
ContactListEvent(
event.id,
event.pubKey,
event.createdAt,
event.tags,
event.content,
event.sig,
)
val follows = contactList.unverifiedFollowKeySet()
// Dispatch state updates to main thread (relay callbacks
// run on arbitrary threads; Compose state is not thread-safe)
scope.launch(Dispatchers.Main) {
followEntries.clear()
followEntries.addAll(
follows.map { FollowEntry(pubkey = it, selected = true) },
)
importState = ImportState.FollowListLoaded(sourcePubkey = pubkey)
}
// Clean up the contact list subscription
try {
relayManager.unsubscribe(subId)
} catch (_: Exception) {
}
activeSubscriptions.remove(subId)
// Start fetching metadata for display names
if (follows.isNotEmpty()) {
val metaSubId = "import-meta-${System.currentTimeMillis()}"
activeSubscriptions.add(metaSubId)
relayManager.subscribe(
subId = metaSubId,
filters =
listOf(
Filter(
kinds = listOf(MetadataEvent.KIND),
authors = follows,
limit = follows.size,
),
),
listener =
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (event.kind == MetadataEvent.KIND) {
val bestName =
try {
val metaJson = jacksonObjectMapper().readTree(event.content)
metaJson.get("display_name")?.asText()?.takeIf { it.isNotBlank() }
?: metaJson.get("name")?.asText()?.takeIf { it.isNotBlank() }
} catch (_: Exception) {
null
}
if (bestName != null) {
// Dispatch to main thread
scope.launch(Dispatchers.Main) {
val idx = followEntries.indexOfFirst { it.pubkey == event.pubKey }
if (idx >= 0) {
followEntries[idx] = followEntries[idx].copy(displayName = bestName)
}
}
}
}
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
// Metadata is best-effort; don't close early
// as multiple relays may have different metadata
}
},
)
// Clean up metadata subscription after 20s
scope.launch {
delay(20_000)
if (metaSubId in activeSubscriptions) {
try {
relayManager.unsubscribe(metaSubId)
} catch (_: Exception) {
}
activeSubscriptions.remove(metaSubId)
}
}
}
}
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
}
},
)
// Timeout: if no contact list received after 15s, show error
scope.launch {
delay(15_000)
if (importState is ImportState.FetchingFollowList) {
try {
relayManager.unsubscribe(subId)
} catch (_: Exception) {
}
activeSubscriptions.remove(subId)
if (followEntries.isEmpty()) {
importState = ImportState.Error("No follow list found for this user. They may not have published a contact list.")
}
}
}
}
}
// Auto-dismiss after Done
LaunchedEffect(importState) {
if (importState is ImportState.Done) {
delay(1500)
onDismiss()
}
}
fun resolveIdentifier() {
val trimmed = input.trim()
if (trimmed.isBlank()) {
importState = ImportState.Error("Enter an identifier")
return
}
importState = ImportState.ResolvingIdentifier
followEntries.clear()
scope.launch {
try {
// Try raw hex pubkey first (exact 64-char hex)
if (trimmed.length == 64 && trimmed.matches(Regex("^[0-9a-fA-F]{64}$"))) {
importState = ImportState.IdentifierResolved(trimmed.lowercase())
return@launch
}
// Try bech32 (npub/nprofile) — only accept if it starts with known prefixes
if (trimmed.startsWith("npub1") || trimmed.startsWith("nprofile1") || trimmed.startsWith("nsec1")) {
val bech32Result = decodePublicKeyAsHexOrNull(trimmed)
if (bech32Result != null && bech32Result.length == 64) {
importState = ImportState.IdentifierResolved(bech32Result)
return@launch
}
importState = ImportState.Error("Invalid npub/nprofile — could not decode")
return@launch
}
// Try Namecoin (.bit, d/, id/)
if (NamecoinNameResolver.isNamecoinIdentifier(trimmed) && namecoinService != null) {
val result = namecoinService.resolvePubkey(trimmed)
if (result != null && result.length == 64) {
importState = ImportState.IdentifierResolved(result)
return@launch
}
importState = ImportState.Error("Namecoin name not found or has no Nostr pubkey")
return@launch
}
// Try NIP-05 HTTP (user@domain)
if (trimmed.contains("@")) {
val result = resolveNip05Http(trimmed)
if (result != null && result.length == 64) {
importState = ImportState.IdentifierResolved(result)
return@launch
}
importState = ImportState.Error("NIP-05 lookup failed — user not found at that domain")
return@launch
}
importState = ImportState.Error("Unrecognized identifier format. Use npub1..., hex pubkey, user@domain, or .bit/d//id/")
} catch (e: Exception) {
importState = ImportState.Error(e.message ?: "Resolution failed")
}
}
}
fun toggleAll(selected: Boolean) {
val updated = followEntries.map { it.copy(selected = selected) }
followEntries.clear()
followEntries.addAll(updated)
}
fun toggleEntry(index: Int) {
if (index in followEntries.indices) {
followEntries[index] = followEntries[index].copy(selected = !followEntries[index].selected)
}
}
fun publishFollows() {
val selected = followEntries.filter { it.selected }
if (selected.isEmpty()) return
importState = ImportState.Publishing
scope.launch {
try {
val contactTags = selected.map { ContactTag(it.pubkey) }
val newContactList =
ContactListEvent.createFromScratch(
followUsers = contactTags,
relayUse = null,
signer = account.signer,
)
relayManager.broadcastToAll(newContactList)
importState = ImportState.Done
} catch (e: Exception) {
importState = ImportState.Error("Failed to publish: ${e.message}")
}
}
}
val selectedCount = followEntries.count { it.selected }
val totalCount = followEntries.size
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surface,
tonalElevation = 6.dp,
) {
Column(
modifier = Modifier.padding(24.dp).fillMaxWidth(),
) {
Text(
"Import Follow List",
style = MaterialTheme.typography.headlineSmall,
)
Spacer(Modifier.height(8.dp))
Text(
"Enter an npub, hex pubkey, NIP-05 (user@domain), or Namecoin identifier " +
"(.bit, d/, id/) to import their follow list.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
// Input field + Resolve button (shown in Idle and Error states)
val showInput =
importState is ImportState.Idle ||
importState is ImportState.Error ||
importState is ImportState.ResolvingIdentifier
if (showInput) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = input,
onValueChange = {
input = it
if (importState is ImportState.Error) {
importState = ImportState.Idle
}
},
label = { Text("Identifier") },
placeholder = { Text("npub1..., alice@example.com, d/alice") },
singleLine = true,
isError = importState is ImportState.Error,
supportingText =
(importState as? ImportState.Error)?.let { err ->
{ Text(err.message, color = MaterialTheme.colorScheme.error) }
},
modifier =
Modifier.weight(1f).onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown && event.key == Key.Enter) {
resolveIdentifier()
true
} else {
false
}
},
)
Button(
onClick = { resolveIdentifier() },
enabled = input.isNotBlank() && importState !is ImportState.ResolvingIdentifier,
) {
if (importState is ImportState.ResolvingIdentifier) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
)
} else {
Text("Resolve")
}
}
}
}
// Fetching follow list state
if (importState is ImportState.FetchingFollowList) {
Spacer(Modifier.height(16.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
Text(
"Fetching follow list from relays…",
style = MaterialTheme.typography.bodyMedium,
)
}
Spacer(Modifier.height(8.dp))
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
// Follow list loaded — show entries with checkboxes
if (importState is ImportState.FollowListLoaded && followEntries.isNotEmpty()) {
Spacer(Modifier.height(12.dp))
// Header with count and select/deselect controls
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"$totalCount follows found",
style = MaterialTheme.typography.labelLarge,
)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
TextButton(onClick = { toggleAll(true) }) {
Text("Select All", style = MaterialTheme.typography.labelSmall)
}
TextButton(onClick = { toggleAll(false) }) {
Text("Deselect All", style = MaterialTheme.typography.labelSmall)
}
}
}
Spacer(Modifier.height(4.dp))
// Scrollable follow list
LazyColumn(
modifier = Modifier.height(300.dp).fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
items(followEntries.size) { index ->
val entry = followEntries[index]
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
) {
Checkbox(
checked = entry.selected,
onCheckedChange = { toggleEntry(index) },
)
Spacer(Modifier.width(8.dp))
Icon(
MaterialSymbols.Person,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(6.dp))
Column {
if (entry.displayName != null) {
Text(
entry.displayName,
style = MaterialTheme.typography.bodyMedium,
)
}
Text(
"${entry.pubkey.take(12)}${entry.pubkey.takeLast(8)}",
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color =
if (entry.displayName != null) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurface
},
)
}
}
}
}
}
// Publishing state
if (importState is ImportState.Publishing) {
Spacer(Modifier.height(16.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
Text(
"Publishing contact list…",
style = MaterialTheme.typography.bodyMedium,
)
}
}
// Done state
if (importState is ImportState.Done) {
Spacer(Modifier.height(16.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
MaterialSymbols.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp),
)
Text(
"Successfully published! Following $selectedCount accounts.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
)
}
}
Spacer(Modifier.height(16.dp))
// Action buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
if (importState !is ImportState.Done) {
OutlinedButton(onClick = onDismiss) {
Text("Cancel")
}
}
if (importState is ImportState.FollowListLoaded && selectedCount > 0) {
Spacer(Modifier.width(8.dp))
Button(
onClick = { publishFollows() },
) {
Text("Follow $selectedCount account${if (selectedCount != 1) "s" else ""}")
}
}
if (importState is ImportState.Error) {
Spacer(Modifier.width(8.dp))
Button(onClick = {
importState = ImportState.Idle
followEntries.clear()
}) {
Text("Try Again")
}
}
}
}
}
}
}
@@ -78,6 +78,7 @@ import com.vitorpamplona.amethyst.commons.feeds.custom.canBecomeFeed
import com.vitorpamplona.amethyst.commons.feeds.custom.toFeedDefinition
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.commons.search.AdvancedSearchBarState
import com.vitorpamplona.amethyst.commons.search.QuerySerializer
import com.vitorpamplona.amethyst.commons.search.SavedSearch
@@ -89,6 +90,8 @@ import com.vitorpamplona.amethyst.desktop.SearchHistoryStore
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
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.SearchFilterFactory
import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
@@ -103,6 +106,8 @@ import com.vitorpamplona.amethyst.desktop.ui.search.AdvancedSearchPanel
import com.vitorpamplona.amethyst.desktop.ui.search.SearchResultsList
import com.vitorpamplona.amethyst.desktop.ui.search.SearchSyncBanner
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinResolveOutcome
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import kotlinx.coroutines.launch
@@ -156,6 +161,60 @@ fun SearchScreen(
// Bech32 parsing (immediate, no debounce)
val bech32Results = remember(displayText) { parseSearchInput(displayText) }
// Namecoin resolution
val namecoinService = LocalNamecoinService.current
val namecoinPrefs = LocalNamecoinPreferences.current
val namecoinEnabled =
namecoinPrefs
?.settings
?.collectAsState()
?.value
?.enabled ?: false
val isNamecoinQuery =
remember(displayText) {
displayText.isNotBlank() && NamecoinNameResolver.isNamecoinIdentifier(displayText.trim())
}
var namecoinState by remember { mutableStateOf<NamecoinResolveState?>(null) }
// Resolve Namecoin identifiers with cancellation of stale lookups.
// Uses resolveDetailed() which returns typed outcomes instead of throwing,
// so we get proper NotFound/Expired/ServersUnreachable states.
LaunchedEffect(displayText, namecoinEnabled) {
if (!namecoinEnabled || !isNamecoinQuery || namecoinService == null) {
namecoinState = null
return@LaunchedEffect
}
namecoinState = NamecoinResolveState.Loading
val outcome = namecoinService.resolveDetailed(displayText.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.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")
}
}
}
// Skip people search when query specifies kinds that don't include profile (kind 0)
val shouldSearchPeople =
(debouncedQuery.kinds.isEmpty() && debouncedQuery.pseudoKinds.isEmpty()) ||
@@ -543,8 +602,78 @@ fun SearchScreen(
Spacer(Modifier.height(16.dp))
// Results
// Namecoin results (shown before everything else when query looks like a Namecoin id)
if (isNamecoinQuery && namecoinState != null) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"Namecoin lookup",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 4.dp),
)
when (val ncState = namecoinState) {
is NamecoinResolveState.Loading -> {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
LinearProgressIndicator(modifier = Modifier.width(120.dp))
Text(
"Resolving ${displayText.trim()}...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
is NamecoinResolveState.Resolved -> {
SearchResultCard(
result =
SearchResult.UserResult(
pubKeyHex = ncState.result.pubkey,
displayId = "${ncState.result.namecoinName}${ncState.result.pubkey.take(12)}...",
),
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
onNavigateToHashtag = onNavigateToHashtag,
)
if (ncState.result.relays.isNotEmpty()) {
Text(
"Relays: ${ncState.result.relays.joinToString(", ")}",
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp),
)
}
}
is NamecoinResolveState.NotFound -> {
Text(
"Name not found on Namecoin blockchain",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
is NamecoinResolveState.Error -> {
Text(
"Resolution error: ${ncState.message}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
null -> {}
}
}
Spacer(Modifier.height(8.dp))
}
val hasAnyResults =
bech32Results.isNotEmpty() || peopleResults.isNotEmpty() || noteResults.isNotEmpty()
bech32Results.isNotEmpty() || peopleResults.isNotEmpty() || noteResults.isNotEmpty() ||
(namecoinState is NamecoinResolveState.Resolved)
if (bech32Results.isNotEmpty()) {
// Show bech32 results (exact lookup)
@@ -46,6 +46,7 @@ import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.network.Nip11Fetcher
import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinPreferences
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode
import com.vitorpamplona.amethyst.desktop.ui.ArticleEditorScreen
@@ -336,6 +337,7 @@ internal fun RootContent(
torStatus = torState.status,
torSettings = torState.settings,
onTorSettingsChanged = torState.onSettingsChanged,
namecoinPreferences = LocalNamecoinPreferences.current,
)
}
@@ -0,0 +1,405 @@
/*
* 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.desktop.ui.settings
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinSettings
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS
/**
* Complete settings section for Namecoin ElectrumX server configuration.
*
* Desktop port of the Android `NamecoinSettingsSection.kt` composable.
* Uses `onPreviewKeyEvent` for Enter-key handling instead of Android's
* `KeyboardActions`/`LocalSoftwareKeyboardController`.
*
* @param settings Current [NamecoinSettings] state
* @param onToggleEnabled Called when user toggles the master switch
* @param onAddServer Called with `host:port[:tcp]` when user adds a server
* @param onRemoveServer Called with the server string to remove
* @param onReset Called when user resets to defaults
*/
@Composable
fun NamecoinSettingsSection(
settings: NamecoinSettings,
onToggleEnabled: (Boolean) -> Unit,
onAddServer: (String) -> Unit,
onRemoveServer: (String) -> Unit,
onReset: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.padding(16.dp)) {
// ── Section header ─────────────────────────────────────────
NamecoinSectionHeader(enabled = settings.enabled, onToggle = onToggleEnabled)
AnimatedVisibility(
visible = settings.enabled,
enter = expandVertically(),
exit = shrinkVertically(),
) {
Column {
Spacer(Modifier.height(12.dp))
// ── Explanation ─────────────────────────────────────
Text(
"Namecoin names (.bit, d/, id/) are resolved via ElectrumX servers. " +
"By default, public community servers are used. " +
"For maximum privacy, add your own server below — when custom " +
"servers are set, the defaults are completely ignored.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
// ── Active servers display ─────────────────────────
NamecoinActiveServersDisplay(settings = settings)
Spacer(Modifier.height(12.dp))
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
Spacer(Modifier.height(12.dp))
// ── Custom servers list ────────────────────────────
NamecoinCustomServersList(
servers = settings.customServers,
onRemove = onRemoveServer,
)
// ── Add server input ───────────────────────────────
NamecoinAddServerInput(onAdd = onAddServer)
Spacer(Modifier.height(8.dp))
// ── Reset button ───────────────────────────────────
if (settings.hasCustomServers) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = onReset) {
Icon(
MaterialSymbols.Refresh,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(4.dp))
Text("Reset to defaults")
}
}
}
}
}
}
}
// ── Sub-composables ────────────────────────────────────────────────────
@Composable
private fun NamecoinSectionHeader(
enabled: Boolean,
onToggle: (Boolean) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
MaterialSymbols.Lock,
contentDescription = null,
tint = Color(0xFF4A90D9), // Namecoin blue
modifier = Modifier.size(22.dp),
)
Spacer(Modifier.width(10.dp))
Column {
Text(
"Namecoin Resolution",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Text(
"Blockchain identity lookups (.bit)",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Switch(
checked = enabled,
onCheckedChange = onToggle,
)
}
}
@Composable
private fun NamecoinActiveServersDisplay(settings: NamecoinSettings) {
val servers = settings.toElectrumxServers() ?: DEFAULT_ELECTRUMX_SERVERS
val isCustom = settings.hasCustomServers
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Active servers",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium,
)
if (isCustom) {
Text(
"CUSTOM",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = Color(0xFF4A90D9),
modifier =
Modifier
.background(
Color(0xFF4A90D9).copy(alpha = 0.1f),
RoundedCornerShape(4.dp),
).padding(horizontal = 6.dp, vertical = 2.dp),
)
} else {
Text(
"DEFAULT",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.height(6.dp))
servers.forEach { server ->
NamecoinServerRow(
displayText =
"${server.host}:${server.port}" +
if (!server.useSsl) " (tcp)" else " (tls)",
isActive = true,
)
}
}
}
@Composable
private fun NamecoinCustomServersList(
servers: List<String>,
onRemove: (String) -> Unit,
) {
if (servers.isEmpty()) {
Text(
"No custom servers configured",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
modifier = Modifier.padding(vertical = 4.dp),
)
} else {
Text(
"Custom servers (used exclusively)",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(bottom = 4.dp),
)
servers.forEach { server ->
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = server,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
IconButton(
onClick = { onRemove(server) },
modifier = Modifier.size(28.dp),
) {
Icon(
MaterialSymbols.Close,
contentDescription = "Remove server",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp),
)
}
}
}
}
Spacer(Modifier.height(8.dp))
}
@Composable
private fun NamecoinAddServerInput(onAdd: (String) -> Unit) {
var input by rememberSaveable { mutableStateOf("") }
var validationError by remember { mutableStateOf<String?>(null) }
fun tryAdd() {
val trimmed = input.trim()
if (trimmed.isBlank()) {
validationError = "Enter a server address"
return
}
val parsed = NamecoinSettings.parseServerString(trimmed)
if (parsed == null) {
validationError = "Invalid format. Use host:port or host:port:tcp"
return
}
validationError = null
onAdd(trimmed)
input = ""
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top,
) {
OutlinedTextField(
value = input,
onValueChange = {
input = it
validationError = null
},
label = { Text("Add ElectrumX server") },
placeholder = { Text("host:port or host:port:tcp") },
singleLine = true,
isError = validationError != null,
supportingText =
validationError?.let { err ->
{ Text(err, color = MaterialTheme.colorScheme.error) }
},
modifier =
Modifier
.weight(1f)
.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown && event.key == Key.Enter) {
tryAdd()
true
} else {
false
}
},
shape = RoundedCornerShape(8.dp),
textStyle =
MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
),
)
Spacer(Modifier.width(8.dp))
IconButton(
onClick = { tryAdd() },
modifier =
Modifier
.padding(top = 8.dp)
.size(40.dp)
.background(
MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
RoundedCornerShape(8.dp),
),
) {
Icon(
MaterialSymbols.Add,
contentDescription = "Add server",
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
@Composable
private fun NamecoinServerRow(
displayText: String,
isActive: Boolean,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "",
fontSize = 10.sp,
color =
if (isActive) {
Color(0xFF2E8B57)
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.padding(end = 6.dp),
)
Text(
text = displayText,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface,
)
}
}