diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/AdvancedSearchBarState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/AdvancedSearchBarState.kt index 2f0c4a9613..4e4fb0eaf2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/AdvancedSearchBarState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/AdvancedSearchBarState.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.commons.nip64Chess.RelaySyncStatus import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -36,45 +37,53 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update -enum class ChangeSource { - TEXT, - FORM, - INIT, -} - +/** + * Desktop's search: the relay traffic, the results it produces, and the panel around them. + * + * Everything about *what is being searched* — the text, its parse, the debounce, the scope, the + * sort orders — is [state], shared with Android. This class used to own its own copy of all of + * it, which is how it came to rank Relevance against the whole box (chips included, so a query + * with a `from:` in it scored on the literal text of its own tokens) while Android ranked against + * the leftover words. + * + * What is left here is genuinely Desktop's: results arrive as relay callbacks rather than from a + * cache scan, and are held as raw [Event]s because Desktop renders them that way. Per-relay sync + * status and the expanded form panel have no Android counterpart at all. + */ @OptIn(FlowPreview::class) class AdvancedSearchBarState( private val scope: CoroutineScope, - private val debounceMs: Long = 300L, ) { - private val _query = MutableStateFlow(SearchQuery.EMPTY) - val query: StateFlow = _query.asStateFlow() + /** What is being searched. Shared. */ + val state = SearchState(scope) - private var _changeSource: ChangeSource = ChangeSource.INIT - val changeSource get() = _changeSource - - private val _rawText = MutableStateFlow("") - val rawText: StateFlow = _rawText.asStateFlow() - - val displayText: StateFlow = - combine(_query, _rawText) { query, raw -> - if (_changeSource == ChangeSource.TEXT) { - raw - } else { - QuerySerializer.serialize(query) - } - }.stateIn(scope, SharingStarted.Eagerly, "") + val query: StateFlow = + state.current + .map { it.query } + .stateIn(scope, SharingStarted.Eagerly, state.query) val debouncedQuery: StateFlow = - _query - .debounce(debounceMs) + state.debouncedForRelays + .map { it.query } .stateIn(scope, SharingStarted.Eagerly, SearchQuery.EMPTY) + /** + * What the field shows, which is simply the box. + * + * There used to be a `ChangeSource` flag here deciding whether to show the raw text or the + * serialized query, because a form edit wrote to the query and typing wrote to the text and + * the two could disagree. [SearchState.edit] writes the token into the box instead, so a + * button press and a typed token are the same thing and there is nothing left to decide. + */ + val displayText: StateFlow get() = state.text + + val eventSortOrder: StateFlow get() = state.eventSortOrder + val peopleSortOrder: StateFlow get() = state.peopleSortOrder + // People search results (from cache + relay) private val _peopleResults = MutableStateFlow>(persistentListOf()) val peopleResults: StateFlow> = _peopleResults.asStateFlow() @@ -83,21 +92,20 @@ class AdvancedSearchBarState( private val _noteResults = MutableStateFlow>(persistentListOf()) val noteResults: StateFlow> = _noteResults.asStateFlow() - // Sort orders - private val _eventSortOrder = MutableStateFlow(SearchSortOrder.EVENT_DEFAULT) - val eventSortOrder: StateFlow = _eventSortOrder.asStateFlow() - - private val _peopleSortOrder = MutableStateFlow(SearchSortOrder.PEOPLE_DEFAULT) - val peopleSortOrder: StateFlow = _peopleSortOrder.asStateFlow() - - // Derived sorted results + /** + * The results in the order the reader asked for, through the shared pipeline. + * + * Ranked on the query's *leftover* terms rather than on the whole box: `from:npub1…` and + * `kind:article` are filters, and hunting for their literal text inside an event's content + * ranks on noise. This was scoring the raw field text until the pipeline took the job over. + */ val sortedNoteResults: StateFlow> = - combine(_noteResults, _eventSortOrder, _rawText) { notes, order, text -> - SearchResultSorter.sortEvents(notes, order, text).toImmutableList() + combine(_noteResults, state.eventSortOrder, query) { notes, order, q -> + SearchPipeline.rank(notes, order, q.text, { it }).toImmutableList() }.stateIn(scope, SharingStarted.Eagerly, persistentListOf()) val sortedPeopleResults: StateFlow> = - combine(_peopleResults, _peopleSortOrder) { people, order -> + combine(_peopleResults, state.peopleSortOrder) { people, order -> SearchResultSorter.sortPeople(people, order).toImmutableList() }.stateIn(scope, SharingStarted.Eagerly, persistentListOf()) @@ -118,91 +126,53 @@ class AdvancedSearchBarState( val relayStates: StateFlow> = _relayStates.asStateFlow() // Text bar input - fun updateFromText(rawText: String) { - _changeSource = ChangeSource.TEXT - _rawText.value = rawText - _query.value = QueryParser.parse(rawText) - } + fun updateFromText(rawText: String) = state.updateText(rawText) - // Form panel inputs - fun updateKinds(kinds: List) { - _changeSource = ChangeSource.FORM - _query.value = _query.value.copy(kinds = kinds.toImmutableList()) - } + // Form panel inputs. Each writes its token into the box; see SearchState.edit. - fun updatePseudoKinds(pseudoKinds: List) { - _changeSource = ChangeSource.FORM - _query.value = _query.value.copy(pseudoKinds = pseudoKinds.toImmutableList()) - } + fun updateKinds(kinds: List) = state.edit { it.copy(kinds = kinds.toImmutableList()) } - fun addAuthor(hexOrName: String) { - _changeSource = ChangeSource.FORM - val current = _query.value - val hex = - com.vitorpamplona.quartz.nip19Bech32 - .decodePublicKeyAsHexOrNull(hexOrName) - if (hex != null) { - if (hex !in current.authors) { - _query.value = current.copy(authors = (current.authors + hex).toImmutableList()) - } - } else { - if (hexOrName !in current.authorNames) { - _query.value = current.copy(authorNames = (current.authorNames + hexOrName).toImmutableList()) + fun updatePseudoKinds(pseudoKinds: List) = state.edit { it.copy(pseudoKinds = pseudoKinds.toImmutableList()) } + + fun addAuthor(hexOrName: String) = + state.edit { current -> + val hex = decodePublicKeyAsHexOrNull(hexOrName) + when { + hex != null && hex !in current.authors -> current.copy(authors = (current.authors + hex).toImmutableList()) + hex == null && hexOrName !in current.authorNames -> current.copy(authorNames = (current.authorNames + hexOrName).toImmutableList()) + else -> current } } - } - fun removeAuthor(hex: String) { - _changeSource = ChangeSource.FORM - val current = _query.value - _query.value = - current.copy( - authors = current.authors.filter { it != hex }.toImmutableList(), - authorNames = current.authorNames.filter { it != hex }.toImmutableList(), + fun removeAuthor(hex: String) = + state.edit { + it.copy( + authors = it.authors.filter { author -> author != hex }.toImmutableList(), + authorNames = it.authorNames.filter { name -> name != hex }.toImmutableList(), ) - } + } fun updateDateRange( since: Long?, until: Long?, - ) { - _changeSource = ChangeSource.FORM - _query.value = _query.value.copy(since = since, until = until) - } + ) = state.edit { it.copy(since = since, until = until) } - fun addHashtag(tag: String) { - _changeSource = ChangeSource.FORM - val current = _query.value - val cleaned = tag.removePrefix("#") - if (cleaned !in current.hashtags) { - _query.value = current.copy(hashtags = (current.hashtags + cleaned).toImmutableList()) + fun addHashtag(tag: String) = + state.edit { current -> + val cleaned = tag.removePrefix("#") + if (cleaned in current.hashtags) current else current.copy(hashtags = (current.hashtags + cleaned).toImmutableList()) } - } - fun removeHashtag(tag: String) { - _changeSource = ChangeSource.FORM - val current = _query.value - _query.value = current.copy(hashtags = current.hashtags.filter { it != tag }.toImmutableList()) - } + fun removeHashtag(tag: String) = state.edit { it.copy(hashtags = it.hashtags.filter { h -> h != tag }.toImmutableList()) } - fun addExcludeTerm(term: String) { - _changeSource = ChangeSource.FORM - val current = _query.value - if (term !in current.excludeTerms) { - _query.value = current.copy(excludeTerms = (current.excludeTerms + term).toImmutableList()) + fun addExcludeTerm(term: String) = + state.edit { current -> + if (term in current.excludeTerms) current else current.copy(excludeTerms = (current.excludeTerms + term).toImmutableList()) } - } - fun removeExcludeTerm(term: String) { - _changeSource = ChangeSource.FORM - val current = _query.value - _query.value = current.copy(excludeTerms = current.excludeTerms.filter { it != term }.toImmutableList()) - } + fun removeExcludeTerm(term: String) = state.edit { it.copy(excludeTerms = it.excludeTerms.filter { t -> t != term }.toImmutableList()) } - fun updateLanguage(lang: String?) { - _changeSource = ChangeSource.FORM - _query.value = _query.value.copy(language = lang) - } + fun updateLanguage(lang: String?) = state.edit { it.copy(language = lang) } fun initRelayStates(relays: Set) { _relayStates.value = @@ -251,23 +221,15 @@ class AdvancedSearchBarState( _panelExpanded.value = !_panelExpanded.value } - fun updateEventSortOrder(order: SearchSortOrder) { - _eventSortOrder.value = order - } + fun updateEventSortOrder(order: SearchSortOrder) = state.updateEventSortOrder(order) - fun updatePeopleSortOrder(order: SearchSortOrder) { - _peopleSortOrder.value = order - } + fun updatePeopleSortOrder(order: SearchSortOrder) = state.updatePeopleSortOrder(order) fun clearSearch() { - _changeSource = ChangeSource.INIT - _rawText.value = "" - _query.value = SearchQuery.EMPTY + state.clear() _peopleResults.value = persistentListOf() _noteResults.value = persistentListOf() _relayStates.value = persistentListOf() - _eventSortOrder.value = SearchSortOrder.EVENT_DEFAULT - _peopleSortOrder.value = SearchSortOrder.PEOPLE_DEFAULT activeSubIds.value = emptySet() eventDeduplicator.clear() } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchPipeline.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchPipeline.kt index 65b4babfdb..aff9d23a50 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchPipeline.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchPipeline.kt @@ -96,8 +96,7 @@ object SearchPipeline { * double holds every sat total exactly up to 2^53, which is past any zap that will exist. * * A front end holding raw events has no zap totals and leaves it at zero, which collapses - * POPULAR into newest for that caller — the same thing [SearchResultSorter.sortEvents] - * already does, said once instead of twice. + * POPULAR into newest for that caller — the only honest answer a raw `Event` can give. * * Sort keys are snapshotted per item before comparing. A `Note` is a mutable box: a newer * addressable event arriving from a relay mid-sort changes `createdAt` under the comparator, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorter.kt index ec686ec088..54425f800f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorter.kt @@ -27,39 +27,6 @@ import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.utils.currentTimeSeconds object SearchResultSorter { - fun sortEvents( - events: List, - order: SearchSortOrder, - searchText: String, - ): List = - when (order) { - SearchSortOrder.NEWEST -> { - events.sortedWith(compareByDescending { it.createdAt }.thenBy { it.id }) - } - - SearchSortOrder.OLDEST -> { - events.sortedWith(compareBy { it.createdAt }.thenBy { it.id }) - } - - SearchSortOrder.RELEVANCE -> { - if (searchText.isBlank()) { - events.sortedWith(compareByDescending { it.createdAt }.thenBy { it.id }) - } else { - events.sortedByDescending { scoreEvent(it, searchText) } - } - } - - SearchSortOrder.POPULAR -> { - // Raw Event has no zap-total; callers that hold Note objects should sort by - // zapsAmount directly. Fall back to newest so the option is still harmless here. - events.sortedWith(compareByDescending { it.createdAt }.thenBy { it.id }) - } - - else -> { - events - } - } - fun sortPeople( people: List, order: SearchSortOrder, diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorterTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorterTest.kt index b3014c131b..96217a9904 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorterTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorterTest.kt @@ -89,24 +89,26 @@ class SearchResultSorterTest { // --- Event sorting --- + private val asEvent: (Event) -> Event? = { it } + @Test fun newestSortsDescending() { val events = listOf(event("a", 100), event("b", 300), event("c", 200)) - val sorted = SearchResultSorter.sortEvents(events, SearchSortOrder.NEWEST, "") + val sorted = SearchPipeline.rank(events, SearchSortOrder.NEWEST, "", asEvent) assertEquals(listOf("b", "c", "a"), sorted.map { it.id }) } @Test fun oldestSortsAscending() { val events = listOf(event("a", 300), event("b", 100), event("c", 200)) - val sorted = SearchResultSorter.sortEvents(events, SearchSortOrder.OLDEST, "") + val sorted = SearchPipeline.rank(events, SearchSortOrder.OLDEST, "", asEvent) assertEquals(listOf("b", "c", "a"), sorted.map { it.id }) } @Test fun relevanceEmptyQueryFallsBackToRecency() { val events = listOf(event("a", 100), event("b", 300), event("c", 200)) - val sorted = SearchResultSorter.sortEvents(events, SearchSortOrder.RELEVANCE, "") + val sorted = SearchPipeline.rank(events, SearchSortOrder.RELEVANCE, "", asEvent) assertEquals(listOf("b", "c", "a"), sorted.map { it.id }) } @@ -114,7 +116,7 @@ class SearchResultSorterTest { fun relevanceExactMatchBeatsPartial() { val exact = event("exact", 100, content = "bitcoin is great") val partial = event("partial", 100, content = "bit of something") - val sorted = SearchResultSorter.sortEvents(listOf(partial, exact), SearchSortOrder.RELEVANCE, "bitcoin") + val sorted = SearchPipeline.rank(listOf(partial, exact), SearchSortOrder.RELEVANCE, "bitcoin", asEvent) assertEquals("exact", sorted.first().id) } @@ -122,7 +124,7 @@ class SearchResultSorterTest { fun relevanceWordBoundaryBeatsSubstring() { val boundary = event("boundary", 100, content = "I love bitcoin and lightning") val substring = event("substr", 100, content = "bitcoinery is not a word") - val sorted = SearchResultSorter.sortEvents(listOf(substring, boundary), SearchSortOrder.RELEVANCE, "bitcoin") + val sorted = SearchPipeline.rank(listOf(substring, boundary), SearchSortOrder.RELEVANCE, "bitcoin", asEvent) assertEquals("boundary", sorted.first().id) } @@ -130,7 +132,7 @@ class SearchResultSorterTest { fun relevanceArticleTitleBoost() { val withTitle = article("titled", 100, content = "some content", title = "Bitcoin Guide") val withoutTitle = event("notitle", 100, content = "bitcoin bitcoin bitcoin") - val sorted = SearchResultSorter.sortEvents(listOf(withoutTitle, withTitle), SearchSortOrder.RELEVANCE, "bitcoin") + val sorted = SearchPipeline.rank(listOf(withoutTitle, withTitle), SearchSortOrder.RELEVANCE, "bitcoin", asEvent) assertEquals("titled", sorted.first().id) } @@ -139,10 +141,11 @@ class SearchResultSorterTest { val multi = event("multi", 100, content = "bitcoin and lightning network") val single = event("single", 100, content = "bitcoin only here") val sorted = - SearchResultSorter.sortEvents( + SearchPipeline.rank( listOf(single, multi), SearchSortOrder.RELEVANCE, "bitcoin lightning", + asEvent, ) assertEquals("multi", sorted.first().id) }