style: spotlessApply formatting + fix icon imports for upstream MaterialSymbols

This commit is contained in:
m
2026-05-08 22:37:24 +10:00
parent 23a7ac4770
commit 9d84d01569
10 changed files with 393 additions and 335 deletions
@@ -418,7 +418,7 @@ class AccountSettings(
saveAccountSettings() saveAccountSettings()
} }
} }
fun updateDisableClientTag(disable: Boolean): Boolean = fun updateDisableClientTag(disable: Boolean): Boolean =
if (syncedSettings.security.updateDisableClientTag(disable)) { if (syncedSettings.security.updateDisableClientTag(disable)) {
saveAccountSettings() saveAccountSettings()
@@ -116,12 +116,14 @@ class NamecoinSettingsTest {
@Test @Test
fun `returns parsed list for valid custom servers`() { fun `returns parsed list for valid custom servers`() {
val settings = NamecoinSettings( val settings =
customServers = listOf( NamecoinSettings(
"server1.com:50006", customServers =
"server2.onion:50001:tcp", listOf(
), "server1.com:50006",
) "server2.onion:50001:tcp",
),
)
val servers = settings.toElectrumxServers() val servers = settings.toElectrumxServers()
assertNotNull(servers) assertNotNull(servers)
assertEquals(2, servers!!.size) assertEquals(2, servers!!.size)
@@ -134,13 +136,15 @@ class NamecoinSettingsTest {
@Test @Test
fun `skips invalid entries in custom server list`() { fun `skips invalid entries in custom server list`() {
val settings = NamecoinSettings( val settings =
customServers = listOf( NamecoinSettings(
"valid.com:50006", customServers =
"invalid", // no port listOf(
"also-invalid:abc", // non-numeric port "valid.com:50006",
), "invalid", // no port
) "also-invalid:abc", // non-numeric port
),
)
val servers = settings.toElectrumxServers() val servers = settings.toElectrumxServers()
assertNotNull(servers) assertNotNull(servers)
assertEquals(1, servers!!.size) assertEquals(1, servers!!.size)
@@ -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
@@ -110,16 +114,10 @@ import com.vitorpamplona.amethyst.desktop.ui.deck.param
import com.vitorpamplona.amethyst.desktop.ui.media.LocalAwtWindow import com.vitorpamplona.amethyst.desktop.ui.media.LocalAwtWindow
import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen
import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState
import com.vitorpamplona.amethyst.desktop.service.namecoin.DesktopNamecoinNameService
import com.vitorpamplona.amethyst.desktop.service.namecoin.DesktopNamecoinPreferences
import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinPreferences
import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinService
import com.vitorpamplona.amethyst.desktop.ui.ImportFollowListDialog
import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileInfoCard import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileInfoCard
import com.vitorpamplona.amethyst.desktop.ui.relay.LocalRelayCategories import com.vitorpamplona.amethyst.desktop.ui.relay.LocalRelayCategories
import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard
import com.vitorpamplona.amethyst.desktop.ui.settings.MediaServerSettings import com.vitorpamplona.amethyst.desktop.ui.settings.MediaServerSettings
import com.vitorpamplona.amethyst.desktop.ui.settings.NamecoinSettingsSection
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -1670,7 +1668,6 @@ fun RelaySettingsScreen(
LazyColumn( LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
modifier = Modifier.weight(1f),
) { ) {
items(relayStatuses.values.toList(), key = { it.url.url }) { status -> items(relayStatuses.values.toList(), key = { it.url.url }) { status ->
RelayStatusCard( RelayStatusCard(
@@ -24,7 +24,6 @@ import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.Nam
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinSettings import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinSettings
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.NamecoinLookupCache import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinLookupCache
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNostrResult import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNostrResult
@@ -49,17 +48,19 @@ class DesktopNamecoinNameService(
) { ) {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val electrumxClient = ElectrumXClient( private val electrumxClient =
socketFactory = { SocketFactory.getDefault() }, ElectrumXClient(
) socketFactory = { SocketFactory.getDefault() },
)
private val resolver = NamecoinNameResolver( private val resolver =
electrumxClient = electrumxClient, NamecoinNameResolver(
serverListProvider = { electrumxClient = electrumxClient,
val settings = preferencesProvider() serverListProvider = {
settings.toElectrumxServers() ?: DEFAULT_ELECTRUMX_SERVERS val settings = preferencesProvider()
}, settings.toElectrumxServers() ?: DEFAULT_ELECTRUMX_SERVERS
) },
)
private val cache = NamecoinLookupCache() private val cache = NamecoinLookupCache()
@@ -92,8 +93,7 @@ class DesktopNamecoinNameService(
/** /**
* Resolve with detailed outcome for error reporting. * Resolve with detailed outcome for error reporting.
*/ */
suspend fun resolveDetailed(identifier: String): NamecoinResolveOutcome = suspend fun resolveDetailed(identifier: String): NamecoinResolveOutcome = resolver.resolveDetailed(identifier)
resolver.resolveDetailed(identifier)
/** /**
* Verify that a Namecoin name maps to the expected pubkey. * Verify that a Namecoin name maps to the expected pubkey.
@@ -120,11 +120,12 @@ class DesktopNamecoinNameService(
scope.launch { scope.launch {
try { try {
val result = resolve(identifier) val result = resolve(identifier)
state.value = if (result != null) { state.value =
NamecoinResolveState.Resolved(result) if (result != null) {
} else { NamecoinResolveState.Resolved(result)
NamecoinResolveState.NotFound } else {
} NamecoinResolveState.NotFound
}
} catch (e: Exception) { } catch (e: Exception) {
state.value = NamecoinResolveState.Error(e.message ?: "Unknown error") state.value = NamecoinResolveState.Error(e.message ?: "Unknown error")
} }
@@ -20,10 +20,10 @@
*/ */
package com.vitorpamplona.amethyst.desktop.service.namecoin package com.vitorpamplona.amethyst.desktop.service.namecoin
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinSettings
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import java.util.prefs.Preferences import java.util.prefs.Preferences
@@ -39,9 +39,10 @@ import java.util.prefs.Preferences
* `serverListProvider` lambda). * `serverListProvider` lambda).
*/ */
class DesktopNamecoinPreferences( class DesktopNamecoinPreferences(
private val prefs: Preferences = Preferences.userNodeForPackage( private val prefs: Preferences =
DesktopNamecoinPreferences::class.java, Preferences.userNodeForPackage(
), DesktopNamecoinPreferences::class.java,
),
) { ) {
private val mapper = jacksonObjectMapper() private val mapper = jacksonObjectMapper()
@@ -101,23 +102,23 @@ class DesktopNamecoinPreferences(
} }
} }
private fun loadFromDisk(): NamecoinSettings { private fun loadFromDisk(): NamecoinSettings =
return try { try {
val enabled = prefs.getBoolean(KEY_ENABLED, true) val enabled = prefs.getBoolean(KEY_ENABLED, true)
val serversJson = prefs.get(KEY_CUSTOM_SERVERS, null) val serversJson = prefs.get(KEY_CUSTOM_SERVERS, null)
val servers = if (serversJson != null) { val servers =
try { if (serversJson != null) {
mapper.readValue<List<String>>(serversJson) try {
} catch (_: Exception) { mapper.readValue<List<String>>(serversJson)
} catch (_: Exception) {
emptyList()
}
} else {
emptyList() emptyList()
} }
} else {
emptyList()
}
NamecoinSettings(enabled = enabled, customServers = servers) NamecoinSettings(enabled = enabled, customServers = servers)
} catch (e: Exception) { } catch (e: Exception) {
System.err.println("NamecoinPrefs: Error reading preferences: ${e.message}") System.err.println("NamecoinPrefs: Error reading preferences: ${e.message}")
NamecoinSettings.DEFAULT NamecoinSettings.DEFAULT
} }
}
} }
@@ -31,13 +31,9 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
@@ -65,6 +61,8 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper 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.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
@@ -92,12 +90,25 @@ import java.util.concurrent.TimeUnit
*/ */
private sealed class ImportState { private sealed class ImportState {
data object Idle : ImportState() data object Idle : ImportState()
data object ResolvingIdentifier : ImportState() data object ResolvingIdentifier : ImportState()
data class IdentifierResolved(val pubkey: String) : ImportState()
data class IdentifierResolved(
val pubkey: String,
) : ImportState()
data object FetchingFollowList : ImportState() data object FetchingFollowList : ImportState()
data class FollowListLoaded(val sourcePubkey: String) : ImportState()
data class Error(val message: String) : ImportState() data class FollowListLoaded(
val sourcePubkey: String,
) : ImportState()
data class Error(
val message: String,
) : ImportState()
data object Publishing : ImportState() data object Publishing : ImportState()
data object Done : ImportState() data object Done : ImportState()
} }
@@ -123,10 +134,12 @@ private suspend fun resolveNip05Http(identifier: String): String? {
val url = "https://$domain/.well-known/nostr.json?name=$encodedName" val url = "https://$domain/.well-known/nostr.json?name=$encodedName"
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
try { try {
val client = OkHttpClient.Builder() val client =
.connectTimeout(10, TimeUnit.SECONDS) OkHttpClient
.readTimeout(10, TimeUnit.SECONDS) .Builder()
.build() .connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build()
val request = Request.Builder().url(url).build() val request = Request.Builder().url(url).build()
val response = client.newCall(request).execute() val response = client.newCall(request).execute()
response.use { resp -> response.use { resp ->
@@ -201,119 +214,127 @@ fun ImportFollowListDialog(
relayManager.subscribe( relayManager.subscribe(
subId = subId, subId = subId,
filters = listOf( filters =
Filter( listOf(
kinds = listOf(ContactListEvent.KIND), Filter(
authors = listOf(pubkey), kinds = listOf(ContactListEvent.KIND),
limit = 1, authors = listOf(pubkey),
limit = 1,
),
), ),
),
relays = relays, relays = relays,
listener = object : SubscriptionListener { listener =
override fun onEvent( object : SubscriptionListener {
event: Event, override fun onEvent(
isLive: Boolean, event: Event,
relay: NormalizedRelayUrl, isLive: Boolean,
forFilters: List<Filter>?, relay: NormalizedRelayUrl,
) { forFilters: List<Filter>?,
if (event.kind == ContactListEvent.KIND && !receivedContactList) { ) {
receivedContactList = true if (event.kind == ContactListEvent.KIND && !receivedContactList) {
val contactList = ContactListEvent( receivedContactList = true
event.id, val contactList =
event.pubKey, ContactListEvent(
event.createdAt, event.id,
event.tags, event.pubKey,
event.content, event.createdAt,
event.sig, event.tags,
) event.content,
val follows = contactList.unverifiedFollowKeySet() event.sig,
)
val follows = contactList.unverifiedFollowKeySet()
// Dispatch state updates to main thread (relay callbacks // Dispatch state updates to main thread (relay callbacks
// run on arbitrary threads; Compose state is not thread-safe) // run on arbitrary threads; Compose state is not thread-safe)
scope.launch(Dispatchers.Main) { scope.launch(Dispatchers.Main) {
followEntries.clear() followEntries.clear()
followEntries.addAll( followEntries.addAll(
follows.map { FollowEntry(pubkey = it, selected = true) }, follows.map { FollowEntry(pubkey = it, selected = true) },
) )
importState = ImportState.FollowListLoaded(sourcePubkey = pubkey) importState = ImportState.FollowListLoaded(sourcePubkey = pubkey)
} }
// Clean up the contact list subscription // Clean up the contact list subscription
try { try {
relayManager.unsubscribe(subId) relayManager.unsubscribe(subId)
} catch (_: Exception) {} } catch (_: Exception) {
activeSubscriptions.remove(subId) }
activeSubscriptions.remove(subId)
// Start fetching metadata for display names // Start fetching metadata for display names
if (follows.isNotEmpty()) { if (follows.isNotEmpty()) {
val metaSubId = "import-meta-${System.currentTimeMillis()}" val metaSubId = "import-meta-${System.currentTimeMillis()}"
activeSubscriptions.add(metaSubId) activeSubscriptions.add(metaSubId)
relayManager.subscribe( relayManager.subscribe(
subId = metaSubId, subId = metaSubId,
filters = listOf( filters =
Filter( listOf(
kinds = listOf(MetadataEvent.KIND), Filter(
authors = follows, kinds = listOf(MetadataEvent.KIND),
limit = follows.size, authors = follows,
), limit = follows.size,
), ),
listener = object : SubscriptionListener { ),
override fun onEvent( listener =
event: Event, object : SubscriptionListener {
isLive: Boolean, override fun onEvent(
relay: NormalizedRelayUrl, event: Event,
forFilters: List<Filter>?, isLive: Boolean,
) { relay: NormalizedRelayUrl,
if (event.kind == MetadataEvent.KIND) { forFilters: List<Filter>?,
val bestName = try { ) {
val metaJson = jacksonObjectMapper().readTree(event.content) if (event.kind == MetadataEvent.KIND) {
metaJson.get("display_name")?.asText()?.takeIf { it.isNotBlank() } val bestName =
?: metaJson.get("name")?.asText()?.takeIf { it.isNotBlank() } try {
} catch (_: Exception) { val metaJson = jacksonObjectMapper().readTree(event.content)
null metaJson.get("display_name")?.asText()?.takeIf { it.isNotBlank() }
} ?: metaJson.get("name")?.asText()?.takeIf { it.isNotBlank() }
if (bestName != null) { } catch (_: Exception) {
// Dispatch to main thread null
scope.launch(Dispatchers.Main) { }
val idx = followEntries.indexOfFirst { it.pubkey == event.pubKey } if (bestName != null) {
if (idx >= 0) { // Dispatch to main thread
followEntries[idx] = followEntries[idx].copy(displayName = bestName) 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>?,
) {
// 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( override fun onEose(
relay: NormalizedRelayUrl, relay: NormalizedRelayUrl,
forFilters: List<Filter>?, forFilters: List<Filter>?,
) { ) {
} }
}, },
) )
// Timeout: if no contact list received after 15s, show error // Timeout: if no contact list received after 15s, show error
@@ -322,7 +343,8 @@ fun ImportFollowListDialog(
if (importState is ImportState.FetchingFollowList) { if (importState is ImportState.FetchingFollowList) {
try { try {
relayManager.unsubscribe(subId) relayManager.unsubscribe(subId)
} catch (_: Exception) {} } catch (_: Exception) {
}
activeSubscriptions.remove(subId) activeSubscriptions.remove(subId)
if (followEntries.isEmpty()) { if (followEntries.isEmpty()) {
importState = ImportState.Error("No follow list found for this user. They may not have published a contact list.") importState = ImportState.Error("No follow list found for this user. They may not have published a contact list.")
@@ -418,11 +440,12 @@ fun ImportFollowListDialog(
scope.launch { scope.launch {
try { try {
val contactTags = selected.map { ContactTag(it.pubkey) } val contactTags = selected.map { ContactTag(it.pubkey) }
val newContactList = ContactListEvent.createFromScratch( val newContactList =
followUsers = contactTags, ContactListEvent.createFromScratch(
relayUse = null, followUsers = contactTags,
signer = account.signer, relayUse = null,
) signer = account.signer,
)
relayManager.broadcastToAll(newContactList) relayManager.broadcastToAll(newContactList)
importState = ImportState.Done importState = ImportState.Done
} catch (e: Exception) { } catch (e: Exception) {
@@ -457,9 +480,10 @@ fun ImportFollowListDialog(
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
// Input field + Resolve button (shown in Idle and Error states) // Input field + Resolve button (shown in Idle and Error states)
val showInput = importState is ImportState.Idle || val showInput =
importState is ImportState.Error || importState is ImportState.Idle ||
importState is ImportState.ResolvingIdentifier importState is ImportState.Error ||
importState is ImportState.ResolvingIdentifier
if (showInput) { if (showInput) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -478,17 +502,19 @@ fun ImportFollowListDialog(
placeholder = { Text("npub1..., alice@example.com, d/alice") }, placeholder = { Text("npub1..., alice@example.com, d/alice") },
singleLine = true, singleLine = true,
isError = importState is ImportState.Error, isError = importState is ImportState.Error,
supportingText = (importState as? ImportState.Error)?.let { err -> supportingText =
{ Text(err.message, color = MaterialTheme.colorScheme.error) } (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) { modifier =
resolveIdentifier() Modifier.weight(1f).onPreviewKeyEvent { event ->
true if (event.type == KeyEventType.KeyDown && event.key == Key.Enter) {
} else { resolveIdentifier()
false true
} } else {
}, false
}
},
) )
Button( Button(
onClick = { resolveIdentifier() }, onClick = { resolveIdentifier() },
@@ -566,7 +592,7 @@ fun ImportFollowListDialog(
) )
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Icon( Icon(
Icons.Default.Person, MaterialSymbols.Person,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -583,11 +609,12 @@ fun ImportFollowListDialog(
"${entry.pubkey.take(12)}${entry.pubkey.takeLast(8)}", "${entry.pubkey.take(12)}${entry.pubkey.takeLast(8)}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
color = if (entry.displayName != null) { color =
MaterialTheme.colorScheme.onSurfaceVariant if (entry.displayName != null) {
} else { MaterialTheme.colorScheme.onSurfaceVariant
MaterialTheme.colorScheme.onSurface } else {
}, MaterialTheme.colorScheme.onSurface
},
) )
} }
} }
@@ -618,7 +645,7 @@ fun ImportFollowListDialog(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Icon( Icon(
Icons.Default.CheckCircle, MaterialSymbols.CheckCircle,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp), modifier = Modifier.size(24.dp),
@@ -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
@@ -102,15 +105,10 @@ import com.vitorpamplona.amethyst.desktop.ui.relay.SearchRelayEditor
import com.vitorpamplona.amethyst.desktop.ui.search.AdvancedSearchPanel 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.amethyst.desktop.service.namecoin.DesktopNamecoinNameService
import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinPreferences
import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinService
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinResolveState
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.NamecoinNameResolver
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinResolveOutcome import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinResolveOutcome
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
@@ -166,10 +164,16 @@ fun SearchScreen(
// Namecoin resolution // Namecoin resolution
val namecoinService = LocalNamecoinService.current val namecoinService = LocalNamecoinService.current
val namecoinPrefs = LocalNamecoinPreferences.current val namecoinPrefs = LocalNamecoinPreferences.current
val namecoinEnabled = namecoinPrefs?.settings?.collectAsState()?.value?.enabled ?: false val namecoinEnabled =
val isNamecoinQuery = remember(displayText) { namecoinPrefs
displayText.isNotBlank() && NamecoinNameResolver.isNamecoinIdentifier(displayText.trim()) ?.settings
} ?.collectAsState()
?.value
?.enabled ?: false
val isNamecoinQuery =
remember(displayText) {
displayText.isNotBlank() && NamecoinNameResolver.isNamecoinIdentifier(displayText.trim())
}
var namecoinState by remember { mutableStateOf<NamecoinResolveState?>(null) } var namecoinState by remember { mutableStateOf<NamecoinResolveState?>(null) }
@@ -183,20 +187,32 @@ fun SearchScreen(
} }
namecoinState = NamecoinResolveState.Loading namecoinState = NamecoinResolveState.Loading
val outcome = namecoinService.resolveDetailed(displayText.trim()) val outcome = namecoinService.resolveDetailed(displayText.trim())
namecoinState = when (outcome) { namecoinState =
is NamecoinResolveOutcome.Success -> when (outcome) {
NamecoinResolveState.Resolved(outcome.result) is NamecoinResolveOutcome.Success -> {
is NamecoinResolveOutcome.NameNotFound -> NamecoinResolveState.Resolved(outcome.result)
NamecoinResolveState.NotFound }
is NamecoinResolveOutcome.NoNostrField ->
NamecoinResolveState.Error("Name exists but has no Nostr pubkey") is NamecoinResolveOutcome.NameNotFound -> {
is NamecoinResolveOutcome.ServersUnreachable -> NamecoinResolveState.NotFound
NamecoinResolveState.Error("ElectrumX servers unreachable — check your connection or try again") }
is NamecoinResolveOutcome.InvalidIdentifier ->
NamecoinResolveState.Error("Invalid Namecoin identifier") is NamecoinResolveOutcome.NoNostrField -> {
is NamecoinResolveOutcome.Timeout -> NamecoinResolveState.Error("Name exists but has no Nostr pubkey")
NamecoinResolveState.Error("Resolution timed out — servers may be slow, try again") }
}
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)
@@ -38,7 +38,6 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.desktop.DesktopScreen import com.vitorpamplona.amethyst.desktop.DesktopScreen
import com.vitorpamplona.amethyst.desktop.RelaySettingsScreen import com.vitorpamplona.amethyst.desktop.RelaySettingsScreen
import com.vitorpamplona.amethyst.desktop.account.AccountManager import com.vitorpamplona.amethyst.desktop.account.AccountManager
import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinPreferences
import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.chess.ChessScreen import com.vitorpamplona.amethyst.desktop.chess.ChessScreen
@@ -47,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
@@ -34,13 +34,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
@@ -66,6 +60,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinSettings
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS
@@ -143,7 +139,7 @@ fun NamecoinSettingsSection(
) { ) {
TextButton(onClick = onReset) { TextButton(onClick = onReset) {
Icon( Icon(
Icons.Default.Refresh, MaterialSymbols.Refresh,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
) )
@@ -171,7 +167,7 @@ private fun NamecoinSectionHeader(
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon( Icon(
Icons.Default.Lock, MaterialSymbols.Lock,
contentDescription = null, contentDescription = null,
tint = Color(0xFF4A90D9), // Namecoin blue tint = Color(0xFF4A90D9), // Namecoin blue
modifier = Modifier.size(22.dp), modifier = Modifier.size(22.dp),
@@ -219,12 +215,12 @@ private fun NamecoinActiveServersDisplay(settings: NamecoinSettings) {
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = Color(0xFF4A90D9), color = Color(0xFF4A90D9),
modifier = Modifier modifier =
.background( Modifier
Color(0xFF4A90D9).copy(alpha = 0.1f), .background(
RoundedCornerShape(4.dp), Color(0xFF4A90D9).copy(alpha = 0.1f),
) RoundedCornerShape(4.dp),
.padding(horizontal = 6.dp, vertical = 2.dp), ).padding(horizontal = 6.dp, vertical = 2.dp),
) )
} else { } else {
Text( Text(
@@ -237,8 +233,9 @@ private fun NamecoinActiveServersDisplay(settings: NamecoinSettings) {
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
servers.forEach { server -> servers.forEach { server ->
NamecoinServerRow( NamecoinServerRow(
displayText = "${server.host}:${server.port}" + displayText =
if (!server.useSsl) " (tcp)" else " (tls)", "${server.host}:${server.port}" +
if (!server.useSsl) " (tcp)" else " (tls)",
isActive = true, isActive = true,
) )
} }
@@ -266,9 +263,10 @@ private fun NamecoinCustomServersList(
) )
servers.forEach { server -> servers.forEach { server ->
Row( Row(
modifier = Modifier modifier =
.fillMaxWidth() Modifier
.padding(vertical = 2.dp), .fillMaxWidth()
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
@@ -285,7 +283,7 @@ private fun NamecoinCustomServersList(
modifier = Modifier.size(28.dp), modifier = Modifier.size(28.dp),
) { ) {
Icon( Icon(
Icons.Default.Close, MaterialSymbols.Close,
contentDescription = "Remove server", contentDescription = "Remove server",
tint = MaterialTheme.colorScheme.error, tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
@@ -332,37 +330,41 @@ private fun NamecoinAddServerInput(onAdd: (String) -> Unit) {
placeholder = { Text("host:port or host:port:tcp") }, placeholder = { Text("host:port or host:port:tcp") },
singleLine = true, singleLine = true,
isError = validationError != null, isError = validationError != null,
supportingText = validationError?.let { err -> supportingText =
{ Text(err, color = MaterialTheme.colorScheme.error) } 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
}
}, },
modifier =
Modifier
.weight(1f)
.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown && event.key == Key.Enter) {
tryAdd()
true
} else {
false
}
},
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
textStyle = MaterialTheme.typography.bodySmall.copy( textStyle =
fontFamily = FontFamily.Monospace, MaterialTheme.typography.bodySmall.copy(
), fontFamily = FontFamily.Monospace,
),
) )
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
IconButton( IconButton(
onClick = { tryAdd() }, onClick = { tryAdd() },
modifier = Modifier modifier =
.padding(top = 8.dp) Modifier
.size(40.dp) .padding(top = 8.dp)
.background( .size(40.dp)
MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), .background(
RoundedCornerShape(8.dp), MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
), RoundedCornerShape(8.dp),
),
) { ) {
Icon( Icon(
Icons.Default.Add, MaterialSymbols.Add,
contentDescription = "Add server", contentDescription = "Add server",
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
) )
@@ -376,19 +378,21 @@ private fun NamecoinServerRow(
isActive: Boolean, isActive: Boolean,
) { ) {
Row( Row(
modifier = Modifier modifier =
.fillMaxWidth() Modifier
.padding(vertical = 2.dp), .fillMaxWidth()
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = "", text = "",
fontSize = 10.sp, fontSize = 10.sp,
color = if (isActive) { color =
Color(0xFF2E8B57) if (isActive) {
} else { Color(0xFF2E8B57)
MaterialTheme.colorScheme.onSurfaceVariant } else {
}, MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.padding(end = 6.dp), modifier = Modifier.padding(end = 6.dp),
) )
Text( Text(
@@ -57,71 +57,77 @@ class DesktopNamecoinPreferencesTest {
} }
@Test @Test
fun `setEnabled persists and updates flow`() = runBlocking { fun `setEnabled persists and updates flow`() =
namecoinPrefs.setEnabled(false) runBlocking {
assertFalse(namecoinPrefs.current.enabled) namecoinPrefs.setEnabled(false)
assertFalse(namecoinPrefs.settings.value.enabled) assertFalse(namecoinPrefs.current.enabled)
assertFalse(namecoinPrefs.settings.value.enabled)
// Verify persistence by creating a new instance with the same prefs node // Verify persistence by creating a new instance with the same prefs node
val reloaded = DesktopNamecoinPreferences(prefs = testPrefs) val reloaded = DesktopNamecoinPreferences(prefs = testPrefs)
assertFalse(reloaded.current.enabled) assertFalse(reloaded.current.enabled)
} }
@Test @Test
fun `addServer persists and updates flow`() = runBlocking { fun `addServer persists and updates flow`() =
namecoinPrefs.addServer("example.com:50006") runBlocking {
assertEquals(listOf("example.com:50006"), namecoinPrefs.current.customServers) namecoinPrefs.addServer("example.com:50006")
assertTrue(namecoinPrefs.current.hasCustomServers) assertEquals(listOf("example.com:50006"), namecoinPrefs.current.customServers)
assertTrue(namecoinPrefs.current.hasCustomServers)
// Verify persistence // Verify persistence
val reloaded = DesktopNamecoinPreferences(prefs = testPrefs) val reloaded = DesktopNamecoinPreferences(prefs = testPrefs)
assertEquals(listOf("example.com:50006"), reloaded.current.customServers) assertEquals(listOf("example.com:50006"), reloaded.current.customServers)
} }
@Test @Test
fun `addServer ignores blank strings`() = runBlocking { fun `addServer ignores blank strings`() =
namecoinPrefs.addServer("") runBlocking {
namecoinPrefs.addServer(" ") namecoinPrefs.addServer("")
assertTrue(namecoinPrefs.current.customServers.isEmpty()) namecoinPrefs.addServer(" ")
} assertTrue(namecoinPrefs.current.customServers.isEmpty())
}
@Test @Test
fun `addServer ignores duplicates`() = runBlocking { fun `addServer ignores duplicates`() =
namecoinPrefs.addServer("example.com:50006") runBlocking {
namecoinPrefs.addServer("example.com:50006") namecoinPrefs.addServer("example.com:50006")
assertEquals(1, namecoinPrefs.current.customServers.size) namecoinPrefs.addServer("example.com:50006")
} assertEquals(1, namecoinPrefs.current.customServers.size)
}
@Test @Test
fun `removeServer persists and updates flow`() = runBlocking { fun `removeServer persists and updates flow`() =
namecoinPrefs.addServer("server1.com:50006") runBlocking {
namecoinPrefs.addServer("server2.com:50001:tcp") namecoinPrefs.addServer("server1.com:50006")
assertEquals(2, namecoinPrefs.current.customServers.size) namecoinPrefs.addServer("server2.com:50001:tcp")
assertEquals(2, namecoinPrefs.current.customServers.size)
namecoinPrefs.removeServer("server1.com:50006") namecoinPrefs.removeServer("server1.com:50006")
assertEquals(listOf("server2.com:50001:tcp"), namecoinPrefs.current.customServers) assertEquals(listOf("server2.com:50001:tcp"), namecoinPrefs.current.customServers)
// Verify persistence // Verify persistence
val reloaded = DesktopNamecoinPreferences(prefs = testPrefs) val reloaded = DesktopNamecoinPreferences(prefs = testPrefs)
assertEquals(listOf("server2.com:50001:tcp"), reloaded.current.customServers) assertEquals(listOf("server2.com:50001:tcp"), reloaded.current.customServers)
} }
@Test @Test
fun `reset restores defaults`() = runBlocking { fun `reset restores defaults`() =
namecoinPrefs.setEnabled(false) runBlocking {
namecoinPrefs.addServer("example.com:50006") namecoinPrefs.setEnabled(false)
assertFalse(namecoinPrefs.current.enabled) namecoinPrefs.addServer("example.com:50006")
assertTrue(namecoinPrefs.current.hasCustomServers) assertFalse(namecoinPrefs.current.enabled)
assertTrue(namecoinPrefs.current.hasCustomServers)
namecoinPrefs.reset() namecoinPrefs.reset()
assertTrue(namecoinPrefs.current.enabled) assertTrue(namecoinPrefs.current.enabled)
assertFalse(namecoinPrefs.current.hasCustomServers) assertFalse(namecoinPrefs.current.hasCustomServers)
// Verify persistence // Verify persistence
val reloaded = DesktopNamecoinPreferences(prefs = testPrefs) val reloaded = DesktopNamecoinPreferences(prefs = testPrefs)
assertTrue(reloaded.current.enabled) assertTrue(reloaded.current.enabled)
assertFalse(reloaded.current.hasCustomServers) assertFalse(reloaded.current.hasCustomServers)
} }
@Test @Test
fun `customServersOrNull returns null when empty`() { fun `customServersOrNull returns null when empty`() {
@@ -129,29 +135,31 @@ class DesktopNamecoinPreferencesTest {
} }
@Test @Test
fun `customServersOrNull returns parsed servers when configured`() = runBlocking { fun `customServersOrNull returns parsed servers when configured`() =
namecoinPrefs.addServer("example.com:50006") runBlocking {
val servers = namecoinPrefs.customServersOrNull namecoinPrefs.addServer("example.com:50006")
assertEquals(1, servers?.size) val servers = namecoinPrefs.customServersOrNull
assertEquals("example.com", servers?.first()?.host) assertEquals(1, servers?.size)
assertEquals(50006, servers?.first()?.port) assertEquals("example.com", servers?.first()?.host)
assertTrue(servers?.first()?.useSsl == true) assertEquals(50006, servers?.first()?.port)
} assertTrue(servers?.first()?.useSsl == true)
}
@Test @Test
fun `round-trip multiple operations`() = runBlocking { fun `round-trip multiple operations`() =
namecoinPrefs.setEnabled(true) runBlocking {
namecoinPrefs.addServer("server1.com:50006") namecoinPrefs.setEnabled(true)
namecoinPrefs.addServer("onion.onion:50001:tcp") namecoinPrefs.addServer("server1.com:50006")
namecoinPrefs.setEnabled(false) namecoinPrefs.addServer("onion.onion:50001:tcp")
namecoinPrefs.removeServer("server1.com:50006") namecoinPrefs.setEnabled(false)
namecoinPrefs.removeServer("server1.com:50006")
val settings = namecoinPrefs.current val settings = namecoinPrefs.current
assertFalse(settings.enabled) assertFalse(settings.enabled)
assertEquals(listOf("onion.onion:50001:tcp"), settings.customServers) assertEquals(listOf("onion.onion:50001:tcp"), settings.customServers)
// Verify full persistence round-trip // Verify full persistence round-trip
val reloaded = DesktopNamecoinPreferences(prefs = testPrefs) val reloaded = DesktopNamecoinPreferences(prefs = testPrefs)
assertEquals(settings, reloaded.current) assertEquals(settings, reloaded.current)
} }
} }