diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt index b37a4832c..b16852dbc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.namecoin +import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinResolveState import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer @@ -151,20 +152,3 @@ class NamecoinNameService( */ suspend fun clearCache() = cache.clear() } - -/** - * Observable state for a Namecoin resolution in progress. - */ -sealed class NamecoinResolveState { - data object Loading : NamecoinResolveState() - - data class Resolved( - val result: NamecoinNostrResult, - ) : NamecoinResolveState() - - data object NotFound : NamecoinResolveState() - - data class Error( - val message: String, - ) : NamecoinResolveState() -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt index 1a859848c..accf61510 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt @@ -20,81 +20,4 @@ */ package com.vitorpamplona.amethyst.service.namecoin -import androidx.compose.runtime.Stable -import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer -import kotlinx.serialization.Serializable - -/** - * Immutable data class representing the current Namecoin resolution config. - * - * When custom servers are configured, they are used EXCLUSIVELY and the - * hardcoded defaults are ignored. This gives privacy-conscious users full - * control over which ElectrumX servers observe their name lookups. - */ -@Serializable -@Stable -data class NamecoinSettings( - /** Whether Namecoin resolution is enabled at all. */ - val enabled: Boolean = true, - /** - * Custom ElectrumX servers. When non-empty, these replace the defaults. - * - * Each entry is `host:port` (TLS) or `host:port:tcp` (plaintext). - */ - val customServers: List = emptyList(), -) { - /** True when the user has configured at least one custom server. */ - val hasCustomServers: Boolean get() = customServers.isNotEmpty() - - /** - * Convert to [ElectrumxServer] instances used by the resolver. - * Returns `null` when no valid custom servers are configured (use defaults). - */ - fun toElectrumxServers(): List? { - if (customServers.isEmpty()) return null - return customServers - .mapNotNull { parseServerString(it) } - .ifEmpty { null } - } - - companion object { - val DEFAULT = NamecoinSettings() - - /** - * Parse `host:port` or `host:port:tcp` into an [ElectrumxServer]. - * - * TLS is the default protocol. Append `:tcp` for plaintext - * (useful for `.onion` addresses and local servers). - * - * `.onion` addresses automatically get `usePinnedTrustStore = true` - * since certificate verification is meaningless over Tor. - */ - fun parseServerString(s: String): ElectrumxServer? { - val parts = s.trim().split(":") - if (parts.size < 2) return null - val host = parts[0].trim() - val port = parts[1].trim().toIntOrNull() ?: return null - if (host.isEmpty() || port <= 0 || port > 65535) return null - val useSsl = parts.getOrNull(2)?.trim()?.lowercase() != "tcp" - val isOnion = host.endsWith(".onion") - // All custom servers use the pinned trust store. ElectrumX - // servers almost universally use self-signed certs, so we - // route them through our pinned SSLSocketFactory (hardcoded - // defaults + TOFU-pinned certs + system CAs). - return ElectrumxServer( - host = host, - port = port, - useSsl = useSsl, - usePinnedTrustStore = true, - ) - } - - /** - * Format an [ElectrumxServer] back to the `host:port[:tcp]` string form. - */ - fun formatServerString(server: ElectrumxServer): String { - val base = "${server.host}:${server.port}" - return if (server.useSsl) base else "$base:tcp" - } - } -} +typealias NamecoinSettings = com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinSettings diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/namecoin/NamecoinResolveState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/namecoin/NamecoinResolveState.kt new file mode 100644 index 000000000..6fffe789c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/namecoin/NamecoinResolveState.kt @@ -0,0 +1,40 @@ +/* + * 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.commons.model.nip05DnsIdentifiers.namecoin + +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNostrResult + +/** + * Observable state for a Namecoin resolution in progress. + */ +sealed class NamecoinResolveState { + data object Loading : NamecoinResolveState() + + data class Resolved( + val result: NamecoinNostrResult, + ) : NamecoinResolveState() + + data object NotFound : NamecoinResolveState() + + data class Error( + val message: String, + ) : NamecoinResolveState() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/namecoin/NamecoinSettings.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/namecoin/NamecoinSettings.kt new file mode 100644 index 000000000..942653aa3 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/namecoin/NamecoinSettings.kt @@ -0,0 +1,96 @@ +/* + * 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.commons.model.nip05DnsIdentifiers.namecoin + +import androidx.compose.runtime.Stable +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer +import kotlinx.serialization.Serializable + +/** + * Immutable data class representing the current Namecoin resolution config. + * + * When custom servers are configured, they are used EXCLUSIVELY and the + * hardcoded defaults are ignored. This gives privacy-conscious users full + * control over which ElectrumX servers observe their name lookups. + */ +@Serializable +@Stable +data class NamecoinSettings( + /** Whether Namecoin resolution is enabled at all. */ + val enabled: Boolean = true, + /** + * Custom ElectrumX servers. When non-empty, these replace the defaults. + * + * Each entry is `host:port` (TLS) or `host:port:tcp` (plaintext). + */ + val customServers: List = emptyList(), +) { + /** True when the user has configured at least one custom server. */ + val hasCustomServers: Boolean get() = customServers.isNotEmpty() + + /** + * Convert to [ElectrumxServer] instances used by the resolver. + * Returns `null` when no valid custom servers are configured (use defaults). + */ + fun toElectrumxServers(): List? { + if (customServers.isEmpty()) return null + return customServers + .mapNotNull { parseServerString(it) } + .ifEmpty { null } + } + + companion object { + val DEFAULT = NamecoinSettings() + + /** + * Parse `host:port` or `host:port:tcp` into an [ElectrumxServer]. + * + * TLS is the default protocol. Append `:tcp` for plaintext + * (useful for `.onion` addresses and local servers). + * + * `.onion` addresses automatically get `usePinnedTrustStore = true` + * since certificate verification is meaningless over Tor. + */ + fun parseServerString(s: String): ElectrumxServer? { + val parts = s.trim().split(":") + if (parts.size < 2) return null + val host = parts[0].trim() + val port = parts[1].trim().toIntOrNull() ?: return null + if (host.isEmpty() || port <= 0 || port > 65535) return null + val useSsl = parts.getOrNull(2)?.trim()?.lowercase() != "tcp" + val isOnion = host.endsWith(".onion") + return ElectrumxServer( + host = host, + port = port, + useSsl = useSsl, + usePinnedTrustStore = true, + ) + } + + /** + * Format an [ElectrumxServer] back to the `host:port[:tcp]` string form. + */ + fun formatServerString(server: ElectrumxServer): String { + val base = "${server.host}:${server.port}" + return if (server.useSsl) base else "$base:tcp" + } + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettingsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/namecoin/NamecoinSettingsTest.kt similarity index 98% rename from amethyst/src/test/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettingsTest.kt rename to commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/namecoin/NamecoinSettingsTest.kt index 661caca6e..5bd20151d 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettingsTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/namecoin/NamecoinSettingsTest.kt @@ -18,7 +18,7 @@ * 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.service.namecoin +package com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer import org.junit.Assert.assertEquals diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 2de05e998..6059c4cc2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -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, @@ -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() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinNameService.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinNameService.kt new file mode 100644 index 000000000..aa5891b97 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinNameService.kt @@ -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 { + val state = MutableStateFlow(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() +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinPreferences.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinPreferences.kt new file mode 100644 index 000000000..42f2b3302 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinPreferences.kt @@ -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 = _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? + 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>(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 + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/LocalNamecoin.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/LocalNamecoin.kt new file mode 100644 index 000000000..fd638814f --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/LocalNamecoin.kt @@ -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 { null } +val LocalNamecoinService = compositionLocalOf { null } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ImportFollowListDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ImportFollowListDialog.kt new file mode 100644 index 000000000..52d3983df --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ImportFollowListDialog.kt @@ -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.Idle) } + val followEntries = remember { mutableStateListOf() } + + // Track active subscription IDs for cleanup + val activeSubscriptions = remember { mutableStateListOf() } + + // 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?, + ) { + 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?, + ) { + 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?, + ) { + // 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?, + ) { + } + }, + ) + + // 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") + } + } + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt index dede95643..d06860e1a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt @@ -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(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) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index 911eb32e1..b9f36a6b9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -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, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/NamecoinSettingsSection.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/NamecoinSettingsSection.kt new file mode 100644 index 000000000..ec3033a65 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/NamecoinSettingsSection.kt @@ -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, + 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(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, + ) + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinPreferencesTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinPreferencesTest.kt new file mode 100644 index 000000000..bd27f3c80 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinPreferencesTest.kt @@ -0,0 +1,165 @@ +/* + * 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 kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.prefs.Preferences + +class DesktopNamecoinPreferencesTest { + private lateinit var testPrefs: Preferences + private lateinit var namecoinPrefs: DesktopNamecoinPreferences + + @Before + fun setup() { + // Use a unique test node to avoid polluting real preferences + testPrefs = Preferences.userRoot().node("amethyst-test-namecoin-${System.nanoTime()}") + namecoinPrefs = DesktopNamecoinPreferences(prefs = testPrefs) + } + + @After + fun cleanup() { + try { + testPrefs.removeNode() + } catch (_: Exception) { + } + } + + @Test + fun `default settings are enabled with no custom servers`() { + val settings = namecoinPrefs.current + assertTrue(settings.enabled) + assertTrue(settings.customServers.isEmpty()) + assertFalse(settings.hasCustomServers) + } + + @Test + fun `setEnabled persists and updates flow`() = + runBlocking { + namecoinPrefs.setEnabled(false) + assertFalse(namecoinPrefs.current.enabled) + assertFalse(namecoinPrefs.settings.value.enabled) + + // Verify persistence by creating a new instance with the same prefs node + val reloaded = DesktopNamecoinPreferences(prefs = testPrefs) + assertFalse(reloaded.current.enabled) + } + + @Test + fun `addServer persists and updates flow`() = + runBlocking { + namecoinPrefs.addServer("example.com:50006") + assertEquals(listOf("example.com:50006"), namecoinPrefs.current.customServers) + assertTrue(namecoinPrefs.current.hasCustomServers) + + // Verify persistence + val reloaded = DesktopNamecoinPreferences(prefs = testPrefs) + assertEquals(listOf("example.com:50006"), reloaded.current.customServers) + } + + @Test + fun `addServer ignores blank strings`() = + runBlocking { + namecoinPrefs.addServer("") + namecoinPrefs.addServer(" ") + assertTrue(namecoinPrefs.current.customServers.isEmpty()) + } + + @Test + fun `addServer ignores duplicates`() = + runBlocking { + namecoinPrefs.addServer("example.com:50006") + namecoinPrefs.addServer("example.com:50006") + assertEquals(1, namecoinPrefs.current.customServers.size) + } + + @Test + fun `removeServer persists and updates flow`() = + runBlocking { + namecoinPrefs.addServer("server1.com:50006") + namecoinPrefs.addServer("server2.com:50001:tcp") + assertEquals(2, namecoinPrefs.current.customServers.size) + + namecoinPrefs.removeServer("server1.com:50006") + assertEquals(listOf("server2.com:50001:tcp"), namecoinPrefs.current.customServers) + + // Verify persistence + val reloaded = DesktopNamecoinPreferences(prefs = testPrefs) + assertEquals(listOf("server2.com:50001:tcp"), reloaded.current.customServers) + } + + @Test + fun `reset restores defaults`() = + runBlocking { + namecoinPrefs.setEnabled(false) + namecoinPrefs.addServer("example.com:50006") + assertFalse(namecoinPrefs.current.enabled) + assertTrue(namecoinPrefs.current.hasCustomServers) + + namecoinPrefs.reset() + assertTrue(namecoinPrefs.current.enabled) + assertFalse(namecoinPrefs.current.hasCustomServers) + + // Verify persistence + val reloaded = DesktopNamecoinPreferences(prefs = testPrefs) + assertTrue(reloaded.current.enabled) + assertFalse(reloaded.current.hasCustomServers) + } + + @Test + fun `customServersOrNull returns null when empty`() { + assertEquals(null, namecoinPrefs.customServersOrNull) + } + + @Test + fun `customServersOrNull returns parsed servers when configured`() = + runBlocking { + namecoinPrefs.addServer("example.com:50006") + val servers = namecoinPrefs.customServersOrNull + assertEquals(1, servers?.size) + assertEquals("example.com", servers?.first()?.host) + assertEquals(50006, servers?.first()?.port) + assertTrue(servers?.first()?.useSsl == true) + } + + @Test + fun `round-trip multiple operations`() = + runBlocking { + namecoinPrefs.setEnabled(true) + namecoinPrefs.addServer("server1.com:50006") + namecoinPrefs.addServer("onion.onion:50001:tcp") + namecoinPrefs.setEnabled(false) + namecoinPrefs.removeServer("server1.com:50006") + + val settings = namecoinPrefs.current + assertFalse(settings.enabled) + assertEquals(listOf("onion.onion:50001:tcp"), settings.customServers) + + // Verify full persistence round-trip + val reloaded = DesktopNamecoinPreferences(prefs = testPrefs) + assertEquals(settings, reloaded.current) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinNameResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinNameResolver.kt index 135f65334..537ee9cb8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinNameResolver.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinNameResolver.kt @@ -281,8 +281,16 @@ class NamecoinNameResolver( * Extract Nostr data from a `d/` domain value. * * Supports: - * { "nostr": "hex-pubkey" } → simple form + * { "nostr": "hex-pubkey" } → simple-string form * { "nostr": { "names": { "alice": "hex" }, ... } } → extended NIP-05-like form + * { "nostr": { "pubkey": "hex", "relays": [...] } } → single-identity form + * + * The single-identity form is the same shape used by `id/` records and is + * the natural way to express "this one name = this one pubkey". It only + * resolves the root local-part (`_`) — there are no sub-identities in + * this shape. If `nostr.names` is also present, it wins for any + * sub-identity; root-lookups fall back to the bare `pubkey` when + * `names["_"]` is missing. */ private fun extractFromDomainValue( value: JsonObject, @@ -290,7 +298,7 @@ class NamecoinNameResolver( ): NamecoinNostrResult? { val nostrField = value["nostr"] ?: return null - // Simple form: "nostr": "hex-pubkey" + // Simple-string form: "nostr": "hex-pubkey" if (nostrField is JsonPrimitive && nostrField.isString) { val pubkey = nostrField.content if (parsed.localPart == "_" && isValidPubkey(pubkey)) { @@ -305,48 +313,73 @@ class NamecoinNameResolver( if (parsed.localPart != "_") return null } - // Extended form: "nostr": { "names": {...}, "relays": {...} } if (nostrField is JsonObject) { - val names = nostrField["names"]?.jsonObject ?: return null + // Extended NIP-05-like form first: "nostr": { "names": {...}, "relays": {...} } + val names = nostrField["names"]?.jsonObject - // Resolve: exact match → "_" root → first entry (root lookups only) - val resolvedLocalPart: String - val pubkey: String + if (names != null) { + val exactMatch = names[parsed.localPart] + val rootMatch = names["_"] + val firstEntry = if (parsed.localPart == "_") names.entries.firstOrNull() else null - val exactMatch = names[parsed.localPart] - val rootMatch = names["_"] - val firstEntry = if (parsed.localPart == "_") names.entries.firstOrNull() else null - - when { - exactMatch is JsonPrimitive && isValidPubkey(exactMatch.content) -> { - resolvedLocalPart = parsed.localPart - pubkey = exactMatch.content + if (exactMatch is JsonPrimitive && isValidPubkey(exactMatch.content)) { + return NamecoinNostrResult( + pubkey = exactMatch.content.lowercase(), + relays = extractRelays(nostrField, exactMatch.content), + namecoinName = parsed.namecoinName, + localPart = parsed.localPart, + ) } - - rootMatch is JsonPrimitive && isValidPubkey(rootMatch.content) -> { - resolvedLocalPart = "_" - pubkey = rootMatch.content + if (rootMatch is JsonPrimitive && isValidPubkey(rootMatch.content)) { + return NamecoinNostrResult( + pubkey = rootMatch.content.lowercase(), + relays = extractRelays(nostrField, rootMatch.content), + namecoinName = parsed.namecoinName, + localPart = "_", + ) } - - firstEntry != null && + if (firstEntry != null && firstEntry.value is JsonPrimitive && - isValidPubkey((firstEntry.value as JsonPrimitive).content) -> { - resolvedLocalPart = firstEntry.key - pubkey = (firstEntry.value as JsonPrimitive).content - } - - else -> { - return null + isValidPubkey((firstEntry.value as JsonPrimitive).content) + ) { + val pk = (firstEntry.value as JsonPrimitive).content + return NamecoinNostrResult( + pubkey = pk.lowercase(), + relays = extractRelays(nostrField, pk), + namecoinName = parsed.namecoinName, + localPart = firstEntry.key, + ) } + // names was present but didn't yield a match. Fall through to + // the single-identity check below — only meaningful for root + // lookups (non-root requests against a names-only record + // correctly stop here). + if (parsed.localPart != "_") return null } - val relays = extractRelays(nostrField, pubkey) - return NamecoinNostrResult( - pubkey = pubkey.lowercase(), - relays = relays, - namecoinName = parsed.namecoinName, - localPart = resolvedLocalPart, - ) + // Single-identity form: "nostr": { "pubkey": "hex", "relays": [...] } + // Only resolves the root local-part; non-root requests against a + // single-identity record fall through to null so we don't hand + // alice@example.bit the root operator's identity. + if (parsed.localPart == "_") { + val pubkey = (nostrField["pubkey"] as? JsonPrimitive)?.content + if (pubkey != null && isValidPubkey(pubkey)) { + val relays = + try { + nostrField["relays"]?.jsonArray?.mapNotNull { + (it as? JsonPrimitive)?.content + } ?: emptyList() + } catch (_: Exception) { + emptyList() + } + return NamecoinNostrResult( + pubkey = pubkey.lowercase(), + relays = relays, + namecoinName = parsed.namecoinName, + localPart = "_", + ) + } + } } return null diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt index a48604a48..721a9a48e 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt @@ -457,7 +457,8 @@ class ElectrumXClient( /** * Parse a Namecoin name and value from a verbose transaction response. * - * Scans each output for a NAME_UPDATE script (starts with OP_3 = 0x53), + * Scans each output for a NAME_UPDATE (OP_3 = 0x53) or NAME_FIRSTUPDATE + * (OP_2 = 0x52) script, * then extracts the name and value from the script's push data. */ private fun parseNameFromTransaction( @@ -482,8 +483,10 @@ class ElectrumXClient( ?.content ?: continue - // NAME_UPDATE scripts start with OP_3 (0x53) - if (!scriptHex.startsWith("53")) continue + // NAME_UPDATE scripts start with OP_3 (0x53); + // NAME_FIRSTUPDATE scripts start with OP_2 (0x52). Both carry the + // current value at the time of that on-chain operation. + if (!scriptHex.startsWith("53") && !scriptHex.startsWith("52")) continue val scriptBytes = hexToBytes(scriptHex) val parsed = parseNameScript(scriptBytes) ?: continue @@ -510,7 +513,9 @@ class ElectrumXClient( * @return Pair of (name, value) as strings, or null if parsing fails */ private fun parseNameScript(script: ByteArray): Pair? { - if (script.isEmpty() || script[0] != OP_NAME_UPDATE) return null + if (script.isEmpty()) return null + val op = script[0] + if (op != OP_NAME_UPDATE && op != OP_NAME_FIRSTUPDATE) return null var pos = 1 @@ -518,6 +523,12 @@ class ElectrumXClient( val (nameBytes, newPos1) = readPushData(script, pos) ?: return null pos = newPos1 + // FIRSTUPDATE has an extra push between name and value; skip it. + if (op == OP_NAME_FIRSTUPDATE) { + val (_, newPos2) = readPushData(script, pos) ?: return null + pos = newPos2 + } + // Read value val (valueBytes, _) = readPushData(script, pos) ?: return null @@ -815,6 +826,7 @@ class ElectrumXClient( const val NAME_EXPIRE_DEPTH = 36_000 // Namecoin script opcodes + private const val OP_NAME_FIRSTUPDATE: Byte = 0x52 // OP_2 repurposed by Namecoin private const val OP_NAME_UPDATE: Byte = 0x53 // OP_3 repurposed by Namecoin private const val OP_2DROP: Byte = 0x6d private const val OP_DROP: Byte = 0x75