From 0f0157b8f086ec755a0f5d82f96a4e498e1648bb Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 13 Aug 2026 09:10:03 +0300 Subject: [PATCH 1/2] feat(desktop): settings accordion of collapsible cards Replace the single long-scroll Settings screen (RelaySettingsScreen) with a searchable-ready accordion of labeled cards. Each setting is a collapsible card (icon + title + subtitle + chevron); Expand all / Collapse all toggle every card; cards start collapsed, multiple can be open at once, and state resets each visit. - New SettingsEntry/SettingsMeta (pure, testable matcher) + SettingsAccordionCard (slot-based header, hover + hand cursor). - Rename RelaySettingsScreen -> SettingsScreen; drive it from an ordered entry list built each recompose (tiny list; avoids stale content-lambda capture). - Extract the inline NWC and Relay blocks into WalletConnectSettingsSection and RelaySettingsSection; the relay list is now a plain Column (not a nested LazyColumn) so it can live inside the outer accordion LazyColumn. - Drop now-redundant internal section titles (card header owns the title) from the five desktop-only sections; NamecoinSettingsSection (shared with Android) is untouched. Co-Authored-By: Claude Opus 4.8 --- .../vitorpamplona/amethyst/desktop/Main.kt | 793 ++++++++++-------- .../desktop/ui/deck/DeckColumnContainer.kt | 4 +- .../ui/settings/HashtagSpamSettingsSection.kt | 6 - .../ui/settings/ImageCompressionSettings.kt | 8 - .../ui/settings/LocalRelaySettingsScreen.kt | 8 - .../ui/settings/MediaServerSettings.kt | 12 +- .../ui/settings/RelaySettingsSection.kt | 132 +++ .../ui/settings/SettingsAccordionCard.kt | 169 ++++ .../desktop/ui/settings/SettingsEntry.kt | 67 ++ .../settings/WalletConnectSettingsSection.kt | 139 +++ .../desktop/ui/tor/TorSettingsSection.kt | 6 - 11 files changed, 960 insertions(+), 384 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/RelaySettingsSection.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/SettingsAccordionCard.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/SettingsEntry.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/WalletConnectSettingsSection.kt 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 19cec16497..820fe25107 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -35,19 +35,14 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -56,6 +51,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -65,6 +61,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyShortcut +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.unit.dp import androidx.compose.ui.window.MenuBar import androidx.compose.ui.window.Window @@ -144,10 +142,14 @@ import com.vitorpamplona.amethyst.desktop.ui.notifications.LocalNotificationRead import com.vitorpamplona.amethyst.desktop.ui.notifications.LocalNotificationSettings 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.ImageCompressionSettings import com.vitorpamplona.amethyst.desktop.ui.settings.MediaServerSettings import com.vitorpamplona.amethyst.desktop.ui.settings.NamecoinSettingsSection +import com.vitorpamplona.amethyst.desktop.ui.settings.RelaySettingsSection +import com.vitorpamplona.amethyst.desktop.ui.settings.SettingsAccordionCard +import com.vitorpamplona.amethyst.desktop.ui.settings.SettingsEntry +import com.vitorpamplona.amethyst.desktop.ui.settings.SettingsMeta +import com.vitorpamplona.amethyst.desktop.ui.settings.WalletConnectSettingsSection import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -165,6 +167,7 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.LogLevel +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentMap import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -2391,7 +2394,7 @@ fun ProfileScreen( } @Composable -fun RelaySettingsScreen( +fun SettingsScreen( relayManager: DesktopRelayConnectionManager, account: AccountState.LoggedIn, accountManager: AccountManager, @@ -2403,14 +2406,20 @@ fun RelaySettingsScreen( namecoinPreferences: DesktopNamecoinPreferences? = null, onBlossomServersChanged: (List) -> Unit = {}, ) { - val relayStatuses by relayManager.relayStatuses.collectAsState() - val connectedRelays by relayManager.connectedRelays.collectAsState() - val nwcConnection by accountManager.nwcConnection.collectAsState() - var newRelayUrl by remember { mutableStateOf("") } - var nwcInput by remember { mutableStateOf("") } - var nwcError by remember { mutableStateOf(null) } - - val nwcScope = rememberCoroutineScope() + val entries = + settingsEntries( + relayManager = relayManager, + account = account, + accountManager = accountManager, + torStatus = torStatus, + torSettings = torSettings, + onTorSettingsChanged = onTorSettingsChanged, + namecoinPreferences = namecoinPreferences, + onBlossomServersChanged = onBlossomServersChanged, + ) + // Absent id = collapsed. Not rememberSaveable: expand state resets each visit. + val expandedIds = remember { mutableStateMapOf() } + val logoutScope = rememberCoroutineScope() com.vitorpamplona.amethyst.desktop.ui.ReadingColumn { val sidePadding = @@ -2421,7 +2430,6 @@ fun RelaySettingsScreen( Modifier .fillMaxWidth() .fillMaxHeight() - .verticalScroll(rememberScrollState()) .padding(horizontal = sidePadding), ) { Row( @@ -2431,352 +2439,449 @@ fun RelaySettingsScreen( .heightIn(min = 48.dp) .padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, ) { Text( "Settings", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onBackground, ) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + TextButton( + onClick = { entries.forEach { expandedIds[it.meta.id] = false } }, + modifier = Modifier.pointerHoverIcon(PointerIcon.Hand), + ) { + Text("Collapse all") + } + TextButton( + onClick = { entries.forEach { expandedIds[it.meta.id] = true } }, + modifier = Modifier.pointerHoverIcon(PointerIcon.Hand), + ) { + Text("Expand all") + } + } } - Spacer(Modifier.height(16.dp)) - - // Account Keys / Backup Section - BackupKeysCard(account = account) - - Spacer(Modifier.height(24.dp)) - - // Wallet Connect Section - Text( - "Wallet Connect (NWC)", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onBackground, - ) - Spacer(Modifier.height(8.dp)) - - Text( - "Connect a Lightning wallet to enable zaps. Get a connection string from Alby, Mutiny, or other NWC-compatible wallets.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(12.dp)) - if (nwcConnection != null) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column { - Text( - "Wallet Connected", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - ) - Text( - "Relay: ${nwcConnection!!.relayUri.url}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + LazyColumn( + modifier = Modifier.fillMaxWidth().weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(entries, key = { it.meta.id }) { entry -> + SettingsAccordionCard( + expanded = expandedIds[entry.meta.id] == true, + onToggle = { + expandedIds[entry.meta.id] = !(expandedIds[entry.meta.id] ?: false) + }, + headlineContent = { Text(entry.meta.title) }, + leadingContent = { + Icon( + symbol = entry.meta.icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + supportingContent = { Text(entry.meta.subtitle) }, + content = entry.content, + ) + } + + item(key = "__logout__") { + Spacer(Modifier.height(4.dp)) OutlinedButton( - onClick = { nwcScope.launch { accountManager.clearNwcConnection(account.npub) } }, + onClick = { logoutScope.launch { accountManager.logout(deleteKey = true) } }, colors = ButtonDefaults.outlinedButtonColors( contentColor = MaterialTheme.colorScheme.error, ), ) { - Text("Disconnect") + Text("Logout") } + Spacer(Modifier.height(24.dp)) } - } else { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - value = nwcInput, - onValueChange = { - nwcInput = it - nwcError = null - }, - label = { Text("NWC Connection String") }, - placeholder = { Text("nostr+walletconnect://...") }, - modifier = Modifier.weight(1f), - singleLine = true, - isError = nwcError != null, - supportingText = nwcError?.let { { Text(it, color = MaterialTheme.colorScheme.error) } }, - ) - Button( - onClick = { - nwcScope.launch { - val result = accountManager.setNwcConnection(account.npub, nwcInput) - result.fold( - onSuccess = { nwcInput = "" }, - onFailure = { nwcError = it.message ?: "Invalid connection string" }, - ) - } - }, - enabled = nwcInput.isNotBlank(), - ) { - Text("Connect") - } - } - } - - Spacer(Modifier.height(24.dp)) - HorizontalDivider() - Spacer(Modifier.height(24.dp)) - - // Media Server Settings (Blossom, kind 10063 — synced with mobile) - val networkBlossomServers by (LocalBlossomServers.current?.collectAsState() ?: remember { mutableStateOf(emptyList()) }) - // The kind-10063 list is authoritative; before it loads (or when the - // user has published none) show the default server. - val effectiveBlossomServers = networkBlossomServers.ifEmpty { listOf(DEFAULT_BLOSSOM_SERVER) } - key(effectiveBlossomServers) { - MediaServerSettings( - initialServers = effectiveBlossomServers, - onServersChanged = onBlossomServersChanged, - ) - } - Spacer(Modifier.height(24.dp)) - HorizontalDivider() - Spacer(Modifier.height(24.dp)) - - // Image Compression Settings - ImageCompressionSettings() - Spacer(Modifier.height(24.dp)) - HorizontalDivider() - Spacer(Modifier.height(24.dp)) - - // Tor Settings - com.vitorpamplona.amethyst.desktop.ui.tor.TorSettingsSection( - torStatus = torStatus, - currentSettings = torSettings, - onSettingsChanged = onTorSettingsChanged, - ) - Spacer(Modifier.height(24.dp)) - HorizontalDivider() - Spacer(Modifier.height(24.dp)) - - // Namecoin Settings (ElectrumX servers for .bit / d/ / id/ resolution) - val namecoinPrefsHere = namecoinPreferences ?: LocalNamecoinPreferences.current - val namecoinServiceHere = LocalNamecoinService.current - if (namecoinPrefsHere != null) { - val namecoinScope = rememberCoroutineScope() - val namecoinSettings by namecoinPrefsHere.settings.collectAsState() - NamecoinSettingsSection( - settings = namecoinSettings, - onToggleEnabled = { enabled -> - namecoinScope.launch { namecoinPrefsHere.setEnabled(enabled) } - }, - onAddServer = { server -> - namecoinScope.launch { namecoinPrefsHere.addServer(server) } - }, - onRemoveServer = { server -> - namecoinScope.launch { namecoinPrefsHere.removeServer(server) } - }, - onReset = { - namecoinScope.launch { namecoinPrefsHere.reset() } - }, - onTestServer = - namecoinServiceHere?.let { svc -> - { server -> svc.client.testServer(server) } - }, - onPinCert = - namecoinServiceHere?.let { svc -> - { pem -> - namecoinPrefsHere.addPinnedCert(pem) - // Apply immediately so the next lookup uses the new pin. - // The same list is shared with the Namecoin Core RPC - // client when present, mirroring Android's behaviour - // where both backends consume one trust store. - namecoinScope.launch { - try { - val pins = namecoinPrefsHere.loadPinnedCerts() - svc.client.setDynamicCerts(pins) - svc.rpcClient?.setDynamicCerts(pins) - } catch (_: Exception) { - // Best-effort — persisted, will apply on next restart. - } - } - } - }, - onSetBackend = { backend -> - namecoinScope.launch { namecoinPrefsHere.setBackend(backend) } - }, - onSetCoreRpcConfig = { cfg -> - namecoinScope.launch { - namecoinPrefsHere.setCoreRpcConfig(cfg) - // Push the new config into the live client so the - // next lookup uses it without restarting the app. - namecoinServiceHere?.rpcClient?.setConfig(cfg) - } - }, - onSetFallbackToCustomElectrumx = { enabled -> - namecoinScope.launch { - namecoinPrefsHere.setFallbackToCustomElectrumx(enabled) - } - }, - onSetFallbackToDefaultElectrumx = { enabled -> - namecoinScope.launch { - namecoinPrefsHere.setFallbackToDefaultElectrumx(enabled) - } - }, - onTestCoreRpc = - namecoinServiceHere?.let { svc -> - { cfg -> - svc.probeCoreRpc(cfg) - ?: com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin - .RpcProbeResult( - success = false, - elapsedMs = 0, - error = "Namecoin Core RPC client not available", - ) - } - }, - ) - Spacer(Modifier.height(24.dp)) - HorizontalDivider() - Spacer(Modifier.height(24.dp)) - } - - // Developer Settings Section (only in debug mode) - if (DebugConfig.isDebugMode) { - com.vitorpamplona.amethyst.desktop.ui - .DevSettingsSection(account = account) - Spacer(Modifier.height(24.dp)) - HorizontalDivider() - Spacer(Modifier.height(24.dp)) - } - - Text( - "Relay Settings", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onBackground, - ) - Spacer(Modifier.height(8.dp)) - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - "${connectedRelays.size} of ${relayStatuses.size} relays connected", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium, - ) - IconButton(onClick = { relayManager.connect() }) { - Icon( - MaterialSymbols.Refresh, - contentDescription = "Reconnect", - tint = MaterialTheme.colorScheme.primary, - ) - } - } - - Spacer(Modifier.height(16.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - value = newRelayUrl, - onValueChange = { newRelayUrl = it }, - label = { Text("Add relay") }, - placeholder = { Text("wss://relay.example.com") }, - modifier = Modifier.weight(1f), - singleLine = true, - ) - Button( - onClick = { - if (newRelayUrl.isNotBlank()) { - relayManager.addRelay(newRelayUrl) - newRelayUrl = "" - } - }, - enabled = newRelayUrl.isNotBlank(), - ) { - Text("Add") - } - } - - Spacer(Modifier.height(16.dp)) - - LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.weight(1f), - ) { - items(relayStatuses.values.toList(), key = { it.url.url }) { status -> - RelayStatusCard( - status = status, - onRemove = { relayManager.removeRelay(status.url) }, - ) - } - } - - Spacer(Modifier.height(16.dp)) - - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedButton(onClick = { relayManager.addDefaultRelays() }) { - Text("Reset to Defaults") - } - } - - Spacer(Modifier.height(16.dp)) - HorizontalDivider() - Spacer(Modifier.height(16.dp)) - - // Local Relay section - val localRelay = com.vitorpamplona.amethyst.desktop.ui.deck.LocalLocalRelayStore.current - if (localRelay != null) { - com.vitorpamplona.amethyst.desktop.ui.settings.LocalRelaySettingsScreen( - localRelayStore = localRelay, - ) - Spacer(Modifier.height(16.dp)) - HorizontalDivider() - Spacer(Modifier.height(16.dp)) - } - - // Privacy lock section - com.vitorpamplona.amethyst.desktop.ui.settings - .PrivacyLockSettingsScreen() - Spacer(Modifier.height(16.dp)) - HorizontalDivider() - Spacer(Modifier.height(16.dp)) - - // Content Filters section — hashtag-spam filter and future - // content-moderation toggles. - Text( - text = "Content Filters", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onBackground, - ) - Spacer(Modifier.height(8.dp)) - com.vitorpamplona.amethyst.desktop.ui.settings.HashtagSpamSettingsSection( - settings = LocalHashtagSpamSettings.current, - ) - Spacer(Modifier.height(16.dp)) - com.vitorpamplona.amethyst.desktop.ui.settings - .ModerationSettingsSection() - Spacer(Modifier.height(16.dp)) - HorizontalDivider() - Spacer(Modifier.height(16.dp)) - - val logoutScope = rememberCoroutineScope() - OutlinedButton( - onClick = { logoutScope.launch { accountManager.logout(deleteKey = true) } }, - colors = - ButtonDefaults.outlinedButtonColors( - contentColor = MaterialTheme.colorScheme.error, - ), - ) { - Text("Logout") } } } } + +/** + * Builds the ordered Settings accordion entries. Rebuilt on each recomposition + * (the list is tiny and collapsed cards render only their header), so each + * entry's [SettingsEntry.content] lambda captures the current param values and + * never goes stale. Reads that must react (relay counts, composition locals) + * happen here or lazily inside each content lambda. + */ +@Composable +private fun settingsEntries( + relayManager: DesktopRelayConnectionManager, + account: AccountState.LoggedIn, + accountManager: AccountManager, + torStatus: com.vitorpamplona.amethyst.commons.tor.TorServiceStatus, + torSettings: com.vitorpamplona.amethyst.commons.tor.TorSettings, + onTorSettingsChanged: (com.vitorpamplona.amethyst.commons.tor.TorSettings) -> Unit, + namecoinPreferences: DesktopNamecoinPreferences?, + onBlossomServersChanged: (List) -> Unit, +): List { + val relayStatuses by relayManager.relayStatuses.collectAsState() + val connectedRelays by relayManager.connectedRelays.collectAsState() + val namecoinPrefsHere = namecoinPreferences ?: LocalNamecoinPreferences.current + val localRelay = com.vitorpamplona.amethyst.desktop.ui.deck.LocalLocalRelayStore.current + + return buildList { + add( + SettingsEntry( + SettingsMeta( + id = "keyBackup", + icon = MaterialSymbols.Key, + title = "Account Keys / Backup", + subtitle = "Back up your secret key", + keywords = + persistentListOf( + "backup", + "nsec", + "key", + "recovery", + "secret key", + "export", + "ncryptsec", + ), + ), + ) { + com.vitorpamplona.amethyst.desktop.ui.keyBackup + .BackupKeysCard(account = account) + }, + ) + + add( + SettingsEntry( + SettingsMeta( + id = "nwc", + icon = MaterialSymbols.Bolt, + title = "Wallet Connect (NWC)", + subtitle = "Connect a Lightning wallet", + keywords = + persistentListOf( + "lightning", + "zap", + "wallet", + "alby", + "mutiny", + "nostr wallet connect", + "connect wallet", + "pay", + ), + ), + ) { + WalletConnectSettingsSection(account = account, accountManager = accountManager) + }, + ) + + if (namecoinPrefsHere != null) { + add( + SettingsEntry( + SettingsMeta( + id = "namecoin", + icon = MaterialSymbols.Lock, + title = "Namecoin Resolution", + subtitle = "Blockchain identity lookups (.bit)", + keywords = + persistentListOf( + ".bit", + "blockchain", + "identity", + "electrumx", + "dns", + "namecoin", + "resolve", + ), + ), + ) { + NamecoinSettingsEntryContent(namecoinPreferences) + }, + ) + } + + add( + SettingsEntry( + SettingsMeta( + id = "blossom", + icon = MaterialSymbols.Image, + title = "Media Servers (Blossom)", + subtitle = "Configure media uploads", + keywords = + persistentListOf( + "upload", + "media", + "blossom", + "server", + "images", + "check all", + "add server", + ), + ), + ) { + val networkBlossomServers by ( + LocalBlossomServers.current?.collectAsState() + ?: remember { mutableStateOf(emptyList()) } + ) + val effectiveBlossomServers = networkBlossomServers.ifEmpty { listOf(DEFAULT_BLOSSOM_SERVER) } + key(effectiveBlossomServers) { + MediaServerSettings( + initialServers = effectiveBlossomServers, + onServersChanged = onBlossomServersChanged, + ) + } + }, + ) + + add( + SettingsEntry( + SettingsMeta( + id = "imageCompression", + icon = MaterialSymbols.Tune, + title = "Image Compression", + subtitle = "Default upload quality", + keywords = + persistentListOf( + "quality", + "compression", + "compress", + "exif", + "upload quality", + "strip metadata", + ), + ), + ) { + ImageCompressionSettings() + }, + ) + + add( + SettingsEntry( + SettingsMeta( + id = "relays", + icon = MaterialSymbols.Podcasts, + title = "Relay Settings", + subtitle = "${connectedRelays.size} of ${relayStatuses.size} relays connected", + keywords = + persistentListOf( + "relay", + "nip-65", + "connect", + "reconnect", + "add relay", + "ws", + "wss", + ), + ), + ) { + RelaySettingsSection(relayManager = relayManager) + }, + ) + + if (localRelay != null) { + add( + SettingsEntry( + SettingsMeta( + id = "localRelay", + icon = MaterialSymbols.Storage, + title = "Local Relay", + subtitle = "Local event cache", + keywords = + persistentListOf( + "cache", + "sqlite", + "offline", + "local event store", + "vacuum", + "prune", + ), + ), + ) { + com.vitorpamplona.amethyst.desktop.ui.settings + .LocalRelaySettingsScreen(localRelayStore = localRelay) + }, + ) + } + + add( + SettingsEntry( + SettingsMeta( + id = "tor", + icon = MaterialSymbols.Shield, + title = "Tor", + subtitle = "Route connections through Tor", + keywords = persistentListOf("tor", "proxy", "privacy", "onion", "socks"), + ), + ) { + com.vitorpamplona.amethyst.desktop.ui.tor.TorSettingsSection( + torStatus = torStatus, + currentSettings = torSettings, + onSettingsChanged = onTorSettingsChanged, + ) + }, + ) + + add( + SettingsEntry( + SettingsMeta( + id = "privacyLock", + icon = MaterialSymbols.Lock, + title = "Privacy Lock", + subtitle = "Inactivity lock & redaction", + keywords = + persistentListOf( + "lock", + "password", + "inactivity", + "redaction", + "screen lock", + ), + ), + ) { + com.vitorpamplona.amethyst.desktop.ui.settings + .PrivacyLockSettingsScreen() + }, + ) + + add( + SettingsEntry( + SettingsMeta( + id = "contentFilters", + icon = MaterialSymbols.FilterAlt, + title = "Content Filters", + subtitle = "Hashtag-spam filter", + keywords = + persistentListOf( + "hashtag", + "spam", + "filter", + "mute", + "threshold", + ), + ), + ) { + com.vitorpamplona.amethyst.desktop.ui.settings.HashtagSpamSettingsSection( + settings = LocalHashtagSpamSettings.current, + ) + }, + ) + + add( + SettingsEntry( + SettingsMeta( + id = "moderation", + icon = MaterialSymbols.Block, + title = "Moderation", + subtitle = "Muted & blocked accounts", + keywords = + persistentListOf( + "mute", + "block", + "report", + "moderation", + "blocked", + "safety", + ), + ), + ) { + com.vitorpamplona.amethyst.desktop.ui.settings + .ModerationSettingsSection() + }, + ) + + if (DebugConfig.isDebugMode) { + add( + SettingsEntry( + SettingsMeta( + id = "developer", + icon = MaterialSymbols.Warning, + title = "Developer Settings", + subtitle = "Debug tools", + keywords = persistentListOf("debug", "developer", "logs"), + ), + ) { + com.vitorpamplona.amethyst.desktop.ui + .DevSettingsSection(account = account) + }, + ) + } + } +} + +/** Namecoin (.bit) resolution settings body for the Settings accordion. */ +@Composable +private fun NamecoinSettingsEntryContent(namecoinPreferences: DesktopNamecoinPreferences?) { + val namecoinPrefsHere = namecoinPreferences ?: LocalNamecoinPreferences.current + val namecoinServiceHere = LocalNamecoinService.current + if (namecoinPrefsHere != null) { + val namecoinScope = rememberCoroutineScope() + val namecoinSettings by namecoinPrefsHere.settings.collectAsState() + NamecoinSettingsSection( + settings = namecoinSettings, + onToggleEnabled = { enabled -> + namecoinScope.launch { namecoinPrefsHere.setEnabled(enabled) } + }, + onAddServer = { server -> + namecoinScope.launch { namecoinPrefsHere.addServer(server) } + }, + onRemoveServer = { server -> + namecoinScope.launch { namecoinPrefsHere.removeServer(server) } + }, + onReset = { + namecoinScope.launch { namecoinPrefsHere.reset() } + }, + onTestServer = + namecoinServiceHere?.let { svc -> + { server -> svc.client.testServer(server) } + }, + onPinCert = + namecoinServiceHere?.let { svc -> + { pem -> + namecoinPrefsHere.addPinnedCert(pem) + namecoinScope.launch { + try { + val pins = namecoinPrefsHere.loadPinnedCerts() + svc.client.setDynamicCerts(pins) + svc.rpcClient?.setDynamicCerts(pins) + } catch (_: Exception) { + // Best-effort — persisted, will apply on next restart. + } + } + } + }, + onSetBackend = { backend -> + namecoinScope.launch { namecoinPrefsHere.setBackend(backend) } + }, + onSetCoreRpcConfig = { cfg -> + namecoinScope.launch { + namecoinPrefsHere.setCoreRpcConfig(cfg) + namecoinServiceHere?.rpcClient?.setConfig(cfg) + } + }, + onSetFallbackToCustomElectrumx = { enabled -> + namecoinScope.launch { + namecoinPrefsHere.setFallbackToCustomElectrumx(enabled) + } + }, + onSetFallbackToDefaultElectrumx = { enabled -> + namecoinScope.launch { + namecoinPrefsHere.setFallbackToDefaultElectrumx(enabled) + } + }, + onTestCoreRpc = + namecoinServiceHere?.let { svc -> + { cfg -> + svc.probeCoreRpc(cfg) + ?: com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin + .RpcProbeResult( + success = false, + elapsedMs = 0, + error = "Namecoin Core RPC client not available", + ) + } + }, + ) + } +} 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 9503e2e0a8..1377340d8f 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 @@ -55,7 +55,7 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.desktop.DesktopScreen -import com.vitorpamplona.amethyst.desktop.RelaySettingsScreen +import com.vitorpamplona.amethyst.desktop.SettingsScreen import com.vitorpamplona.amethyst.desktop.account.AccountManager import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache @@ -494,7 +494,7 @@ internal fun RootContent( DeckColumnType.Settings -> { val torState = com.vitorpamplona.amethyst.desktop.ui.tor.LocalTorState.current - RelaySettingsScreen( + SettingsScreen( relayManager = relayManager, account = account, accountManager = accountManager, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/HashtagSpamSettingsSection.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/HashtagSpamSettingsSection.kt index e6c77083cd..c8bfd2ce37 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/HashtagSpamSettingsSection.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/HashtagSpamSettingsSection.kt @@ -62,12 +62,6 @@ fun HashtagSpamSettingsSection( LaunchedEffect(committed) { live = committed.toFloat() } Column(modifier = modifier.fillMaxWidth()) { - Text( - text = "Hashtag-spam filter", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onBackground, - ) - Spacer(Modifier.height(8.dp)) Row(verticalAlignment = Alignment.CenterVertically) { Switch(checked = enabled, onCheckedChange = settings::setEnabled) Spacer(Modifier.width(8.dp)) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/ImageCompressionSettings.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/ImageCompressionSettings.kt index b44b29cc76..af5e3ea951 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/ImageCompressionSettings.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/ImageCompressionSettings.kt @@ -37,7 +37,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.service.upload.CompressionQuality import com.vitorpamplona.amethyst.desktop.ImageCompressionStore @@ -57,13 +56,6 @@ fun ImageCompressionSettings(modifier: Modifier = Modifier) { val stripExif by ImageCompressionStore.stripExif.collectAsState() Column(modifier = modifier.fillMaxWidth()) { - Text( - "Image Compression", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) - Spacer(Modifier.height(12.dp)) - Text( "Default quality", style = MaterialTheme.typography.bodyMedium, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/LocalRelaySettingsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/LocalRelaySettingsScreen.kt index ee7d6a872c..9a4a0f130f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/LocalRelaySettingsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/LocalRelaySettingsScreen.kt @@ -77,14 +77,6 @@ fun LocalRelaySettingsScreen( val dbSizeBytes by localRelayStore.dbSizeBytes.collectAsState() Column(modifier = modifier.fillMaxWidth()) { - Text( - text = "Local Relay", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onBackground, - ) - - Spacer(modifier = Modifier.height(8.dp)) - // Status section StatusSection( enabled = enabled, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/MediaServerSettings.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/MediaServerSettings.kt index 69c273472d..5a26af72b5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/MediaServerSettings.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/MediaServerSettings.kt @@ -92,17 +92,9 @@ fun MediaServerSettings( } } - // Parent (RelaySettingsScreen) provides the 12dp horizontal gutter, so this - // column no longer adds its own all-sides 16dp which was showing up as an - // extra frame inside the settings screen. + // Parent (the Settings accordion card) provides the horizontal gutter and + // the section title, so this column renders the body only. Column(modifier = modifier.fillMaxWidth()) { - Text( - "Media Servers (Blossom)", - style = MaterialTheme.typography.titleSmall, - ) - - Spacer(Modifier.height(8.dp)) - Text( "Configure Blossom servers for media uploads. First server is the default.", style = MaterialTheme.typography.bodySmall, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/RelaySettingsSection.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/RelaySettingsSection.kt new file mode 100644 index 0000000000..fef309063a --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/RelaySettingsSection.kt @@ -0,0 +1,132 @@ +/* + * 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.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.material3.Button +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard + +/** + * Relay Settings body: connection count + reconnect, add-relay input, the list of + * connected relays, and a reset-to-defaults action. Extracted from the old inline + * settings screen; the relay list is a plain [Column] (not a nested `LazyColumn`) + * because this now renders inside the outer Settings accordion `LazyColumn`, where + * an unbounded inner lazy list would crash. The list is small, so recycling is not + * needed. The section title is owned by the accordion card header. + */ +@Composable +fun RelaySettingsSection( + relayManager: DesktopRelayConnectionManager, + modifier: Modifier = Modifier, +) { + val relayStatuses by relayManager.relayStatuses.collectAsState() + val connectedRelays by relayManager.connectedRelays.collectAsState() + var newRelayUrl by remember { mutableStateOf("") } + + Column(modifier = modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "${connectedRelays.size} of ${relayStatuses.size} relays connected", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + ) + IconButton(onClick = { relayManager.connect() }) { + Icon( + MaterialSymbols.Refresh, + contentDescription = "Reconnect", + tint = MaterialTheme.colorScheme.primary, + ) + } + } + + Spacer(Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = newRelayUrl, + onValueChange = { newRelayUrl = it }, + label = { Text("Add relay") }, + placeholder = { Text("wss://relay.example.com") }, + modifier = Modifier.weight(1f), + singleLine = true, + ) + Button( + onClick = { + if (newRelayUrl.isNotBlank()) { + relayManager.addRelay(newRelayUrl) + newRelayUrl = "" + } + }, + enabled = newRelayUrl.isNotBlank(), + ) { + Text("Add") + } + } + + Spacer(Modifier.height(16.dp)) + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + relayStatuses.values.forEach { status -> + RelayStatusCard( + status = status, + onRemove = { relayManager.removeRelay(status.url) }, + ) + } + } + + Spacer(Modifier.height(16.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { relayManager.addDefaultRelays() }) { + Text("Reset to Defaults") + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/SettingsAccordionCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/SettingsAccordionCard.kt new file mode 100644 index 0000000000..bd19f09a74 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/SettingsAccordionCard.kt @@ -0,0 +1,169 @@ +/* + * 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.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols + +/** + * A collapsible settings card for the Settings accordion. The header row (icon + + * headline + supporting text + trailing chevron) is always shown and toggles the + * body; the body is composed only while [expanded] via [AnimatedVisibility]. + * + * Header regions are slots rather than primitive strings so a section can put a + * control (e.g. an enable toggle) in [trailingContent] instead of the default + * chevron. This is the single card pattern for the accordion — mouse affordances + * (hover highlight + hand cursor) live here so every card behaves identically. + */ +@Composable +fun SettingsAccordionCard( + expanded: Boolean, + onToggle: () -> Unit, + headlineContent: @Composable () -> Unit, + modifier: Modifier = Modifier, + leadingContent: (@Composable () -> Unit)? = null, + supportingContent: (@Composable () -> Unit)? = null, + trailingContent: @Composable RowScope.() -> Unit = { SettingsCardDefaults.Chevron(expanded) }, + content: @Composable () -> Unit, +) { + val interactionSource = remember { MutableInteractionSource() } + val hovered by interactionSource.collectIsHoveredAsState() + + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable( + interactionSource = interactionSource, + indication = LocalIndication.current, + onClick = onToggle, + ).pointerHoverIcon(PointerIcon.Hand) + .background( + if (hovered) { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.04f) + } else { + Color.Transparent + }, + ).padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (leadingContent != null) { + Box( + modifier = + Modifier + .size(40.dp) + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)), + contentAlignment = Alignment.Center, + ) { + leadingContent() + } + } + Column(modifier = Modifier.weight(1f)) { + ProvideTextStyle( + MaterialTheme.typography.titleMedium.copy( + color = MaterialTheme.colorScheme.onSurface, + ), + ) { + headlineContent() + } + if (supportingContent != null) { + ProvideTextStyle( + MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) { + supportingContent() + } + } + } + trailingContent() + } + + AnimatedVisibility( + visible = expanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + ) { + content() + } + } + } + } +} + +/** Defaults for [SettingsAccordionCard], mirroring Material3's `XxxDefaults` idiom. */ +object SettingsCardDefaults { + @Composable + fun Chevron(expanded: Boolean) { + Icon( + symbol = if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = LocalContentColor.current.copy(alpha = 0.7f), + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/SettingsEntry.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/SettingsEntry.kt new file mode 100644 index 0000000000..c2c10d113f --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/SettingsEntry.kt @@ -0,0 +1,67 @@ +/* + * 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.runtime.Composable +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import kotlinx.collections.immutable.ImmutableList + +/** + * Pure, searchable metadata for one entry in the Settings accordion. + * + * Kept UI-free and free of any Compose slot so [matches] is trivially unit + * testable and the type stays a value-comparable [Immutable] data class. The + * matcher indexes the visible [title], the [subtitle], and a curated [keywords] + * list that also carries action synonyms (e.g. "reconnect", "connect wallet") + * so a user typing an action term still surfaces the card that hosts it. + */ +@Immutable +data class SettingsMeta( + val id: String, + val icon: MaterialSymbol, + val title: String, + val subtitle: String, + val keywords: ImmutableList, +) { + fun matches(query: String): Boolean { + val q = query.trim() + if (q.isBlank()) return true + return title.contains(q, ignoreCase = true) || + subtitle.contains(q, ignoreCase = true) || + keywords.any { it.contains(q, ignoreCase = true) } + } +} + +/** + * One accordion entry: its searchable [meta] plus the composable [content] that + * renders the section body when the card is expanded. + * + * [Immutable] is an honest promise here — every field except [content] is a + * `val` of a stable type, and a stable holder may carry a composable member. + * That lets the accordion cards skip on unrelated recompositions as long as the + * entry list is remembered (see the screen's builder). + */ +@Immutable +class SettingsEntry( + val meta: SettingsMeta, + val content: @Composable () -> Unit, +) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/WalletConnectSettingsSection.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/WalletConnectSettingsSection.kt new file mode 100644 index 0000000000..9324759ecf --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/WalletConnectSettingsSection.kt @@ -0,0 +1,139 @@ +/* + * 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.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.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.unit.dp +import com.vitorpamplona.amethyst.desktop.account.AccountManager +import com.vitorpamplona.amethyst.desktop.account.AccountState +import kotlinx.coroutines.launch + +/** + * Wallet Connect (NWC) settings body. Lets the user connect/disconnect a + * Lightning wallet via a `nostr+walletconnect://` string. Extracted verbatim + * from the old inline settings screen so it can be slotted into the Settings + * accordion; the section title is now owned by the accordion card header. + */ +@Composable +fun WalletConnectSettingsSection( + account: AccountState.LoggedIn, + accountManager: AccountManager, + modifier: Modifier = Modifier, +) { + val nwcConnection by accountManager.nwcConnection.collectAsState() + var nwcInput by remember { mutableStateOf("") } + var nwcError by remember { mutableStateOf(null) } + val nwcScope = rememberCoroutineScope() + + Column(modifier = modifier.fillMaxWidth()) { + Text( + "Connect a Lightning wallet to enable zaps. Get a connection string from Alby, Mutiny, or other NWC-compatible wallets.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(12.dp)) + + if (nwcConnection != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text( + "Wallet Connected", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + Text( + "Relay: ${nwcConnection!!.relayUri.url}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + OutlinedButton( + onClick = { nwcScope.launch { accountManager.clearNwcConnection(account.npub) } }, + colors = + ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Text("Disconnect") + } + } + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = nwcInput, + onValueChange = { + nwcInput = it + nwcError = null + }, + label = { Text("NWC Connection String") }, + placeholder = { Text("nostr+walletconnect://...") }, + modifier = Modifier.weight(1f), + singleLine = true, + isError = nwcError != null, + supportingText = nwcError?.let { { Text(it, color = MaterialTheme.colorScheme.error) } }, + ) + Button( + onClick = { + nwcScope.launch { + val result = accountManager.setNwcConnection(account.npub, nwcInput) + result.fold( + onSuccess = { nwcInput = "" }, + onFailure = { nwcError = it.message ?: "Invalid connection string" }, + ) + } + }, + enabled = nwcInput.isNotBlank(), + ) { + Text("Connect") + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorSettingsSection.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorSettingsSection.kt index 1020fc3c30..7474a2d686 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorSettingsSection.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorSettingsSection.kt @@ -68,12 +68,6 @@ fun TorSettingsSection( modifier = Modifier.fillMaxWidth(), ) { Row(verticalAlignment = Alignment.CenterVertically) { - Text( - "Tor", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onBackground, - ) - Spacer(Modifier.width(12.dp)) TorStatusIndicator(status = torStatus) } TextButton(onClick = { showDialog = true }) { From 136983f130887f1a5aaa36341466cfc44bc7cd77 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 13 Aug 2026 09:17:58 +0300 Subject: [PATCH 2/2] feat(desktop): search + reveal for settings accordion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a pinned, auto-focused search field above the Settings accordion. Typing filters cards case-insensitively across title, subtitle and curated keywords (which include action synonyms like "reconnect"/"connect wallet"); matches are force-expanded and the list scrolls to the top hit. Esc and the clear button reset the query and collapse everything. A no-match query shows a placeholder; the Logout footer hides while searching. - Filtering is a plain in-memory filter over the ~11 entries (no debounce, no derivedStateOf needed for a rebuilt list this small). - SettingsMetaTest covers the pure matcher (blank→all, title/subtitle/keyword hits, case-insensitivity, trimming, non-match). Co-Authored-By: Claude Opus 4.8 --- ...08-11-settings-accordion-manual-testing.md | 50 +++++++++ .../vitorpamplona/amethyst/desktop/Main.kt | 101 +++++++++++++++--- .../desktop/ui/settings/SettingsMetaTest.kt | 74 +++++++++++++ 3 files changed, 212 insertions(+), 13 deletions(-) create mode 100644 desktopApp/plans/2026-08-11-settings-accordion-manual-testing.md create mode 100644 desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/SettingsMetaTest.kt diff --git a/desktopApp/plans/2026-08-11-settings-accordion-manual-testing.md b/desktopApp/plans/2026-08-11-settings-accordion-manual-testing.md new file mode 100644 index 0000000000..9b82f2ba7d --- /dev/null +++ b/desktopApp/plans/2026-08-11-settings-accordion-manual-testing.md @@ -0,0 +1,50 @@ +# Manual Testing — Desktop Settings Searchable Accordion + +Feature branch: `feat/desktop-settings-search-accordion` +Run: `./gradlew :desktopApp:run` → open **Settings** (sidebar, File → Settings, or Cmd/Ctrl+,). + +Legend: ☐ untested · ✅ pass · ❌ fail (note issue) + +## Accordion basics +- ✅ Settings opens as a list of **collapsed** cards (icon + bold title + subtitle + chevron), matching the mockups. +- ✅ Clicking a card header expands it; the chevron flips (▼→▲); content animates in. +- ✅ Multiple cards can be open at once. +- ✅ Each card header shows a **hand cursor** on hover and a subtle hover highlight. +- ✅ No card shows a duplicated title inside its body (card header owns the title). +- ✅ Leaving Settings and returning **resets** all cards to collapsed. + +## Expand / Collapse all +- ✅ "Expand all" opens every card; "Collapse all" closes every card. + +## Search (filter + reveal) +- ✅ The search field is **auto-focused** on open (typing goes straight into it). +- ✅ Typing filters cards case-insensitively by title (e.g. `wallet` → Wallet Connect). +- ✅ Matching cards are **auto-expanded** and the list scrolls to the top match. +- ✅ Action keywords surface the right card: `reconnect` → Relay Settings; `connect wallet` → Wallet Connect; `.bit` → Namecoin; `exif` → Image Compression; `sqlite` → Local Relay; `nsec`/`backup` → Account Keys / Backup; `mute`/`block` → Moderation. +- ✅ A no-match query (e.g. `zzzzz`) shows the "No settings match …" placeholder and no cards. +- ✅ The clear (✕) button and **Esc** both clear the query and return to the full collapsed list. +- ✅ Global shortcuts still work while the field is focused: **Cmd/Ctrl+K** (app drawer), **Cmd/Ctrl+,** (settings). + +## Each section still works (functional parity) +- ✅ **Account Keys / Backup:** `BackupKeysCard` renders; PrivacyLock-gated nsec reveal + NIP-49 encrypted backup work (absorbed from upstream — verify unchanged). +- ✅ **Moderation:** muted/blocked lists render; mute/unmute/unblock work (absorbed from upstream — verify unchanged). +- ✅ **Wallet Connect (NWC):** connect with a `nostr+walletconnect://…` string; shows "Wallet Connected"; Disconnect works. +- ✅ **Namecoin Resolution:** enable toggle + server list render; add/remove/test server works (shared with Android — verify unchanged). +- ✅ **Media Servers (Blossom):** server list + Add + Check All work; health status appears on expand. +- ✅ **Image Compression:** quality presets + EXIF toggle work. +- ✅ **Relay Settings:** connection count + Reconnect; Add relay; per-relay remove; Reset to Defaults. Relay list scrolls with the page (no nested-scroll crash). +- ✅ **Local Relay:** stats/storage/export sub-sections render and function. +- ✅ **Tor:** status indicator + Advanced… dialog + mode selector work. +- ✅ **Privacy Lock:** lock/inactivity/redaction cards work. +- ✅ **Content Filters:** hashtag-spam switch + threshold slider work. +- ✅ **Developer Settings:** present only in debug builds. +- ✅ **Logout** button appears at the bottom (hidden while searching) and logs out. + +## Regression +- ✅ No `ConcurrentModificationException` / crash opening or scrolling Settings. +- ✅ Long sections (Namecoin, Local Relay) expand and scroll into view without layout glitches. + +## PoW +- Manually exercised on macOS (Compose Desktop) with a live account — app boots + cleanly, local relay hydrates, no exceptions; all rows above pass. +- Screenshots (collapsed grid · expanded card · search-filtered · no-match) attached in the PR description. 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 820fe25107..a8b783160d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -35,9 +35,12 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface @@ -58,11 +61,18 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester 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.KeyShortcut +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.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.MenuBar import androidx.compose.ui.window.Window @@ -2421,6 +2431,16 @@ fun SettingsScreen( val expandedIds = remember { mutableStateMapOf() } val logoutScope = rememberCoroutineScope() + var query by remember { mutableStateOf("") } + val focusRequester = remember { FocusRequester() } + val listState = rememberLazyListState() + // Tiny list; a plain filter is cheaper than derivedStateOf here. + val visible = if (query.isBlank()) entries else entries.filter { it.meta.matches(query) } + + LaunchedEffect(Unit) { focusRequester.requestFocus() } + // A non-blank query reveals matches at the top of the list. + LaunchedEffect(query) { if (query.isNotBlank()) listState.animateScrollToItem(0) } + com.vitorpamplona.amethyst.desktop.ui.ReadingColumn { val sidePadding = com.vitorpamplona.amethyst.desktop.ui @@ -2462,15 +2482,56 @@ fun SettingsScreen( } } + Spacer(Modifier.height(8.dp)) + + OutlinedTextField( + value = query, + onValueChange = { query = it }, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown && + event.key == Key.Escape && + query.isNotEmpty() + ) { + query = "" + expandedIds.clear() + true + } else { + false + } + }, + placeholder = { Text("Search settings…") }, + leadingIcon = { Icon(MaterialSymbols.Search, contentDescription = null) }, + trailingIcon = { + if (query.isNotEmpty()) { + IconButton( + onClick = { + query = "" + expandedIds.clear() + }, + modifier = Modifier.pointerHoverIcon(PointerIcon.Hand), + ) { + Icon(MaterialSymbols.Clear, contentDescription = "Clear search") + } + } + }, + singleLine = true, + ) + Spacer(Modifier.height(12.dp)) LazyColumn( + state = listState, modifier = Modifier.fillMaxWidth().weight(1f), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - items(entries, key = { it.meta.id }) { entry -> + items(visible, key = { it.meta.id }) { entry -> SettingsAccordionCard( - expanded = expandedIds[entry.meta.id] == true, + // A non-blank query force-expands matches to reveal them. + expanded = if (query.isBlank()) expandedIds[entry.meta.id] == true else true, onToggle = { expandedIds[entry.meta.id] = !(expandedIds[entry.meta.id] ?: false) }, @@ -2487,18 +2548,32 @@ fun SettingsScreen( ) } - item(key = "__logout__") { - Spacer(Modifier.height(4.dp)) - OutlinedButton( - onClick = { logoutScope.launch { accountManager.logout(deleteKey = true) } }, - colors = - ButtonDefaults.outlinedButtonColors( - contentColor = MaterialTheme.colorScheme.error, - ), - ) { - Text("Logout") + if (query.isNotBlank() && visible.isEmpty()) { + item(key = "__empty__") { + Text( + "No settings match \"$query\"", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(vertical = 32.dp), + ) + } + } + + if (query.isBlank()) { + item(key = "__logout__") { + Spacer(Modifier.height(4.dp)) + OutlinedButton( + onClick = { logoutScope.launch { accountManager.logout(deleteKey = true) } }, + colors = + ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Text("Logout") + } + Spacer(Modifier.height(24.dp)) } - Spacer(Modifier.height(24.dp)) } } } diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/SettingsMetaTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/SettingsMetaTest.kt new file mode 100644 index 0000000000..5d6b9a9eaf --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/SettingsMetaTest.kt @@ -0,0 +1,74 @@ +/* + * 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 com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import kotlinx.collections.immutable.persistentListOf +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SettingsMetaTest { + private val nwc = + SettingsMeta( + id = "nwc", + icon = MaterialSymbols.Bolt, + title = "Wallet Connect (NWC)", + subtitle = "Connect a Lightning wallet", + keywords = persistentListOf("lightning", "zap", "connect wallet", "alby"), + ) + + @Test + fun blankQueryMatchesEverything() { + assertTrue(nwc.matches("")) + assertTrue(nwc.matches(" ")) + } + + @Test + fun matchesByTitleCaseInsensitively() { + assertTrue(nwc.matches("wallet")) + assertTrue(nwc.matches("WALLET")) + assertTrue(nwc.matches("nwc")) + } + + @Test + fun matchesBySubtitle() { + assertTrue(nwc.matches("lightning")) + } + + @Test + fun matchesByActionKeyword() { + // A user typing an action term surfaces the card that hosts it. + assertTrue(nwc.matches("zap")) + assertTrue(nwc.matches("Alby")) + } + + @Test + fun surroundingWhitespaceIsTrimmed() { + assertTrue(nwc.matches(" zap ")) + } + + @Test + fun nonMatchingQueryReturnsFalse() { + assertFalse(nwc.matches("relay")) + assertFalse(nwc.matches("blossom")) + } +}