Merge pull request #1914 from mstrofnone/desktop-namecoin-port
Port Namecoin NIP-05 resolution to Desktop app
This commit is contained in:
+1
-17
@@ -20,6 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.service.namecoin
|
package com.vitorpamplona.amethyst.service.namecoin
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinResolveState
|
||||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS
|
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS
|
||||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient
|
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient
|
||||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer
|
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer
|
||||||
@@ -151,20 +152,3 @@ class NamecoinNameService(
|
|||||||
*/
|
*/
|
||||||
suspend fun clearCache() = cache.clear()
|
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()
|
|
||||||
}
|
|
||||||
|
|||||||
+1
-78
@@ -20,81 +20,4 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.service.namecoin
|
package com.vitorpamplona.amethyst.service.namecoin
|
||||||
|
|
||||||
import androidx.compose.runtime.Stable
|
typealias NamecoinSettings = com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinSettings
|
||||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Immutable data class representing the current Namecoin resolution config.
|
|
||||||
*
|
|
||||||
* When custom servers are configured, they are used EXCLUSIVELY and the
|
|
||||||
* hardcoded defaults are ignored. This gives privacy-conscious users full
|
|
||||||
* control over which ElectrumX servers observe their name lookups.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
@Stable
|
|
||||||
data class NamecoinSettings(
|
|
||||||
/** Whether Namecoin resolution is enabled at all. */
|
|
||||||
val enabled: Boolean = true,
|
|
||||||
/**
|
|
||||||
* Custom ElectrumX servers. When non-empty, these replace the defaults.
|
|
||||||
*
|
|
||||||
* Each entry is `host:port` (TLS) or `host:port:tcp` (plaintext).
|
|
||||||
*/
|
|
||||||
val customServers: List<String> = 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<ElectrumxServer>? {
|
|
||||||
if (customServers.isEmpty()) return null
|
|
||||||
return customServers
|
|
||||||
.mapNotNull { parseServerString(it) }
|
|
||||||
.ifEmpty { null }
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
val DEFAULT = NamecoinSettings()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse `host:port` or `host:port:tcp` into an [ElectrumxServer].
|
|
||||||
*
|
|
||||||
* TLS is the default protocol. Append `:tcp` for plaintext
|
|
||||||
* (useful for `.onion` addresses and local servers).
|
|
||||||
*
|
|
||||||
* `.onion` addresses automatically get `usePinnedTrustStore = true`
|
|
||||||
* since certificate verification is meaningless over Tor.
|
|
||||||
*/
|
|
||||||
fun parseServerString(s: String): ElectrumxServer? {
|
|
||||||
val parts = s.trim().split(":")
|
|
||||||
if (parts.size < 2) return null
|
|
||||||
val host = parts[0].trim()
|
|
||||||
val port = parts[1].trim().toIntOrNull() ?: return null
|
|
||||||
if (host.isEmpty() || port <= 0 || port > 65535) return null
|
|
||||||
val useSsl = parts.getOrNull(2)?.trim()?.lowercase() != "tcp"
|
|
||||||
val isOnion = host.endsWith(".onion")
|
|
||||||
// All custom servers use the pinned trust store. ElectrumX
|
|
||||||
// servers almost universally use self-signed certs, so we
|
|
||||||
// route them through our pinned SSLSocketFactory (hardcoded
|
|
||||||
// defaults + TOFU-pinned certs + system CAs).
|
|
||||||
return ElectrumxServer(
|
|
||||||
host = host,
|
|
||||||
port = port,
|
|
||||||
useSsl = useSsl,
|
|
||||||
usePinnedTrustStore = true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format an [ElectrumxServer] back to the `host:port[:tcp]` string form.
|
|
||||||
*/
|
|
||||||
fun formatServerString(server: ElectrumxServer): String {
|
|
||||||
val base = "${server.host}:${server.port}"
|
|
||||||
return if (server.useSsl) base else "$base:tcp"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNostrResult
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observable state for a Namecoin resolution in progress.
|
||||||
|
*/
|
||||||
|
sealed class NamecoinResolveState {
|
||||||
|
data object Loading : NamecoinResolveState()
|
||||||
|
|
||||||
|
data class Resolved(
|
||||||
|
val result: NamecoinNostrResult,
|
||||||
|
) : NamecoinResolveState()
|
||||||
|
|
||||||
|
data object NotFound : NamecoinResolveState()
|
||||||
|
|
||||||
|
data class Error(
|
||||||
|
val message: String,
|
||||||
|
) : NamecoinResolveState()
|
||||||
|
}
|
||||||
+96
@@ -0,0 +1,96 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable data class representing the current Namecoin resolution config.
|
||||||
|
*
|
||||||
|
* When custom servers are configured, they are used EXCLUSIVELY and the
|
||||||
|
* hardcoded defaults are ignored. This gives privacy-conscious users full
|
||||||
|
* control over which ElectrumX servers observe their name lookups.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@Stable
|
||||||
|
data class NamecoinSettings(
|
||||||
|
/** Whether Namecoin resolution is enabled at all. */
|
||||||
|
val enabled: Boolean = true,
|
||||||
|
/**
|
||||||
|
* Custom ElectrumX servers. When non-empty, these replace the defaults.
|
||||||
|
*
|
||||||
|
* Each entry is `host:port` (TLS) or `host:port:tcp` (plaintext).
|
||||||
|
*/
|
||||||
|
val customServers: List<String> = 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<ElectrumxServer>? {
|
||||||
|
if (customServers.isEmpty()) return null
|
||||||
|
return customServers
|
||||||
|
.mapNotNull { parseServerString(it) }
|
||||||
|
.ifEmpty { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val DEFAULT = NamecoinSettings()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse `host:port` or `host:port:tcp` into an [ElectrumxServer].
|
||||||
|
*
|
||||||
|
* TLS is the default protocol. Append `:tcp` for plaintext
|
||||||
|
* (useful for `.onion` addresses and local servers).
|
||||||
|
*
|
||||||
|
* `.onion` addresses automatically get `usePinnedTrustStore = true`
|
||||||
|
* since certificate verification is meaningless over Tor.
|
||||||
|
*/
|
||||||
|
fun parseServerString(s: String): ElectrumxServer? {
|
||||||
|
val parts = s.trim().split(":")
|
||||||
|
if (parts.size < 2) return null
|
||||||
|
val host = parts[0].trim()
|
||||||
|
val port = parts[1].trim().toIntOrNull() ?: return null
|
||||||
|
if (host.isEmpty() || port <= 0 || port > 65535) return null
|
||||||
|
val useSsl = parts.getOrNull(2)?.trim()?.lowercase() != "tcp"
|
||||||
|
val isOnion = host.endsWith(".onion")
|
||||||
|
return ElectrumxServer(
|
||||||
|
host = host,
|
||||||
|
port = port,
|
||||||
|
useSsl = useSsl,
|
||||||
|
usePinnedTrustStore = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an [ElectrumxServer] back to the `host:port[:tcp]` string form.
|
||||||
|
*/
|
||||||
|
fun formatServerString(server: ElectrumxServer): String {
|
||||||
|
val base = "${server.host}:${server.port}"
|
||||||
|
return if (server.useSsl) base else "$base:tcp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -18,7 +18,7 @@
|
|||||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
* 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.
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.service.namecoin
|
package com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin
|
||||||
|
|
||||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer
|
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
@@ -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.highlights.DesktopHighlightStore
|
||||||
import com.vitorpamplona.amethyst.desktop.service.images.DesktopImageLoaderSetup
|
import com.vitorpamplona.amethyst.desktop.service.images.DesktopImageLoaderSetup
|
||||||
import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool
|
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.subscriptions.DesktopRelaySubscriptionsCoordinator
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.ComposeNoteDialog
|
import com.vitorpamplona.amethyst.desktop.ui.ComposeNoteDialog
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.ConnectingRelaysScreen
|
import com.vitorpamplona.amethyst.desktop.ui.ConnectingRelaysScreen
|
||||||
@@ -253,6 +257,8 @@ fun main() {
|
|||||||
val accountManager = remember { AccountManager.create() }
|
val accountManager = remember { AccountManager.create() }
|
||||||
val accountState by accountManager.accountState.collectAsState()
|
val accountState by accountManager.accountState.collectAsState()
|
||||||
var showAppDrawer by remember { mutableStateOf(false) }
|
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
|
// Tor state at Window level — survives key() app rebuild
|
||||||
var torSettings by remember {
|
var torSettings by remember {
|
||||||
@@ -377,6 +383,18 @@ fun main() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
Separator()
|
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(
|
Item(
|
||||||
"Logout",
|
"Logout",
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -607,6 +625,8 @@ fun main() {
|
|||||||
onDismissAppDrawer = { showAppDrawer = false },
|
onDismissAppDrawer = { showAppDrawer = false },
|
||||||
onShowAppDrawer = { showAppDrawer = true },
|
onShowAppDrawer = { showAppDrawer = true },
|
||||||
replyToNote = replyToNote,
|
replyToNote = replyToNote,
|
||||||
|
showImportFollowListDialog = showImportFollowListDialog,
|
||||||
|
onDismissImportFollowListDialog = { showImportFollowListDialog = false },
|
||||||
onRestartApp = { appRestartKey++ },
|
onRestartApp = { appRestartKey++ },
|
||||||
torManager = torManager,
|
torManager = torManager,
|
||||||
torTypeFlow = torTypeFlow,
|
torTypeFlow = torTypeFlow,
|
||||||
@@ -635,6 +655,8 @@ fun App(
|
|||||||
onDismissAppDrawer: () -> Unit,
|
onDismissAppDrawer: () -> Unit,
|
||||||
onShowAppDrawer: () -> Unit,
|
onShowAppDrawer: () -> Unit,
|
||||||
replyToNote: com.vitorpamplona.quartz.nip01Core.core.Event?,
|
replyToNote: com.vitorpamplona.quartz.nip01Core.core.Event?,
|
||||||
|
showImportFollowListDialog: Boolean = false,
|
||||||
|
onDismissImportFollowListDialog: () -> Unit = {},
|
||||||
onRestartApp: () -> Unit = {},
|
onRestartApp: () -> Unit = {},
|
||||||
torManager: com.vitorpamplona.amethyst.desktop.tor.DesktopTorManager,
|
torManager: com.vitorpamplona.amethyst.desktop.tor.DesktopTorManager,
|
||||||
torTypeFlow: kotlinx.coroutines.flow.MutableStateFlow<com.vitorpamplona.amethyst.commons.tor.TorType>,
|
torTypeFlow: kotlinx.coroutines.flow.MutableStateFlow<com.vitorpamplona.amethyst.commons.tor.TorType>,
|
||||||
@@ -911,6 +933,14 @@ fun App(
|
|||||||
val account = accountState as AccountState.LoggedIn
|
val account = accountState as AccountState.LoggedIn
|
||||||
val nwcConnection by accountManager.nwcConnection.collectAsState()
|
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
|
// Load NWC connection on first composition
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
accountManager.loadNwcConnection()
|
accountManager.loadNwcConnection()
|
||||||
@@ -932,6 +962,8 @@ fun App(
|
|||||||
onRestartApp()
|
onRestartApp()
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
LocalNamecoinPreferences provides namecoinPreferences,
|
||||||
|
LocalNamecoinService provides namecoinService,
|
||||||
) {
|
) {
|
||||||
MainContent(
|
MainContent(
|
||||||
layoutMode = layoutMode,
|
layoutMode = layoutMode,
|
||||||
@@ -1425,6 +1457,7 @@ fun RelaySettingsScreen(
|
|||||||
com.vitorpamplona.amethyst.commons.tor
|
com.vitorpamplona.amethyst.commons.tor
|
||||||
.TorSettings(torType = com.vitorpamplona.amethyst.commons.tor.TorType.OFF),
|
.TorSettings(torType = com.vitorpamplona.amethyst.commons.tor.TorType.OFF),
|
||||||
onTorSettingsChanged: (com.vitorpamplona.amethyst.commons.tor.TorSettings) -> Unit = {},
|
onTorSettingsChanged: (com.vitorpamplona.amethyst.commons.tor.TorSettings) -> Unit = {},
|
||||||
|
namecoinPreferences: DesktopNamecoinPreferences? = null,
|
||||||
) {
|
) {
|
||||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||||
val connectedRelays by relayManager.connectedRelays.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.feeds.custom.toFeedDefinition
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
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.AdvancedSearchBarState
|
||||||
import com.vitorpamplona.amethyst.commons.search.QuerySerializer
|
import com.vitorpamplona.amethyst.commons.search.QuerySerializer
|
||||||
import com.vitorpamplona.amethyst.commons.search.SavedSearch
|
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.account.AccountState
|
||||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
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.DesktopRelaySubscriptionsCoordinator
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.SearchFilterFactory
|
import com.vitorpamplona.amethyst.desktop.subscriptions.SearchFilterFactory
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
|
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.SearchResultsList
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.search.SearchSyncBanner
|
import com.vitorpamplona.amethyst.desktop.ui.search.SearchSyncBanner
|
||||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
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 com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -156,6 +161,60 @@ fun SearchScreen(
|
|||||||
// Bech32 parsing (immediate, no debounce)
|
// Bech32 parsing (immediate, no debounce)
|
||||||
val bech32Results = remember(displayText) { parseSearchInput(displayText) }
|
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)
|
// Skip people search when query specifies kinds that don't include profile (kind 0)
|
||||||
val shouldSearchPeople =
|
val shouldSearchPeople =
|
||||||
(debouncedQuery.kinds.isEmpty() && debouncedQuery.pseudoKinds.isEmpty()) ||
|
(debouncedQuery.kinds.isEmpty() && debouncedQuery.pseudoKinds.isEmpty()) ||
|
||||||
@@ -543,8 +602,78 @@ fun SearchScreen(
|
|||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
// Results
|
// 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 =
|
val hasAnyResults =
|
||||||
bech32Results.isNotEmpty() || peopleResults.isNotEmpty() || noteResults.isNotEmpty()
|
bech32Results.isNotEmpty() || peopleResults.isNotEmpty() || noteResults.isNotEmpty() ||
|
||||||
|
(namecoinState is NamecoinResolveState.Resolved)
|
||||||
|
|
||||||
if (bech32Results.isNotEmpty()) {
|
if (bech32Results.isNotEmpty()) {
|
||||||
// Show bech32 results (exact lookup)
|
// 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.network.Nip11Fetcher
|
||||||
import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore
|
import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore
|
||||||
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
|
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.DesktopRelaySubscriptionsCoordinator
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode
|
import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.ArticleEditorScreen
|
import com.vitorpamplona.amethyst.desktop.ui.ArticleEditorScreen
|
||||||
@@ -336,6 +337,7 @@ internal fun RootContent(
|
|||||||
torStatus = torState.status,
|
torStatus = torState.status,
|
||||||
torSettings = torState.settings,
|
torSettings = torState.settings,
|
||||||
onTorSettingsChanged = torState.onSettingsChanged,
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+165
@@ -0,0 +1,165 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.desktop.service.namecoin
|
||||||
|
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import java.util.prefs.Preferences
|
||||||
|
|
||||||
|
class DesktopNamecoinPreferencesTest {
|
||||||
|
private lateinit var testPrefs: Preferences
|
||||||
|
private lateinit var namecoinPrefs: DesktopNamecoinPreferences
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
// Use a unique test node to avoid polluting real preferences
|
||||||
|
testPrefs = Preferences.userRoot().node("amethyst-test-namecoin-${System.nanoTime()}")
|
||||||
|
namecoinPrefs = DesktopNamecoinPreferences(prefs = testPrefs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun cleanup() {
|
||||||
|
try {
|
||||||
|
testPrefs.removeNode()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `default settings are enabled with no custom servers`() {
|
||||||
|
val settings = namecoinPrefs.current
|
||||||
|
assertTrue(settings.enabled)
|
||||||
|
assertTrue(settings.customServers.isEmpty())
|
||||||
|
assertFalse(settings.hasCustomServers)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `setEnabled persists and updates flow`() =
|
||||||
|
runBlocking {
|
||||||
|
namecoinPrefs.setEnabled(false)
|
||||||
|
assertFalse(namecoinPrefs.current.enabled)
|
||||||
|
assertFalse(namecoinPrefs.settings.value.enabled)
|
||||||
|
|
||||||
|
// Verify persistence by creating a new instance with the same prefs node
|
||||||
|
val reloaded = DesktopNamecoinPreferences(prefs = testPrefs)
|
||||||
|
assertFalse(reloaded.current.enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `addServer persists and updates flow`() =
|
||||||
|
runBlocking {
|
||||||
|
namecoinPrefs.addServer("example.com:50006")
|
||||||
|
assertEquals(listOf("example.com:50006"), namecoinPrefs.current.customServers)
|
||||||
|
assertTrue(namecoinPrefs.current.hasCustomServers)
|
||||||
|
|
||||||
|
// Verify persistence
|
||||||
|
val reloaded = DesktopNamecoinPreferences(prefs = testPrefs)
|
||||||
|
assertEquals(listOf("example.com:50006"), reloaded.current.customServers)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `addServer ignores blank strings`() =
|
||||||
|
runBlocking {
|
||||||
|
namecoinPrefs.addServer("")
|
||||||
|
namecoinPrefs.addServer(" ")
|
||||||
|
assertTrue(namecoinPrefs.current.customServers.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `addServer ignores duplicates`() =
|
||||||
|
runBlocking {
|
||||||
|
namecoinPrefs.addServer("example.com:50006")
|
||||||
|
namecoinPrefs.addServer("example.com:50006")
|
||||||
|
assertEquals(1, namecoinPrefs.current.customServers.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `removeServer persists and updates flow`() =
|
||||||
|
runBlocking {
|
||||||
|
namecoinPrefs.addServer("server1.com:50006")
|
||||||
|
namecoinPrefs.addServer("server2.com:50001:tcp")
|
||||||
|
assertEquals(2, namecoinPrefs.current.customServers.size)
|
||||||
|
|
||||||
|
namecoinPrefs.removeServer("server1.com:50006")
|
||||||
|
assertEquals(listOf("server2.com:50001:tcp"), namecoinPrefs.current.customServers)
|
||||||
|
|
||||||
|
// Verify persistence
|
||||||
|
val reloaded = DesktopNamecoinPreferences(prefs = testPrefs)
|
||||||
|
assertEquals(listOf("server2.com:50001:tcp"), reloaded.current.customServers)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reset restores defaults`() =
|
||||||
|
runBlocking {
|
||||||
|
namecoinPrefs.setEnabled(false)
|
||||||
|
namecoinPrefs.addServer("example.com:50006")
|
||||||
|
assertFalse(namecoinPrefs.current.enabled)
|
||||||
|
assertTrue(namecoinPrefs.current.hasCustomServers)
|
||||||
|
|
||||||
|
namecoinPrefs.reset()
|
||||||
|
assertTrue(namecoinPrefs.current.enabled)
|
||||||
|
assertFalse(namecoinPrefs.current.hasCustomServers)
|
||||||
|
|
||||||
|
// Verify persistence
|
||||||
|
val reloaded = DesktopNamecoinPreferences(prefs = testPrefs)
|
||||||
|
assertTrue(reloaded.current.enabled)
|
||||||
|
assertFalse(reloaded.current.hasCustomServers)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `customServersOrNull returns null when empty`() {
|
||||||
|
assertEquals(null, namecoinPrefs.customServersOrNull)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `customServersOrNull returns parsed servers when configured`() =
|
||||||
|
runBlocking {
|
||||||
|
namecoinPrefs.addServer("example.com:50006")
|
||||||
|
val servers = namecoinPrefs.customServersOrNull
|
||||||
|
assertEquals(1, servers?.size)
|
||||||
|
assertEquals("example.com", servers?.first()?.host)
|
||||||
|
assertEquals(50006, servers?.first()?.port)
|
||||||
|
assertTrue(servers?.first()?.useSsl == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `round-trip multiple operations`() =
|
||||||
|
runBlocking {
|
||||||
|
namecoinPrefs.setEnabled(true)
|
||||||
|
namecoinPrefs.addServer("server1.com:50006")
|
||||||
|
namecoinPrefs.addServer("onion.onion:50001:tcp")
|
||||||
|
namecoinPrefs.setEnabled(false)
|
||||||
|
namecoinPrefs.removeServer("server1.com:50006")
|
||||||
|
|
||||||
|
val settings = namecoinPrefs.current
|
||||||
|
assertFalse(settings.enabled)
|
||||||
|
assertEquals(listOf("onion.onion:50001:tcp"), settings.customServers)
|
||||||
|
|
||||||
|
// Verify full persistence round-trip
|
||||||
|
val reloaded = DesktopNamecoinPreferences(prefs = testPrefs)
|
||||||
|
assertEquals(settings, reloaded.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
+60
-27
@@ -281,8 +281,16 @@ class NamecoinNameResolver(
|
|||||||
* Extract Nostr data from a `d/` domain value.
|
* Extract Nostr data from a `d/` domain value.
|
||||||
*
|
*
|
||||||
* Supports:
|
* Supports:
|
||||||
* { "nostr": "hex-pubkey" } → simple form
|
* { "nostr": "hex-pubkey" } → simple-string form
|
||||||
* { "nostr": { "names": { "alice": "hex" }, ... } } → extended NIP-05-like form
|
* { "nostr": { "names": { "alice": "hex" }, ... } } → extended NIP-05-like form
|
||||||
|
* { "nostr": { "pubkey": "hex", "relays": [...] } } → single-identity form
|
||||||
|
*
|
||||||
|
* The single-identity form is the same shape used by `id/` records and is
|
||||||
|
* the natural way to express "this one name = this one pubkey". It only
|
||||||
|
* resolves the root local-part (`_`) — there are no sub-identities in
|
||||||
|
* this shape. If `nostr.names` is also present, it wins for any
|
||||||
|
* sub-identity; root-lookups fall back to the bare `pubkey` when
|
||||||
|
* `names["_"]` is missing.
|
||||||
*/
|
*/
|
||||||
private fun extractFromDomainValue(
|
private fun extractFromDomainValue(
|
||||||
value: JsonObject,
|
value: JsonObject,
|
||||||
@@ -290,7 +298,7 @@ class NamecoinNameResolver(
|
|||||||
): NamecoinNostrResult? {
|
): NamecoinNostrResult? {
|
||||||
val nostrField = value["nostr"] ?: return null
|
val nostrField = value["nostr"] ?: return null
|
||||||
|
|
||||||
// Simple form: "nostr": "hex-pubkey"
|
// Simple-string form: "nostr": "hex-pubkey"
|
||||||
if (nostrField is JsonPrimitive && nostrField.isString) {
|
if (nostrField is JsonPrimitive && nostrField.isString) {
|
||||||
val pubkey = nostrField.content
|
val pubkey = nostrField.content
|
||||||
if (parsed.localPart == "_" && isValidPubkey(pubkey)) {
|
if (parsed.localPart == "_" && isValidPubkey(pubkey)) {
|
||||||
@@ -305,49 +313,74 @@ class NamecoinNameResolver(
|
|||||||
if (parsed.localPart != "_") return null
|
if (parsed.localPart != "_") return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extended form: "nostr": { "names": {...}, "relays": {...} }
|
|
||||||
if (nostrField is JsonObject) {
|
if (nostrField is JsonObject) {
|
||||||
val names = nostrField["names"]?.jsonObject ?: return null
|
// Extended NIP-05-like form first: "nostr": { "names": {...}, "relays": {...} }
|
||||||
|
val names = nostrField["names"]?.jsonObject
|
||||||
// Resolve: exact match → "_" root → first entry (root lookups only)
|
|
||||||
val resolvedLocalPart: String
|
|
||||||
val pubkey: String
|
|
||||||
|
|
||||||
|
if (names != null) {
|
||||||
val exactMatch = names[parsed.localPart]
|
val exactMatch = names[parsed.localPart]
|
||||||
val rootMatch = names["_"]
|
val rootMatch = names["_"]
|
||||||
val firstEntry = if (parsed.localPart == "_") names.entries.firstOrNull() else null
|
val firstEntry = if (parsed.localPart == "_") names.entries.firstOrNull() else null
|
||||||
|
|
||||||
when {
|
if (exactMatch is JsonPrimitive && isValidPubkey(exactMatch.content)) {
|
||||||
exactMatch is JsonPrimitive && isValidPubkey(exactMatch.content) -> {
|
return NamecoinNostrResult(
|
||||||
resolvedLocalPart = parsed.localPart
|
pubkey = exactMatch.content.lowercase(),
|
||||||
pubkey = exactMatch.content
|
relays = extractRelays(nostrField, exactMatch.content),
|
||||||
|
namecoinName = parsed.namecoinName,
|
||||||
|
localPart = parsed.localPart,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
if (rootMatch is JsonPrimitive && isValidPubkey(rootMatch.content)) {
|
||||||
rootMatch is JsonPrimitive && isValidPubkey(rootMatch.content) -> {
|
return NamecoinNostrResult(
|
||||||
resolvedLocalPart = "_"
|
pubkey = rootMatch.content.lowercase(),
|
||||||
pubkey = rootMatch.content
|
relays = extractRelays(nostrField, rootMatch.content),
|
||||||
|
namecoinName = parsed.namecoinName,
|
||||||
|
localPart = "_",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
if (firstEntry != null &&
|
||||||
firstEntry != null &&
|
|
||||||
firstEntry.value is JsonPrimitive &&
|
firstEntry.value is JsonPrimitive &&
|
||||||
isValidPubkey((firstEntry.value as JsonPrimitive).content) -> {
|
isValidPubkey((firstEntry.value as JsonPrimitive).content)
|
||||||
resolvedLocalPart = firstEntry.key
|
) {
|
||||||
pubkey = (firstEntry.value as JsonPrimitive).content
|
val pk = (firstEntry.value as JsonPrimitive).content
|
||||||
|
return NamecoinNostrResult(
|
||||||
|
pubkey = pk.lowercase(),
|
||||||
|
relays = extractRelays(nostrField, pk),
|
||||||
|
namecoinName = parsed.namecoinName,
|
||||||
|
localPart = firstEntry.key,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// names was present but didn't yield a match. Fall through to
|
||||||
|
// the single-identity check below — only meaningful for root
|
||||||
|
// lookups (non-root requests against a names-only record
|
||||||
|
// correctly stop here).
|
||||||
|
if (parsed.localPart != "_") return null
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
// Single-identity form: "nostr": { "pubkey": "hex", "relays": [...] }
|
||||||
return null
|
// Only resolves the root local-part; non-root requests against a
|
||||||
|
// single-identity record fall through to null so we don't hand
|
||||||
|
// alice@example.bit the root operator's identity.
|
||||||
|
if (parsed.localPart == "_") {
|
||||||
|
val pubkey = (nostrField["pubkey"] as? JsonPrimitive)?.content
|
||||||
|
if (pubkey != null && isValidPubkey(pubkey)) {
|
||||||
|
val relays =
|
||||||
|
try {
|
||||||
|
nostrField["relays"]?.jsonArray?.mapNotNull {
|
||||||
|
(it as? JsonPrimitive)?.content
|
||||||
|
} ?: emptyList()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
emptyList()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
val relays = extractRelays(nostrField, pubkey)
|
|
||||||
return NamecoinNostrResult(
|
return NamecoinNostrResult(
|
||||||
pubkey = pubkey.lowercase(),
|
pubkey = pubkey.lowercase(),
|
||||||
relays = relays,
|
relays = relays,
|
||||||
namecoinName = parsed.namecoinName,
|
namecoinName = parsed.namecoinName,
|
||||||
localPart = resolvedLocalPart,
|
localPart = "_",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-4
@@ -457,7 +457,8 @@ class ElectrumXClient(
|
|||||||
/**
|
/**
|
||||||
* Parse a Namecoin name and value from a verbose transaction response.
|
* Parse a Namecoin name and value from a verbose transaction response.
|
||||||
*
|
*
|
||||||
* Scans each output for a NAME_UPDATE script (starts with OP_3 = 0x53),
|
* Scans each output for a NAME_UPDATE (OP_3 = 0x53) or NAME_FIRSTUPDATE
|
||||||
|
* (OP_2 = 0x52) script,
|
||||||
* then extracts the name and value from the script's push data.
|
* then extracts the name and value from the script's push data.
|
||||||
*/
|
*/
|
||||||
private fun parseNameFromTransaction(
|
private fun parseNameFromTransaction(
|
||||||
@@ -482,8 +483,10 @@ class ElectrumXClient(
|
|||||||
?.content
|
?.content
|
||||||
?: continue
|
?: continue
|
||||||
|
|
||||||
// NAME_UPDATE scripts start with OP_3 (0x53)
|
// NAME_UPDATE scripts start with OP_3 (0x53);
|
||||||
if (!scriptHex.startsWith("53")) continue
|
// NAME_FIRSTUPDATE scripts start with OP_2 (0x52). Both carry the
|
||||||
|
// current value at the time of that on-chain operation.
|
||||||
|
if (!scriptHex.startsWith("53") && !scriptHex.startsWith("52")) continue
|
||||||
|
|
||||||
val scriptBytes = hexToBytes(scriptHex)
|
val scriptBytes = hexToBytes(scriptHex)
|
||||||
val parsed = parseNameScript(scriptBytes) ?: continue
|
val parsed = parseNameScript(scriptBytes) ?: continue
|
||||||
@@ -510,7 +513,9 @@ class ElectrumXClient(
|
|||||||
* @return Pair of (name, value) as strings, or null if parsing fails
|
* @return Pair of (name, value) as strings, or null if parsing fails
|
||||||
*/
|
*/
|
||||||
private fun parseNameScript(script: ByteArray): Pair<String, String>? {
|
private fun parseNameScript(script: ByteArray): Pair<String, String>? {
|
||||||
if (script.isEmpty() || script[0] != OP_NAME_UPDATE) return null
|
if (script.isEmpty()) return null
|
||||||
|
val op = script[0]
|
||||||
|
if (op != OP_NAME_UPDATE && op != OP_NAME_FIRSTUPDATE) return null
|
||||||
|
|
||||||
var pos = 1
|
var pos = 1
|
||||||
|
|
||||||
@@ -518,6 +523,12 @@ class ElectrumXClient(
|
|||||||
val (nameBytes, newPos1) = readPushData(script, pos) ?: return null
|
val (nameBytes, newPos1) = readPushData(script, pos) ?: return null
|
||||||
pos = newPos1
|
pos = newPos1
|
||||||
|
|
||||||
|
// FIRSTUPDATE has an extra <rand> push between name and value; skip it.
|
||||||
|
if (op == OP_NAME_FIRSTUPDATE) {
|
||||||
|
val (_, newPos2) = readPushData(script, pos) ?: return null
|
||||||
|
pos = newPos2
|
||||||
|
}
|
||||||
|
|
||||||
// Read value
|
// Read value
|
||||||
val (valueBytes, _) = readPushData(script, pos) ?: return null
|
val (valueBytes, _) = readPushData(script, pos) ?: return null
|
||||||
|
|
||||||
@@ -815,6 +826,7 @@ class ElectrumXClient(
|
|||||||
const val NAME_EXPIRE_DEPTH = 36_000
|
const val NAME_EXPIRE_DEPTH = 36_000
|
||||||
|
|
||||||
// Namecoin script opcodes
|
// Namecoin script opcodes
|
||||||
|
private const val OP_NAME_FIRSTUPDATE: Byte = 0x52 // OP_2 repurposed by Namecoin
|
||||||
private const val OP_NAME_UPDATE: Byte = 0x53 // OP_3 repurposed by Namecoin
|
private const val OP_NAME_UPDATE: Byte = 0x53 // OP_3 repurposed by Namecoin
|
||||||
private const val OP_2DROP: Byte = 0x6d
|
private const val OP_2DROP: Byte = 0x6d
|
||||||
private const val OP_DROP: Byte = 0x75
|
private const val OP_DROP: Byte = 0x75
|
||||||
|
|||||||
Reference in New Issue
Block a user