From d75e80bea67cf8c5e0cf30ea4cc25805948bf017 Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 3 Jun 2026 00:55:33 +0200 Subject: [PATCH] feat(settings): curated search keywords for all rows + word-prefix search matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add keyword blobs so settings resolve by concept/protocol name, not just title — e.g. "blossom" -> Media Servers, "audio rooms" -> Nests Servers, "negentropy" -> Event Sync, "nsec" -> Backup Keys. NIP numbers omitted by preference. --- .../loggedIn/settings/SettingsCatalog.kt | 29 ++-- .../settings/SettingsCatalogBuilder.kt | 151 +++++++++++++++--- amethyst/src/main/res/values/strings.xml | 23 +++ .../settings/SettingsCatalogFilterTest.kt | 19 +++ 4 files changed, 189 insertions(+), 33 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalog.kt index bdbe765bd8..a4320aaf0f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalog.kt @@ -52,27 +52,36 @@ data class SettingsCategory( val entries: List, ) +private val SEARCH_DELIMITERS = Regex("[^\\p{L}\\p{N}]+") + +/** Lowercases and splits text into searchable words on any non-letter/digit run. */ +private fun String.searchWords(): List = lowercase().split(SEARCH_DELIMITERS).filter { it.isNotEmpty() } + /** - * Filters [catalog] by [query] (case-insensitive substring over category title + entry title + keywords). - * Blank/whitespace query returns [catalog] unchanged. Categories left with no - * matching entries are dropped. Pure — no Compose/Android — so it is unit-testable; - * string-resource resolution is injected via [stringLookup]. + * Filters [catalog] by [query] using case-insensitive **word-prefix** matching over + * category title + entry title + keywords: an entry matches when every word in the + * query is the prefix of some word in that haystack (so `dark mo` matches "dark mode", + * but `tor` does not match "his**tor**y"). Blank/whitespace query returns [catalog] + * unchanged. Categories left with no matching entries are dropped. Pure — no + * Compose/Android — so it is unit-testable; string-resource resolution is injected + * via [stringLookup]. */ fun filterSettings( catalog: List, query: String, stringLookup: (Int) -> String, ): List { - val needle = query.trim().lowercase() - if (needle.isEmpty()) return catalog + val terms = query.searchWords() + if (terms.isEmpty()) return catalog return catalog.mapNotNull { category -> - val categoryTitle = stringLookup(category.titleRes) + val categoryWords = stringLookup(category.titleRes).searchWords() val matched = category.entries.filter { entry -> - val keywords = entry.keywordsRes?.let { stringLookup(it) } ?: "" - val haystack = (categoryTitle + " " + stringLookup(entry.titleRes) + " " + keywords).lowercase() - haystack.contains(needle) + val words = + categoryWords + stringLookup(entry.titleRes).searchWords() + + entry.keywordsRes?.let { stringLookup(it).searchWords() }.orEmpty() + terms.all { term -> words.any { it.startsWith(term) } } } if (matched.isEmpty()) null else category.copy(entries = matched) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt index 8d67aa2f2d..f5a4bd1c82 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt @@ -47,27 +47,71 @@ fun buildSettingsCatalog( icon = SettingsIcon.Painter(R.drawable.relays, 4), keywordsRes = R.string.relay_setup_search_keywords, ) { nav.nav(Route.EditRelays) }, - SettingsEntry(R.string.event_sync_title, SettingsIcon.Symbol(MaterialSymbols.Sync)) { nav.nav(Route.EventSync) }, - SettingsEntry(R.string.route_import_follows, SettingsIcon.Symbol(MaterialSymbols.GroupAdd)) { nav.nav(Route.ImportFollowsSelectUser) }, - SettingsEntry(R.string.media_servers, SettingsIcon.Symbol(MaterialSymbols.CloudUpload)) { nav.nav(Route.EditMediaServers) }, - SettingsEntry(R.string.nests_servers_title, SettingsIcon.Symbol(MaterialSymbols.CloudUpload)) { nav.nav(Route.EditNestsServers) }, - SettingsEntry(R.string.profile_badges_title, SettingsIcon.Symbol(MaterialSymbols.MilitaryTech)) { nav.nav(Route.ProfileBadges) }, - SettingsEntry(R.string.favorite_dvms_title, SettingsIcon.Symbol(MaterialSymbols.AutoAwesome)) { nav.nav(Route.EditFavoriteAlgoFeeds) }, - SettingsEntry(R.string.reactions, SettingsIcon.Symbol(MaterialSymbols.FavoriteBorder)) { nav.nav(Route.UpdateReactionType) }, - SettingsEntry(R.string.video_player_settings, SettingsIcon.Symbol(MaterialSymbols.VideoSettings)) { nav.nav(Route.VideoPlayerSettings) }, + SettingsEntry( + titleRes = R.string.event_sync_title, + icon = SettingsIcon.Symbol(MaterialSymbols.Sync), + keywordsRes = R.string.event_sync_search_keywords, + ) { nav.nav(Route.EventSync) }, + SettingsEntry( + titleRes = R.string.route_import_follows, + icon = SettingsIcon.Symbol(MaterialSymbols.GroupAdd), + keywordsRes = R.string.import_follows_search_keywords, + ) { nav.nav(Route.ImportFollowsSelectUser) }, + SettingsEntry( + titleRes = R.string.media_servers, + icon = SettingsIcon.Symbol(MaterialSymbols.CloudUpload), + keywordsRes = R.string.media_servers_search_keywords, + ) { nav.nav(Route.EditMediaServers) }, + SettingsEntry( + titleRes = R.string.nests_servers_title, + icon = SettingsIcon.Symbol(MaterialSymbols.CloudUpload), + keywordsRes = R.string.nests_servers_search_keywords, + ) { nav.nav(Route.EditNestsServers) }, + SettingsEntry( + titleRes = R.string.profile_badges_title, + icon = SettingsIcon.Symbol(MaterialSymbols.MilitaryTech), + keywordsRes = R.string.profile_badges_search_keywords, + ) { nav.nav(Route.ProfileBadges) }, + SettingsEntry( + titleRes = R.string.favorite_dvms_title, + icon = SettingsIcon.Symbol(MaterialSymbols.AutoAwesome), + keywordsRes = R.string.favorite_dvms_search_keywords, + ) { nav.nav(Route.EditFavoriteAlgoFeeds) }, + SettingsEntry( + titleRes = R.string.reactions, + icon = SettingsIcon.Symbol(MaterialSymbols.FavoriteBorder), + keywordsRes = R.string.reactions_search_keywords, + ) { nav.nav(Route.UpdateReactionType) }, + SettingsEntry( + titleRes = R.string.video_player_settings, + icon = SettingsIcon.Symbol(MaterialSymbols.VideoSettings), + keywordsRes = R.string.video_player_search_keywords, + ) { nav.nav(Route.VideoPlayerSettings) }, SettingsEntry( titleRes = R.string.zaps, icon = SettingsIcon.Symbol(MaterialSymbols.Bolt), keywordsRes = R.string.zaps_search_keywords, ) { nav.nav(Route.UpdateZapAmount()) }, - SettingsEntry(R.string.payment_targets, SettingsIcon.Symbol(MaterialSymbols.Payment)) { nav.nav(Route.EditPaymentTargets) }, + SettingsEntry( + titleRes = R.string.payment_targets, + icon = SettingsIcon.Symbol(MaterialSymbols.Payment), + keywordsRes = R.string.payment_targets_search_keywords, + ) { nav.nav(Route.EditPaymentTargets) }, SettingsEntry( titleRes = R.string.security_filters, icon = SettingsIcon.Symbol(MaterialSymbols.Security), keywordsRes = R.string.security_filters_search_keywords, ) { nav.nav(Route.SecurityFilters) }, - SettingsEntry(R.string.call_settings, SettingsIcon.Symbol(MaterialSymbols.Phone)) { nav.nav(Route.CallSettings) }, - SettingsEntry(R.string.translations, SettingsIcon.Symbol(MaterialSymbols.Translate)) { nav.nav(Route.UserSettings) }, + SettingsEntry( + titleRes = R.string.call_settings, + icon = SettingsIcon.Symbol(MaterialSymbols.Phone), + keywordsRes = R.string.call_settings_search_keywords, + ) { nav.nav(Route.CallSettings) }, + SettingsEntry( + titleRes = R.string.translations, + icon = SettingsIcon.Symbol(MaterialSymbols.Translate), + keywordsRes = R.string.translations_search_keywords, + ) { nav.nav(Route.UserSettings) }, ), ) @@ -81,8 +125,16 @@ fun buildSettingsCatalog( icon = SettingsIcon.Painter(R.drawable.ic_tor, 1), keywordsRes = R.string.privacy_options_search_keywords, ) { nav.nav(Route.PrivacyOptions) }, - SettingsEntry(R.string.ots_explorer_settings, SettingsIcon.Symbol(MaterialSymbols.Search)) { nav.nav(Route.OtsSettings) }, - SettingsEntry(R.string.namecoin_settings, SettingsIcon.Symbol(MaterialSymbols.Security)) { nav.nav(Route.NamecoinSettings) }, + SettingsEntry( + titleRes = R.string.ots_explorer_settings, + icon = SettingsIcon.Symbol(MaterialSymbols.Search), + keywordsRes = R.string.ots_explorer_search_keywords, + ) { nav.nav(Route.OtsSettings) }, + SettingsEntry( + titleRes = R.string.namecoin_settings, + icon = SettingsIcon.Symbol(MaterialSymbols.Security), + keywordsRes = R.string.namecoin_search_keywords, + ) { nav.nav(Route.NamecoinSettings) }, SettingsEntry( titleRes = R.string.ui_preferences, icon = SettingsIcon.Symbol(MaterialSymbols.Settings), @@ -93,12 +145,36 @@ fun buildSettingsCatalog( icon = SettingsIcon.Symbol(MaterialSymbols.Notifications), keywordsRes = R.string.notification_settings_search_keywords, ) { nav.nav(Route.NotificationSettings) }, - SettingsEntry(R.string.calendar_reminder_settings_title, SettingsIcon.Symbol(MaterialSymbols.CalendarMonth)) { nav.nav(Route.CalendarReminderSettings) }, - SettingsEntry(R.string.compose_settings, SettingsIcon.Symbol(MaterialSymbols.Edit)) { nav.nav(Route.ComposeSettings) }, - SettingsEntry(R.string.reactions_settings, SettingsIcon.Symbol(MaterialSymbols.ThumbUp)) { nav.nav(Route.ReactionsSettings) }, - SettingsEntry(R.string.bottom_bar_settings, SettingsIcon.Symbol(MaterialSymbols.Dashboard)) { nav.nav(Route.BottomBarSettings) }, - SettingsEntry(R.string.home_tabs_settings, SettingsIcon.Symbol(MaterialSymbols.Home)) { nav.nav(Route.HomeTabsSettings) }, - SettingsEntry(R.string.profile_ui_settings, SettingsIcon.Symbol(MaterialSymbols.AccountCircle)) { nav.nav(Route.ProfileUiSettings) }, + SettingsEntry( + titleRes = R.string.calendar_reminder_settings_title, + icon = SettingsIcon.Symbol(MaterialSymbols.CalendarMonth), + keywordsRes = R.string.calendar_reminder_search_keywords, + ) { nav.nav(Route.CalendarReminderSettings) }, + SettingsEntry( + titleRes = R.string.compose_settings, + icon = SettingsIcon.Symbol(MaterialSymbols.Edit), + keywordsRes = R.string.compose_search_keywords, + ) { nav.nav(Route.ComposeSettings) }, + SettingsEntry( + titleRes = R.string.reactions_settings, + icon = SettingsIcon.Symbol(MaterialSymbols.ThumbUp), + keywordsRes = R.string.reactions_settings_search_keywords, + ) { nav.nav(Route.ReactionsSettings) }, + SettingsEntry( + titleRes = R.string.bottom_bar_settings, + icon = SettingsIcon.Symbol(MaterialSymbols.Dashboard), + keywordsRes = R.string.bottom_bar_search_keywords, + ) { nav.nav(Route.BottomBarSettings) }, + SettingsEntry( + titleRes = R.string.home_tabs_settings, + icon = SettingsIcon.Symbol(MaterialSymbols.Home), + keywordsRes = R.string.home_tabs_search_keywords, + ) { nav.nav(Route.HomeTabsSettings) }, + SettingsEntry( + titleRes = R.string.profile_ui_settings, + icon = SettingsIcon.Symbol(MaterialSymbols.AccountCircle), + keywordsRes = R.string.profile_ui_search_keywords, + ) { nav.nav(Route.ProfileUiSettings) }, ), ) @@ -109,11 +185,40 @@ fun buildSettingsCatalog( entries = buildList { if (hasPrivateKey) { - add(SettingsEntry(R.string.backup_keys, SettingsIcon.Symbol(MaterialSymbols.Key), isDanger = true) { nav.nav(Route.AccountBackup) }) - add(SettingsEntry(R.string.request_to_vanish, SettingsIcon.Symbol(MaterialSymbols.DeleteForever), isDanger = true) { nav.nav(Route.RequestToVanish) }) + add( + SettingsEntry( + titleRes = R.string.backup_keys, + icon = SettingsIcon.Symbol(MaterialSymbols.Key), + keywordsRes = R.string.backup_keys_search_keywords, + isDanger = true, + ) { nav.nav(Route.AccountBackup) }, + ) + add( + SettingsEntry( + titleRes = R.string.request_to_vanish, + icon = SettingsIcon.Symbol(MaterialSymbols.DeleteForever), + keywordsRes = R.string.request_to_vanish_search_keywords, + isDanger = true, + ) { nav.nav(Route.RequestToVanish) }, + ) } - add(SettingsEntry(R.string.vanish_history, SettingsIcon.Symbol(MaterialSymbols.History), isDanger = true) { nav.nav(Route.VanishEvents) }) - add(SettingsEntry(R.string.reset_marmot_state, SettingsIcon.Symbol(MaterialSymbols.DeleteSweep), isDanger = true, onClick = onResetMarmot)) + add( + SettingsEntry( + titleRes = R.string.vanish_history, + icon = SettingsIcon.Symbol(MaterialSymbols.History), + keywordsRes = R.string.vanish_history_search_keywords, + isDanger = true, + ) { nav.nav(Route.VanishEvents) }, + ) + add( + SettingsEntry( + titleRes = R.string.reset_marmot_state, + icon = SettingsIcon.Symbol(MaterialSymbols.DeleteSweep), + keywordsRes = R.string.reset_marmot_search_keywords, + isDanger = true, + onClick = onResetMarmot, + ), + ) }, ) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 9215ed73f6..313080bc63 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1516,6 +1516,29 @@ push, alerts, sounds, vibration spam, block, mute, filter, warnings lightning, sats, tips, wallet, amount + blossom, uploads, images, photos, files, cdn, storage + negentropy, sync, reconcile, backfill + contacts, follows, follow list, import + audio rooms, live, spaces, rooms + badges, awards + dvm, data vending machine, algo, algorithm, feeds + emoji, like, reaction + video, player, playback, autoplay, mute + zap split, split, recipients, forward zaps + webrtc, video call, voice call, calls + language, translate, locale + opentimestamps, timestamp, ots, proof + namecoin, dns, identity, name + calendar, events, reminders, rsvp + draft, posting, editor, auto-save + emoji, reactions, like + navigation, tabs, nav bar + tabs, feeds, threads, conversations + profile, layout + nsec, private key, seed, mnemonic, export + delete account, vanish, gdpr + deletion, delete events + mls, group chat, messaging, reset Danger Zone Reset Marmot State diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogFilterTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogFilterTest.kt index 6430af319a..f044f28fd7 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogFilterTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogFilterTest.kt @@ -126,4 +126,23 @@ class SettingsCatalogFilterTest { assertTrue(result[0].isDanger) assertTrue(result[0].entries[0].isDanger) } + + @Test + fun prefixOfAWordMatches() { + // "rel" is a prefix of "Relay" (title); "the" is a prefix of "theme" (keyword). + assertEquals(1, run("rel")[0].entries[0].titleRes) + assertEquals(2, run("the")[0].entries[0].titleRes) + } + + @Test + fun midWordTermDoesNotMatch() { + // Word-prefix, not substring: "ackup" is inside "Backup" but not a prefix of any word. + assertTrue(run("ackup").isEmpty()) + } + + @Test + fun everyQueryTermMustPrefixSomeWord() { + assertEquals(2, run("dark size")[0].entries[0].titleRes) // both terms hit UI Preferences + assertTrue(run("dark zzz").isEmpty()) // second term matches nothing + } }