diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt index ff55cb0a61..41b85b068a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt @@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.commons.search.SearchFilterBuilder import com.vitorpamplona.amethyst.commons.search.SearchScope import com.vitorpamplona.amethyst.commons.search.SearchSortOrder import com.vitorpamplona.amethyst.commons.search.SearchSource +import com.vitorpamplona.amethyst.commons.search.nameSearchTerms import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache @@ -419,11 +420,12 @@ class SearchBarViewModel( override val isRefreshing = derivedStateOf { searchValue.isNotBlank() } /** - * What is left of the box once the filter tokens are lifted out — what a name search should - * actually be given. A blank result means the query named only filters, and a name search on - * "everything" is not a useful answer. + * The single word a name search should be given — see [nameSearchTerms]. Not simply the + * leftover text: a query that is nothing but `#bitcoin` leaves no leftovers, and handing the + * finders an empty string means they answer with nobody rather than with the channel called + * "Bitcoin" that the reader was plainly looking for. */ - private fun plainTerms(term: String): String = QueryParser.parse(term).text + private fun plainTerms(term: String): String = QueryParser.parse(term).nameSearchTerms() /** * Could this text name an event rather than describe one? A bech32 pointer, or a run of hex diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/NameSearchTerms.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/NameSearchTerms.kt new file mode 100644 index 0000000000..3759b47f38 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/NameSearchTerms.kt @@ -0,0 +1,44 @@ +/* + * 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.commons.search + +/** + * The one word a *name* search should be given, from a query written in the token language. + * + * The people and channel finders match a single string against a name — `startsWith`, `contains` — + * so they cannot be handed the whole box. Handing them the raw text was wrong once the tokens + * started meaning something: `#bitcoin` went in verbatim and matched no channel called "Bitcoin". + * Handing them only the leftover terms is wrong in the other direction, because a query that is + * *nothing but* a token leaves nothing behind, and `#bitcoin` would find nobody at all. + * + * So: the leftover words when there are any, and otherwise the word the reader actually typed + * inside the token. Someone typing `#bitcoin` into a search box means "bitcoin" by it, and someone + * typing `from:vitor` means "vitor" — neither means "search for nothing". + * + * One term, never a join: `"bitcoin lightning"` is not a name any channel starts with, so where a + * query names two tokens the first is the one that stands for it. + */ +fun SearchQuery.nameSearchTerms(): String { + if (text.isNotBlank()) return text + hashtags.firstOrNull()?.let { return it } + authorNames.firstOrNull()?.let { return it } + return "" +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/NameSearchTermsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/NameSearchTermsTest.kt new file mode 100644 index 0000000000..de552391dc --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/NameSearchTermsTest.kt @@ -0,0 +1,65 @@ +/* + * 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.commons.search + +import kotlin.test.Test +import kotlin.test.assertEquals + +class NameSearchTermsTest { + private fun terms(input: String) = QueryParser.parse(input).nameSearchTerms() + + @Test + fun plainWordsPassStraightThrough() { + assertEquals("vitor", terms("vitor")) + assertEquals("bitcoin lightning", terms("bitcoin lightning")) + } + + @Test + fun aQueryThatIsNothingButAHashtagStillNamesItsWord() { + // The regression this exists for: the leftover text of `#bitcoin` is empty, so a name + // search given only the leftovers found nobody — not even a channel called "Bitcoin". + assertEquals("bitcoin", terms("#bitcoin")) + } + + @Test + fun anUnresolvedAuthorNameIsTheWordTheReaderTyped() { + assertEquals("vitor", terms("from:vitor")) + } + + @Test + fun leftoverWordsWinOverATokensValue() { + // They typed a word as well as a tag; the word is what they are naming. + assertEquals("lightning", terms("#bitcoin lightning")) + } + + @Test + fun onlyOneTermIsEverReturned() { + // These finders match a single name, so a join would match nothing at all. + assertEquals("bitcoin", terms("#bitcoin #lightning")) + } + + @Test + fun aQueryNamingOnlyFiltersNamesNothing() { + // A pure date or kind window says nothing about what somebody is called. + assertEquals("", terms("since:2026-01-01")) + assertEquals("", terms("")) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt index 5cdebd781e..8a9eabe17b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt @@ -86,6 +86,7 @@ import com.vitorpamplona.amethyst.commons.search.SearchQuery import com.vitorpamplona.amethyst.commons.search.SearchResult import com.vitorpamplona.amethyst.commons.search.SearchResultFilter import com.vitorpamplona.amethyst.commons.search.UserSearchEngine +import com.vitorpamplona.amethyst.commons.search.nameSearchTerms import com.vitorpamplona.amethyst.commons.search.parseSearchInput import com.vitorpamplona.amethyst.commons.ui.search.SearchFieldState import com.vitorpamplona.amethyst.commons.ui.search.TokenizedSearchField @@ -274,10 +275,9 @@ fun SearchScreen( createSearchPeopleSubscription( relays = searchRelays, - searchQuery = - debouncedQuery.text.ifBlank { - QuerySerializer.serialize(debouncedQuery) - }, + // The word a name search means, not the query written back out: serializing + // `#bitcoin` asked the relay's people index for the literal "#bitcoin". + searchQuery = debouncedQuery.nameSearchTerms(), limit = 20, onEvent = { event, _, relay, _ -> if (state.trackRelayEvent(relay.url, event.id)) {