From 136983f130887f1a5aaa36341466cfc44bc7cd77 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 13 Aug 2026 09:17:58 +0300 Subject: [PATCH] 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")) + } +}