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..ac1a19092 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -110,10 +110,15 @@ import com.vitorpamplona.amethyst.desktop.ui.deck.param import com.vitorpamplona.amethyst.desktop.ui.media.LocalAwtWindow import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState +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.ui.profile.ProfileInfoCard import com.vitorpamplona.amethyst.desktop.ui.relay.LocalRelayCategories import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard import com.vitorpamplona.amethyst.desktop.ui.settings.MediaServerSettings +import com.vitorpamplona.amethyst.desktop.ui.settings.NamecoinSettingsSection 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 @@ -695,6 +700,10 @@ fun App( } val localCache = remember { DesktopLocalCache() } + val namecoinPreferences = remember { DesktopNamecoinPreferences() } + val namecoinService = remember { + DesktopNamecoinNameService(preferencesProvider = { namecoinPreferences.current }) + } val accountState by accountManager.accountState.collectAsState() val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) } @@ -872,6 +881,10 @@ fun App( ProvideMaterialSymbols( weight = com.vitorpamplona.amethyst.desktop.platform.PlatformIconWeight.current, ) { + CompositionLocalProvider( + LocalNamecoinPreferences provides namecoinPreferences, + LocalNamecoinService provides namecoinService, + ) { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background, @@ -1036,6 +1049,7 @@ fun App( } } } + } // end CompositionLocalProvider } } @@ -1425,6 +1439,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() @@ -1635,6 +1650,7 @@ fun RelaySettingsScreen( LazyColumn( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.weight(1f), + modifier = Modifier.weight(1f), ) { items(relayStatuses.values.toList(), key = { it.url.url }) { status -> RelayStatusCard( 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..a56bf22cf --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinNameService.kt @@ -0,0 +1,154 @@ +/* + * 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.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer +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() +} + +/** + * 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/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..4e419a8df --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinPreferences.kt @@ -0,0 +1,122 @@ +/* + * 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.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +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 { + return 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/service/namecoin/NamecoinSettings.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/NamecoinSettings.kt new file mode 100644 index 000000000..3bd73046c --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/NamecoinSettings.kt @@ -0,0 +1,94 @@ +/* + * 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.Stable +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer + +/** + * 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. + */ +@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 `trustAllCerts = 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, + trustAllCerts = isOnion || !useSsl, + ) + } + + /** + * 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/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..efb3d0009 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ImportFollowListDialog.kt @@ -0,0 +1,295 @@ +/* + * 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.material.icons.Icons +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +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.runtime.Composable +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.vitorpamplona.amethyst.desktop.service.namecoin.DesktopNamecoinNameService +import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinService +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import kotlinx.coroutines.launch + +/** + * Import Follow List dialog for Desktop. + * + * Lets users enter an identifier (npub, hex, NIP-05, or Namecoin), + * resolves it to a pubkey. The actual follow list fetching from relays + * is a placeholder — full implementation requires relay subscription + * infrastructure integration. + */ +@Composable +fun ImportFollowListDialog( + onDismiss: () -> Unit, + onImport: (List) -> Unit, +) { + val namecoinService = LocalNamecoinService.current + val scope = rememberCoroutineScope() + + var input by remember { mutableStateOf("") } + var resolvedPubkey by remember { mutableStateOf(null) } + var isResolving by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + var followList = remember { mutableStateListOf() } + var isFetching by remember { mutableStateOf(false) } + + fun resolveIdentifier() { + val trimmed = input.trim() + if (trimmed.isBlank()) { + error = "Enter an identifier" + return + } + + error = null + isResolving = true + resolvedPubkey = null + followList.clear() + + scope.launch { + try { + // Try bech32 first + val bech32Result = decodePublicKeyAsHexOrNull(trimmed) + if (bech32Result != null) { + resolvedPubkey = bech32Result + isResolving = false + return@launch + } + + // Try hex pubkey + if (trimmed.matches(Regex("^[0-9a-fA-F]{64}$"))) { + resolvedPubkey = trimmed.lowercase() + isResolving = false + return@launch + } + + // Try Namecoin + if (NamecoinNameResolver.isNamecoinIdentifier(trimmed) && namecoinService != null) { + val result = namecoinService.resolvePubkey(trimmed) + if (result != null) { + resolvedPubkey = result + isResolving = false + return@launch + } + } + + error = "Could not resolve identifier" + isResolving = false + } catch (e: Exception) { + error = e.message ?: "Resolution failed" + isResolving = false + } + } + } + + 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, 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 + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = input, + onValueChange = { + input = it + error = null + }, + label = { Text("Identifier") }, + placeholder = { Text("npub1..., alice@example.bit, d/example") }, + singleLine = true, + isError = error != null, + supportingText = error?.let { err -> + { Text(err, 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() && !isResolving, + ) { + if (isResolving) { + CircularProgressIndicator(modifier = Modifier.size(16.dp)) + } else { + Text("Resolve") + } + } + } + + // Resolved pubkey display + if (resolvedPubkey != null) { + Spacer(Modifier.height(12.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + Icons.Default.Person, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + Text( + "Resolved: ${resolvedPubkey!!.take(16)}...${resolvedPubkey!!.takeLast(8)}", + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.primary, + ) + } + + Spacer(Modifier.height(8.dp)) + Text( + "Follow list fetching requires relay subscriptions. " + + "The resolved pubkey can be used to follow this user directly.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(Modifier.height(16.dp)) + + // Follow list preview (placeholder for when relay fetching is implemented) + if (followList.isNotEmpty()) { + Text( + "Follow List (${followList.size} users)", + style = MaterialTheme.typography.labelMedium, + ) + Spacer(Modifier.height(8.dp)) + LazyColumn( + modifier = Modifier.height(300.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + items(followList) { entry -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(4.dp), + ) { + Checkbox( + checked = entry.selected, + onCheckedChange = { entry.selected = it }, + ) + Spacer(Modifier.width(8.dp)) + Text( + entry.pubkey.take(16) + "...", + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + ) + } + } + } + } + + // Action buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton(onClick = onDismiss) { + Text("Cancel") + } + if (resolvedPubkey != null) { + Spacer(Modifier.width(8.dp)) + Button( + onClick = { + resolvedPubkey?.let { pk -> + onImport(listOf(pk)) + } + }, + ) { + Text("Follow User") + } + } + } + } + } + } +} + +data class FollowEntry( + val pubkey: String, + var selected: Boolean = true, +) 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..0a47d4e69 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 @@ -102,8 +102,15 @@ import com.vitorpamplona.amethyst.desktop.ui.relay.SearchRelayEditor 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.amethyst.desktop.service.namecoin.DesktopNamecoinNameService +import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinPreferences +import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinService +import com.vitorpamplona.amethyst.desktop.service.namecoin.NamecoinResolveState 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.flow.mapLatest import kotlinx.coroutines.launch @Composable @@ -156,6 +163,35 @@ 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 + LaunchedEffect(displayText, namecoinEnabled) { + if (!namecoinEnabled || !isNamecoinQuery || namecoinService == null) { + namecoinState = null + return@LaunchedEffect + } + namecoinState = NamecoinResolveState.Loading + try { + val result = namecoinService.resolve(displayText.trim()) + namecoinState = if (result != null) { + NamecoinResolveState.Resolved(result) + } else { + NamecoinResolveState.NotFound + } + } catch (e: Exception) { + namecoinState = NamecoinResolveState.Error(e.message ?: "Resolution failed") + } + } + // Skip people search when query specifies kinds that don't include profile (kind 0) val shouldSearchPeople = (debouncedQuery.kinds.isEmpty() && debouncedQuery.pseudoKinds.isEmpty()) || 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..a376ecd06 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 @@ -38,6 +38,7 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.desktop.DesktopScreen import com.vitorpamplona.amethyst.desktop.RelaySettingsScreen import com.vitorpamplona.amethyst.desktop.account.AccountManager +import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinPreferences import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.chess.ChessScreen @@ -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..80aa897c4 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/NamecoinSettingsSection.kt @@ -0,0 +1,401 @@ +/* + * 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.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +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.desktop.service.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( + Icons.Default.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( + Icons.Default.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( + Icons.Default.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( + Icons.Default.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..98da8aa44 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinPreferencesTest.kt @@ -0,0 +1,157 @@ +/* + * 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/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/NamecoinSettingsTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/NamecoinSettingsTest.kt new file mode 100644 index 000000000..ea578196c --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/NamecoinSettingsTest.kt @@ -0,0 +1,177 @@ +/* + * 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.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class NamecoinSettingsTest { + // ── Server string parsing ────────────────────────────────────────── + + @Test + fun `parses host colon port as TLS`() { + val s = NamecoinSettings.parseServerString("example.com:50006") + assertNotNull(s) + assertEquals("example.com", s!!.host) + assertEquals(50006, s.port) + assertTrue(s.useSsl) + } + + @Test + fun `parses host colon port colon tcp as plaintext`() { + val s = NamecoinSettings.parseServerString("example.com:50001:tcp") + assertNotNull(s) + assertEquals("example.com", s!!.host) + assertEquals(50001, s.port) + assertFalse(s.useSsl) + } + + @Test + fun `parses onion address`() { + val s = NamecoinSettings.parseServerString("abc123def.onion:50001:tcp") + assertNotNull(s) + assertEquals("abc123def.onion", s!!.host) + assertEquals(50001, s.port) + assertFalse(s.useSsl) + assertTrue(s.trustAllCerts) + } + + @Test + fun `trims whitespace`() { + val s = NamecoinSettings.parseServerString(" example.com : 50006 ") + assertNotNull(s) + assertEquals("example.com", s!!.host) + assertEquals(50006, s.port) + } + + @Test + fun `rejects empty host`() { + assertNull(NamecoinSettings.parseServerString(":50006")) + } + + @Test + fun `rejects invalid port`() { + assertNull(NamecoinSettings.parseServerString("example.com:abc")) + assertNull(NamecoinSettings.parseServerString("example.com:0")) + assertNull(NamecoinSettings.parseServerString("example.com:99999")) + } + + @Test + fun `rejects no port`() { + assertNull(NamecoinSettings.parseServerString("example.com")) + } + + // ── Format round-trip ────────────────────────────────────────────── + + @Test + fun `formats TLS server without suffix`() { + val server = ElectrumxServer("example.com", 50006, true) + assertEquals("example.com:50006", NamecoinSettings.formatServerString(server)) + } + + @Test + fun `formats TCP server with tcp suffix`() { + val server = ElectrumxServer("example.com", 50001, false) + assertEquals("example.com:50001:tcp", NamecoinSettings.formatServerString(server)) + } + + @Test + fun `round-trips server string through parse and format`() { + val original = "myserver.onion:50001:tcp" + val parsed = NamecoinSettings.parseServerString(original)!! + val formatted = NamecoinSettings.formatServerString(parsed) + assertEquals(original, formatted) + } + + // ── toElectrumxServers ───────────────────────────────────────────── + + @Test + fun `returns null when no custom servers`() { + val settings = NamecoinSettings(customServers = emptyList()) + assertNull(settings.toElectrumxServers()) + } + + @Test + fun `returns parsed list for valid custom servers`() { + val settings = NamecoinSettings( + customServers = listOf( + "server1.com:50006", + "server2.onion:50001:tcp", + ), + ) + val servers = settings.toElectrumxServers() + assertNotNull(servers) + assertEquals(2, servers!!.size) + assertEquals("server1.com", servers[0].host) + assertTrue(servers[0].useSsl) + assertEquals("server2.onion", servers[1].host) + assertFalse(servers[1].useSsl) + assertTrue(servers[1].trustAllCerts) + } + + @Test + fun `skips invalid entries in custom server list`() { + val settings = NamecoinSettings( + customServers = listOf( + "valid.com:50006", + "invalid", // no port + "also-invalid:abc", // non-numeric port + ), + ) + val servers = settings.toElectrumxServers() + assertNotNull(servers) + assertEquals(1, servers!!.size) + assertEquals("valid.com", servers[0].host) + } + + @Test + fun `returns null when all custom servers are invalid`() { + val settings = NamecoinSettings(customServers = listOf("bad", "also-bad")) + assertNull(settings.toElectrumxServers()) + } + + // ── hasCustomServers flag ────────────────────────────────────────── + + @Test + fun `hasCustomServers is false when empty`() { + assertFalse(NamecoinSettings().hasCustomServers) + } + + @Test + fun `hasCustomServers is true when populated`() { + assertTrue(NamecoinSettings(customServers = listOf("x:1")).hasCustomServers) + } + + // ── Default settings ─────────────────────────────────────────────── + + @Test + fun `default settings are enabled with no custom servers`() { + val d = NamecoinSettings.DEFAULT + assertTrue(d.enabled) + assertTrue(d.customServers.isEmpty()) + assertFalse(d.hasCustomServers) + } +}