diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 1f803a690..bf0a9e6ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -418,7 +418,7 @@ class AccountSettings( saveAccountSettings() } } - + fun updateDisableClientTag(disable: Boolean): Boolean = if (syncedSettings.security.updateDisableClientTag(disable)) { saveAccountSettings() diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/namecoin/NamecoinSettingsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/namecoin/NamecoinSettingsTest.kt index 6cf693a6c..5bd20151d 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/namecoin/NamecoinSettingsTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/namecoin/NamecoinSettingsTest.kt @@ -116,12 +116,14 @@ class NamecoinSettingsTest { @Test fun `returns parsed list for valid custom servers`() { - val settings = NamecoinSettings( - customServers = listOf( - "server1.com:50006", - "server2.onion:50001:tcp", - ), - ) + val settings = + NamecoinSettings( + customServers = + listOf( + "server1.com:50006", + "server2.onion:50001:tcp", + ), + ) val servers = settings.toElectrumxServers() assertNotNull(servers) assertEquals(2, servers!!.size) @@ -134,13 +136,15 @@ class NamecoinSettingsTest { @Test fun `skips invalid entries in custom server list`() { - val settings = NamecoinSettings( - customServers = listOf( - "valid.com:50006", - "invalid", // no port - "also-invalid:abc", // non-numeric port - ), - ) + val settings = + NamecoinSettings( + customServers = + listOf( + "valid.com:50006", + "invalid", // no port + "also-invalid:abc", // non-numeric port + ), + ) val servers = settings.toElectrumxServers() assertNotNull(servers) assertEquals(1, servers!!.size) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index c0383fbde..6059c4cc2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -89,6 +89,10 @@ import com.vitorpamplona.amethyst.desktop.platform.applyNativeWindowChrome import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore import com.vitorpamplona.amethyst.desktop.service.images.DesktopImageLoaderSetup import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool +import com.vitorpamplona.amethyst.desktop.service.namecoin.DesktopNamecoinNameService +import com.vitorpamplona.amethyst.desktop.service.namecoin.DesktopNamecoinPreferences +import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinPreferences +import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinService import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.ui.ComposeNoteDialog import com.vitorpamplona.amethyst.desktop.ui.ConnectingRelaysScreen @@ -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.LocalIsImmersiveFullscreen import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState -import com.vitorpamplona.amethyst.desktop.service.namecoin.DesktopNamecoinNameService -import com.vitorpamplona.amethyst.desktop.service.namecoin.DesktopNamecoinPreferences -import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinPreferences -import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinService -import com.vitorpamplona.amethyst.desktop.ui.ImportFollowListDialog import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileInfoCard import com.vitorpamplona.amethyst.desktop.ui.relay.LocalRelayCategories import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard import com.vitorpamplona.amethyst.desktop.ui.settings.MediaServerSettings -import com.vitorpamplona.amethyst.desktop.ui.settings.NamecoinSettingsSection import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -1670,7 +1668,6 @@ fun RelaySettingsScreen( LazyColumn( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.weight(1f), - modifier = Modifier.weight(1f), ) { items(relayStatuses.values.toList(), key = { it.url.url }) { status -> RelayStatusCard( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinNameService.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinNameService.kt index a9ef46e63..aa5891b97 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinNameService.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinNameService.kt @@ -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.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient -import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinLookupCache import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNostrResult @@ -49,17 +48,19 @@ class DesktopNamecoinNameService( ) { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private val electrumxClient = ElectrumXClient( - socketFactory = { SocketFactory.getDefault() }, - ) + private val electrumxClient = + ElectrumXClient( + socketFactory = { SocketFactory.getDefault() }, + ) - private val resolver = NamecoinNameResolver( - electrumxClient = electrumxClient, - serverListProvider = { - val settings = preferencesProvider() - settings.toElectrumxServers() ?: DEFAULT_ELECTRUMX_SERVERS - }, - ) + private val resolver = + NamecoinNameResolver( + electrumxClient = electrumxClient, + serverListProvider = { + val settings = preferencesProvider() + settings.toElectrumxServers() ?: DEFAULT_ELECTRUMX_SERVERS + }, + ) private val cache = NamecoinLookupCache() @@ -92,8 +93,7 @@ class DesktopNamecoinNameService( /** * Resolve with detailed outcome for error reporting. */ - suspend fun resolveDetailed(identifier: String): NamecoinResolveOutcome = - resolver.resolveDetailed(identifier) + suspend fun resolveDetailed(identifier: String): NamecoinResolveOutcome = resolver.resolveDetailed(identifier) /** * Verify that a Namecoin name maps to the expected pubkey. @@ -120,11 +120,12 @@ class DesktopNamecoinNameService( scope.launch { try { val result = resolve(identifier) - state.value = if (result != null) { - NamecoinResolveState.Resolved(result) - } else { - NamecoinResolveState.NotFound - } + state.value = + if (result != null) { + NamecoinResolveState.Resolved(result) + } else { + NamecoinResolveState.NotFound + } } catch (e: Exception) { state.value = NamecoinResolveState.Error(e.message ?: "Unknown error") } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinPreferences.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinPreferences.kt index c9df87f3c..42f2b3302 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinPreferences.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinPreferences.kt @@ -20,10 +20,10 @@ */ 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.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 @@ -39,9 +39,10 @@ import java.util.prefs.Preferences * `serverListProvider` lambda). */ class DesktopNamecoinPreferences( - private val prefs: Preferences = Preferences.userNodeForPackage( - DesktopNamecoinPreferences::class.java, - ), + private val prefs: Preferences = + Preferences.userNodeForPackage( + DesktopNamecoinPreferences::class.java, + ), ) { private val mapper = jacksonObjectMapper() @@ -101,23 +102,23 @@ class DesktopNamecoinPreferences( } } - private fun loadFromDisk(): NamecoinSettings { - return try { + private fun loadFromDisk(): NamecoinSettings = + try { val enabled = prefs.getBoolean(KEY_ENABLED, true) val serversJson = prefs.get(KEY_CUSTOM_SERVERS, null) - val servers = if (serversJson != null) { - try { - mapper.readValue>(serversJson) - } catch (_: Exception) { + val servers = + if (serversJson != null) { + try { + mapper.readValue>(serversJson) + } catch (_: Exception) { + emptyList() + } + } else { emptyList() } - } else { - emptyList() - } NamecoinSettings(enabled = enabled, customServers = servers) } catch (e: Exception) { System.err.println("NamecoinPrefs: Error reading preferences: ${e.message}") NamecoinSettings.DEFAULT } - } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ImportFollowListDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ImportFollowListDialog.kt index e3500ad2e..52d3983df 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ImportFollowListDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ImportFollowListDialog.kt @@ -31,13 +31,9 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.Person import androidx.compose.material3.Button import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton @@ -65,6 +61,8 @@ 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 @@ -92,12 +90,25 @@ import java.util.concurrent.TimeUnit */ private sealed class ImportState { data object Idle : ImportState() + data object ResolvingIdentifier : ImportState() - data class IdentifierResolved(val pubkey: String) : 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 class FollowListLoaded( + val sourcePubkey: String, + ) : ImportState() + + data class Error( + val message: String, + ) : ImportState() + data object Publishing : 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" return withContext(Dispatchers.IO) { try { - val client = OkHttpClient.Builder() - .connectTimeout(10, TimeUnit.SECONDS) - .readTimeout(10, TimeUnit.SECONDS) - .build() + 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 -> @@ -201,119 +214,127 @@ fun ImportFollowListDialog( relayManager.subscribe( subId = subId, - filters = listOf( - Filter( - kinds = listOf(ContactListEvent.KIND), - authors = listOf(pubkey), - limit = 1, + filters = + listOf( + Filter( + kinds = listOf(ContactListEvent.KIND), + authors = listOf(pubkey), + limit = 1, + ), ), - ), relays = relays, - listener = object : SubscriptionListener { - override fun onEvent( - event: Event, - isLive: Boolean, - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - if (event.kind == ContactListEvent.KIND && !receivedContactList) { - receivedContactList = true - val contactList = ContactListEvent( - event.id, - event.pubKey, - event.createdAt, - event.tags, - event.content, - event.sig, - ) - val follows = contactList.unverifiedFollowKeySet() + listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event.kind == ContactListEvent.KIND && !receivedContactList) { + receivedContactList = true + val contactList = + ContactListEvent( + event.id, + event.pubKey, + event.createdAt, + event.tags, + event.content, + event.sig, + ) + val follows = contactList.unverifiedFollowKeySet() - // Dispatch state updates to main thread (relay callbacks - // run on arbitrary threads; Compose state is not thread-safe) - scope.launch(Dispatchers.Main) { - followEntries.clear() - followEntries.addAll( - follows.map { FollowEntry(pubkey = it, selected = true) }, - ) - importState = ImportState.FollowListLoaded(sourcePubkey = pubkey) - } + // 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) + // Clean up the contact list subscription + try { + relayManager.unsubscribe(subId) + } catch (_: Exception) { + } + activeSubscriptions.remove(subId) - // Start fetching metadata for display names - if (follows.isNotEmpty()) { - val metaSubId = "import-meta-${System.currentTimeMillis()}" - activeSubscriptions.add(metaSubId) - relayManager.subscribe( - subId = metaSubId, - filters = listOf( - Filter( - kinds = listOf(MetadataEvent.KIND), - authors = follows, - limit = follows.size, - ), - ), - listener = object : SubscriptionListener { - override fun onEvent( - event: Event, - isLive: Boolean, - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - if (event.kind == MetadataEvent.KIND) { - val bestName = try { - val metaJson = jacksonObjectMapper().readTree(event.content) - metaJson.get("display_name")?.asText()?.takeIf { it.isNotBlank() } - ?: metaJson.get("name")?.asText()?.takeIf { it.isNotBlank() } - } catch (_: Exception) { - null - } - if (bestName != null) { - // Dispatch to main thread - scope.launch(Dispatchers.Main) { - val idx = followEntries.indexOfFirst { it.pubkey == event.pubKey } - if (idx >= 0) { - followEntries[idx] = followEntries[idx].copy(displayName = bestName) + // Start fetching metadata for display names + if (follows.isNotEmpty()) { + val metaSubId = "import-meta-${System.currentTimeMillis()}" + activeSubscriptions.add(metaSubId) + relayManager.subscribe( + subId = metaSubId, + filters = + listOf( + Filter( + kinds = listOf(MetadataEvent.KIND), + authors = follows, + limit = follows.size, + ), + ), + listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event.kind == MetadataEvent.KIND) { + val bestName = + try { + val metaJson = jacksonObjectMapper().readTree(event.content) + metaJson.get("display_name")?.asText()?.takeIf { it.isNotBlank() } + ?: metaJson.get("name")?.asText()?.takeIf { it.isNotBlank() } + } catch (_: Exception) { + null + } + if (bestName != null) { + // Dispatch to main thread + scope.launch(Dispatchers.Main) { + val idx = followEntries.indexOfFirst { it.pubkey == event.pubKey } + if (idx >= 0) { + followEntries[idx] = followEntries[idx].copy(displayName = bestName) + } + } } } } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + // Metadata is best-effort; don't close early + // as multiple relays may have different metadata + } + }, + ) + + // Clean up metadata subscription after 20s + scope.launch { + delay(20_000) + if (metaSubId in activeSubscriptions) { + try { + relayManager.unsubscribe(metaSubId) + } catch (_: Exception) { } + activeSubscriptions.remove(metaSubId) } - - override fun onEose( - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - // 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?, - ) { - } - }, + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + } + }, ) // Timeout: if no contact list received after 15s, show error @@ -322,7 +343,8 @@ fun ImportFollowListDialog( if (importState is ImportState.FetchingFollowList) { try { relayManager.unsubscribe(subId) - } catch (_: Exception) {} + } 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.") @@ -418,11 +440,12 @@ fun ImportFollowListDialog( scope.launch { try { val contactTags = selected.map { ContactTag(it.pubkey) } - val newContactList = ContactListEvent.createFromScratch( - followUsers = contactTags, - relayUse = null, - signer = account.signer, - ) + val newContactList = + ContactListEvent.createFromScratch( + followUsers = contactTags, + relayUse = null, + signer = account.signer, + ) relayManager.broadcastToAll(newContactList) importState = ImportState.Done } catch (e: Exception) { @@ -457,9 +480,10 @@ fun ImportFollowListDialog( 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 + val showInput = + importState is ImportState.Idle || + importState is ImportState.Error || + importState is ImportState.ResolvingIdentifier if (showInput) { Row( modifier = Modifier.fillMaxWidth(), @@ -478,17 +502,19 @@ fun ImportFollowListDialog( 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 - } - }, + 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() }, @@ -566,7 +592,7 @@ fun ImportFollowListDialog( ) Spacer(Modifier.width(8.dp)) Icon( - Icons.Default.Person, + MaterialSymbols.Person, contentDescription = null, modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, @@ -583,11 +609,12 @@ fun ImportFollowListDialog( "${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 - }, + color = + if (entry.displayName != null) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurface + }, ) } } @@ -618,7 +645,7 @@ fun ImportFollowListDialog( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Icon( - Icons.Default.CheckCircle, + MaterialSymbols.CheckCircle, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(24.dp), diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt index 2e24cf849..d06860e1a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt @@ -78,6 +78,7 @@ import com.vitorpamplona.amethyst.commons.feeds.custom.canBecomeFeed import com.vitorpamplona.amethyst.commons.feeds.custom.toFeedDefinition import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinResolveState import com.vitorpamplona.amethyst.commons.search.AdvancedSearchBarState import com.vitorpamplona.amethyst.commons.search.QuerySerializer import com.vitorpamplona.amethyst.commons.search.SavedSearch @@ -89,6 +90,8 @@ import com.vitorpamplona.amethyst.desktop.SearchHistoryStore import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinPreferences +import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinService import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.SearchFilterFactory import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig @@ -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.SearchResultsList import com.vitorpamplona.amethyst.desktop.ui.search.SearchSyncBanner -import com.vitorpamplona.amethyst.desktop.service.namecoin.DesktopNamecoinNameService -import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinPreferences -import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinService -import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinResolveState import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinResolveOutcome import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull -import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.launch @Composable @@ -166,10 +164,16 @@ fun SearchScreen( // 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()) - } + val namecoinEnabled = + namecoinPrefs + ?.settings + ?.collectAsState() + ?.value + ?.enabled ?: false + val isNamecoinQuery = + remember(displayText) { + displayText.isNotBlank() && NamecoinNameResolver.isNamecoinIdentifier(displayText.trim()) + } var namecoinState by remember { mutableStateOf(null) } @@ -183,20 +187,32 @@ fun SearchScreen( } 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") - } + 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) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index a376ecd06..b9f36a6b9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -38,7 +38,6 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.desktop.DesktopScreen import com.vitorpamplona.amethyst.desktop.RelaySettingsScreen import com.vitorpamplona.amethyst.desktop.account.AccountManager -import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinPreferences import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.chess.ChessScreen @@ -47,6 +46,7 @@ import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.network.Nip11Fetcher import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore +import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinPreferences import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode import com.vitorpamplona.amethyst.desktop.ui.ArticleEditorScreen diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/NamecoinSettingsSection.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/NamecoinSettingsSection.kt index 3368ccda1..ec3033a65 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/NamecoinSettingsSection.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/NamecoinSettingsSection.kt @@ -34,13 +34,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Lock -import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -66,6 +60,8 @@ 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 @@ -143,7 +139,7 @@ fun NamecoinSettingsSection( ) { TextButton(onClick = onReset) { Icon( - Icons.Default.Refresh, + MaterialSymbols.Refresh, contentDescription = null, modifier = Modifier.size(16.dp), ) @@ -171,7 +167,7 @@ private fun NamecoinSectionHeader( ) { Row(verticalAlignment = Alignment.CenterVertically) { Icon( - Icons.Default.Lock, + MaterialSymbols.Lock, contentDescription = null, tint = Color(0xFF4A90D9), // Namecoin blue modifier = Modifier.size(22.dp), @@ -219,12 +215,12 @@ private fun NamecoinActiveServersDisplay(settings: NamecoinSettings) { 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), + modifier = + Modifier + .background( + Color(0xFF4A90D9).copy(alpha = 0.1f), + RoundedCornerShape(4.dp), + ).padding(horizontal = 6.dp, vertical = 2.dp), ) } else { Text( @@ -237,8 +233,9 @@ private fun NamecoinActiveServersDisplay(settings: NamecoinSettings) { Spacer(Modifier.height(6.dp)) servers.forEach { server -> NamecoinServerRow( - displayText = "${server.host}:${server.port}" + - if (!server.useSsl) " (tcp)" else " (tls)", + displayText = + "${server.host}:${server.port}" + + if (!server.useSsl) " (tcp)" else " (tls)", isActive = true, ) } @@ -266,9 +263,10 @@ private fun NamecoinCustomServersList( ) servers.forEach { server -> Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 2.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { @@ -285,7 +283,7 @@ private fun NamecoinCustomServersList( modifier = Modifier.size(28.dp), ) { Icon( - Icons.Default.Close, + MaterialSymbols.Close, contentDescription = "Remove server", tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(16.dp), @@ -332,37 +330,41 @@ private fun NamecoinAddServerInput(onAdd: (String) -> Unit) { 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 - } + 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, - ), + 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), - ), + modifier = + Modifier + .padding(top = 8.dp) + .size(40.dp) + .background( + MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), + RoundedCornerShape(8.dp), + ), ) { Icon( - Icons.Default.Add, + MaterialSymbols.Add, contentDescription = "Add server", tint = MaterialTheme.colorScheme.primary, ) @@ -376,19 +378,21 @@ private fun NamecoinServerRow( isActive: Boolean, ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 2.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( text = "•", fontSize = 10.sp, - color = if (isActive) { - Color(0xFF2E8B57) - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, + color = + if (isActive) { + Color(0xFF2E8B57) + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, modifier = Modifier.padding(end = 6.dp), ) Text( diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinPreferencesTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinPreferencesTest.kt index 98da8aa44..bd27f3c80 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinPreferencesTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinPreferencesTest.kt @@ -57,71 +57,77 @@ class DesktopNamecoinPreferencesTest { } @Test - fun `setEnabled persists and updates flow`() = runBlocking { - namecoinPrefs.setEnabled(false) - assertFalse(namecoinPrefs.current.enabled) - assertFalse(namecoinPrefs.settings.value.enabled) + 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) - } + // 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) + 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) - } + // 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()) - } + 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) - } + 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) + 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) + 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) - } + // 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) + 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) + 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) - } + // Verify persistence + val reloaded = DesktopNamecoinPreferences(prefs = testPrefs) + assertTrue(reloaded.current.enabled) + assertFalse(reloaded.current.hasCustomServers) + } @Test fun `customServersOrNull returns null when empty`() { @@ -129,29 +135,31 @@ class DesktopNamecoinPreferencesTest { } @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) - } + 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") + 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) + 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) - } + // Verify full persistence round-trip + val reloaded = DesktopNamecoinPreferences(prefs = testPrefs) + assertEquals(settings, reloaded.current) + } }