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